| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """ |
| Run a WorkBlockSpec JSON file through the SpecExecutor. |
| |
| Usage: |
| python3 run_spec.py <spec.json> [--dry-run] [--workspace PATH] |
| |
| Options: |
| --dry-run Print the resolved spec (with env vars substituted) and exit. |
| --workspace Override workspace root (default: /home/josh/Faux_Clawdbot). |
| |
| Exit codes: |
| 0 All steps passed, status = completed |
| 1 Spec aborted or one or more steps failed |
| 2 Spec file not found, invalid JSON, or schema validation failed |
| """ |
|
|
| import json |
| import os |
| import re |
| import sys |
| from pathlib import Path |
|
|
| try: |
| from dotenv import load_dotenv |
| load_dotenv(Path(__file__).parent / ".env") |
| except ImportError: |
| pass |
|
|
| |
|
|
| def _token_from_git_credentials(host: str = "github.com") -> str | None: |
| """Extract a token from ~/.git-credentials for the given host.""" |
| creds_path = Path("~/.git-credentials").expanduser() |
| if not creds_path.exists(): |
| return None |
| creds = creds_path.read_text() |
| pattern = rf"https://[^:]+:([^@]+)@{re.escape(host)}" |
| m = re.search(pattern, creds) |
| return m.group(1) if m else None |
|
|
|
|
| def _inject_credentials() -> None: |
| """Ensure GITHUB_TOKEN is in the environment, pulling from git-credentials if needed.""" |
| if os.getenv("GITHUB_TOKEN"): |
| return |
| token = _token_from_git_credentials("github.com") |
| if token: |
| os.environ["GITHUB_TOKEN"] = token |
| else: |
| print("WARNING: GITHUB_TOKEN not set and not found in ~/.git-credentials", file=sys.stderr) |
|
|
|
|
| |
|
|
| def _substitute_env(spec: dict) -> dict: |
| """Walk all shell_execute command strings and substitute ${VAR} patterns.""" |
| spec = json.loads(json.dumps(spec)) |
| for step in spec.get("steps", []): |
| if step.get("tool") == "shell_execute": |
| cmd = step.get("params", {}).get("command", "") |
| def _replace(m): |
| val = os.environ.get(m.group(1), "") |
| if not val: |
| print(f"WARNING: ${{{m.group(1)}}} is unset in environment", file=sys.stderr) |
| return val |
| step["params"]["command"] = re.sub(r"\$\{([^}]+)\}", _replace, cmd) |
| return spec |
|
|
|
|
| |
|
|
| def _notify_discord(report: dict, evaluation: dict | None = None) -> None: |
| """Post spec completion summary + QB evaluation to Discord webhook. Silent on failure.""" |
| try: |
| import requests as _requests |
| except ImportError: |
| return |
| webhook_url = os.environ.get("QB_DISCORD_WEBHOOK", "").strip() |
| if not webhook_url: |
| return |
| try: |
| status = report.get("status", "unknown") |
| summary = report.get("summary", {}) |
| block_id = report.get("block_id", "unknown") |
| passed = summary.get("passed", 0) |
| failed = summary.get("failed", 0) |
| elapsed = summary.get("elapsed_seconds", 0) |
| abort_reason = report.get("abort_reason", "") |
| status_emoji = {"completed": "β
", "aborted": "π¨", "rejected": "β"}.get(status, "β οΈ") |
| lines = [ |
| f"{status_emoji} **Spec {status.upper()}** β `{block_id}`", |
| f"Steps: {passed} passed, {failed} failed | Elapsed: {elapsed:.1f}s", |
| ] |
| if abort_reason: |
| lines.append(f"Abort: {abort_reason}") |
|
|
| if evaluation is not None: |
| decision = evaluation.get("decision", "unknown").upper() |
| decision_emoji = {"DONE": "β
", "ITERATE": "π", "ESCALATE": "β¬οΈ"}.get(decision, "β") |
| qualitative = evaluation.get("qualitative") or {} |
| qb_error = qualitative.get("error") |
| if qb_error: |
| lines.append(f"\n**QB Reviewer:** β οΈ Evaluation unavailable β {qb_error}") |
| else: |
| response_text = qualitative.get("response", "").strip() |
| preview = response_text[:300] + ("β¦" if len(response_text) > 300 else "") |
| lines.append(f"\n**QB Reviewer:** {decision_emoji} {decision}") |
| if preview: |
| lines.append(f"> {preview}") |
| hints = evaluation.get("iteration_hints", []) |
| if hints: |
| lines.append("**Hints:** " + " | ".join(hints[:3])) |
|
|
| payload = {"content": "\n".join(lines), "username": "Codemine"} |
| _requests.post(webhook_url, json=payload, timeout=5) |
| except Exception as exc: |
| import logging as _logging |
| _logging.getLogger("run_spec").warning("Discord webhook failed: %s", exc) |
|
|
|
|
| |
|
|
| def main() -> int: |
| args = sys.argv[1:] |
| if not args or args[0] in ("-h", "--help"): |
| print(__doc__) |
| return 0 |
|
|
| spec_path = None |
| dry_run = False |
| workspace_override = None |
|
|
| i = 0 |
| while i < len(args): |
| if args[i] == "--dry-run": |
| dry_run = True |
| elif args[i] == "--workspace" and i + 1 < len(args): |
| workspace_override = args[i + 1] |
| i += 1 |
| elif not args[i].startswith("--"): |
| spec_path = args[i] |
| i += 1 |
|
|
| if spec_path is None: |
| print("ERROR: no spec file given", file=sys.stderr) |
| return 2 |
|
|
| |
| try: |
| spec = json.loads(Path(spec_path).read_text()) |
| except FileNotFoundError: |
| print(f"ERROR: spec file not found: {spec_path}", file=sys.stderr) |
| return 2 |
| except json.JSONDecodeError as e: |
| print(f"ERROR: invalid JSON in spec: {e}", file=sys.stderr) |
| return 2 |
|
|
| |
| sys.path.insert(0, str(Path(__file__).parent)) |
| try: |
| from work_block_schema import validate_spec |
| ok, errors = validate_spec(spec) |
| if not ok: |
| print("ERROR: spec failed schema validation:", file=sys.stderr) |
| for err in errors: |
| print(f" - {err}", file=sys.stderr) |
| return 2 |
| except ImportError: |
| print("WARNING: work_block_schema not found β skipping validation", file=sys.stderr) |
|
|
| |
| _inject_credentials() |
| spec = _substitute_env(spec) |
|
|
| if dry_run: |
| print(json.dumps(spec, indent=2)) |
| return 0 |
|
|
| |
| workspace = Path(workspace_override) if workspace_override else Path(__file__).parent |
|
|
| from spec_executor import SpecExecutor |
| from worker_ng import get_worker_ng |
| executor = SpecExecutor( |
| tool_registry={}, |
| policy_check_fn=lambda *a, **kw: (True, "permitted by run_spec"), |
| worker_ng=get_worker_ng(), |
| workspace=workspace, |
| ) |
|
|
| |
| report = executor.execute_block(spec) |
| print(json.dumps(report, indent=2)) |
|
|
| status = report.get("status", "unknown") |
| summary = report.get("summary", {}) |
| failed = summary.get("failed", 0) |
|
|
| |
| |
| |
| evaluation = None |
| if status in ("completed", "complete") and failed == 0: |
| try: |
| from report_evaluator import evaluate_report |
| print("\n--- QB Reviewer evaluating... ---", file=sys.stderr) |
| evaluation = evaluate_report(report, spec=spec, use_persona=True) |
| print(json.dumps(evaluation, indent=2)) |
| decision = evaluation.get("decision", "unknown").upper() |
| qb_response = (evaluation.get("qualitative") or {}).get("response", "") |
| print(f"\n--- QB decision: {decision} ---", file=sys.stderr) |
| if qb_response: |
| print(qb_response, file=sys.stderr) |
| except Exception as exc: |
| print(f"WARNING: QB Reviewer evaluation failed: {exc}", file=sys.stderr) |
|
|
| _notify_discord(report, evaluation=evaluation) |
|
|
| if status in ("completed", "complete") and failed == 0: |
| print(f"\nβ {report.get('block_id')} β {status} ({summary.get('passed', 0)} passed, {summary.get('elapsed_seconds', 0):.1f}s)", file=sys.stderr) |
| return 0 |
| else: |
| abort_reason = report.get("abort_reason", "") |
| print(f"\nβ {report.get('block_id')} β {status}: {abort_reason}", file=sys.stderr) |
| return 1 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|