diff --git a/docs/MCP_SETUP.md b/docs/MCP_SETUP.md index d060fc4..c0dbb79 100644 --- a/docs/MCP_SETUP.md +++ b/docs/MCP_SETUP.md @@ -22,7 +22,7 @@ References canoniques: ## Chemins supportes - `kicad`: `tools/hw/run_kicad_mcp.sh` -- `validate-specs`: `python3 tools/validate_specs.py --mcp` +- `validate-specs`: `tools/run_validate_specs_mcp.sh` - `knowledge-base`: `tools/run_knowledge_base_mcp.sh` - `github-dispatch`: `tools/run_github_dispatch_mcp.sh` - `freecad`: `tools/run_freecad_mcp.sh` @@ -43,7 +43,11 @@ Les micro-serveurs `kicad_kic_ai` de `mascarade` restent suivis comme surfaces a ## Prerequis - le repo compagnon `mascarade` existe en voisin (`../mascarade`) ou via `MASCARADE_DIR` -- Docker est disponible pour `kicad`, `freecad` et `openscad` +- les runtimes hote suivants sont auto-detectes si presents: + - KiCad app macOS avec `kicad-cli` + - FreeCAD app macOS avec `freecadcmd` + - OpenSCAD CLI local; si plusieurs binaires existent, le chemin CLI stable est prefere au snapshot `.app` +- Docker reste le fallback pour `kicad`, `freecad` et `openscad` - `node` est disponible pour le build/API compagnon - le venv `mascarade/core/.venv` existe, ou `MASCARADE_CORE_PYTHON` pointe vers un Python avec les dependances knowledge base (`httpx`) - `KNOWLEDGE_BASE_PROVIDER` selectionne `memos` ou `docmost` @@ -93,8 +97,8 @@ Le fichier versionne [mcp.json](../mcp.json) pointe vers les serveurs MCP reelle }, "validate-specs": { "type": "local", - "command": "python3", - "args": ["tools/validate_specs.py", "--mcp"], + "command": "bash", + "args": ["tools/run_validate_specs_mcp.sh"], "tools": ["*"] }, "knowledge-base": { @@ -181,6 +185,15 @@ Etat de validation courant: - `codex mcp list` contient bien `kicad`, `validate-specs`, `knowledge-base`, `github-dispatch`, `freecad`, `openscad`, `huggingface` et `playwright` - `Playwright MCP` est valide sur le Mac cible via `npx -y @playwright/mcp@latest --help` - seul reliquat Mac connu: le worktree `/Users/electron/mascarade` est dirty, donc aucun `git pull` n'a ete force sur ce clone +- execution cible sur le Mac operateur reel validee avec enregistrement effectif des serveurs dans Codex + +## Sources hote macOS retenues + +- KiCad: application locale macOS detectee dans `/Applications/KiCad/KiCad.app` +- FreeCAD: releases officielles `https://github.com/FreeCAD/FreeCAD/releases` +- OpenSCAD: + - snapshot app possible via `https://files.openscad.org/snapshots/OpenSCAD-2026.03.07.dmg` + - pour le runtime MCP local, le binaire CLI `OpenSCAD 2021.01` est prefere quand il est installe et repond correctement en non-interactif ## Usage @@ -188,7 +201,7 @@ Depuis `Kill_LIFE`: ```bash tools/hw/run_kicad_mcp.sh -python3 tools/validate_specs.py --mcp +tools/run_validate_specs_mcp.sh tools/run_knowledge_base_mcp.sh tools/run_github_dispatch_mcp.sh tools/run_freecad_mcp.sh diff --git a/docs/plans/18_plan_enchainement_autonome_des_lots_utiles.md b/docs/plans/18_plan_enchainement_autonome_des_lots_utiles.md new file mode 100644 index 0000000..9a15fed --- /dev/null +++ b/docs/plans/18_plan_enchainement_autonome_des_lots_utiles.md @@ -0,0 +1,77 @@ +# 18) Plan d'enchainement autonome des lots utiles + +Last updated: 2026-03-09 06:06:59 + +Ce plan est regenere localement par `tools/autonomous_next_lots.py`. + +## Objectif + +Detecter les deltas utiles a traiter, prioriser le prochain lot executable, +mettre a jour un plan/todo operateur, puis relancer les validations associees. + +## Regles de priorite + +1. lot dirty avec validations requises cassables +2. lot dirty avec validations advisory ou docs +3. repo clean mais en retard sur le remote +4. regime stable sans lot local detecte + +## Etat Git courant + +- branche: `## feat/mac-mcp-cad-host-bootstrap` +- dirty paths: `16` +- ahead: `0` +- behind: `0` + +### Fichiers dirty detectes + +- `docs/MCP_SETUP.md` +- `docs/plans/README.md` +- `mcp.json` +- `tools/bootstrap_mac_mcp.sh` +- `tools/hw/cad_stack.sh` +- `tools/hw/run_kicad_mcp.sh` +- `tools/lib/runtime_home.sh` +- `tools/mcp_smoke_common.py` +- `tools/run_github_dispatch_mcp.sh` +- `tools/run_knowledge_base_mcp.sh` +- `tools/validate_specs_mcp_smoke.py` +- `docs/plans/18_plan_enchainement_autonome_des_lots_utiles.md` +- `docs/plans/18_todo_enchainement_autonome_des_lots_utiles.md` +- `tools/autonomous_next_lots.py` +- `tools/run_autonomous_next_lots.sh` +- `tools/run_validate_specs_mcp.sh` + +## Lots detectes + +### 1. `mcp-runtime` — Alignement MCP runtime local + +Stabiliser les launchers MCP, le bootstrap Mac, la resolution du repo compagnon et la doc operateur associee. + +- references: `docs/plans/15_plan_mcp_runtime_alignment.md`, `docs/plans/17_plan_target_architecture_mcp_agentics_2028.md` +- validations: `3` done, `2` advisory, `0` blocked + +### 2. `cad-mcp-host` — Runtime CAD host-first + +Qualifier KiCad, FreeCAD et OpenSCAD en host-first sur macOS tout en gardant le fallback conteneur operable. + +- references: `docs/plans/16_plan_cad_modeling_stack.md`, `docs/plans/17_plan_target_architecture_mcp_agentics_2028.md` +- validations: `4` done, `0` advisory, `0` blocked + +### 3. `python-local` — Execution Python repo-locale + +Garder les scripts et smokes sur l'interpreteur repo-local plutot que sur le Python systeme. + +- references: `docs/plans/15_plan_mcp_runtime_alignment.md` +- validations: `1` done, `0` advisory, `0` blocked + +## Questions a poser seulement si besoin reel + +- Aucune question bloquante detectee sur ce cycle. + +## Commandes operateur + +- `bash tools/run_autonomous_next_lots.sh status` +- `bash tools/run_autonomous_next_lots.sh run` +- `bash tools/run_autonomous_next_lots.sh json` + diff --git a/docs/plans/18_todo_enchainement_autonome_des_lots_utiles.md b/docs/plans/18_todo_enchainement_autonome_des_lots_utiles.md new file mode 100644 index 0000000..1a5bf54 --- /dev/null +++ b/docs/plans/18_todo_enchainement_autonome_des_lots_utiles.md @@ -0,0 +1,38 @@ +# 18) TODO enchainement autonome des lots utiles + +Last updated: 2026-03-09 06:06:59 + +Ce fichier est regenere localement par `tools/autonomous_next_lots.py`. + +## `mcp-runtime` — Alignement MCP runtime local + +- done: lot detecte (Stabiliser les launchers MCP, le bootstrap Mac, la resolution du repo compagnon et la doc operateur associee.) +- done: `bash tools/bootstrap_mac_mcp.sh codex` + resume: codex mcp add openscad --env MASCARADE_DIR=/Users/electron/mascarade-main -- bash /Users/electron/Kill_LIFE/tools/run_openscad_mcp.sh | codex mcp add huggingface --url https://huggingface.co/mcp --bearer-token-env-var HUGGINGFACE_API_KEY | codex mcp add playwright -- npx -y @playwright/mcp@latest +- done: `bash tools/bootstrap_mac_mcp.sh json` + resume: } | } | } +- done: `.venv/bin/python tools/validate_specs_mcp_smoke.py --json --quick` + resume: {"status": "ready", "protocol_version": "2025-03-26", "server_name": "validate-specs", "tool_count": 2, "checks": ["initialize", "tools/list"], "error": null} +- advisory: `.venv/bin/python tools/knowledge_base_mcp_smoke.py --json --quick` + resume: {"status": "degraded", "protocol_version": "2025-03-26", "server_name": "knowledge-base", "provider": "memos", "tool_count": 4, "checks": ["initialize", "tools/list"], "secret_configured": false, "live_validation": "missing_secret", "error": "memos auth missing"} +- advisory: `.venv/bin/python tools/github_dispatch_mcp_smoke.py --json --quick` + resume: {"status": "degraded", "protocol_version": "2025-03-26", "server_name": "github-dispatch", "tool_count": 3, "checks": ["initialize", "tools/list"], "token_configured": false, "live_requested": false, "live_validation": "missing_secret", "error": "GitHub dispatch auth missing"} + +## `cad-mcp-host` — Runtime CAD host-first + +- done: lot detecte (Qualifier KiCad, FreeCAD et OpenSCAD en host-first sur macOS tout en gardant le fallback conteneur operable.) +- done: `bash tools/hw/run_kicad_mcp.sh --doctor` + resume: KICAD_PYTHON_STDERR_LOG_LEVEL=WARNING | REQUESTED_RUNTIME=auto | SELECTED_RUNTIME=host +- done: `bash tools/hw/cad_stack.sh doctor` + resume: 1.0.2 | PlatformIO Core, version 6.1.19 | OpenSCAD version 2021.01 +- done: `.venv/bin/python tools/freecad_mcp_smoke.py --quick --json` + resume: {"status": "ready", "protocol_version": "2025-03-26", "server_name": "freecad", "tool_count": 4, "checks": ["initialize", "tools/list", "get_runtime_info"], "error": null} +- done: `.venv/bin/python tools/openscad_mcp_smoke.py --quick --json` + resume: {"status": "ready", "protocol_version": "2025-03-26", "server_name": "openscad", "tool_count": 4, "checks": ["initialize", "tools/list", "get_runtime_info"], "error": null} + +## `python-local` — Execution Python repo-locale + +- done: lot detecte (Garder les scripts et smokes sur l'interpreteur repo-local plutot que sur le Python systeme.) +- done: `bash tools/test_python.sh --venv-dir .venv --suite stable` + resume: ---------------------------------------------------------------------- | Ran 3 tests in 0.014s | OK + diff --git a/docs/plans/README.md b/docs/plans/README.md index 37fbbcf..e3d7215 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -25,6 +25,7 @@ Ces plans sont des **runbooks** : tu peux les suivre tels quels, ou les transfor 15. [Plan d'alignement MCP local](15_plan_mcp_runtime_alignment.md) 16. [Plan de stack CAD modelling locale](16_plan_cad_modeling_stack.md) 17. [Plan cible MCP + agentics + A2A](17_plan_target_architecture_mcp_agentics_2028.md) +18. [Plan d'enchainement autonome des lots utiles](18_plan_enchainement_autonome_des_lots_utiles.md) --- diff --git a/mcp.json b/mcp.json index eaa654d..1db5481 100644 --- a/mcp.json +++ b/mcp.json @@ -8,8 +8,8 @@ }, "validate-specs": { "type": "local", - "command": "python3", - "args": ["tools/validate_specs.py", "--mcp"], + "command": "bash", + "args": ["tools/run_validate_specs_mcp.sh"], "tools": ["*"] }, "knowledge-base": { diff --git a/tools/autonomous_next_lots.py b/tools/autonomous_next_lots.py new file mode 100755 index 0000000..698085e --- /dev/null +++ b/tools/autonomous_next_lots.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +"""Detect, validate, and document the next useful local lots.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PLAN_DOC = ROOT / "docs" / "plans" / "18_plan_enchainement_autonome_des_lots_utiles.md" +TODO_DOC = ROOT / "docs" / "plans" / "18_todo_enchainement_autonome_des_lots_utiles.md" + + +@dataclass(frozen=True) +class Validation: + name: str + cmd: tuple[str, ...] + required: bool = True + + +@dataclass(frozen=True) +class Lot: + key: str + title: str + description: str + priority: int + paths: tuple[str, ...] + plan_refs: tuple[str, ...] + validations: tuple[Validation, ...] + + +LOTS: tuple[Lot, ...] = ( + Lot( + key="mcp-runtime", + title="Alignement MCP runtime local", + description=( + "Stabiliser les launchers MCP, le bootstrap Mac, la resolution du repo " + "compagnon et la doc operateur associee." + ), + priority=10, + paths=( + "docs/MCP_SETUP.md", + "mcp.json", + "tools/bootstrap_mac_mcp.sh", + "tools/lib/runtime_home.sh", + "tools/mcp_smoke_common.py", + "tools/run_github_dispatch_mcp.sh", + "tools/run_knowledge_base_mcp.sh", + "tools/run_validate_specs_mcp.sh", + "tools/validate_specs_mcp_smoke.py", + ), + plan_refs=( + "docs/plans/15_plan_mcp_runtime_alignment.md", + "docs/plans/17_plan_target_architecture_mcp_agentics_2028.md", + ), + validations=( + Validation( + name="bootstrap_codex_dry_run", + cmd=("bash", "tools/bootstrap_mac_mcp.sh", "codex"), + ), + Validation( + name="bootstrap_json_dry_run", + cmd=("bash", "tools/bootstrap_mac_mcp.sh", "json"), + ), + Validation( + name="validate_specs_mcp_smoke", + cmd=(".venv/bin/python", "tools/validate_specs_mcp_smoke.py", "--json", "--quick"), + ), + Validation( + name="knowledge_base_mcp_smoke", + cmd=(".venv/bin/python", "tools/knowledge_base_mcp_smoke.py", "--json", "--quick"), + required=False, + ), + Validation( + name="github_dispatch_mcp_smoke", + cmd=(".venv/bin/python", "tools/github_dispatch_mcp_smoke.py", "--json", "--quick"), + required=False, + ), + ), + ), + Lot( + key="cad-mcp-host", + title="Runtime CAD host-first", + description=( + "Qualifier KiCad, FreeCAD et OpenSCAD en host-first sur macOS tout en " + "gardant le fallback conteneur operable." + ), + priority=20, + paths=( + "docs/MCP_SETUP.md", + "tools/hw/cad_stack.sh", + "tools/hw/run_kicad_mcp.sh", + "tools/run_freecad_mcp.sh", + "tools/run_openscad_mcp.sh", + ), + plan_refs=( + "docs/plans/16_plan_cad_modeling_stack.md", + "docs/plans/17_plan_target_architecture_mcp_agentics_2028.md", + ), + validations=( + Validation( + name="kicad_doctor", + cmd=("bash", "tools/hw/run_kicad_mcp.sh", "--doctor"), + ), + Validation( + name="cad_stack_doctor", + cmd=("bash", "tools/hw/cad_stack.sh", "doctor"), + ), + Validation( + name="freecad_mcp_smoke", + cmd=(".venv/bin/python", "tools/freecad_mcp_smoke.py", "--quick", "--json"), + ), + Validation( + name="openscad_mcp_smoke", + cmd=(".venv/bin/python", "tools/openscad_mcp_smoke.py", "--quick", "--json"), + ), + ), + ), + Lot( + key="python-local", + title="Execution Python repo-locale", + description=( + "Garder les scripts et smokes sur l'interpreteur repo-local plutot que " + "sur le Python systeme." + ), + priority=30, + paths=( + "tools/run_validate_specs_mcp.sh", + "tools/validate_specs_mcp_smoke.py", + "tools/test_python.sh", + ), + plan_refs=("docs/plans/15_plan_mcp_runtime_alignment.md",), + validations=( + Validation( + name="python_stable_suite", + cmd=("bash", "tools/test_python.sh", "--venv-dir", ".venv", "--suite", "stable"), + ), + ), + ), +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("status", "run", "json"), nargs="?", default="status") + parser.add_argument("--max-lots", type=int, default=0, help="Limit execution to the first N detected lots.") + parser.add_argument( + "--no-write", + action="store_true", + help="Do not rewrite plan/todo markdown files.", + ) + return parser.parse_args() + + +def now_label() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def run_cmd(cmd: tuple[str, ...]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + list(cmd), + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + +def git_status() -> tuple[str, list[str], int, int]: + proc = run_cmd(("git", "status", "--short", "--branch")) + if proc.returncode != 0: + raise SystemExit(proc.stderr.strip() or proc.stdout.strip() or "git status failed") + lines = proc.stdout.splitlines() + branch = lines[0] if lines else "## unknown" + ahead = 0 + behind = 0 + match = re.search(r"\[([^\]]+)\]", branch) + if match: + for chunk in match.group(1).split(","): + chunk = chunk.strip() + if chunk.startswith("ahead "): + ahead = int(chunk.split()[1]) + elif chunk.startswith("behind "): + behind = int(chunk.split()[1]) + dirty_paths: list[str] = [] + for line in lines[1:]: + if not line: + continue + payload = line[3:] + if " -> " in payload: + payload = payload.split(" -> ", 1)[1] + dirty_paths.append(payload.strip()) + return branch, dirty_paths, ahead, behind + + +def matches_path(candidate: str, tracked: str) -> bool: + return tracked == candidate or tracked.startswith(candidate + "/") + + +def detect_lots(dirty_paths: list[str]) -> list[Lot]: + matched: list[Lot] = [] + for lot in LOTS: + if any(matches_path(path, dirty) for path in lot.paths for dirty in dirty_paths): + matched.append(lot) + matched.sort(key=lambda item: item.priority) + return matched + + +def output_tail(text: str) -> str: + lines = [line.rstrip() for line in text.splitlines() if line.strip()] + if not lines: + return "" + return " | ".join(lines[-3:]) + + +def classify_blocker(output: str) -> str | None: + lower = output.lower() + if any(token in lower for token in ("auth", "token", "permission", "credential", "secret")): + return "Secrets/identifiants manquants pour une validation live." + if "docker" in lower: + return "Runtime Docker/CAD indisponible ou non accessible." + if "not found" in lower or "no such file" in lower: + return "Chemin ou outil attendu manquant." + return None + + +def execute_lot(lot: Lot) -> list[dict[str, object]]: + results: list[dict[str, object]] = [] + for validation in lot.validations: + proc = run_cmd(validation.cmd) + combined = "\n".join(part for part in (proc.stdout, proc.stderr) if part).strip() + if proc.returncode == 0: + status = "done" + elif validation.required: + status = "blocked" + else: + status = "advisory" + results.append( + { + "name": validation.name, + "cmd": list(validation.cmd), + "required": validation.required, + "status": status, + "returncode": proc.returncode, + "summary": output_tail(combined), + "blocker": classify_blocker(combined) if proc.returncode != 0 else None, + } + ) + if status == "blocked": + break + return results + + +def render_plan( + branch: str, + dirty_paths: list[str], + ahead: int, + behind: int, + lots: list[Lot], + results: dict[str, list[dict[str, object]]], +) -> str: + lines = [ + "# 18) Plan d'enchainement autonome des lots utiles", + "", + f"Last updated: {now_label()}", + "", + "Ce plan est regenere localement par `tools/autonomous_next_lots.py`.", + "", + "## Objectif", + "", + "Detecter les deltas utiles a traiter, prioriser le prochain lot executable,", + "mettre a jour un plan/todo operateur, puis relancer les validations associees.", + "", + "## Regles de priorite", + "", + "1. lot dirty avec validations requises cassables", + "2. lot dirty avec validations advisory ou docs", + "3. repo clean mais en retard sur le remote", + "4. regime stable sans lot local detecte", + "", + "## Etat Git courant", + "", + f"- branche: `{branch}`", + f"- dirty paths: `{len(dirty_paths)}`", + f"- ahead: `{ahead}`", + f"- behind: `{behind}`", + "", + ] + if dirty_paths: + lines.append("### Fichiers dirty detectes") + lines.append("") + for path in dirty_paths: + lines.append(f"- `{path}`") + lines.append("") + + lines.append("## Lots detectes") + lines.append("") + if not lots: + lines.extend( + [ + "- Aucun lot local utile detecte.", + "- Si le repo est clean et a jour, le prochain lot utile devient un chantier decide par l'operateur.", + "", + ] + ) + else: + for index, lot in enumerate(lots, start=1): + lines.append(f"### {index}. `{lot.key}` — {lot.title}") + lines.append("") + lines.append(lot.description) + lines.append("") + refs = ", ".join(f"`{ref}`" for ref in lot.plan_refs) + lines.append(f"- references: {refs}") + lot_results = results.get(lot.key, []) + if lot_results: + done = sum(1 for item in lot_results if item["status"] == "done") + advisory = sum(1 for item in lot_results if item["status"] == "advisory") + blocked = sum(1 for item in lot_results if item["status"] == "blocked") + lines.append( + f"- validations: `{done}` done, `{advisory}` advisory, `{blocked}` blocked" + ) + else: + lines.append("- validations: non executees sur ce cycle") + lines.append("") + + blockers = sorted( + { + str(item["blocker"]) + for lot_items in results.values() + for item in lot_items + if item.get("status") == "blocked" and item.get("blocker") + } + ) + lines.append("## Questions a poser seulement si besoin reel") + lines.append("") + if blockers: + for blocker in blockers: + lines.append(f"- {blocker}") + else: + lines.append("- Aucune question bloquante detectee sur ce cycle.") + lines.append("") + + lines.append("## Commandes operateur") + lines.append("") + lines.append("- `bash tools/run_autonomous_next_lots.sh status`") + lines.append("- `bash tools/run_autonomous_next_lots.sh run`") + lines.append("- `bash tools/run_autonomous_next_lots.sh json`") + lines.append("") + return "\n".join(lines) + + +def render_todo(lots: list[Lot], results: dict[str, list[dict[str, object]]]) -> str: + lines = [ + "# 18) TODO enchainement autonome des lots utiles", + "", + f"Last updated: {now_label()}", + "", + "Ce fichier est regenere localement par `tools/autonomous_next_lots.py`.", + "", + ] + if not lots: + lines.extend( + [ + "- done: aucun lot local utile detecte", + "- next: attendre un nouveau delta ou choisir explicitement un chantier", + "", + ] + ) + return "\n".join(lines) + + for lot in lots: + lines.append(f"## `{lot.key}` — {lot.title}") + lines.append("") + lines.append(f"- done: lot detecte ({lot.description})") + lot_results = results.get(lot.key, []) + if not lot_results: + lines.append("- pending: validations non executees") + else: + for item in lot_results: + cmd = " ".join(item["cmd"]) + status = str(item["status"]) + summary = str(item["summary"] or "aucun detail") + lines.append(f"- {status}: `{cmd}`") + lines.append(f" resume: {summary}") + lines.append("") + return "\n".join(lines) + + +def write_docs(plan_text: str, todo_text: str) -> None: + PLAN_DOC.write_text(plan_text + "\n", encoding="utf-8") + TODO_DOC.write_text(todo_text + "\n", encoding="utf-8") + + +def main() -> int: + args = parse_args() + branch, dirty_paths, ahead, behind = git_status() + lots = detect_lots(dirty_paths) + if args.max_lots > 0: + lots = lots[: args.max_lots] + + results: dict[str, list[dict[str, object]]] = {} + if args.mode == "run": + for lot in lots: + results[lot.key] = execute_lot(lot) + + plan_text = render_plan(branch, dirty_paths, ahead, behind, lots, results) + todo_text = render_todo(lots, results) + should_write = not args.no_write and args.mode != "json" + if should_write: + write_docs(plan_text, todo_text) + + payload = { + "branch": branch, + "dirty_paths": dirty_paths, + "ahead": ahead, + "behind": behind, + "lots": [ + { + "key": lot.key, + "title": lot.title, + "priority": lot.priority, + "plan_refs": list(lot.plan_refs), + "results": results.get(lot.key, []), + } + for lot in lots + ], + "plan_doc": str(PLAN_DOC.relative_to(ROOT)), + "todo_doc": str(TODO_DOC.relative_to(ROOT)), + } + + if args.mode == "json": + print(json.dumps(payload, ensure_ascii=True, indent=2)) + return 0 + + print(f"branch: {branch}") + print(f"dirty_paths: {len(dirty_paths)}") + print(f"lots: {', '.join(lot.key for lot in lots) if lots else 'none'}") + print(f"plan_doc: {payload['plan_doc']}") + print(f"todo_doc: {payload['todo_doc']}") + if args.mode == "run": + for lot in lots: + lot_results = results.get(lot.key, []) + done = sum(1 for item in lot_results if item["status"] == "done") + advisory = sum(1 for item in lot_results if item["status"] == "advisory") + blocked = sum(1 for item in lot_results if item["status"] == "blocked") + print(f"{lot.key}: done={done} advisory={advisory} blocked={blocked}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bootstrap_mac_mcp.sh b/tools/bootstrap_mac_mcp.sh index 30cec16..58d2aa5 100755 --- a/tools/bootstrap_mac_mcp.sh +++ b/tools/bootstrap_mac_mcp.sh @@ -5,11 +5,12 @@ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" MODE="${1:-codex}" KILL_LIFE_DIR="$REPO_DIR" -MASCARADE_DIR="${MASCARADE_DIR:-$(cd "$REPO_DIR/../mascarade" 2>/dev/null && pwd || true)}" CODEX_BIN="${CODEX_BIN:-codex}" INCLUDE_PLAYWRIGHT=1 APPLY=0 +source "$REPO_DIR/tools/lib/runtime_home.sh" + usage() { cat <<'EOF' Usage: @@ -59,8 +60,8 @@ emit_json() { }, "validate-specs": { "type": "local", - "command": "python3", - "args": ["$KILL_LIFE_DIR/tools/validate_specs.py", "--mcp"], + "command": "bash", + "args": ["$KILL_LIFE_DIR/tools/run_validate_specs_mcp.sh"], "tools": ["*"] }, "knowledge-base": { @@ -161,7 +162,7 @@ codex_add() { emit_codex() { codex_add kicad --env "MASCARADE_DIR=$MASCARADE_DIR" -- bash "$KILL_LIFE_DIR/tools/hw/run_kicad_mcp.sh" - codex_add validate-specs -- python3 "$KILL_LIFE_DIR/tools/validate_specs.py" --mcp + codex_add validate-specs -- bash "$KILL_LIFE_DIR/tools/run_validate_specs_mcp.sh" codex_add knowledge-base --env "MASCARADE_DIR=$MASCARADE_DIR" -- bash "$KILL_LIFE_DIR/tools/run_knowledge_base_mcp.sh" codex_add github-dispatch --env "MASCARADE_DIR=$MASCARADE_DIR" -- bash "$KILL_LIFE_DIR/tools/run_github_dispatch_mcp.sh" codex_add freecad --env "MASCARADE_DIR=$MASCARADE_DIR" -- bash "$KILL_LIFE_DIR/tools/run_freecad_mcp.sh" @@ -204,6 +205,15 @@ while [[ $# -gt 0 ]]; do done [[ -n "$KILL_LIFE_DIR" ]] || { echo "Unable to resolve Kill_LIFE path" >&2; exit 1; } +if [[ -z "${MASCARADE_DIR:-}" ]]; then + MASCARADE_DIR="$( + kill_life_resolve_mascarade_dir \ + "$REPO_DIR" \ + "core/mascarade/integrations/knowledge_base.py" \ + "core/mascarade/integrations/github_dispatch.py" \ + "finetune/kicad_mcp_server" + )" +fi [[ -n "$MASCARADE_DIR" ]] || { echo "Unable to resolve mascarade companion path" >&2; exit 1; } case "$MODE" in diff --git a/tools/hw/cad_stack.sh b/tools/hw/cad_stack.sh index 0fe2100..0ce12b0 100755 --- a/tools/hw/cad_stack.sh +++ b/tools/hw/cad_stack.sh @@ -52,6 +52,54 @@ compose() { docker compose -f "$COMPOSE_FILE" "$@" } +resolve_host_kicad_cli() { + if [ -n "${KICAD_CLI:-}" ] && [ -x "${KICAD_CLI}" ]; then + printf '%s' "$KICAD_CLI" + return 0 + fi + if [ -x /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli ]; then + printf '%s' /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli + return 0 + fi + command -v kicad-cli 2>/dev/null || true +} + +resolve_host_freecad_cmd() { + if [ -n "${FREECAD_CMD:-}" ] && [ -x "${FREECAD_CMD}" ]; then + printf '%s' "$FREECAD_CMD" + return 0 + fi + if [ -x /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd ]; then + printf '%s' /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd + return 0 + fi + command -v freecadcmd 2>/dev/null || command -v FreeCADCmd 2>/dev/null || true +} + +resolve_host_openscad() { + local -a candidates=() + local candidate="" + + if [ -n "${OPENSCAD_BIN:-}" ] && [ -x "${OPENSCAD_BIN}" ]; then + candidates+=("${OPENSCAD_BIN}") + fi + if command -v openscad >/dev/null 2>&1; then + candidates+=("$(command -v openscad)") + fi + if [ -x /Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD ]; then + candidates+=(/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD) + fi + + for candidate in "${candidates[@]}"; do + if "$candidate" --version >/dev/null 2>&1; then + printf '%s' "$candidate" + return 0 + fi + done + + return 1 +} + ensure_service_up() { local service="$1" if ! compose ps --status running --services | grep -qx "$service"; then @@ -140,25 +188,51 @@ up_cmd() { } doctor_cmd() { - ensure_service_up kicad-headless - ensure_service_up freecad-headless - ensure_service_up openscad-headless + local host_kicad="" + local host_freecad="" + local host_openscad="" + + host_kicad="$(resolve_host_kicad_cli)" + host_freecad="$(resolve_host_freecad_cmd)" + host_openscad="$(resolve_host_openscad)" + + if [ -z "$host_kicad" ]; then + ensure_service_up kicad-headless + fi + if [ -z "$host_freecad" ]; then + ensure_service_up freecad-headless + fi + if [ -z "$host_openscad" ]; then + ensure_service_up openscad-headless + fi ensure_service_up platformio - run_shell_as_host_user \ - kicad-headless \ - /workspace/.cad-home/kicad-headless \ - 'mkdir -p "$HOME" && kicad-cli version' + if [ -n "$host_kicad" ]; then + "$host_kicad" version + else + run_shell_as_host_user \ + kicad-headless \ + /workspace/.cad-home/kicad-headless \ + 'mkdir -p "$HOME" && kicad-cli version' + fi - run_shell_in_service_user \ - freecad-headless \ - /workspace/.cad-home/freecad-headless \ - 'mkdir -p "$HOME" && freecadcmd -c "import FreeCAD; print(\".\".join(FreeCAD.Version()[:3]))"' + if [ -n "$host_freecad" ]; then + "$host_freecad" -c 'import FreeCAD; print(".".join(FreeCAD.Version()[:3]))' + else + run_shell_in_service_user \ + freecad-headless \ + /workspace/.cad-home/freecad-headless \ + 'mkdir -p "$HOME" && freecadcmd -c "import FreeCAD; print(\".\".join(FreeCAD.Version()[:3]))"' + fi - run_shell_as_host_user \ - openscad-headless \ - /workspace/.cad-home/openscad-headless \ - 'mkdir -p "$HOME" && openscad --version' + if [ -n "$host_openscad" ]; then + "$host_openscad" --version + else + run_shell_as_host_user \ + openscad-headless \ + /workspace/.cad-home/openscad-headless \ + 'mkdir -p "$HOME" && openscad --version' + fi run_shell_as_host_user \ platformio \ @@ -177,6 +251,12 @@ doctor_mcp_cmd() { } kicad_cli_cmd() { + local host_kicad="" + host_kicad="$(resolve_host_kicad_cli)" + if [ -n "$host_kicad" ]; then + "$host_kicad" "$@" + return + fi ensure_service_up kicad-headless run_tool_as_host_user \ kicad-headless \ @@ -187,6 +267,12 @@ kicad_cli_cmd() { } freecad_cmd() { + local host_freecad="" + host_freecad="$(resolve_host_freecad_cmd)" + if [ -n "$host_freecad" ]; then + "$host_freecad" "$@" + return + fi ensure_service_up freecad-headless run_tool_in_service_user \ freecad-headless \ @@ -197,6 +283,12 @@ freecad_cmd() { } openscad_cmd() { + local host_openscad="" + host_openscad="$(resolve_host_openscad)" + if [ -n "$host_openscad" ]; then + "$host_openscad" "$@" + return + fi ensure_service_up openscad-headless run_tool_as_host_user \ openscad-headless \ diff --git a/tools/hw/run_kicad_mcp.sh b/tools/hw/run_kicad_mcp.sh index d22c004..c956e5a 100755 --- a/tools/hw/run_kicad_mcp.sh +++ b/tools/hw/run_kicad_mcp.sh @@ -69,6 +69,7 @@ build_pythonpath() { value="${PYTHONPATH}" else candidates=( + /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages /usr/lib/kicad/lib/python3/dist-packages /usr/local/lib/kicad/lib/python3/dist-packages /usr/lib/python3/dist-packages @@ -96,6 +97,11 @@ resolve_probe_python() { return 0 fi + if [ -x /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3.9 ]; then + printf '%s' /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3.9 + return 0 + fi + if [ -x "$SERVER_DIR/venv/bin/python" ]; then printf '%s' "$SERVER_DIR/venv/bin/python" return 0 @@ -184,7 +190,11 @@ export KICAD_MCP_IMAGE="${KICAD_MCP_IMAGE:-kill_life_cad-kicad-mcp:latest}" export KICAD_MCP_LOG_LEVEL="${KICAD_MCP_LOG_LEVEL:-warn}" export KICAD_PYTHON_STDERR_LOG_LEVEL="${KICAD_PYTHON_STDERR_LOG_LEVEL:-WARNING}" export KICAD_PYTHON_FILE_LOG_LEVEL="${KICAD_PYTHON_FILE_LOG_LEVEL:-INFO}" -MASCARADE_DIR="${MASCARADE_DIR:-$REPO_PARENT/mascarade}" +MASCARADE_DIR="$( + kill_life_resolve_mascarade_dir \ + "$ROOT_DIR" \ + "finetune/kicad_mcp_server" +)" SERVER_DIR="$MASCARADE_DIR/finetune/kicad_mcp_server" ENTRYPOINT="${KICAD_MCP_ENTRYPOINT:-$SERVER_DIR/dist/index.js}" NODE_BIN="${NODE_BIN:-node}" diff --git a/tools/lib/runtime_home.sh b/tools/lib/runtime_home.sh index 0080656..6079575 100644 --- a/tools/lib/runtime_home.sh +++ b/tools/lib/runtime_home.sh @@ -41,3 +41,49 @@ kill_life_runtime_home_init() { kill_life_runtime_home_ensure() { mkdir -p "$RUNTIME_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" } + +kill_life_abs_path() { + local input="$1" + (cd "$input" >/dev/null 2>&1 && pwd) +} + +kill_life_resolve_mascarade_dir() { + local root_dir="$1" + shift || true + local -a required_paths=("$@") + local -a candidates=() + local candidate="" + local abs_candidate="" + local required="" + local ok=1 + + if [ -n "${MASCARADE_DIR:-}" ]; then + candidates+=("$MASCARADE_DIR") + fi + + candidates+=( + "$root_dir/../mascarade" + "$root_dir/../mascarade-main" + ) + + for candidate in "${candidates[@]}"; do + [ -n "$candidate" ] || continue + abs_candidate="$(kill_life_abs_path "$candidate" || true)" + [ -n "$abs_candidate" ] || continue + + ok=0 + for required in "${required_paths[@]}"; do + if [ -n "$required" ] && [ ! -e "$abs_candidate/$required" ]; then + ok=1 + break + fi + done + + if [ "$ok" -eq 0 ]; then + printf '%s' "$abs_candidate" + return 0 + fi + done + + return 1 +} diff --git a/tools/mcp_smoke_common.py b/tools/mcp_smoke_common.py index 2d27577..056c78b 100644 --- a/tools/mcp_smoke_common.py +++ b/tools/mcp_smoke_common.py @@ -15,9 +15,35 @@ from typing import Any PROTOCOL_VERSION = "2025-03-26" ENV_ASSIGN_RE = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$") -DEFAULT_MASCARADE_DIR = Path( - os.environ.get("MASCARADE_DIR", Path(__file__).resolve().parents[2] / "mascarade") -).resolve() +def resolve_default_mascarade_dir() -> Path: + env_value = os.environ.get("MASCARADE_DIR", "").strip() + candidates = [] + if env_value: + candidates.append(Path(env_value)) + + root = Path(__file__).resolve().parents[2] + candidates.extend([root / "mascarade", root / "mascarade-main"]) + + preferred_paths = ( + "core/mascarade/integrations/knowledge_base.py", + "core/mascarade/integrations/github_dispatch.py", + "finetune/kicad_mcp_server", + ) + + for candidate in candidates: + resolved = candidate.resolve() + if all((resolved / rel).exists() for rel in preferred_paths): + return resolved + + for candidate in candidates: + resolved = candidate.resolve() + if (resolved / "core/mascarade").exists(): + return resolved + + return (root / "mascarade").resolve() + + +DEFAULT_MASCARADE_DIR = resolve_default_mascarade_dir() class SmokeError(RuntimeError): diff --git a/tools/run_autonomous_next_lots.sh b/tools/run_autonomous_next_lots.sh new file mode 100755 index 0000000..46417f7 --- /dev/null +++ b/tools/run_autonomous_next_lots.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PYTHON_BIN="$ROOT_DIR/.venv/bin/python" + +if [[ ! -x "$PYTHON_BIN" ]]; then + PYTHON_BIN="python3" +fi + +exec "$PYTHON_BIN" "$ROOT_DIR/tools/autonomous_next_lots.py" "$@" diff --git a/tools/run_github_dispatch_mcp.sh b/tools/run_github_dispatch_mcp.sh index f33289c..7c8e280 100755 --- a/tools/run_github_dispatch_mcp.sh +++ b/tools/run_github_dispatch_mcp.sh @@ -2,11 +2,15 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -MASCARADE_DIR="${MASCARADE_DIR:-$ROOT_DIR/../mascarade}" -MASCARADE_ENV_FILE="${MASCARADE_ENV_FILE:-$MASCARADE_DIR/.env}" SERVER_SCRIPT="$ROOT_DIR/tools/github_dispatch_mcp.py" source "$ROOT_DIR/tools/lib/runtime_home.sh" kill_life_runtime_home_init "$ROOT_DIR" "github-dispatch-mcp" +MASCARADE_DIR="$( + kill_life_resolve_mascarade_dir \ + "$ROOT_DIR" \ + "core/mascarade/integrations/github_dispatch.py" +)" +MASCARADE_ENV_FILE="${MASCARADE_ENV_FILE:-$MASCARADE_DIR/.env}" load_mascarade_env() { [[ -r "$MASCARADE_ENV_FILE" ]] || return 0 @@ -62,5 +66,6 @@ fi } kill_life_runtime_home_ensure +export MASCARADE_DIR export PYTHONPATH="$MASCARADE_DIR/core${PYTHONPATH:+:$PYTHONPATH}" exec "$CORE_PYTHON" "$SERVER_SCRIPT" "$@" diff --git a/tools/run_knowledge_base_mcp.sh b/tools/run_knowledge_base_mcp.sh index 4a78205..7e50ff1 100755 --- a/tools/run_knowledge_base_mcp.sh +++ b/tools/run_knowledge_base_mcp.sh @@ -2,11 +2,15 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -MASCARADE_DIR="${MASCARADE_DIR:-$ROOT_DIR/../mascarade}" -MASCARADE_ENV_FILE="${MASCARADE_ENV_FILE:-$MASCARADE_DIR/.env}" SERVER_SCRIPT="$ROOT_DIR/tools/knowledge_base_mcp.py" source "$ROOT_DIR/tools/lib/runtime_home.sh" kill_life_runtime_home_init "$ROOT_DIR" "knowledge-base-mcp" +MASCARADE_DIR="$( + kill_life_resolve_mascarade_dir \ + "$ROOT_DIR" \ + "core/mascarade/integrations/knowledge_base.py" +)" +MASCARADE_ENV_FILE="${MASCARADE_ENV_FILE:-$MASCARADE_DIR/.env}" load_mascarade_env() { [[ -r "$MASCARADE_ENV_FILE" ]] || return 0 @@ -62,5 +66,6 @@ fi } kill_life_runtime_home_ensure +export MASCARADE_DIR export PYTHONPATH="$MASCARADE_DIR/core${PYTHONPATH:+:$PYTHONPATH}" exec "$CORE_PYTHON" "$SERVER_SCRIPT" "$@" diff --git a/tools/run_validate_specs_mcp.sh b/tools/run_validate_specs_mcp.sh new file mode 100755 index 0000000..a80ebf4 --- /dev/null +++ b/tools/run_validate_specs_mcp.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SERVER_SCRIPT="$ROOT_DIR/tools/validate_specs.py" +VENV_DIR="${KILL_LIFE_VENV_DIR:-$ROOT_DIR/.venv}" + +detect_python() { + local candidate="${KILL_LIFE_PYTHON:-}" + if [[ -n "$candidate" && -x "$candidate" ]]; then + printf '%s' "$candidate" + return 0 + fi + + candidate="$VENV_DIR/bin/python" + if [[ -x "$candidate" ]]; then + printf '%s' "$candidate" + return 0 + fi + + if command -v python3 >/dev/null 2>&1; then + if python3 -c 'import yaml' >/dev/null 2>&1; then + printf '%s' "$(command -v python3)" + return 0 + fi + fi + + return 1 +} + +PYTHON_BIN="$(detect_python || true)" + +if [[ "${1:-}" == "--doctor" ]]; then + cat <&2 + exit 1 +} + +if [[ "$#" -eq 0 ]]; then + exec "$PYTHON_BIN" "$SERVER_SCRIPT" --mcp +fi + +exec "$PYTHON_BIN" "$SERVER_SCRIPT" "$@" diff --git a/tools/validate_specs_mcp_smoke.py b/tools/validate_specs_mcp_smoke.py index 35fd2dc..441c03f 100755 --- a/tools/validate_specs_mcp_smoke.py +++ b/tools/validate_specs_mcp_smoke.py @@ -18,7 +18,7 @@ from mcp_smoke_common import ( ) ROOT = Path(__file__).resolve().parents[1] -SERVER = ROOT / "tools" / "validate_specs.py" +SERVER = ROOT / "tools" / "run_validate_specs_mcp.sh" def parse_args() -> argparse.Namespace: @@ -31,7 +31,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() - proc = spawn_server(["python3", str(SERVER), "--mcp"], ROOT) + proc = spawn_server(["bash", str(SERVER)], ROOT) payload = { "status": "failed", "protocol_version": None,