diff --git a/mimo-swarm/scripts/auto-merger.py b/mimo-swarm/scripts/auto-merger.py new file mode 100755 index 00000000..a484e36b --- /dev/null +++ b/mimo-swarm/scripts/auto-merger.py @@ -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() diff --git a/mimo-swarm/scripts/auto-reviewer.py b/mimo-swarm/scripts/auto-reviewer.py new file mode 100755 index 00000000..0d1045d4 --- /dev/null +++ b/mimo-swarm/scripts/auto-reviewer.py @@ -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() diff --git a/mimo-swarm/scripts/mimo-dispatcher.py b/mimo-swarm/scripts/mimo-dispatcher.py new file mode 100755 index 00000000..a3c98763 --- /dev/null +++ b/mimo-swarm/scripts/mimo-dispatcher.py @@ -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 .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) diff --git a/mimo-swarm/scripts/mimo-worker.sh b/mimo-swarm/scripts/mimo-worker.sh new file mode 100755 index 00000000..448cb5bb --- /dev/null +++ b/mimo-swarm/scripts/mimo-worker.sh @@ -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 +# The prompt file contains all instructions for the worker. + +set -euo pipefail + +PROMPT_FILE="${1:?Usage: mimo-worker.sh }" +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" <