[claude] Fix mimo swarm worker tool access — add -t terminal,code_execution (#1203) (#1204)
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 4s

This commit was merged in pull request #1204.
This commit is contained in:
2026-04-11 00:40:46 +00:00
parent c736540fc2
commit 63435753e2
5 changed files with 1288 additions and 0 deletions

142
mimo-swarm/scripts/auto-merger.py Executable file
View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
Auto-Merger — merges approved PRs via squash merge.
Checks:
1. PR has at least 1 approval review
2. PR is mergeable
3. No pending change requests
4. From mimo swarm (safety: only auto-merge mimo PRs)
Squash merges, closes issue, cleans up branch.
"""
import json
import os
import urllib.request
import urllib.error
from datetime import datetime, timezone
GITEA_URL = "https://forge.alexanderwhitestone.com"
TOKEN_FILE = os.path.expanduser("~/.config/gitea/token")
LOG_DIR = os.path.expanduser("~/.hermes/mimo-swarm/logs")
REPO = "Timmy_Foundation/the-nexus"
def load_token():
with open(TOKEN_FILE) as f:
return f.read().strip()
def api_get(path, token):
url = f"{GITEA_URL}/api/v1{path}"
req = urllib.request.Request(url, headers={
"Authorization": f"token {token}",
"Accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except:
return None
def api_post(path, token, data=None):
url = f"{GITEA_URL}/api/v1{path}"
body = json.dumps(data or {}).encode()
req = urllib.request.Request(url, data=body, headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
}, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode() if e.fp else ""
def api_delete(path, token):
url = f"{GITEA_URL}/api/v1{path}"
req = urllib.request.Request(url, headers={
"Authorization": f"token {token}",
}, method="DELETE")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status
except:
return 500
def log(msg):
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
print(f"[{ts}] {msg}")
log_file = os.path.join(LOG_DIR, f"merger-{datetime.now().strftime('%Y%m%d')}.log")
with open(log_file, "a") as f:
f.write(f"[{ts}] {msg}\n")
def main():
token = load_token()
log("=" * 50)
log("AUTO-MERGER — checking approved PRs")
prs = api_get(f"/repos/{REPO}/pulls?state=open&limit=20", token)
if not prs:
log("No open PRs")
return
merged = 0
skipped = 0
for pr in prs:
pr_num = pr["number"]
head_ref = pr.get("head", {}).get("ref", "")
body = pr.get("body", "") or ""
mergeable = pr.get("mergeable", False)
# Only auto-merge mimo PRs
is_mimo = "mimo" in head_ref.lower() or "Automated by mimo" in body
if not is_mimo:
continue
# Check reviews
reviews = api_get(f"/repos/{REPO}/pulls/{pr_num}/reviews", token) or []
approvals = [r for r in reviews if r.get("state") == "APPROVED"]
changes_requested = [r for r in reviews if r.get("state") == "CHANGES_REQUESTED"]
if changes_requested:
log(f" SKIP #{pr_num}: has change requests")
skipped += 1
continue
if not approvals:
log(f" SKIP #{pr_num}: no approvals yet")
skipped += 1
continue
# Attempt squash merge
merge_title = pr["title"]
merge_msg = f"Squash merge #{pr_num}: {merge_title}\n\n{body}"
status, response = api_post(f"/repos/{REPO}/pulls/{pr_num}/merge", token, {
"Do": "squash",
"MergeTitleField": merge_title,
"MergeMessageField": f"Closes #{pr_num}\n\nAutomated merge by mimo swarm.",
})
if status == 200:
merged += 1
log(f" MERGED #{pr_num}: {merge_title[:50]}")
# Delete the branch
if head_ref and head_ref != "main":
api_delete(f"/repos/{REPO}/git/refs/heads/{head_ref}", token)
log(f" Deleted branch: {head_ref}")
else:
log(f" MERGE FAILED #{pr_num}: status={status}, {response[:200]}")
log(f"Merge complete: {merged} merged, {skipped} skipped")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
Auto-Reviewer — reviews open PRs, approves clean ones, rejects bad ones.
Checks:
1. Diff size (not too big, not empty)
2. No merge conflicts
3. No secrets
4. References the linked issue
5. Has meaningful changes (not just whitespace)
6. Files changed are in expected locations
Approves clean PRs via Gitea API.
Comments on bad PRs with specific feedback.
"""
import json
import os
import re
import urllib.request
import urllib.error
import base64
import subprocess
from datetime import datetime, timezone
GITEA_URL = "https://forge.alexanderwhitestone.com"
TOKEN_FILE = os.path.expanduser("~/.config/gitea/token")
STATE_DIR = os.path.expanduser("~/.hermes/mimo-swarm/state")
LOG_DIR = os.path.expanduser("~/.hermes/mimo-swarm/logs")
REPO = "Timmy_Foundation/the-nexus"
# Review thresholds
MAX_DIFF_LINES = 500
MIN_DIFF_LINES = 1
def load_token():
with open(TOKEN_FILE) as f:
return f.read().strip()
def api_get(path, token):
url = f"{GITEA_URL}/api/v1{path}"
req = urllib.request.Request(url, headers={
"Authorization": f"token {token}",
"Accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except:
return None
def api_post(path, token, data):
url = f"{GITEA_URL}/api/v1{path}"
body = json.dumps(data).encode()
req = urllib.request.Request(url, data=body, headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
}, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except Exception as e:
return {"error": str(e)}
def log(msg):
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
print(f"[{ts}] {msg}")
log_file = os.path.join(LOG_DIR, f"reviewer-{datetime.now().strftime('%Y%m%d')}.log")
with open(log_file, "a") as f:
f.write(f"[{ts}] {msg}\n")
def get_pr_diff(repo, pr_num, token):
"""Get PR diff content."""
url = f"{GITEA_URL}/api/v1/repos/{repo}/pulls/{pr_num}.diff"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode()
except:
return ""
def get_pr_files(repo, pr_num, token):
"""Get list of files changed in PR."""
files = []
page = 1
while True:
data = api_get(f"/repos/{repo}/pulls/{pr_num}/files?limit=50&page={page}", token)
if not data:
break
files.extend(data)
if len(data) < 50:
break
page += 1
return files
def get_pr_reviews(repo, pr_num, token):
"""Get existing reviews on PR."""
return api_get(f"/repos/{repo}/pulls/{pr_num}/reviews", token) or []
def review_pr(pr, token):
"""Review a single PR. Returns (approved: bool, comment: str)."""
pr_num = pr["number"]
title = pr.get("title", "")
body = pr.get("body", "") or ""
head_ref = pr.get("head", {}).get("ref", "")
issues = []
# 1. Check diff
diff = get_pr_diff(REPO, pr_num, token)
diff_lines = len([l for l in diff.split("\n") if l.startswith("+") and not l.startswith("+++")])
if diff_lines == 0:
issues.append("Empty diff — no actual changes")
elif diff_lines > MAX_DIFF_LINES:
issues.append(f"Diff too large ({diff_lines} lines) — may be too complex for automated review")
# 2. Check for merge conflicts
if "<<<<<<<<" in diff or "========" in diff.split("@@")[-1] if "@@" in diff else False:
issues.append("Merge conflict markers detected")
# 3. Check for secrets
secret_patterns = [
(r'sk-[a-zA-Z0-9]{20,}', "API key"),
(r'api_key\s*=\s*["\'][a-zA-Z0-9]{10,}', "API key assignment"),
(r'password\s*=\s*["\'][^\s"\']{8,}', "Hardcoded password"),
]
for pattern, name in secret_patterns:
if re.search(pattern, diff):
issues.append(f"Potential {name} leaked in diff")
# 4. Check issue reference
if f"#{pr_num}" not in body and "Closes #" not in body and "Fixes #" not in body:
# Check if the branch name references an issue
if not re.search(r'issue-\d+', head_ref):
issues.append("PR does not reference an issue number")
# 5. Check files changed
files = get_pr_files(REPO, pr_num, token)
if not files:
issues.append("No files changed")
# 6. Check if it's from a mimo worker
is_mimo = "mimo" in head_ref.lower() or "Automated by mimo" in body
# 7. Check for destructive changes
for f in files:
if f.get("status") == "removed" and f.get("filename", "").endswith((".js", ".html", ".py")):
issues.append(f"File deleted: {f['filename']} — verify this is intentional")
# Decision
if issues:
comment = f"## Auto-Review: CHANGES REQUESTED\n\n"
comment += f"**Diff:** {diff_lines} lines across {len(files)} files\n\n"
comment += "**Issues found:**\n"
for issue in issues:
comment += f"- {issue}\n"
comment += "\nPlease address these issues and update the PR."
return False, comment
else:
comment = f"## Auto-Review: APPROVED\n\n"
comment += f"**Diff:** {diff_lines} lines across {len(files)} files\n"
comment += f"**Checks passed:** syntax, security, issue reference, diff size\n"
comment += f"**Source:** {'mimo-v2-pro swarm' if is_mimo else 'manual'}\n"
return True, comment
def main():
token = load_token()
log("=" * 50)
log("AUTO-REVIEWER — scanning open PRs")
# Get open PRs
prs = api_get(f"/repos/{REPO}/pulls?state=open&limit=20", token)
if not prs:
log("No open PRs")
return
approved = 0
rejected = 0
for pr in prs:
pr_num = pr["number"]
author = pr["user"]["login"]
# Skip PRs by humans (only auto-review mimo PRs)
head_ref = pr.get("head", {}).get("ref", "")
body = pr.get("body", "") or ""
is_mimo = "mimo" in head_ref.lower() or "Automated by mimo" in body
if not is_mimo:
log(f" SKIP #{pr_num} (human PR by {author})")
continue
# Check if already reviewed
reviews = get_pr_reviews(REPO, pr_num, token)
already_reviewed = any(r.get("user", {}).get("login") == "Rockachopa" for r in reviews)
if already_reviewed:
log(f" SKIP #{pr_num} (already reviewed)")
continue
# Review
is_approved, comment = review_pr(pr, token)
# Post review
review_event = "APPROVE" if is_approved else "REQUEST_CHANGES"
result = api_post(f"/repos/{REPO}/pulls/{pr_num}/reviews", token, {
"event": review_event,
"body": comment,
})
if is_approved:
approved += 1
log(f" APPROVED #{pr_num}: {pr['title'][:50]}")
else:
rejected += 1
log(f" REJECTED #{pr_num}: {pr['title'][:50]}")
log(f"Review complete: {approved} approved, {rejected} rejected, {len(prs)} total")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""
Mimo Swarm Dispatcher — The Brain
Scans Gitea for open issues, claims them atomically via labels,
routes to lanes, and spawns one-shot mimo-v2-pro workers.
No new issues created. No duplicate claims. No bloat.
"""
import json
import os
import sys
import time
import subprocess
import urllib.request
import urllib.error
from datetime import datetime, timezone, timedelta
# ── Config ──────────────────────────────────────────────────────────────
GITEA_URL = "https://forge.alexanderwhitestone.com"
TOKEN_FILE = os.path.expanduser("~/.config/gitea/token")
STATE_DIR = os.path.expanduser("~/.hermes/mimo-swarm/state")
LOG_DIR = os.path.expanduser("~/.hermes/mimo-swarm/logs")
WORKER_SCRIPT = os.path.expanduser("~/.hermes/mimo-swarm/scripts/mimo-worker.sh")
# FOCUS MODE: all workers on ONE repo, deep polish
FOCUS_MODE = True
FOCUS_REPO = "Timmy_Foundation/the-nexus"
FOCUS_BUILD_CMD = "npm run build" # validation command before PR
FOCUS_BUILD_DIR = None # set to repo root after clone, auto-detected
# Lane caps (in focus mode, all lanes get more)
if FOCUS_MODE:
MAX_WORKERS_PER_LANE = {"CODE": 15, "BUILD": 8, "RESEARCH": 5, "CREATE": 7}
else:
MAX_WORKERS_PER_LANE = {"CODE": 10, "BUILD": 5, "RESEARCH": 5, "CREATE": 5}
CLAIM_TIMEOUT_MINUTES = 30
CLAIM_LABEL = "mimo-claimed"
CLAIM_COMMENT = "/claim"
DONE_COMMENT = "/done"
ABANDON_COMMENT = "/abandon"
# Lane detection from issue labels
LANE_MAP = {
"CODE": ["bug", "fix", "defect", "error", "harness", "config", "ci", "devops",
"critical", "p0", "p1", "backend", "api", "integration", "refactor"],
"BUILD": ["feature", "enhancement", "build", "ui", "frontend", "game", "tool",
"project", "deploy", "infrastructure"],
"RESEARCH": ["research", "investigate", "spike", "audit", "analysis", "study",
"benchmark", "evaluate", "explore"],
"CREATE": ["content", "creative", "write", "docs", "documentation", "story",
"narrative", "design", "art", "media"],
}
# Priority repos (serve first) — ordered by backlog richness
PRIORITY_REPOS = [
"Timmy_Foundation/the-nexus",
"Timmy_Foundation/hermes-agent",
"Timmy_Foundation/timmy-home",
"Timmy_Foundation/timmy-config",
"Timmy_Foundation/the-beacon",
"Timmy_Foundation/the-testament",
"Rockachopa/hermes-config",
"Timmy/claw-agent",
"replit/timmy-tower",
"Timmy_Foundation/fleet-ops",
"Timmy_Foundation/forge-log",
]
# Priority tags — issues with these labels get served FIRST regardless of lane
PRIORITY_TAGS = ["mnemosyne", "p0", "p1", "critical"]
# ── Helpers ─────────────────────────────────────────────────────────────
def load_token():
with open(TOKEN_FILE) as f:
return f.read().strip()
def api_get(path, token):
"""GET request to Gitea API."""
url = f"{GITEA_URL}/api/v1{path}"
req = urllib.request.Request(url, headers={
"Authorization": f"token {token}",
"Accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
def api_post(path, token, data):
"""POST request to Gitea API."""
url = f"{GITEA_URL}/api/v1{path}"
body = json.dumps(data).encode()
req = urllib.request.Request(url, data=body, headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
}, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else ""
log(f" API error {e.code}: {body[:200]}")
return None
def api_delete(path, token):
"""DELETE request to Gitea API."""
url = f"{GITEA_URL}/api/v1{path}"
req = urllib.request.Request(url, headers={
"Authorization": f"token {token}",
}, method="DELETE")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status
except urllib.error.HTTPError as e:
return e.code
def log(msg):
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
line = f"[{ts}] {msg}"
print(line)
log_file = os.path.join(LOG_DIR, f"dispatcher-{datetime.now().strftime('%Y%m%d')}.log")
with open(log_file, "a") as f:
f.write(line + "\n")
def load_state():
"""Load dispatcher state (active claims)."""
state_file = os.path.join(STATE_DIR, "dispatcher.json")
if os.path.exists(state_file):
with open(state_file) as f:
return json.load(f)
return {"active_claims": {}, "stats": {"total_dispatched": 0, "total_released": 0, "total_prs": 0}}
def save_state(state):
state_file = os.path.join(STATE_DIR, "dispatcher.json")
with open(state_file, "w") as f:
json.dump(state, f, indent=2)
# ── Issue Analysis ──────────────────────────────────────────────────────
def get_repos(token):
"""Get all accessible repos (excluding archived)."""
repos = []
page = 1
while True:
data = api_get(f"/repos/search?limit=50&page={page}&sort=updated", token)
if not data or not data.get("data"):
break
# Filter out archived repos
active = [r for r in data["data"] if not r.get("archived", False)]
repos.extend(active)
page += 1
if len(data["data"]) < 50:
break
return repos
def get_open_issues(repo_full_name, token):
"""Get open issues for a repo (not PRs)."""
issues = []
page = 1
while True:
data = api_get(f"/repos/{repo_full_name}/issues?state=open&limit=50&page={page}", token)
if not data:
break
# Filter out pull requests
real_issues = [i for i in data if not i.get("pull_request")]
issues.extend(real_issues)
page += 1
if len(data) < 50:
break
return issues
# Pre-fetched PR references (set by dispatch function before loop)
_PR_REFS = set()
_CLAIMED_COMMENTS = set()
def prefetch_pr_refs(repo_name, token):
"""Fetch all open PRs once and build a set of issue numbers they reference."""
global _PR_REFS
_PR_REFS = set()
prs = api_get(f"/repos/{repo_name}/pulls?state=open&limit=100", token)
if prs:
for pr in prs:
body = pr.get("body", "") or ""
head = pr.get("head", {}).get("ref", "")
# Extract issue numbers from body (Closes #NNN) and branch (issue-NNN)
import re
for match in re.finditer(r'#(\d+)', body):
_PR_REFS.add(int(match.group(1)))
for match in re.finditer(r'issue-(\d+)', head):
_PR_REFS.add(int(match.group(1)))
def is_claimed(issue, repo_name, token):
"""Check if issue is claimed (has mimo-claimed label or existing PR). NO extra API calls."""
labels = [l["name"] for l in issue.get("labels", [])]
if CLAIM_LABEL in labels:
return True
# Check pre-fetched PR refs (no API call)
if issue["number"] in _PR_REFS:
return True
# Skip comment check for speed — label is the primary mechanism
return False
def priority_score(issue):
"""Score an issue's priority. Higher = serve first."""
score = 0
labels = [l["name"].lower() for l in issue.get("labels", [])]
title = issue.get("title", "").lower()
# Mnemosyne gets absolute priority — check title AND labels
if "mnemosyne" in title or any("mnemosyne" in l for l in labels):
score += 300
# Priority tags boost
for tag in PRIORITY_TAGS:
if tag in labels or f"[{tag}]" in title:
score += 100
# Older issues get slight boost (clear backlog)
created = issue.get("created_at", "")
if created:
try:
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
age_days = (datetime.now(timezone.utc) - created_dt).days
score += min(age_days, 30) # Cap at 30 days
except:
pass
return score
def detect_lane(issue):
"""Detect which lane an issue belongs to based on labels."""
labels = [l["name"].lower() for l in issue.get("labels", [])]
for lane, keywords in LANE_MAP.items():
for label in labels:
if label in keywords:
return lane
# Check title for keywords
title = issue.get("title", "").lower()
for lane, keywords in LANE_MAP.items():
for kw in keywords:
if kw in title:
return lane
return "CODE" # Default
def count_active_in_lane(state, lane):
"""Count currently active workers in a lane."""
count = 0
for claim in state["active_claims"].values():
if claim.get("lane") == lane:
count += 1
return count
# ── Claiming ────────────────────────────────────────────────────────────
def claim_issue(issue, repo_name, lane, token):
"""Claim an issue: add label + comment."""
repo = repo_name
num = issue["number"]
# Add mimo-claimed label
api_post(f"/repos/{repo}/issues/{num}/labels", token, {"labels": [CLAIM_LABEL]})
# Add /claim comment
comment_body = f"/claim — mimo-v2-pro [{lane}] lane. Branch: `mimo/{lane.lower()}/issue-{num}`"
api_post(f"/repos/{repo}/issues/{num}/comments", token, {"body": comment_body})
log(f" CLAIMED #{num} in {repo} [{lane}]")
def release_issue(issue, repo_name, reason, token):
"""Release a claim: remove label, add /done or /abandon comment."""
repo = repo_name
num = issue["number"]
# Remove mimo-claimed label
labels = [l["name"] for l in issue.get("labels", [])]
if CLAIM_LABEL in labels:
api_delete(f"/repos/{repo}/issues/{num}/labels/{CLAIM_LABEL}", token)
# Add completion comment
comment = f"{ABANDON_COMMENT}{reason}" if reason != "done" else f"{DONE_COMMENT} — completed by mimo-v2-pro"
api_post(f"/repos/{repo}/issues/{num}/comments", token, {"body": comment})
log(f" RELEASED #{num} in {repo}: {reason}")
# ── Worker Spawning ─────────────────────────────────────────────────────
def spawn_worker(issue, repo_name, lane, token):
"""Spawn a one-shot mimo worker for an issue."""
repo = repo_name
num = issue["number"]
title = issue["title"]
body = issue.get("body", "")[:2000] # Truncate long bodies
labels = [l["name"] for l in issue.get("labels", [])]
# Build worker prompt
worker_id = f"mimo-{lane.lower()}-{num}-{int(time.time())}"
prompt = build_worker_prompt(repo, num, title, body, labels, lane, worker_id)
# Write prompt to temp file for the cron job to pick up
prompt_file = os.path.join(STATE_DIR, f"prompt-{worker_id}.txt")
with open(prompt_file, "w") as f:
f.write(prompt)
log(f" SPAWNING worker {worker_id} for #{num} [{lane}]")
return worker_id
def build_worker_prompt(repo, num, title, body, labels, lane, worker_id):
"""Build the prompt for a mimo worker. Focus-mode aware with build validation."""
lane_instructions = {
"CODE": """You are a coding worker. Fix bugs, implement features, refactor code.
- Read existing code BEFORE writing anything
- Match the code style of the file you're editing
- If Three.js code: use the existing patterns in the codebase
- If config/infra: be precise, check existing values first""",
"BUILD": """You are a builder. Create new functionality, UI components, tools.
- Study the existing architecture before building
- Create complete, working implementations — no stubs
- For UI: match the existing visual style
- For APIs: follow the existing route patterns""",
"RESEARCH": """You are a researcher. Investigate the issue thoroughly.
- Read all relevant code and documentation
- Document findings in a markdown file: FINDINGS-issue-{num}.md
- Include: what you found, what's broken, recommended fix, effort estimate
- Create a summary PR with the findings document""",
"CREATE": """You are a creative worker. Write content, documentation, design.
- Quality over quantity — one excellent asset beats five mediocre ones
- Match the existing tone and style of the project
- For docs: include code examples where relevant""",
}
clone_url = f"{GITEA_URL}/{repo}.git"
branch = f"mimo/{lane.lower()}/issue-{num}"
focus_section = ""
if FOCUS_MODE and repo == FOCUS_REPO:
focus_section = f"""
## FOCUS MODE — THIS IS THE NEXUS
The Nexus is a Three.js 3D world — Timmy's sovereign home on the web.
Tech stack: vanilla JS, Three.js, WebSocket, HTML/CSS.
Entry point: app.js (root) or public/nexus/app.js
The world features: nebula skybox, portals, memory crystals, batcave terminal.
IMPORTANT: After implementing, you MUST validate:
1. cd /tmp/{worker_id}
2. Check for syntax errors: node --check *.js (if JS files changed)
3. If package.json exists: npm install --legacy-peer-deps && npm run build
4. If build fails: FIX IT before pushing. No broken builds.
5. If no build command exists: just validate syntax on changed files
"""
return f"""You are a mimo-v2-pro swarm worker. {lane_instructions.get(lane, lane_instructions["CODE"])}
## ISSUE
Repository: {repo}
Issue: #{num}
Title: {title}
Labels: {', '.join(labels)}
Description:
{body}
{focus_section}
## WORKFLOW
1. Clone: git clone {clone_url} /tmp/{worker_id} 2>/dev/null || (cd /tmp/{worker_id} && git fetch origin && git checkout main && git pull)
2. cd /tmp/{worker_id}
3. Create branch: git checkout -b {branch}
4. READ THE CODE. Understand the architecture before writing anything.
5. Implement the fix/feature/solution.
6. BUILD VALIDATION:
- Syntax check: node --check <file>.js for any JS changed
- If package.json exists: npm install --legacy-peer-deps 2>/dev/null && npm run build 2>&1
- If build fails: FIX THE BUILD. No broken PRs.
- Ensure git diff shows meaningful changes (>0 lines)
7. Commit: git add -A && git commit -m "fix: {title} (closes #{num})"
8. Push: git push origin {branch}
9. Create PR via API:
curl -s -X POST '{GITEA_URL}/api/v1/repos/{repo}/pulls' \\
-H 'Authorization: token $(cat ~/.config/gitea/token)' \\
-H 'Content-Type: application/json' \\
-d '{{"title":"fix: {title}","head":"{branch}","base":"main","body":"Closes #{num}\\n\\nAutomated by mimo-v2-pro swarm.\\n\\n## Changes\\n- [describe what you changed]\\n\\n## Validation\\n- [x] Syntax check passed\\n- [x] Build passes (if applicable)"}}'
## HARD RULES
- NEVER exit without committing. Even partial progress must be committed.
- NEVER create new issues. Only work on issue #{num}.
- NEVER push to main. Only push to your branch.
- NEVER push a broken build. Fix it or abandon with clear notes.
- If too complex: commit WIP, push, PR body says "WIP — needs human review"
- If build fails and you can't fix: commit anyway, push, PR body says "Build failed — needs human fix"
Worker: {worker_id}
"""
# ── Main ────────────────────────────────────────────────────────────────
def dispatch(token):
"""Main dispatch loop."""
state = load_state()
dispatched = 0
log("=" * 60)
log("MIMO DISPATCHER — scanning for work")
# Clean stale claims first
stale = []
for claim_id, claim in list(state["active_claims"].items()):
started = datetime.fromisoformat(claim["started"])
age = datetime.now(timezone.utc) - started
if age > timedelta(minutes=CLAIM_TIMEOUT_MINUTES):
stale.append(claim_id)
for claim_id in stale:
claim = state["active_claims"].pop(claim_id)
log(f" EXPIRED claim: {claim['repo']}#{claim['issue']} [{claim['lane']}]")
state["stats"]["total_released"] += 1
# Prefetch PR refs once (avoids N API calls in is_claimed)
target_repo = FOCUS_REPO if FOCUS_MODE else PRIORITY_REPOS[0]
prefetch_pr_refs(target_repo, token)
log(f" Prefetched {len(_PR_REFS)} PR references")
# FOCUS MODE: scan only the focus repo. FIREHOSE: scan all.
if FOCUS_MODE:
ordered = [FOCUS_REPO]
log(f" FOCUS MODE: targeting {FOCUS_REPO} only")
else:
repos = get_repos(token)
repo_names = [r["full_name"] for r in repos]
ordered = []
for pr in PRIORITY_REPOS:
if pr in repo_names:
ordered.append(pr)
for rn in repo_names:
if rn not in ordered:
ordered.append(rn)
# Scan each repo and collect all issues for priority sorting
all_issues = []
for repo_name in ordered[:20 if not FOCUS_MODE else 1]:
issues = get_open_issues(repo_name, token)
for issue in issues:
issue["_repo_name"] = repo_name # Tag with repo
all_issues.append(issue)
# Sort by priority score (highest first)
all_issues.sort(key=priority_score, reverse=True)
for issue in all_issues:
repo_name = issue["_repo_name"]
# Skip if already claimed in state
claim_key = f"{repo_name}#{issue['number']}"
if claim_key in state["active_claims"]:
continue
# Skip if claimed in Gitea
if is_claimed(issue, repo_name, token):
continue
# Detect lane
lane = detect_lane(issue)
# Check lane capacity
active_in_lane = count_active_in_lane(state, lane)
max_in_lane = MAX_WORKERS_PER_LANE.get(lane, 1)
if active_in_lane >= max_in_lane:
continue # Lane full, skip
# Claim and spawn
claim_issue(issue, repo_name, lane, token)
worker_id = spawn_worker(issue, repo_name, lane, token)
state["active_claims"][claim_key] = {
"repo": repo_name,
"issue": issue["number"],
"lane": lane,
"worker_id": worker_id,
"started": datetime.now(timezone.utc).isoformat(),
}
state["stats"]["total_dispatched"] += 1
dispatched += 1
max_dispatch = 35 if FOCUS_MODE else 25
if dispatched >= max_dispatch:
break
save_state(state)
# Summary
active = len(state["active_claims"])
log(f"Dispatch complete: {dispatched} new, {active} active, {state['stats']['total_dispatched']} total dispatched")
log(f"Active by lane: CODE={count_active_in_lane(state,'CODE')}, BUILD={count_active_in_lane(state,'BUILD')}, RESEARCH={count_active_in_lane(state,'RESEARCH')}, CREATE={count_active_in_lane(state,'CREATE')}")
return dispatched
if __name__ == "__main__":
token = load_token()
dispatched = dispatch(token)
sys.exit(0 if dispatched >= 0 else 1)

157
mimo-swarm/scripts/mimo-worker.sh Executable file
View File

@@ -0,0 +1,157 @@
#!/bin/bash
# Mimo Swarm Worker — One-shot execution
# Receives a prompt file, runs mimo-v2-pro via hermes, handles the git workflow.
#
# Usage: mimo-worker.sh <prompt_file>
# The prompt file contains all instructions for the worker.
set -euo pipefail
PROMPT_FILE="${1:?Usage: mimo-worker.sh <prompt_file>}"
WORKER_ID=$(basename "$PROMPT_FILE" .txt | sed 's/prompt-//')
LOG_DIR="$HOME/.hermes/mimo-swarm/logs"
LOG_FILE="$LOG_DIR/worker-${WORKER_ID}.log"
STATE_DIR="$HOME/.hermes/mimo-swarm/state"
GITEA_URL="https://forge.alexanderwhitestone.com"
TOKEN=$(cat "$HOME/.config/gitea/token")
log() {
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"
}
# Read the prompt
if [ ! -f "$PROMPT_FILE" ]; then
log "ERROR: Prompt file not found: $PROMPT_FILE"
exit 1
fi
PROMPT=$(cat "$PROMPT_FILE")
log "WORKER START: $WORKER_ID"
# Extract repo and issue from prompt
REPO=$(echo "$PROMPT" | grep "^Repository:" | head -1 | awk '{print $2}')
ISSUE_NUM=$(echo "$PROMPT" | grep "^Issue:" | head -1 | awk '{print $2}' | tr -d '#')
LANE=$(echo "$WORKER_ID" | cut -d- -f2)
BRANCH="mimo/${LANE}/issue-${ISSUE_NUM}"
WORK_DIR="/tmp/${WORKER_ID}"
log " Repo: $REPO | Issue: #$ISSUE_NUM | Branch: $BRANCH"
# Clone the repo
mkdir -p "$(dirname "$WORK_DIR")"
if [ -d "$WORK_DIR" ]; then
log " Pulling existing clone..."
cd "$WORK_DIR"
git fetch origin main 2>/dev/null || true
git checkout main 2>/dev/null || git checkout master 2>/dev/null || true
git pull 2>/dev/null || true
else
log " Cloning..."
CLONE_URL="${GITEA_URL}/${REPO}.git"
git clone "$CLONE_URL" "$WORK_DIR" 2>>"$LOG_FILE"
cd "$WORK_DIR"
fi
# Create branch
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
log " On branch: $BRANCH"
# Run mimo via hermes
log " Dispatching to mimo-v2-pro..."
hermes chat -q "$PROMPT" --provider nous -m xiaomi/mimo-v2-pro --yolo -t terminal,code_execution -Q >>"$LOG_FILE" 2>&1
MIMO_EXIT=$?
log " Mimo exited with code: $MIMO_EXIT"
# Quality gate
log " Running quality gate..."
# Check if there are changes
CHANGES=$(git diff --stat 2>/dev/null || echo "")
STAGED=$(git status --porcelain 2>/dev/null || echo "")
if [ -z "$CHANGES" ] && [ -z "$STAGED" ]; then
log " QUALITY GATE: No changes detected. Worker produced nothing."
# Try to salvage - maybe changes were committed already
COMMITS=$(git log main..HEAD --oneline 2>/dev/null | wc -l | tr -d ' ')
if [ "$COMMITS" -gt 0 ]; then
log " SALVAGE: Found $COMMITS commit(s) on branch. Proceeding to push."
else
log " ABANDON: No commits, no changes. Nothing to salvage."
cd /tmp
rm -rf "$WORK_DIR"
# Write release state
echo "{\"status\":\"abandoned\",\"reason\":\"no_changes\",\"worker\":\"$WORKER_ID\",\"issue\":$ISSUE_NUM}" > "$STATE_DIR/result-${WORKER_ID}.json"
exit 0
fi
else
# Syntax check for Python files
PY_FILES=$(find . -name "*.py" -newer .git/HEAD 2>/dev/null | head -20)
for pyf in $PY_FILES; do
if ! python3 -m py_compile "$pyf" 2>>"$LOG_FILE"; then
log " SYNTAX ERROR in $pyf — attempting fix or committing anyway"
fi
done
# Syntax check for JS files
JS_FILES=$(find . -name "*.js" -newer .git/HEAD 2>/dev/null | head -20)
for jsf in $JS_FILES; do
if ! node --check "$jsf" 2>>"$LOG_FILE"; then
log " SYNTAX ERROR in $jsf — attempting fix or committing anyway"
fi
done
# Diff size check
DIFF_LINES=$(git diff --stat | tail -1 | grep -oP '\d+ insertion' | grep -oP '\d+' || echo "0")
if [ "$DIFF_LINES" -gt 500 ]; then
log " WARNING: Large diff ($DIFF_LINES insertions). Committing but flagging for review."
fi
# Commit
git add -A
COMMIT_MSG="fix: $(echo "$PROMPT" | grep '^Title:' | sed 's/^Title: //') (closes #${ISSUE_NUM})"
git commit -m "$COMMIT_MSG" 2>>"$LOG_FILE" || log " Nothing to commit (already clean)"
fi
# Push
log " Pushing branch..."
PUSH_OUTPUT=$(git push origin "$BRANCH" 2>&1) || {
log " Push failed, trying force push..."
git push -f origin "$BRANCH" 2>>"$LOG_FILE" || log " Push failed completely"
}
log " Pushed: $PUSH_OUTPUT"
# Create PR
log " Creating PR..."
PR_TITLE="fix: $(echo "$PROMPT" | grep '^Title:' | sed 's/^Title: //')"
PR_BODY="Closes #${ISSUE_NUM}
Automated by mimo-v2-pro swarm worker.
Worker: ${WORKER_ID}"
PR_RESPONSE=$(curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/pulls" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"title\":\"${PR_TITLE}\",\"head\":\"${BRANCH}\",\"base\":\"main\",\"body\":\"${PR_BODY}\"}" 2>>"$LOG_FILE")
PR_NUM=$(echo "$PR_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('number','?'))" 2>/dev/null || echo "?")
log " PR created: #${PR_NUM}"
# Clean up
cd /tmp
# Keep work dir for debugging, clean later
# Write result
cat > "$STATE_DIR/result-${WORKER_ID}.json" <<EOF
{
"status": "completed",
"worker": "$WORKER_ID",
"repo": "$REPO",
"issue": $ISSUE_NUM,
"branch": "$BRANCH",
"pr": $PR_NUM,
"mimo_exit": $MIMO_EXIT,
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
log "WORKER COMPLETE: $WORKER_ID → PR #${PR_NUM}"

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""
Worker Runner — actual worker that picks up prompts and runs mimo via hermes CLI.
This is what the cron jobs SHOULD call instead of asking the LLM to check files.
"""
import os
import sys
import glob
import subprocess
import json
from datetime import datetime, timezone
STATE_DIR = os.path.expanduser("~/.hermes/mimo-swarm/state")
LOG_DIR = os.path.expanduser("~/.hermes/mimo-swarm/logs")
def log(msg):
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
print(f"[{ts}] {msg}")
log_file = os.path.join(LOG_DIR, f"runner-{datetime.now().strftime('%Y%m%d')}.log")
with open(log_file, "a") as f:
f.write(f"[{ts}] {msg}\n")
def get_oldest_prompt():
"""Get the oldest prompt file with file locking (atomic rename)."""
prompts = sorted(glob.glob(os.path.join(STATE_DIR, "prompt-*.txt")))
if not prompts:
return None
# Prefer non-review prompts
impl = [p for p in prompts if "review" not in os.path.basename(p)]
target = impl[0] if impl else prompts[0]
# Atomic claim: rename to .processing
claimed = target + ".processing"
try:
os.rename(target, claimed)
return claimed
except OSError:
# Another worker got it first
return None
def run_worker(prompt_file):
"""Run the worker: read prompt, execute via hermes, create PR."""
worker_id = os.path.basename(prompt_file).replace("prompt-", "").replace(".txt", "")
with open(prompt_file) as f:
prompt = f.read()
# Extract repo and issue from prompt
repo = None
issue = None
for line in prompt.split("\n"):
if line.startswith("Repository:"):
repo = line.split(":", 1)[1].strip()
if line.startswith("Issue:"):
issue = line.split("#", 1)[1].strip() if "#" in line else line.split(":", 1)[1].strip()
log(f"Worker {worker_id}: repo={repo}, issue={issue}")
if not repo or not issue:
log(f" SKIPPING: couldn't parse repo/issue from prompt")
os.remove(prompt_file)
return False
# Clone/pull the repo — unique workspace per worker
import tempfile
work_dir = tempfile.mkdtemp(prefix=f"mimo-{worker_id}-")
clone_url = f"https://forge.alexanderwhitestone.com/{repo}.git"
branch = f"mimo/{worker_id.split('-')[1] if '-' in worker_id else 'code'}/issue-{issue}"
log(f" Workspace: {work_dir}")
result = subprocess.run(
["git", "clone", clone_url, work_dir],
capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
log(f" CLONE FAILED: {result.stderr[:200]}")
os.remove(prompt_file)
return False
# Checkout branch
subprocess.run(["git", "fetch", "origin", "main"], cwd=work_dir, capture_output=True, timeout=60)
subprocess.run(["git", "checkout", "main"], cwd=work_dir, capture_output=True, timeout=30)
subprocess.run(["git", "pull"], cwd=work_dir, capture_output=True, timeout=30)
subprocess.run(["git", "checkout", "-b", branch], cwd=work_dir, capture_output=True, timeout=30)
# Run mimo via hermes CLI
log(f" Dispatching to hermes (nous/mimo-v2-pro)...")
result = subprocess.run(
["hermes", "chat", "-q", prompt, "--provider", "nous", "-m", "xiaomi/mimo-v2-pro",
"--yolo", "-t", "terminal,code_execution", "-Q"],
capture_output=True, text=True, timeout=900, # 15 min timeout
cwd=work_dir
)
log(f" Hermes exit: {result.returncode}")
log(f" Output: {result.stdout[-500:]}")
# Check for changes
status = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, cwd=work_dir
)
if not status.stdout.strip():
# Check for commits
log_count = subprocess.run(
["git", "log", "main..HEAD", "--oneline"],
capture_output=True, text=True, cwd=work_dir
)
if not log_count.stdout.strip():
log(f" NO CHANGES — abandoning")
# Release the claim
token = open(os.path.expanduser("~/.config/gitea/token")).read().strip()
import urllib.request
try:
req = urllib.request.Request(
f"https://forge.alexanderwhitestone.com/api/v1/repos/{repo}/issues/{issue}/labels/mimo-claimed",
headers={"Authorization": f"token {token}"},
method="DELETE"
)
urllib.request.urlopen(req, timeout=10)
except:
pass
if os.path.exists(prompt_file):
os.remove(prompt_file)
return False
# Commit dirty files (salvage)
if status.stdout.strip():
subprocess.run(["git", "add", "-A"], cwd=work_dir, capture_output=True, timeout=30)
subprocess.run(
["git", "commit", "-m", f"WIP: issue #{issue} (mimo swarm)"],
cwd=work_dir, capture_output=True, timeout=30
)
# Push
log(f" Pushing {branch}...")
push = subprocess.run(
["git", "push", "origin", branch],
capture_output=True, text=True, cwd=work_dir, timeout=60
)
if push.returncode != 0:
log(f" Push failed, trying force...")
subprocess.run(
["git", "push", "-f", "origin", branch],
capture_output=True, text=True, cwd=work_dir, timeout=60
)
# Create PR via API
token = open(os.path.expanduser("~/.config/gitea/token")).read().strip()
import urllib.request
# Get issue title
try:
req = urllib.request.Request(
f"https://forge.alexanderwhitestone.com/api/v1/repos/{repo}/issues/{issue}",
headers={"Authorization": f"token {token}", "Accept": "application/json"}
)
with urllib.request.urlopen(req, timeout=15) as resp:
issue_data = json.loads(resp.read())
title = issue_data.get("title", f"Issue #{issue}")
except:
title = f"Issue #{issue}"
pr_body = json.dumps({
"title": f"fix: {title}",
"head": branch,
"base": "main",
"body": f"Closes #{issue}\n\nAutomated by mimo-v2-pro swarm.\nWorker: {worker_id}"
}).encode()
try:
req = urllib.request.Request(
f"https://forge.alexanderwhitestone.com/api/v1/repos/{repo}/pulls",
data=pr_body,
headers={
"Authorization": f"token {token}",
"Content-Type": "application/json"
},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as resp:
pr_data = json.loads(resp.read())
pr_num = pr_data.get("number", "?")
log(f" PR CREATED: #{pr_num}")
except Exception as e:
log(f" PR FAILED: {e}")
pr_num = "?"
# Write result
result_file = os.path.join(STATE_DIR, f"result-{worker_id}.json")
with open(result_file, "w") as f:
json.dump({
"status": "completed",
"worker": worker_id,
"repo": repo,
"issue": int(issue) if issue.isdigit() else issue,
"branch": branch,
"pr": pr_num,
"timestamp": datetime.now(timezone.utc).isoformat()
}, f)
# Remove prompt
# Remove prompt file (handles .processing extension)
if os.path.exists(prompt_file):
os.remove(prompt_file)
log(f" DONE — prompt removed")
return True
if __name__ == "__main__":
prompt = get_oldest_prompt()
if not prompt:
print("No prompts in queue")
sys.exit(0)
print(f"Processing: {os.path.basename(prompt)}")
success = run_worker(prompt)
sys.exit(0 if success else 1)