Compare commits
5 Commits
feat/cost-
...
claw-code/
| Author | SHA1 | Date | |
|---|---|---|---|
| 954a4847ad | |||
| 0c723199ec | |||
| 317140efcf | |||
| 2b308f300a | |||
| 9146bcb4b2 |
2
.claw/sessions/session-1775428216801-0.jsonl
Normal file
2
.claw/sessions/session-1775428216801-0.jsonl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{"created_at_ms":1775428216801,"session_id":"session-1775428216801-0","type":"session_meta","updated_at_ms":1775428216801,"version":1}
|
||||||
|
{"message":{"blocks":[{"text":"You are Code Claw running as the Gitea user claw-code.\n\nRepository: Timmy_Foundation/timmy-config\nIssue: #232 — [SMOKE] Add Code Claw local state dirs to .gitignore\nBranch: claw-code/issue-232\n\nRead the issue and recent comments, then implement the smallest correct change.\nYou are in a git repo checkout already.\n\nIssue body:\n## What\nAdd Code Claw local state dirs to `.gitignore` so local Qwen/OpenRouter runs do not pollute repo status.\n\n## Acceptance criteria\n- add `.claw-qwen36-openrouter/`\n- add `.claw-qwen36-gitea/`\n- no other repo changes\n\n\nRecent comments:\n🟠 Code Claw (OpenRouter qwen/qwen3.6-plus:free) picking up this issue via 15-minute heartbeat.\n\nTimestamp: 2026-04-05T22:26:31Z\n\nRules:\n- Make focused code/config/doc changes only if they directly address the issue.\n- Prefer the smallest proof-oriented fix.\n- Run relevant verification commands if obvious.\n- Do NOT create PRs yourself; the outer worker handles commit/push/PR.\n- If the task is too large or not code-fit, leave the tree unchanged.\n","type":"text"}],"role":"user"},"type":"message"}
|
||||||
41
COST_SAVING.md
Normal file
41
COST_SAVING.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
# Sovereign Efficiency: Local-First & Cost Saving Guide
|
||||||
|
|
||||||
|
This guide outlines the strategy for eliminating waste and optimizing flow within the Timmy Foundation ecosystem.
|
||||||
|
|
||||||
|
## 1. Smart Model Routing (SMR)
|
||||||
|
**Goal:** Use the right tool for the job. Don't use a 14B or 70B model to say "Hello" or "Task complete."
|
||||||
|
|
||||||
|
- **Action:** Enable `smart_model_routing` in `config.yaml`.
|
||||||
|
- **Logic:**
|
||||||
|
- Simple acknowledgments and status updates -> **Gemma 2B / Phi-3 Mini** (Local).
|
||||||
|
- Complex reasoning and coding -> **Hermes 14B / Llama 3 70B** (Local).
|
||||||
|
- Fortress-grade synthesis -> **Claude 3.5 Sonnet / Gemini 1.5 Pro** (Cloud - Emergency Only).
|
||||||
|
|
||||||
|
## 2. Context Compression
|
||||||
|
**Goal:** Keep the KV cache lean. Long sessions shouldn't slow down the "Thought Stream."
|
||||||
|
|
||||||
|
- **Action:** Enable `compression` in `config.yaml`.
|
||||||
|
- **Threshold:** Set to `0.5` to trigger summarization when the context is half full.
|
||||||
|
- **Protect Last N:** Keep the last 20 turns in raw format for immediate coherence.
|
||||||
|
|
||||||
|
## 3. Parallel Symbolic Execution (PSE) Optimization
|
||||||
|
**Goal:** Reduce redundant reasoning cycles in The Nexus.
|
||||||
|
|
||||||
|
- **Action:** The Nexus now uses **Adaptive Reasoning Frequency**. If the world stability is high (>0.9), reasoning cycles are halved.
|
||||||
|
- **Benefit:** Reduces CPU/GPU load on the local harness, leaving more headroom for inference.
|
||||||
|
|
||||||
|
## 4. L402 Cost Transparency
|
||||||
|
**Goal:** Treat compute as a finite resource.
|
||||||
|
|
||||||
|
- **Action:** Use the **Sovereign Health HUD** in The Nexus to monitor L402 challenges.
|
||||||
|
- **Metric:** Track "Sats per Thought" to identify which agents are "token-heavy."
|
||||||
|
|
||||||
|
## 5. Waste Elimination (Ghost Triage)
|
||||||
|
**Goal:** Remove stale state.
|
||||||
|
|
||||||
|
- **Action:** Run the `triage_sprint.ts` script weekly to assign or archive stale issues.
|
||||||
|
- **Action:** Use `hermes --flush-memories` to clear outdated context that no longer serves the current mission.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Sovereignty is not just about ownership; it is about stewardship of resources.*
|
||||||
37
FRONTIER_LOCAL.md
Normal file
37
FRONTIER_LOCAL.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
# The Frontier Local Agenda: Technical Standards v1.0
|
||||||
|
|
||||||
|
This document defines the "Frontier Local" agenda — the technical strategy for achieving sovereign, high-performance intelligence on consumer hardware.
|
||||||
|
|
||||||
|
## 1. The Multi-Layered Mind (MLM)
|
||||||
|
We do not rely on a single "God Model." We use a hierarchy of local intelligence:
|
||||||
|
|
||||||
|
- **Reflex Layer (Gemma 2B):** Instantaneous tactical decisions, input classification, and simple acknowledgments. Latency: <100ms.
|
||||||
|
- **Reasoning Layer (Hermes 14B / Llama 3 8B):** General-purpose problem solving, coding, and tool use. Latency: <1s.
|
||||||
|
- **Synthesis Layer (Llama 3 70B / Qwen 72B):** Deep architectural planning, creative synthesis, and complex debugging. Latency: <5s.
|
||||||
|
|
||||||
|
## 2. Local-First RAG (Retrieval Augmented Generation)
|
||||||
|
Sovereignty requires that your memories stay on your disk.
|
||||||
|
|
||||||
|
- **Embedding:** Use `nomic-embed-text` or `all-minilm` locally via Ollama.
|
||||||
|
- **Vector Store:** Use a local instance of ChromaDB or LanceDB.
|
||||||
|
- **Privacy:** Zero data leaves the local network for indexing or retrieval.
|
||||||
|
|
||||||
|
## 3. Speculative Decoding
|
||||||
|
Where supported by the harness (e.g., llama.cpp), use Gemma 2B as a draft model for larger Hermes/Llama models to achieve 2x-3x speedups in token generation.
|
||||||
|
|
||||||
|
## 4. The "Gemma Scout" Protocol
|
||||||
|
Gemma 2B is our "Scout." It pre-processes every user request to:
|
||||||
|
1. Detect PII (Personally Identifiable Information) for redaction.
|
||||||
|
2. Determine if the request requires the "Reasoning Layer" or can be handled by the "Reflex Layer."
|
||||||
|
3. Extract keywords for local memory retrieval.
|
||||||
|
|
||||||
|
|
||||||
|
## 5. Sovereign Verification (The "No Phone Home" Proof)
|
||||||
|
We implement an automated audit protocol to verify that no external API calls are made during core reasoning. This is the "Sovereign Audit" layer.
|
||||||
|
|
||||||
|
## 6. Local Tool Orchestration (MCP)
|
||||||
|
The Model Context Protocol (MCP) is used to connect the local mind to local hardware (file system, local databases, home automation) without cloud intermediaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Intelligence is a utility. Sovereignty is a right. The Frontier is Local.*
|
||||||
23
SOVEREIGN_AUDIT.md
Normal file
23
SOVEREIGN_AUDIT.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
# Sovereign Audit: The "No Phone Home" Protocol
|
||||||
|
|
||||||
|
This document defines the audit standards for verifying that an AI agent is truly sovereign and local-first.
|
||||||
|
|
||||||
|
## 1. Network Isolation
|
||||||
|
- **Standard:** The core reasoning engine (llama.cpp, Ollama) must function without an active internet connection.
|
||||||
|
- **Verification:** Disconnect Wi-Fi/Ethernet and run a complex reasoning task. If it fails, sovereignty is compromised.
|
||||||
|
|
||||||
|
## 2. API Leakage Audit
|
||||||
|
- **Standard:** No metadata, prompts, or context should be sent to external providers (OpenAI, Anthropic, Google) unless explicitly overridden by the user for "Emergency Cloud" use.
|
||||||
|
- **Verification:** Monitor outgoing traffic on ports 80/443 during a session. Core reasoning should only hit `localhost` or local network IPs.
|
||||||
|
|
||||||
|
## 3. Data Residency
|
||||||
|
- **Standard:** All "Memories" (Vector DB, Chat History, SOUL.md) must reside on the user's physical disk.
|
||||||
|
- **Verification:** Check the `~/.timmy/memories` and `~/.timmy/config` directories. No data should be stored in cloud-managed databases.
|
||||||
|
|
||||||
|
## 4. Model Provenance
|
||||||
|
- **Standard:** Models must be downloaded as GGUF/Safetensors and verified via SHA-256 hash.
|
||||||
|
- **Verification:** Run `sha256sum` on the local model weights and compare against the official repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
*If you don't own the weights, you don't own the mind.*
|
||||||
29
config.yaml
29
config.yaml
@@ -20,7 +20,12 @@ terminal:
|
|||||||
modal_image: nikolaik/python-nodejs:python3.11-nodejs20
|
modal_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||||
daytona_image: nikolaik/python-nodejs:python3.11-nodejs20
|
daytona_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||||
container_cpu: 1
|
container_cpu: 1
|
||||||
container_memory: 5120
|
container_embeddings:
|
||||||
|
provider: ollama
|
||||||
|
model: nomic-embed-text
|
||||||
|
base_url: http://localhost:11434/v1
|
||||||
|
|
||||||
|
memory: 5120
|
||||||
container_disk: 51200
|
container_disk: 51200
|
||||||
container_persistent: true
|
container_persistent: true
|
||||||
docker_volumes: []
|
docker_volumes: []
|
||||||
@@ -34,21 +39,26 @@ checkpoints:
|
|||||||
enabled: true
|
enabled: true
|
||||||
max_snapshots: 50
|
max_snapshots: 50
|
||||||
compression:
|
compression:
|
||||||
enabled: false
|
enabled: true
|
||||||
threshold: 0.5
|
threshold: 0.5
|
||||||
target_ratio: 0.2
|
target_ratio: 0.2
|
||||||
protect_last_n: 20
|
protect_last_n: 20
|
||||||
summary_model: ''
|
summary_model: ''
|
||||||
summary_provider: ''
|
summary_provider: ''
|
||||||
summary_base_url: ''
|
summary_base_url: ''
|
||||||
|
synthesis_model:
|
||||||
|
provider: custom
|
||||||
|
model: llama3:70b
|
||||||
|
base_url: http://localhost:8081/v1
|
||||||
|
|
||||||
smart_model_routing:
|
smart_model_routing:
|
||||||
enabled: false
|
enabled: true
|
||||||
max_simple_chars: 200
|
max_simple_chars: 400
|
||||||
max_simple_words: 35
|
max_simple_words: 75
|
||||||
cheap_model:
|
cheap_model:
|
||||||
provider: ''
|
provider: 'ollama'
|
||||||
model: ''
|
model: 'gemma2:2b'
|
||||||
base_url: ''
|
base_url: 'http://localhost:11434/v1'
|
||||||
api_key: ''
|
api_key: ''
|
||||||
auxiliary:
|
auxiliary:
|
||||||
vision:
|
vision:
|
||||||
@@ -165,6 +175,9 @@ command_allowlist: []
|
|||||||
quick_commands: {}
|
quick_commands: {}
|
||||||
personalities: {}
|
personalities: {}
|
||||||
security:
|
security:
|
||||||
|
sovereign_audit: true
|
||||||
|
no_phone_home: true
|
||||||
|
|
||||||
redact_secrets: true
|
redact_secrets: true
|
||||||
tirith_enabled: true
|
tirith_enabled: true
|
||||||
tirith_path: tirith
|
tirith_path: tirith
|
||||||
|
|||||||
633
workforce-manager.py
Normal file
633
workforce-manager.py
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Workforce Manager - Epic #204 / Milestone #218
|
||||||
|
|
||||||
|
Reads fleet routing, Wolf evaluation scores, and open Gitea issues across
|
||||||
|
Timmy_Foundation repos. Assigns each issue to the best-available agent,
|
||||||
|
tracks success rates, and dispatches work.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python workforce-manager.py # Scan, assign, dispatch
|
||||||
|
python workforce-manager.py --dry-run # Show assignments without dispatching
|
||||||
|
python workforce-manager.py --status # Show agent status and open issue count
|
||||||
|
python workforce-manager.py --cron # Run silently, save to log
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print("FATAL: requests is required. pip install requests", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FLEET_ROUTING_PATH = Path.home() / ".hermes" / "fleet-routing.json"
|
||||||
|
WOLF_RESULTS_DIR = Path.home() / ".hermes" / "wolf" / "results"
|
||||||
|
GITEA_TOKEN_PATH = Path.home() / ".hermes" / "gitea_token_vps"
|
||||||
|
GITEA_API_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||||
|
WORKFORCE_STATE_PATH = Path.home() / ".hermes" / "workforce-state.json"
|
||||||
|
ORG_NAME = "Timmy_Foundation"
|
||||||
|
|
||||||
|
# Role-to-agent-role mapping heuristics
|
||||||
|
ROLE_KEYWORDS = {
|
||||||
|
"code-generation": [
|
||||||
|
"code", "implement", "feature", "function", "class", "script",
|
||||||
|
"build", "create", "add", "module", "component",
|
||||||
|
],
|
||||||
|
"issue-triage": [
|
||||||
|
"triage", "categorize", "tag", "label", "organize",
|
||||||
|
"backlog", "sort", "prioritize", "review issue",
|
||||||
|
],
|
||||||
|
"on-request-queries": [
|
||||||
|
"query", "search", "lookup", "find", "check",
|
||||||
|
"info", "report", "status",
|
||||||
|
],
|
||||||
|
"devops": [
|
||||||
|
"deploy", "ci", "cd", "pipeline", "docker", "container",
|
||||||
|
"server", "infrastructure", "config", "nginx", "cron",
|
||||||
|
"setup", "install", "environment", "provision",
|
||||||
|
"build", "release", "workflow",
|
||||||
|
],
|
||||||
|
"documentation": [
|
||||||
|
"doc", "readme", "document", "write", "guide",
|
||||||
|
"spec", "wiki", "changelog", "tutorial",
|
||||||
|
"explain", "describe",
|
||||||
|
],
|
||||||
|
"code-review": [
|
||||||
|
"review", "refactor", "fix", "bug", "debug",
|
||||||
|
"test", "lint", "style", "improve",
|
||||||
|
"clean up", "optimize", "performance",
|
||||||
|
],
|
||||||
|
"triage-routing": [
|
||||||
|
"route", "assign", "triage", "dispatch",
|
||||||
|
"organize", "categorize",
|
||||||
|
],
|
||||||
|
"small-tasks": [
|
||||||
|
"small", "quick", "minor", "typo", "label",
|
||||||
|
"update", "rename", "cleanup",
|
||||||
|
],
|
||||||
|
"inactive": [],
|
||||||
|
"unknown": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Priority keywords (higher = more urgent, route to more capable agent)
|
||||||
|
PRIORITY_KEYWORDS = {
|
||||||
|
"critical": 5,
|
||||||
|
"urgent": 4,
|
||||||
|
"block": 4,
|
||||||
|
"bug": 3,
|
||||||
|
"fix": 3,
|
||||||
|
"security": 5,
|
||||||
|
"deploy": 2,
|
||||||
|
"feature": 1,
|
||||||
|
"enhancement": 1,
|
||||||
|
"documentation": 1,
|
||||||
|
"cleanup": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cost tier priority (lower index = prefer first)
|
||||||
|
TIER_ORDER = ["free", "cheap", "prepaid", "unknown"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data loading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_json(path: Path) -> Any:
|
||||||
|
if not path.exists():
|
||||||
|
logging.warning("File not found: %s", path)
|
||||||
|
return None
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_fleet_routing() -> List[dict]:
|
||||||
|
data = load_json(FLEET_ROUTING_PATH)
|
||||||
|
if data and "agents" in data:
|
||||||
|
return data["agents"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def load_wolf_scores() -> Dict[str, dict]:
|
||||||
|
"""Load Wolf evaluation scores from results directory."""
|
||||||
|
scores: Dict[str, dict] = {}
|
||||||
|
if not WOLF_RESULTS_DIR.exists():
|
||||||
|
return scores
|
||||||
|
for f in sorted(WOLF_RESULTS_DIR.glob("*.json")):
|
||||||
|
data = load_json(f)
|
||||||
|
if data and "model_scores" in data:
|
||||||
|
for entry in data["model_scores"]:
|
||||||
|
model = entry.get("model", "")
|
||||||
|
if model:
|
||||||
|
scores[model] = entry
|
||||||
|
return scores
|
||||||
|
|
||||||
|
|
||||||
|
def load_workforce_state() -> dict:
|
||||||
|
if WORKFORCE_STATE_PATH.exists():
|
||||||
|
return load_json(WORKFORCE_STATE_PATH) or {}
|
||||||
|
return {"assignments": [], "agent_stats": {}, "last_run": None}
|
||||||
|
|
||||||
|
|
||||||
|
def save_workforce_state(state: dict) -> None:
|
||||||
|
WORKFORCE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(WORKFORCE_STATE_PATH, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
logging.info("Workforce state saved to %s", WORKFORCE_STATE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gitea API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class GiteaAPI:
|
||||||
|
"""Thin wrapper for Gitea REST API."""
|
||||||
|
|
||||||
|
def __init__(self, token: str, base_url: str = GITEA_API_BASE):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
"Authorization": f"token {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
||||||
|
r = self.session.get(f"{self.base_url}{path}", params=params)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def _post(self, path: str, data: dict) -> Any:
|
||||||
|
r = self.session.post(f"{self.base_url}{path}", json=data)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def _patch(self, path: str, data: dict) -> Any:
|
||||||
|
r = self.session.patch(f"{self.base_url}{path}", json=data)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def get_org_repos(self, org: str) -> List[dict]:
|
||||||
|
return self._get(f"/orgs/{org}/repos", params={"limit": 100})
|
||||||
|
|
||||||
|
def get_open_issues(self, owner: str, repo: str, page: int = 1) -> List[dict]:
|
||||||
|
params = {"state": "open", "type": "issues", "limit": 50, "page": page}
|
||||||
|
return self._get(f"/repos/{owner}/{repo}/issues", params=params)
|
||||||
|
|
||||||
|
def get_all_open_issues(self, org: str) -> List[dict]:
|
||||||
|
"""Fetch all open issues across all org repos."""
|
||||||
|
repos = self.get_org_repos(org)
|
||||||
|
all_issues = []
|
||||||
|
for repo in repos:
|
||||||
|
name = repo["name"]
|
||||||
|
try:
|
||||||
|
# Paginate through all issues
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
issues = self.get_open_issues(org, name, page=page)
|
||||||
|
if not issues:
|
||||||
|
break
|
||||||
|
all_issues.extend(issues)
|
||||||
|
if len(issues) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
logging.info("Loaded %d open issues from %s/%s", len(all_issues), org, name)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.warning("Failed to load issues from %s/%s: %s", org, name, exc)
|
||||||
|
return all_issues
|
||||||
|
|
||||||
|
def add_issue_comment(self, owner: str, repo: str, issue_num: int, body: str) -> dict:
|
||||||
|
return self._post(f"/repos/{owner}/{repo}/issues/{issue_num}/comments", {"body": body})
|
||||||
|
|
||||||
|
def add_issue_label(self, owner: str, repo: str, issue_num: int, label: str) -> dict:
|
||||||
|
return self._post(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{issue_num}/labels",
|
||||||
|
{"labels": [label]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def assign_issue(self, owner: str, repo: str, issue_num: int, assignee: str) -> dict:
|
||||||
|
return self._patch(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{issue_num}",
|
||||||
|
{"assignees": [assignee]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scoring & Assignment Logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def classify_issue(issue: dict) -> str:
|
||||||
|
"""Determine the best agent role for an issue based on title/body."""
|
||||||
|
title = (issue.get("title", "") or "").lower()
|
||||||
|
body = (issue.get("body", "") or "").lower()
|
||||||
|
text = f"{title} {body}"
|
||||||
|
labels = [l.get("name", "").lower() for l in issue.get("labels", [])]
|
||||||
|
text += " " + " ".join(labels)
|
||||||
|
|
||||||
|
best_role = "small-tasks" # default
|
||||||
|
best_score = 0
|
||||||
|
|
||||||
|
for role, keywords in ROLE_KEYWORDS.items():
|
||||||
|
if not keywords:
|
||||||
|
continue
|
||||||
|
score = sum(2 for kw in keywords if kw in text)
|
||||||
|
# Boost if a matching label exists
|
||||||
|
for label in labels:
|
||||||
|
if any(kw in label for kw in keywords):
|
||||||
|
score += 3
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_role = role
|
||||||
|
|
||||||
|
return best_role
|
||||||
|
|
||||||
|
|
||||||
|
def compute_priority(issue: dict) -> int:
|
||||||
|
"""Compute issue priority from keywords."""
|
||||||
|
title = (issue.get("title", "") or "").lower()
|
||||||
|
body = (issue.get("body", "") or "").lower()
|
||||||
|
text = f"{title} {body}"
|
||||||
|
return sum(v for k, v in PRIORITY_KEYWORDS.items() if k in text)
|
||||||
|
|
||||||
|
|
||||||
|
def score_agent_for_issue(agent: dict, role: str, wolf_scores: dict, priority: int) -> float:
|
||||||
|
"""Score how well an agent matches an issue. Higher is better."""
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# Primary: role match
|
||||||
|
agent_role = agent.get("role", "unknown")
|
||||||
|
if agent_role == role:
|
||||||
|
score += 10.0
|
||||||
|
elif agent_role == "small-tasks" and role in ("issue-triage", "on-request-queries"):
|
||||||
|
score += 6.0
|
||||||
|
elif agent_role == "triage-routing" and role in ("issue-triage", "triage-routing"):
|
||||||
|
score += 8.0
|
||||||
|
elif agent_role == "code-generation" and role in ("code-review", "devops"):
|
||||||
|
score += 4.0
|
||||||
|
|
||||||
|
# Wolf quality bonus
|
||||||
|
model = agent.get("model", "")
|
||||||
|
wolf_entry = None
|
||||||
|
for wm, ws in wolf_scores.items():
|
||||||
|
if model and model.lower() in wm.lower():
|
||||||
|
wolf_entry = ws
|
||||||
|
break
|
||||||
|
if wolf_entry and wolf_entry.get("success"):
|
||||||
|
score += wolf_entry.get("total", 0) * 3.0
|
||||||
|
|
||||||
|
# Cost efficiency: prefer free/cheap for low priority
|
||||||
|
tier = agent.get("tier", "unknown")
|
||||||
|
tier_idx = TIER_ORDER.index(tier) if tier in TIER_ORDER else 3
|
||||||
|
if priority <= 1 and tier in ("free", "cheap"):
|
||||||
|
score += 4.0
|
||||||
|
elif priority >= 3 and tier in ("prepaid",):
|
||||||
|
score += 3.0
|
||||||
|
else:
|
||||||
|
score += (3 - tier_idx) * 1.0
|
||||||
|
|
||||||
|
# Activity bonus
|
||||||
|
if agent.get("active", False):
|
||||||
|
score += 2.0
|
||||||
|
|
||||||
|
# Repo familiarity bonus: more repos slightly better
|
||||||
|
repo_count = agent.get("repo_count", 0)
|
||||||
|
score += min(repo_count * 0.2, 2.0)
|
||||||
|
|
||||||
|
return round(score, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def find_best_agent(agents: List[dict], role: str, wolf_scores: dict, priority: int,
|
||||||
|
exclude: Optional[List[str]] = None) -> Optional[dict]:
|
||||||
|
"""Find the best agent for the given role and priority."""
|
||||||
|
exclude = exclude or []
|
||||||
|
candidates = []
|
||||||
|
for agent in agents:
|
||||||
|
if agent.get("name") in exclude:
|
||||||
|
continue
|
||||||
|
if not agent.get("active", False):
|
||||||
|
continue
|
||||||
|
s = score_agent_for_issue(agent, role, wolf_scores, priority)
|
||||||
|
candidates.append((s, agent))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
return candidates[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def dispatch_assignment(api: GiteaAPI, issue: dict, agent: dict, dry_run: bool = False) -> dict:
|
||||||
|
"""Assign an issue to an agent and optionally post a comment."""
|
||||||
|
owner = ORG_NAME
|
||||||
|
repo = issue.get("repository", {}).get("name", "")
|
||||||
|
|
||||||
|
# Extract repo from issue repo_url if not in the repository key
|
||||||
|
if not repo:
|
||||||
|
repo_url = issue.get("repository_url", "")
|
||||||
|
if repo_url:
|
||||||
|
repo = repo_url.rstrip("/").split("/")[-1]
|
||||||
|
|
||||||
|
if not repo:
|
||||||
|
return {"success": False, "error": "Cannot determine repository for issue"}
|
||||||
|
|
||||||
|
issue_num = issue.get("number")
|
||||||
|
agent_name = agent.get("name", "unknown")
|
||||||
|
|
||||||
|
comment_body = (
|
||||||
|
f"🤖 **Workforce Manager assigned this issue to: @{agent_name}**\n\n"
|
||||||
|
f"- **Agent:** {agent_name}\n"
|
||||||
|
f"- **Model:** {agent.get('model', 'unknown')}\n"
|
||||||
|
f"- **Role:** {agent.get('role', 'unknown')}\n"
|
||||||
|
f"- **Tier:** {agent.get('tier', 'unknown')}\n"
|
||||||
|
f"- **Assigned at:** {datetime.now(timezone.utc).isoformat()}\n\n"
|
||||||
|
f"*Automated assignment by Workforce Manager (Epic #204)*"
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"dry_run": True,
|
||||||
|
"repo": repo,
|
||||||
|
"issue_number": issue_num,
|
||||||
|
"assignee": agent_name,
|
||||||
|
"comment": comment_body,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
api.assign_issue(owner, repo, issue_num, agent_name)
|
||||||
|
api.add_issue_comment(owner, repo, issue_num, comment_body)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"repo": repo,
|
||||||
|
"issue_number": issue_num,
|
||||||
|
"issue_title": issue.get("title", ""),
|
||||||
|
"assignee": agent_name,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"repo": repo,
|
||||||
|
"issue_number": issue_num,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State Tracking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def update_agent_stats(state: dict, result: dict) -> None:
|
||||||
|
"""Update per-agent success tracking."""
|
||||||
|
agent_name = result.get("assignee", "unknown")
|
||||||
|
if "agent_stats" not in state:
|
||||||
|
state["agent_stats"] = {}
|
||||||
|
if agent_name not in state["agent_stats"]:
|
||||||
|
state["agent_stats"][agent_name] = {
|
||||||
|
"total_assigned": 0,
|
||||||
|
"successful": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"success_rate": 0.0,
|
||||||
|
"last_assignment": None,
|
||||||
|
"assigned_issues": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = state["agent_stats"][agent_name]
|
||||||
|
stats["total_assigned"] += 1
|
||||||
|
stats["last_assignment"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
stats["assigned_issues"].append({
|
||||||
|
"repo": result.get("repo", ""),
|
||||||
|
"issue_number": result.get("issue_number"),
|
||||||
|
"success": result.get("success", False),
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
stats["successful"] += 1
|
||||||
|
else:
|
||||||
|
stats["failed"] += 1
|
||||||
|
|
||||||
|
total = stats["successful"] + stats["failed"]
|
||||||
|
stats["success_rate"] = round(stats["successful"] / total, 3) if total > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def print_status(state: dict, agents: List[dict], issues_count: int) -> None:
|
||||||
|
"""Print workforce status."""
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f"Workforce Manager Status - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
# Fleet summary
|
||||||
|
active = [a for a in agents if a.get("active")]
|
||||||
|
print(f"\nFleet: {len(active)} active agents, {len(agents)} total")
|
||||||
|
tier_counts = {}
|
||||||
|
for a in active:
|
||||||
|
t = a.get("tier", "unknown")
|
||||||
|
tier_counts[t] = tier_counts.get(t, 0) + 1
|
||||||
|
for t, c in sorted(tier_counts.items()):
|
||||||
|
print(f" {t}: {c} agents")
|
||||||
|
|
||||||
|
# Agent scores
|
||||||
|
wolf = load_wolf_scores()
|
||||||
|
print(f"\nAgent Details:")
|
||||||
|
print(f" {'Name':<25} {'Model':<30} {'Role':<18} {'Tier':<10}")
|
||||||
|
for a in agents:
|
||||||
|
if not a.get("active"):
|
||||||
|
continue
|
||||||
|
stats = state.get("agent_stats", {}).get(a["name"], {})
|
||||||
|
rate = stats.get("success_rate", 0.0)
|
||||||
|
total = stats.get("total_assigned", 0)
|
||||||
|
wolf_badge = ""
|
||||||
|
for wm, ws in wolf.items():
|
||||||
|
if a["model"] and a["model"].lower() in wm.lower() and ws.get("success"):
|
||||||
|
wolf_badge = f"[wolf:{ws['total']}]"
|
||||||
|
break
|
||||||
|
name_str = f"{a['name']} {wolf_badge}"
|
||||||
|
if total > 0:
|
||||||
|
name_str += f" (s/r: {rate}, n={total})"
|
||||||
|
print(f" {name_str:<45} {a.get('role', 'unknown'):<18} {a.get('tier', '?'):<10}")
|
||||||
|
|
||||||
|
print(f"\nOpen Issues: {issues_count}")
|
||||||
|
print(f"Assignments Made: {len(state.get('assignments', []))}")
|
||||||
|
if state.get("last_run"):
|
||||||
|
print(f"Last Run: {state['last_run']}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Workforce Manager - Assign Gitea issues to AI agents")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Show assignments without dispatching")
|
||||||
|
parser.add_argument("--status", action="store_true", help="Show workforce status only")
|
||||||
|
parser.add_argument("--cron", action="store_true", help="Run silently for cron scheduling")
|
||||||
|
parser.add_argument("--label", type=str, help="Only process issues with this label")
|
||||||
|
parser.add_argument("--max-issues", type=int, default=100, help="Max issues to process per run")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
if args.cron:
|
||||||
|
logging.basicConfig(level=logging.WARNING, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
else:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
|
||||||
|
logging.info("Workforce Manager starting")
|
||||||
|
|
||||||
|
# Load data
|
||||||
|
agents = load_fleet_routing()
|
||||||
|
if not agents:
|
||||||
|
logging.error("No agents found in fleet-routing.json")
|
||||||
|
return 1
|
||||||
|
logging.info("Loaded %d agents from fleet routing", len(agents))
|
||||||
|
|
||||||
|
wolf_scores = load_wolf_scores()
|
||||||
|
if wolf_scores:
|
||||||
|
logging.info("Loaded %d model scores from Wolf results", len(wolf_scores))
|
||||||
|
|
||||||
|
state = load_workforce_state()
|
||||||
|
|
||||||
|
# Load Gitea token
|
||||||
|
if GITEA_TOKEN_PATH.exists():
|
||||||
|
token = GITEA_TOKEN_PATH.read_text().strip()
|
||||||
|
else:
|
||||||
|
logging.error("Gitea token not found at %s", GITEA_TOKEN_PATH)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
api = GiteaAPI(token)
|
||||||
|
|
||||||
|
# Status-only mode
|
||||||
|
if args.status:
|
||||||
|
# Quick open issue count
|
||||||
|
repos = api.get_org_repos(ORG_NAME)
|
||||||
|
total = sum(r.get("open_issues_count", 0) for r in repos)
|
||||||
|
print_status(state, agents, total)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Fetch open issues
|
||||||
|
if not args.cron:
|
||||||
|
print(f"Scanning open issues across {ORG_NAME} repos...")
|
||||||
|
|
||||||
|
issues = api.get_all_open_issues(ORG_NAME)
|
||||||
|
|
||||||
|
# Filter by label
|
||||||
|
if args.label:
|
||||||
|
issues = [
|
||||||
|
i for i in issues
|
||||||
|
if any(args.label in (l.get("name", "") or "").lower() for l in i.get("labels", []))
|
||||||
|
]
|
||||||
|
|
||||||
|
if args.label:
|
||||||
|
logging.info("Filtered to %d issues with label '%s'", len(issues), args.label)
|
||||||
|
else:
|
||||||
|
logging.info("Found %d open issues", len(issues))
|
||||||
|
|
||||||
|
# Skip issues already assigned
|
||||||
|
already_assigned_nums = set()
|
||||||
|
for a in state.get("assignments", []):
|
||||||
|
already_assigned_nums.add((a.get("repo"), a.get("issue_number")))
|
||||||
|
|
||||||
|
issues = [
|
||||||
|
i for i in issues
|
||||||
|
if not i.get("assignee") and
|
||||||
|
not (i.get("repository", {}).get("name"), i.get("number")) in already_assigned_nums
|
||||||
|
]
|
||||||
|
logging.info("%d unassigned issues to process", len(issues))
|
||||||
|
|
||||||
|
# Sort by priority
|
||||||
|
issues_with_priority = [(compute_priority(i), i) for i in issues]
|
||||||
|
issues_with_priority.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
issues = [i for _, i in issues_with_priority[:args.max_issues]]
|
||||||
|
|
||||||
|
# Assign issues
|
||||||
|
assignments = []
|
||||||
|
agent_exclusions: Dict[str, List[str]] = {} # repo -> list of assigned agents per run
|
||||||
|
global_exclusions: List[str] = [] # agents already at capacity per run
|
||||||
|
max_per_agent_per_run = 5
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
role = classify_issue(issue)
|
||||||
|
priority = compute_priority(issue)
|
||||||
|
repo = issue.get("repository", {}).get("name", "")
|
||||||
|
|
||||||
|
# Avoid assigning same agent twice to same repo in one run
|
||||||
|
repo_excluded = agent_exclusions.get(repo, [])
|
||||||
|
|
||||||
|
# Also exclude agents already at assignment cap
|
||||||
|
cap_excluded = [
|
||||||
|
name for name, stats in state.get("agent_stats", {}).items()
|
||||||
|
if stats.get("total_assigned", 0) > max_per_agent_per_run
|
||||||
|
]
|
||||||
|
|
||||||
|
excluded = list(set(repo_excluded + global_exclusions + cap_excluded))
|
||||||
|
|
||||||
|
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=excluded)
|
||||||
|
if not agent:
|
||||||
|
# Relax exclusions if no agent found
|
||||||
|
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=[])
|
||||||
|
if not agent:
|
||||||
|
logging.warning("No suitable agent for issue #%d: %s (role=%s)",
|
||||||
|
issue.get("number"), issue.get("title", ""), role)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = dispatch_assignment(api, issue, agent, dry_run=args.dry_run)
|
||||||
|
assignments.append(result)
|
||||||
|
update_agent_stats(state, result)
|
||||||
|
|
||||||
|
# Track per-repo exclusions
|
||||||
|
if repo not in agent_exclusions:
|
||||||
|
agent_exclusions[repo] = []
|
||||||
|
agent_exclusions[repo].append(agent["name"])
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f" [DRY] #{issue['number']}: {issue.get('title','')[:60]} → @{agent['name']} ({role}, p={priority})")
|
||||||
|
else:
|
||||||
|
status_str = "OK" if result.get("success") else "FAIL"
|
||||||
|
print(f" [{status_str}] #{issue['number']}: {issue.get('title','')[:60]} → @{agent['name']} ({role}, p={priority})")
|
||||||
|
|
||||||
|
# Save state
|
||||||
|
state["assignments"].extend([{
|
||||||
|
"repo": a.get("repo"),
|
||||||
|
"issue_number": a.get("issue_number"),
|
||||||
|
"assignee": a.get("assignee"),
|
||||||
|
"success": a.get("success", False),
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
} for a in assignments])
|
||||||
|
state["last_run"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
save_workforce_state(state)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
ok = sum(1 for a in assignments if a.get("success"))
|
||||||
|
fail = len(assignments) - ok
|
||||||
|
logging.info("Done: %d assigned, %d succeeded, %d failed", len(assignments), ok, fail)
|
||||||
|
|
||||||
|
if not args.cron:
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f"Summary: {len(assignments)} assignments, {ok} OK, {fail} failed")
|
||||||
|
# Show agent stats
|
||||||
|
for name, stats in state.get("agent_stats", {}).items():
|
||||||
|
if stats.get("total_assigned", 0) > 0:
|
||||||
|
print(f" @{name}: {stats['successful']}/{stats['total_assigned']} ({stats.get('success_rate', 0):.0%} success)")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user