#!/usr/bin/env python3 """Automaton status and enforcement script. Manages task state, validates phase transitions, enforces approval gates, and provides enforcement hooks for agent tool integrations. Integration Contract for Harnesses ------------------------------------ Before allowing any file edit, a harness MUST call: python ~/.automaton/scripts/status.py --can-edit --project {project} [--file {path}] [--json] Exit code 0 = ALLOWED, exit code 1 = DENIED, exit code 2 = ERROR. Modes: 1. --can-edit --project {p} Is editing allowed on this project at all? Checks that at least one task is in implement or doc_review phase. 2. --can-edit --project {p} --file {path} Same as (1) but also checks that the file is within the project scope. 3. --can-edit --project {p} --task {t} Is this specific task in an edit-allowed phase? 4. --can-edit --project {p} --task {t} --file {path} Same as (3) but also checks file scope. Add --json for machine-readable output on the last line. Usage: python status.py --task {name} Show task status python status.py --list List all tasks python status.py --create-task {name} Create a new task python status.py --transition {phase} --task {n} Transition task phase python status.py --approve --task {name} Approve current phase python status.py --validate-folder --task {name} Validate task folder python status.py --audit Audit all tasks python status.py --claim --task {name} --agent {id} Claim task (multi-agent) python status.py --release --task {name} --agent {id} Release task (multi-agent) python status.py --next-available --agent {id} Find available work python status.py --available --agent {id} List available work python status.py --can-edit --project {p} [--task {name}] [--file {path}] Check if edits allowed (harness hook) python status.py --scope-check --task {name} --file {path} Check file scope """ from __future__ import annotations import argparse import json import os import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Optional AUTOMATON_DIR = Path.home() / ".automaton" VALID_PHASES = [ "new", "research", "research:awaiting_approval", "research:approved", "decomposition", "decomposition:awaiting_approval", "decomposition:approved", "design", "design:awaiting_approval", "design:approved", "test_design", "test_design:awaiting_approval", "test_design:approved", "implement", "code_review", "code_review:awaiting_approval", "code_review:approved", "bug_find", "adversarial_bug_find", "doc_review", "referee", "complete", "human_intervention", ] BASE_PHASES = [ "new", "research", "decomposition", "design", "test_design", "implement", "code_review", "bug_find", "adversarial_bug_find", "doc_review", "referee", "complete", "human_intervention", ] APPROVAL_PHASES = {"research", "decomposition", "design", "test_design", "code_review"} LEGAL_TRANSITIONS = { "new": ["research"], "research": ["research:awaiting_approval", "decomposition", "design", "implement"], "research:awaiting_approval": ["research:approved"], "research:approved": ["decomposition", "design", "implement"], "decomposition": ["decomposition:awaiting_approval"], "decomposition:awaiting_approval": ["decomposition:approved"], "decomposition:approved": ["complete"], "design": ["design:awaiting_approval", "test_design", "implement"], "design:awaiting_approval": ["design:approved"], "design:approved": ["test_design", "implement"], "test_design": ["test_design:awaiting_approval", "implement"], "test_design:awaiting_approval": ["test_design:approved"], "test_design:approved": ["implement"], "implement": ["code_review"], "code_review": ["code_review:awaiting_approval"], "code_review:awaiting_approval": ["code_review:approved"], "code_review:approved": ["bug_find"], "bug_find": ["adversarial_bug_find"], "adversarial_bug_find": ["doc_review"], "doc_review": ["referee"], "referee": ["complete", "human_intervention"], "human_intervention": ["referee", "complete"], } PHASE_REQUIRED_ARTIFACTS = { "research": "SPEC.md", "decomposition": "DECOMPOSITION.md", "design": "DESIGN.md", "test_design": "TEST_PLAN.md", "implement": "IMPLEMENTATION.md", "code_review": "CODE_REVIEW.md", "bug_find": "BUG_REPORT.md", "adversarial_bug_find": "ADVERSARIAL_BUG_REPORT.md", "doc_review": "DOC_REVIEW.md", "referee": "VERDICT.md", } FORBIDDEN_ARTIFACTS = { "new": ["SPEC.md", "DESIGN.md", "DECOMPOSITION.md", "TEST_PLAN.md", "IMPLEMENTATION.md", "CODE_REVIEW.md", "BUG_REPORT.md", "ADVERSARIAL_BUG_REPORT.md", "DOC_REVIEW.md", "VERDICT.md"], "research": ["DESIGN.md", "DECOMPOSITION.md", "TEST_PLAN.md", "IMPLEMENTATION.md", "CODE_REVIEW.md", "BUG_REPORT.md", "ADVERSARIAL_BUG_REPORT.md", "DOC_REVIEW.md", "VERDICT.md"], "decomposition": ["DESIGN.md", "TEST_PLAN.md", "IMPLEMENTATION.md", "CODE_REVIEW.md", "BUG_REPORT.md", "ADVERSARIAL_BUG_REPORT.md", "DOC_REVIEW.md", "VERDICT.md"], "design": ["DECOMPOSITION.md", "TEST_PLAN.md", "IMPLEMENTATION.md", "CODE_REVIEW.md", "BUG_REPORT.md", "ADVERSARIAL_BUG_REPORT.md", "DOC_REVIEW.md", "VERDICT.md"], "test_design": ["DECOMPOSITION.md", "IMPLEMENTATION.md", "CODE_REVIEW.md", "BUG_REPORT.md", "ADVERSARIAL_BUG_REPORT.md", "DOC_REVIEW.md", "VERDICT.md"], "implement": ["CODE_REVIEW.md", "BUG_REPORT.md", "ADVERSARIAL_BUG_REPORT.md", "DOC_REVIEW.md", "VERDICT.md"], "code_review": ["BUG_REPORT.md", "ADVERSARIAL_BUG_REPORT.md", "DOC_REVIEW.md", "VERDICT.md"], "bug_find": ["ADVERSARIAL_BUG_REPORT.md", "DOC_REVIEW.md", "VERDICT.md"], "adversarial_bug_find": ["DOC_REVIEW.md", "VERDICT.md"], "doc_review": ["VERDICT.md"], "referee": [], "complete": [], "human_intervention": [], } NON_ARTIFACT_FILES = {".state", ".state.tmp", ".state.lock", ".state.approvals", ".state.implementer", "VRAM_CONFIG.md", "PARENT_SPEC.md", "REVIEW.md"} PHASE_PRIORITY = { "referee": 12, "doc_review": 11, "adversarial_bug_find": 10, "bug_find": 9, "code_review": 8, "implement": 7, "test_design": 6, "design": 5, "decomposition": 4, "research": 3, "new": 2, } ALLOWED_ACTIONS_MAP = { "research": ["Read project files", "Ask clarifying questions", "Write SPEC.md"], "decomposition": ["Read SPEC.md", "Ask decomposition questions", "Write DECOMPOSITION.md", "Run VRAM detection"], "design": ["Read SPEC.md", "Ask design questions", "Write DESIGN.md"], "test_design": ["Read SPEC.md and DESIGN.md", "Ask test questions", "Write TEST_PLAN.md"], "implement": ["Edit code", "Write tests", "Create IMPLEMENTATION.md", "Run test suite"], "code_review": ["Read code", "Read SPEC.md", "Read DESIGN.md", "Read IMPLEMENTATION.md", "Write CODE_REVIEW.md"], "bug_find": ["Read code", "Read SPEC.md", "Read IMPLEMENTATION.md", "Write BUG_REPORT.md"], "adversarial_bug_find": ["Read code", "Read SPEC.md", "Read BUG_REPORT.md", "Write ADVERSARIAL_BUG_REPORT.md"], "doc_review": ["Read DESIGN.md", "Read code", "Read docs", "Write DOC_REVIEW.md", "Update documentation"], "referee": ["Read all artifacts", "Write VERDICT.md"], "new": ["Start research"], } FORBIDDEN_ACTIONS_MAP = { "research": ["Edit code", "Create IMPLEMENTATION.md", "Create DESIGN.md", "Create any artifact other than SPEC.md", "Skip to implementation"], "decomposition": ["Edit code", "Create IMPLEMENTATION.md", "Modify SPEC.md", "Create sub-task folders (Orchestrator does this)"], "design": ["Edit code", "Create IMPLEMENTATION.md", "Modify SPEC.md", "Skip to implementation"], "test_design": ["Edit code", "Write test implementations", "Create IMPLEMENTATION.md", "Modify SPEC.md or DESIGN.md"], "implement": ["Create new tasks", "Modify SPEC.md or DESIGN.md", "Transition to bug-find phase (Orchestrator does this)"], "code_review": ["Edit code", "Fix bugs or issues", "Modify SPEC.md", "Modify DESIGN.md", "Modify IMPLEMENTATION.md", "Create any artifact other than CODE_REVIEW.md"], "bug_find": ["Edit code", "Fix bugs (separate implementation task)", "Modify SPEC.md"], "adversarial_bug_find": ["Edit code", "Fix bugs", "Modify SPEC.md or BUG_REPORT.md"], "doc_review": ["Edit non-documentation code", "Modify SPEC.md", "Modify DESIGN.md"], "referee": ["Edit code", "Modify any artifact other than VERDICT.md"], "new": ["Edit code", "Create any artifact"], } NEXT_PHASE_MAP = { "new": "research", "research": "design or implement", "decomposition": "sub-task research", "design": "test_design or implement", "test_design": "implement", "implement": "code_review", "code_review": "bug_find", "bug_find": "adversarial_bug_find", "adversarial_bug_find": "doc_review", "doc_review": "referee", "referee": "complete or human_intervention", } def _base_phase(phase: str) -> str: if ":" in phase: return phase.split(":")[0] return phase def _find_project_dir(project: Optional[str] = None) -> Path: if project: p = Path(project).resolve() if (p / ".automaton").exists() or p == AUTOMATON_DIR: return p print(f"WARNING: '{project}' has no .automaton/ directory. Tasks will be stored at {p / '.automaton' / 'tasks'}.", file=sys.stderr) return p cwd = Path.cwd().resolve() if cwd == AUTOMATON_DIR: return AUTOMATON_DIR if (cwd / ".automaton").exists(): return cwd if cwd.parent == AUTOMATON_DIR: return AUTOMATON_DIR print(f"ERROR: Not in an automaton project directory (cwd={cwd}). " f"Use --project to specify the project path, or run from a directory with .automaton/ or from ~/.automaton/.", file=sys.stderr) sys.exit(1) def _task_dir(task_name: str, project: Optional[str] = None) -> Path: project_dir = _find_project_dir(project) if project_dir == AUTOMATON_DIR: base = AUTOMATON_DIR / "tasks" else: base = project_dir / ".automaton" / "tasks" parts = task_name.split("/") if len(parts) > 1: parent = "/".join(parts[:-1]) return base / parent / "subtasks" / parts[-1] return base / task_name def _all_task_dirs(project: Optional[str] = None) -> list[tuple[str, Path]]: project_dir = _find_project_dir(project) if project_dir == AUTOMATON_DIR: base = AUTOMATON_DIR / "tasks" else: base = project_dir / ".automaton" / "tasks" tasks = [] if not base.exists(): return tasks for entry in sorted(base.iterdir()): if entry.is_dir() and not entry.name.startswith("."): tasks.append((entry.name, entry)) subtasks = entry / "subtasks" if subtasks.exists(): for sub in sorted(subtasks.iterdir()): if sub.is_dir() and not sub.name.startswith("."): tasks.append((f"{entry.name}/{sub.name}", sub)) return tasks def _read_state(task_path: Path) -> Optional[str]: state_file = task_path / ".state" if state_file.exists(): content = state_file.read_text().strip() if content in VALID_PHASES: return content base = content.split(":")[0] if ":" in content else content if base in BASE_PHASES: return content return None def _write_state(task_path: Path, phase: str) -> None: tmp = task_path / ".state.tmp" tmp.write_text(f"{phase}\n") tmp.replace(task_path / ".state") def _read_state_approvals(task_path: Path) -> list[str]: approvals_file = task_path / ".state.approvals" if approvals_file.exists(): return approvals_file.read_text().strip().splitlines() return [] def _append_approval(task_path: Path, phase: str, approver: str) -> None: approvals_file = task_path / ".state.approvals" ts = datetime.now(timezone.utc).isoformat() line = f"{phase}|{ts}|{approver}\n" with open(approvals_file, "a") as f: f.write(line) def _infer_state_from_artifacts(task_path: Path) -> Optional[str]: artifacts = {} for name in ["SPEC.md", "DECOMPOSITION.md", "DESIGN.md", "TEST_PLAN.md", "IMPLEMENTATION.md", "CODE_REVIEW.md", "BUG_REPORT.md", "ADVERSARIAL_BUG_REPORT.md", "DOC_REVIEW.md", "VERDICT.md"]: f = task_path / name if f.exists() and f.stat().st_size > 0: artifacts[name] = True if "VERDICT.md" in artifacts: content = (task_path / "VERDICT.md").read_text() if "PASS" in content: return "complete" return "human_intervention" if "DOC_REVIEW.md" in artifacts: return "referee" if "ADVERSARIAL_BUG_REPORT.md" in artifacts and "BUG_REPORT.md" in artifacts: return "doc_review" if "BUG_REPORT.md" in artifacts: return "adversarial_bug_find" if "CODE_REVIEW.md" in artifacts: return "bug_find" if "IMPLEMENTATION.md" in artifacts: return "code_review" if "TEST_PLAN.md" in artifacts: return "implement" if "DESIGN.md" in artifacts: return "test_design" if "DECOMPOSITION.md" in artifacts and "SPEC.md" in artifacts: return "decomposition" if "SPEC.md" in artifacts: return "research" return "new" def _require_state(task_path: Path, task_name: str) -> Optional[str]: """Read .state file and refuse operations on tasks without one. Returns the phase string if .state exists, or prints an error and returns None. """ phase = _read_state(task_path) if phase is None: print(f"ERROR: Task '{task_name}' has no .state file. This task was likely created before v2.0 state enforcement.") print(f" Run: python ~/.automaton/scripts/status.py --upgrade --task {task_name} --project ") print(f" Or: python ~/.automaton/scripts/status.py --audit --project (to upgrade all tasks at once)") return None return phase def _is_kebab_case(name: str) -> bool: return bool(re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name)) def _parse_agent_config(project: Optional[str] = None) -> dict: project_dir = _find_project_dir(project) agent_file = project_dir / ".automaton" / ".agent.md" if project_dir != AUTOMATON_DIR else AUTOMATON_DIR / ".agent.md" if not agent_file.exists(): agent_file = AUTOMATON_DIR / ".agent.md" config = {"mode": "single-agent", "agents": {}, "lock_timeout": "30m"} if not agent_file.exists(): return config content = agent_file.read_text() in_agent_section = False current_agent = None for line in content.splitlines(): stripped = line.strip() if stripped.startswith("## Agent Configuration"): in_agent_section = True continue if in_agent_section and stripped.startswith("## "): break if not in_agent_section: continue if stripped.lower().startswith("mode:"): config["mode"] = stripped.split(":", 1)[1].strip().lower() elif stripped.lower().startswith("lock timeout:"): config["lock_timeout"] = stripped.split(":", 1)[1].strip() elif stripped.startswith("- id:"): current_agent = stripped.split("id:")[1].strip() config["agents"][current_agent] = {"phases": [], "role": "worker"} elif current_agent and "phases:" in stripped.lower(): phases_str = stripped.split(":", 1)[1].strip() phases = [p.strip().strip("[]") for p in phases_str.split(",")] config["agents"][current_agent]["phases"] = phases elif current_agent and "role:" in stripped.lower(): config["agents"][current_agent]["role"] = stripped.split(":", 1)[1].strip() return config def _is_multi_agent(project: Optional[str] = None) -> bool: config = _parse_agent_config(project) return config.get("mode") == "multi-agent" def _lock_timeout_seconds(project: Optional[str] = None) -> int: config = _parse_agent_config(project) timeout_str = config.get("lock_timeout", "30m") match = re.match(r'(\d+)(m|h|s)', timeout_str) if not match: return 1800 val, unit = int(match.group(1)), match.group(2) if unit == "h": return val * 3600 if unit == "m": return val * 60 return val # --- Command implementations --- def cmd_show_task(args): task_path = _task_dir(args.task, args.project) if not task_path.exists(): print(f"ERROR: Task '{args.task}' not found in {task_path.parent}") return 2 phase = _require_state(task_path, args.task) if phase is None: return 1 base = _base_phase(phase) allowed = ALLOWED_ACTIONS_MAP.get(base, []) forbidden = FORBIDDEN_ACTIONS_MAP.get(base, []) next_phase = NEXT_PHASE_MAP.get(base, "—") next_artifact = PHASE_REQUIRED_ARTIFACTS.get(base, "—") print(f"Task: {args.task}") print(f"Phase: {phase} (from .state)") print(f"State file: {task_path / '.state'}") if phase in (f"{p}:awaiting_approval" for p in APPROVAL_PHASES): print(f"Approval: AWAITING — user sign-off required before proceeding") elif phase in (f"{p}:approved" for p in APPROVAL_PHASES): print(f"Approval: APPROVED — ready to transition to next phase") print(f"Allowed actions:") for a in allowed: print(f" - {a}") print(f"Forbidden actions:") for f in forbidden: print(f" - {f}") print(f"Next artifact needed: {next_artifact}") print(f"Next phase: {next_phase}") return 0 def cmd_list(args): tasks = _all_task_dirs(args.project) if not tasks: print("No tasks found.") return 0 print(f"{'Task':<35} {'Phase':<30} {'Next Step'}") print("-" * 80) has_untracked = False for name, path in tasks: phase = _read_state(path) if phase is None: print(f"{name:<35} {'UNTRACKED (no .state)':<30} Run --upgrade --task {name}") has_untracked = True continue base = _base_phase(phase) next_step = NEXT_PHASE_MAP.get(base, "—") print(f"{name:<35} {phase:<30} {next_step}") if has_untracked: print("\nNOTE: Tasks marked UNTRACKED were created before v2.0 state enforcement.") print(" Run --upgrade to bootstrap .state files, or --audit to see all violations.") return 0 def cmd_create_task(args): task_name = args.create_task if not _is_kebab_case(task_name): print(f"ERROR: Task name '{task_name}' must be kebab-case (lowercase, hyphens, no spaces)") return 2 task_path = _task_dir(task_name, args.project) if task_path.exists(): print(f"ERROR: Task '{task_name}' already exists at {task_path}") return 2 task_path.mkdir(parents=True) _write_state(task_path, "new") (task_path / ".state.approvals").write_text("") print(f"Created task '{task_name}' in state 'new'.") print(f"Use --transition research to begin.") return 0 def _auto_update_verdict_on_complete(task_path): """When transitioning human_intervention→complete, update VERDICT.md to PASS.""" verdict_file = task_path / "VERDICT.md" if not verdict_file.exists(): return import datetime content = verdict_file.read_text() lines = content.splitlines() new_lines = [] found_status = False for line in lines: low = line.strip().lower() if low.startswith("## status") or low.startswith("- **status**"): new_lines.append("## Status: PASS") found_status = True else: new_lines.append(line) if not found_status: new_lines.insert(0, "## Status: PASS") new_lines.append("") new_lines.append(f"*(Status auto-updated to PASS on human_intervention → complete transition at {datetime.datetime.now(datetime.timezone.utc).isoformat()})*") verdict_file.write_text("\n".join(new_lines) + "\n") def cmd_transition(args): task_path = _task_dir(args.task, args.project) if not task_path.exists(): print(f"ERROR: Task '{args.task}' not found in {task_path.parent}") return 2 current = _require_state(task_path, args.task) if current is None: return 1 target = args.transition base_current = _base_phase(current) if target not in VALID_PHASES: print(f"ERROR: Unknown phase '{target}'. Valid phases: {', '.join(VALID_PHASES)}") return 2 allowed = LEGAL_TRANSITIONS.get(current, []) if target not in allowed: allowed_str = ", ".join(allowed) if allowed else "(no legal transitions)" print(f"ERROR: Cannot transition from '{current}' to '{target}'. Legal transitions from '{current}' are: {allowed_str}") return 1 if current.endswith(":awaiting_approval") and target != f"{base_current}:approved": print(f"ERROR: Cannot transition from '{current}' to '{target}'. Current phase is awaiting approval — use --approve to grant approval first.") return 1 for phase_base, artifact in PHASE_REQUIRED_ARTIFACTS.items(): if _base_phase(target) == phase_base or (target.startswith(phase_base + ":")): af = task_path / artifact if current != target and not af.exists() or af.exists() and af.stat().st_size == 0: pass forbidden_in_folder = _check_forbidden_artifacts(task_path, base_current) if forbidden_in_folder: print(f"ERROR: Cannot transition to {target} phase. Found out-of-order artifacts:") for art, belongs_to in forbidden_in_folder: print(f" - {art} (belongs to {belongs_to} phase)") print("Remove out-of-order artifacts before transitioning.") return 1 required = PHASE_REQUIRED_ARTIFACTS.get(base_current) if required and target != current: # only check required artifact when leaving a phase pass if base_current in PHASE_REQUIRED_ARTIFACTS and base_current != "new": req = PHASE_REQUIRED_ARTIFACTS.get(_base_phase(current)) if req: af = task_path / req if not af.exists() or af.stat().st_size == 0: print(f"ERROR: Cannot transition from '{current}' to '{target}'. Required artifact '{req}' is missing or empty in task folder.") return 1 if current == "human_intervention" and target == "complete": _auto_update_verdict_on_complete(task_path) if current == "implement" and target == "code_review": lock_file = task_path / ".state.lock" if lock_file.exists(): content = lock_file.read_text().strip() lines = dict(l.split(": ", 1) for l in content.splitlines() if ": " in l) implementer = lines.get("agent", "unknown") (task_path / ".state.implementer").write_text(f"{implementer}\n") _write_state(task_path, target) print(f"Transitioned task '{args.task}' from '{current}' to '{target}'.") return 0 def cmd_approve(args): task_path = _task_dir(args.task, args.project) if not task_path.exists(): print(f"ERROR: Task '{args.task}' not found in {task_path.parent}") return 2 current = _require_state(task_path, args.task) if current is None: return 1 base = _base_phase(current) if base not in APPROVAL_PHASES: print(f"This phase ({base}) does not require approval.") return 0 if not current.endswith(":awaiting_approval"): print(f"ERROR: Current phase is '{current}' (not awaiting approval). Current sub-state must be '{base}:awaiting_approval' before approval can be granted.") return 1 new_phase = f"{base}:approved" _write_state(task_path, new_phase) approver = args.agent if hasattr(args, "agent") and args.agent else "user" _append_approval(task_path, new_phase, approver) print(f"Approved task '{args.task}' — transitioned from '{current}' to '{new_phase}'.") print(f"Approval recorded by: {approver}") return 0 def cmd_validate_folder(args): task_path = _task_dir(args.task, args.project) if not task_path.exists(): print(f"ERROR: Task '{args.task}' not found in {task_path.parent}") return 2 state_file = task_path / ".state" if not state_file.exists(): phase = _infer_state_from_artifacts(task_path) if phase: print(f"Task: {args.task}") print(f"Phase: {phase} (inferred from artifacts — no .state file)") print(f"Folder validation: WARN — task has no .state file (pre-v2.0 task).") print(f" Run 'python ~/.automaton/scripts/status.py --upgrade --task {args.task} --project ' to bootstrap .state file.") return 1 else: print(f"Task: {args.task}") print(f"Phase: unknown (no .state file and no artifacts)") print(f"Folder validation: FAIL — task has no .state file.") print(f" Run 'python ~/.automaton/scripts/status.py --upgrade --task {args.task} --project ' to bootstrap .state file.") return 1 phase = _read_state(task_path) if phase is None: print(f"Task: {args.task}") print(f"Phase: unknown (corrupted .state file)") print(f"Folder validation: FAIL — .state file exists but is empty or unreadable.") print(f" Run 'python ~/.automaton/scripts/status.py --upgrade --task {args.task} --project ' to re-bootstrap .state file.") return 1 base = _base_phase(phase) forbidden = _check_forbidden_artifacts(task_path, base) print(f"Task: {args.task}") print(f"Phase: {phase} (from .state)") if forbidden: print(f"Folder validation: FAIL — found out-of-order artifacts:") for art, belongs_to in forbidden: print(f" - {art} (belongs to {belongs_to} phase, not yet reached)") print("These artifacts indicate phase-skipping. Remove them or revert to the correct phase.") return 1 print(f"Folder validation: PASS — no out-of-order artifacts found") return 0 def _check_forbidden_artifacts(task_path: Path, phase: str) -> list[tuple[str, str]]: """Returns list of (artifact_name, phase_it_belongs_to) for forbidden artifacts found.""" forbidden_names = FORBIDDEN_ARTIFACTS.get(phase, []) artifact_to_phase = { "SPEC.md": "research", "DECOMPOSITION.md": "decomposition", "DESIGN.md": "design", "TEST_PLAN.md": "test_design", "IMPLEMENTATION.md": "implement", "CODE_REVIEW.md": "code_review", "BUG_REPORT.md": "bug_find", "ADVERSARIAL_BUG_REPORT.md": "adversarial_bug_find", "DOC_REVIEW.md": "doc_review", "VERDICT.md": "referee", } found = [] for name in forbidden_names: f = task_path / name if f.exists() and f.stat().st_size > 0: belongs_to = artifact_to_phase.get(name, "unknown") found.append((name, belongs_to)) return found def _audit_category3(project_dir, tasks): """Audit Category 3: Git-based unauthorized modification detection.""" import subprocess # Collect active edit-allowed tasks active_edit_tasks = set() for name, path in tasks: phase = _read_state(path) if phase and _base_phase(phase) in ("implement", "doc_review"): active_edit_tasks.add(name) # Get uncommitted changes (working tree + staged) try: result = subprocess.run( ["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, cwd=str(project_dir), timeout=10 ) uncommitted = [f.strip() for f in result.stdout.splitlines() if f.strip()] except Exception as e: print(f"[WARN] Failed to check git diff: {e}") return 0 try: result = subprocess.run( ["git", "diff", "--cached", "--name-only", "HEAD"], capture_output=True, text=True, cwd=str(project_dir), timeout=10 ) staged = [f.strip() for f in result.stdout.splitlines() if f.strip()] except Exception: staged = [] all_changed = set(uncommitted + staged) violations = 0 if not all_changed: print("[PASS] No uncommitted modifications detected") return violations # Exclude files inside task folders task_names = {name for name, _ in tasks} unauthorized = set() for changed_file in all_changed: parts = Path(changed_file).parts is_in_task_folder = len(parts) >= 2 and parts[0] == "tasks" and parts[1] in task_names if not is_in_task_folder: unauthorized.add(changed_file) if not unauthorized: print("[PASS] All uncommitted changes are within task folders — no unauthorized modifications") return violations if not active_edit_tasks: print("[FAIL] No task in implement or doc_review phase, but uncommitted changes exist outside task folders:") for f in sorted(unauthorized): print(f" - {f}") violations += 1 print(f" To allow edits: create a task and transition to implement phase") else: print(f"[INFO] Active edit tasks: {', '.join(sorted(active_edit_tasks))}") print("[INFO] Uncommitted changes outside task folders exist (may be authorized if within active task scope):") for f in sorted(unauthorized): print(f" - {f}") return violations def cmd_audit(args): project_dir = _find_project_dir(args.project) tasks = _all_task_dirs(args.project) if not tasks: print("No tasks found.") return 0 violations = 0 print(f"Audit Report for {project_dir}\n") cat1_violations = [] cat2_violations = [] cat4_violations = [] for name, path in tasks: state_file = path / ".state" if not state_file.exists(): phase = _infer_state_from_artifacts(path) cat4_violations.append((name, phase or "unknown")) continue phase = _read_state(path) if phase is None: phase = _infer_state_from_artifacts(path) if phase is None: phase = "unknown" base = _base_phase(phase) forbidden = _check_forbidden_artifacts(path, base) if forbidden: cat1_violations.append((name, phase, forbidden)) expected = PHASE_REQUIRED_ARTIFACTS.get(base) if expected: af = path / expected inconsistency = False details = [] if base == "implement" and (path / "IMPLEMENTATION.md").exists() and (path / "IMPLEMENTATION.md").stat().st_size == 0: inconsistency = True details.append(f"IMPLEMENTATION.md is empty but .state says {base}") if base in ("bug_find", "adversarial_bug_find", "code_review", "doc_review", "referee") and not (path / "IMPLEMENTATION.md").exists(): inconsistency = True details.append(f".state says {base} but IMPLEMENTATION.md is missing") if inconsistency: cat2_violations.append((name, phase, details)) print("=== Category 1: Out-of-order Artifacts ===") if not cat1_violations: for name, path in tasks: phase = _read_state(path) or _infer_state_from_artifacts(path) or "unknown" print(f"[PASS] {name}: no violations") else: for name, path in tasks: phase = _read_state(path) or _infer_state_from_artifacts(path) or "unknown" found = [(n, p) for n, ph, items in cat1_violations if n == name for n, p in items] if any(n == name for n, _, _ in cat1_violations): phase_for_name = next(ph for n, ph, _ in cat1_violations if n == name) items = next(items for n, ph, items in cat1_violations if n == name) print(f"[FAIL] {name} (phase: {phase_for_name}): {', '.join(f'{a} ({p} phase artifact)' for a, p in items)}") violations += 1 else: print(f"[PASS] {name}: no violations") print("\n=== Category 2: State-Artifact Inconsistency ===") for name, path in tasks: state_file = path / ".state" if not state_file.exists(): phase = _infer_state_from_artifacts(path) or "unknown" else: phase = _read_state(path) or _infer_state_from_artifacts(path) or "unknown" inconsistencies = [d for n, ph, d in cat2_violations if n == name] if inconsistencies: for detail in inconsistencies[0]: print(f"[WARN] {name}: {detail}") violations += 1 else: base = _base_phase(phase) expected = PHASE_REQUIRED_ARTIFACTS.get(base) if expected: af = path / expected if af.exists() and af.stat().st_size > 0: print(f"[PASS] {name}: .state ({phase}) matches artifacts ({expected} exists)") else: print(f"[INFO] {name}: .state ({phase}) — expected artifact {expected} not yet produced") else: print(f"[PASS] {name}: .state ({phase}) — no artifact requirement for this phase") print("\n=== Category 4: Manually Created Tasks ===") if not cat4_violations: for name, path in tasks: if (path / ".state").exists(): print(f"[PASS] {name}: has .state file") else: for name, inferred_phase in cat4_violations: print(f"[FAIL] {name}: no .state file (manually created or pre-v2.0 task) — inferred phase: {inferred_phase}") print(f" Run 'python ~/.automaton/scripts/status.py --upgrade --task {name} --project ' to bootstrap .state file") violations += 1 print("\n=== Category 3: Unauthorized Modifications ===") git_dir = project_dir / ".git" if not git_dir.exists(): print("Skipped: not a git repository") else: violations += _audit_category3(project_dir, tasks) print("\n=== Category 5: Stuck Tasks ===") import time as _time stuck_threshold = 60 stuck_found = 0 for name, path in tasks: state_file = path / ".state" if not state_file.exists(): continue phase = _read_state(path) if phase is None or phase in ("complete", "human_intervention"): continue mtime = state_file.stat().st_mtime age_minutes = (_time.time() - mtime) / 60 if age_minutes > stuck_threshold: print(f"[WARN] {name}: stuck at '{phase}' for {age_minutes:.0f} minutes (threshold: {stuck_threshold} min)") stuck_found += 1 violations += 1 if stuck_found == 0: print(f"[PASS] No stuck tasks (threshold: {stuck_threshold} min)") print(f"\n=== Summary ===") total = len(tasks) print(f"{total} tasks audited") if violations: print(f"{violations} violation(s) found") return 1 print("No violations found") return 0 def cmd_upgrade(args): if args.task: task_path = _task_dir(args.task, args.project) if not task_path.exists(): print(f"ERROR: Task '{args.task}' not found in {task_path.parent}") return 2 state_file = task_path / ".state" if state_file.exists(): current = _read_state(task_path) print(f"Task '{args.task}' already has .state file (phase: {current})") return 0 phase = _infer_state_from_artifacts(task_path) if phase: _write_state(task_path, phase) approvals_file = task_path / ".state.approvals" if not approvals_file.exists(): approvals_file.write_text("") print(f"Bootstrapped .state for task '{args.task}': phase '{phase}' (inferred from artifacts)") return 0 else: _write_state(task_path, "new") approvals_file = task_path / ".state.approvals" if not approvals_file.exists(): approvals_file.write_text("") print(f"Bootstrapped .state for task '{args.task}': phase 'new' (no artifacts found)") return 0 else: tasks = _all_task_dirs(args.project) if not tasks: print("No tasks found to upgrade.") return 0 bootstrapped = 0 skipped = 0 for name, path in tasks: state_file = path / ".state" if state_file.exists(): skipped += 1 continue phase = _infer_state_from_artifacts(path) if phase: _write_state(path, phase) approvals_file = path / ".state.approvals" if not approvals_file.exists(): approvals_file.write_text("") print(f" {name}: bootstrapped as '{phase}'") bootstrapped += 1 else: _write_state(path, "new") approvals_file = path / ".state.approvals" if not approvals_file.exists(): approvals_file.write_text("") print(f" {name}: bootstrapped as 'new' (no artifacts found)") bootstrapped += 1 if (path / "subtasks").exists(): for sub_dir in sorted((path / "subtasks").iterdir()): if not sub_dir.is_dir(): continue sub_state = sub_dir / ".state" if sub_state.exists(): continue sub_phase = _infer_state_from_artifacts(sub_dir) if sub_phase: _write_state(sub_dir, sub_phase) sub_approvals = sub_dir / ".state.approvals" if not sub_approvals.exists(): sub_approvals.write_text("") print(f" {name}/subtasks/{sub_dir.name}: bootstrapped as '{sub_phase}'") else: _write_state(sub_dir, "new") sub_approvals = sub_dir / ".state.approvals" if not sub_approvals.exists(): sub_approvals.write_text("") print(f" {name}/subtasks/{sub_dir.name}: bootstrapped as 'new'") print(f"\nUpgraded: {bootstrapped}, Skipped (already had .state): {skipped}") return 0 def cmd_can_edit(args): project_dir = _find_project_dir(args.project) if not args.task: edit_tasks = [] tasks = _all_task_dirs(args.project) for name, path in tasks: phase = _read_state(path) if phase is None: continue base = _base_phase(phase) if base in ("implement", "doc_review"): state_file = path / ".state" state_mtime = state_file.stat().st_mtime if state_file.exists() else 0 edit_tasks.append((name, base, path, state_mtime)) if not edit_tasks: print("DENIED: No tasks in implement or doc_review phase. Create a task and transition it to implement before editing files.") if args.json_output: print(json.dumps({"allowed": False, "reason": "no_edit_tasks", "tasks": []})) return 1 if args.file: file_path = Path(args.file).resolve() proj_str = str(project_dir.resolve()) scope_tasks = [] out_of_scope = [] for name, base, path, state_mtime in edit_tasks: if str(file_path).startswith(proj_str): scope_tasks.append({"task": name, "phase": base, "state_mtime": state_mtime}) else: out_of_scope.append({"task": name, "phase": base, "file": str(file_path)}) if not scope_tasks: print(f"DENIED: File '{file_path}' is outside project '{project_dir}'. No task allows editing this file.") if args.json_output: print(json.dumps({"allowed": False, "reason": "out_of_scope", "out_of_scope": out_of_scope, "tasks": []})) return 1 primary = scope_tasks[0] import time as _time now = _time.time() max_state_age = max(t["state_mtime"] for t in scope_tasks) age_minutes = (now - max_state_age) / 60 if age_minutes > 30: latest_task = max(scope_tasks, key=lambda t: t["state_mtime"]) print(f"DENIED: Task '{latest_task['task']}' has been in {latest_task['phase']} phase for {age_minutes:.0f} minutes (stale). Create a new task for new work.") if args.json_output: print(json.dumps({"allowed": False, "reason": "stale_task", "stale_task": latest_task["task"], "stale_minutes": round(age_minutes), "all_edit_tasks": scope_tasks})) return 1 print(f"ALLOWED: Task '{primary['task']}' is in {primary['phase']} phase and file '{file_path}' is within project '{project_dir}'.") if args.json_output: print(json.dumps({"allowed": True, "reason": "edit_task_in_scope", "primary_task": {"task": primary["task"], "phase": primary["phase"]}, "all_edit_tasks": [{"task": t["task"], "phase": t["phase"]} for t in scope_tasks]})) return 0 import time as _time now = _time.time() max_state_age = max(mtime for _, _, _, mtime in edit_tasks) age_minutes = (now - max_state_age) / 60 latest = max(edit_tasks, key=lambda t: t[3]) if age_minutes > 30: print(f"DENIED: Task '{latest[0]}' has been in {latest[1]} phase for {age_minutes:.0f} minutes (stale). Create a new task for new work.") if args.json_output: print(json.dumps({"allowed": False, "reason": "stale_task", "stale_task": latest[0], "stale_minutes": round(age_minutes), "all_edit_tasks": [{"task": n, "phase": b} for n, b, _, _ in edit_tasks]})) return 1 primary = edit_tasks[0] print(f"ALLOWED: Task '{primary[0]}' is in {primary[1]} phase — code edits are permitted.") if args.json_output: print(json.dumps({"allowed": True, "reason": "edit_task", "primary_task": {"task": primary[0], "phase": primary[1]}, "all_edit_tasks": [{"task": n, "phase": b} for n, b, _, _ in edit_tasks]})) return 0 task_path = _task_dir(args.task, args.project) if not task_path.exists(): print(f"ERROR: Task '{args.task}' not found") return 2 phase = _require_state(task_path, args.task) if phase is None: return 1 base = _base_phase(phase) if args.file: file_path = Path(args.file).resolve() proj_str = str(project_dir.resolve()) auto_str = str(AUTOMATON_DIR) if project_dir == AUTOMATON_DIR: if not str(file_path).startswith(auto_str): print(f"OUT_OF_SCOPE: File '{file_path}' is outside the framework directory") if args.json_output: print(json.dumps({"allowed": False, "reason": "out_of_scope", "task": args.task, "phase": base})) return 1 else: if not str(file_path).startswith(proj_str): print(f"OUT_OF_SCOPE: File '{file_path}' is outside project '{project_dir}'. Only framework project can modify framework files.") if args.json_output: print(json.dumps({"allowed": False, "reason": "out_of_scope", "task": args.task, "phase": base, "file": str(file_path)})) return 1 if base in ("implement", "doc_review"): print(f"ALLOWED: Task '{args.task}' is in {base} phase — code edits are permitted.") if args.json_output: print(json.dumps({"allowed": True, "reason": "edit_phase", "task": args.task, "phase": base})) return 0 print(f"DENIED: Task '{args.task}' is in {base} phase. Code edits require implement or doc_review phase.") if args.json_output: print(json.dumps({"allowed": False, "reason": "wrong_phase", "task": args.task, "phase": base, "allowed_phases": ["implement", "doc_review"]})) return 1 def cmd_touch(args): """Update .state mtime to reset stale-task timer without changing phase.""" task_path = _task_dir(args.task, args.project) if not task_path.exists(): print(f"ERROR: Task '{args.task}' not found") return 2 state_file = task_path / ".state" if not state_file.exists(): print(f"ERROR: Task '{args.task}' has no .state file. Run --upgrade first.") return 1 import os os.utime(str(state_file), None) phase = _read_state(task_path) print(f"Touched task '{args.task}' (phase: {phase}) — activity clock reset.") return 0 def cmd_scope_check(args): project_dir = _find_project_dir(args.project) file_path = Path(args.file).resolve() proj_str = str(project_dir.resolve()) if str(file_path).startswith(proj_str): print(f"IN_SCOPE: File '{file_path}' is within project '{project_dir}'") return 0 if project_dir != AUTOMATON_DIR: auto_str = str(AUTOMATON_DIR) if str(file_path).startswith(auto_str): print(f"OUT_OF_SCOPE: File '{file_path}' is in the framework directory, but current project is '{project_dir}'. Only framework project can modify framework files.") return 1 print(f"OUT_OF_SCOPE: File '{file_path}' is outside project '{project_dir}'") return 1 def cmd_same_session(args): task_path = _task_dir(args.task, args.project) if not task_path.exists(): print(f"ERROR: Task '{args.task}' not found") return 2 state_file = task_path / ".state" if not state_file.exists(): print(f"DIFFERENT_SESSION: Task '{args.task}' has no .state file") return 0 import time mtime = state_file.stat().st_mtime age_minutes = (time.time() - mtime) / 60 threshold = 30 if age_minutes < threshold: print(f"SAME_SESSION: Task '{args.task}' .state was modified {age_minutes:.0f} minutes ago (threshold: {threshold} min)") return 1 print(f"DIFFERENT_SESSION: Task '{args.task}' .state was modified {age_minutes:.0f} minutes ago (threshold: {threshold} min)") return 0 def cmd_claim(args): task_path = _task_dir(args.task, args.project) if not task_path.exists(): print(f"ERROR: Task '{args.task}' not found in {task_path.parent}") return 2 config = _parse_agent_config(args.project) if config["mode"] != "multi-agent": print(f"Claimed task '{args.task}' (single-agent mode — no lock needed)") return 0 if not args.agent: print("ERROR: --agent is required in multi-agent mode") return 2 agent_phases = config["agents"].get(args.agent, {}).get("phases", []) if not agent_phases: print(f"ERROR: Agent '{args.agent}' not found in Agent Configuration") return 2 phase = _require_state(task_path, args.task) if phase is None: return 1 base = _base_phase(phase) if "*" not in agent_phases and base not in agent_phases: print(f"ERROR: Agent '{args.agent}' is not configured for phase '{base}'. Allowed phases: {', '.join(agent_phases)}") return 1 if base == "code_review": implementer_file = task_path / ".state.implementer" if implementer_file.exists(): implementer = implementer_file.read_text().strip() if implementer == args.agent: print(f"ERROR: Agent '{args.agent}' implemented this task and cannot claim the code_review phase. Reviewer must be different from implementer.") return 1 lock_file = task_path / ".state.lock" timeout_sec = _lock_timeout_seconds(args.project) if lock_file.exists(): content = lock_file.read_text().strip() lines = dict(l.split(": ", 1) for l in content.splitlines() if ": " in l) existing_agent = lines.get("agent", "unknown") expires_str = lines.get("expires", "") if expires_str: try: from datetime import datetime as dt expires = dt.fromisoformat(expires_str.replace("Z", "+00:00")) if datetime.now(timezone.utc) < expires: print(f"ERROR: Task '{args.task}' is claimed by agent '{existing_agent}' (expires: {expires_str}). Retry after expiry or release the claim.") return 1 else: print(f"WARN: Task '{args.task}' had stale lock from agent '{existing_agent}' (expired: {expires_str}). Overclaiming for agent '{args.agent}'.") except Exception: pass from datetime import datetime as dt, timedelta now = datetime.now(timezone.utc) expires = now + timedelta(seconds=timeout_sec) lock_content = f"agent: {args.agent}\nphase: {phase}\nclaimed: {now.isoformat()}\nexpires: {expires.isoformat()}\n" tmp = task_path / ".state.lock.tmp" tmp.write_text(lock_content) tmp.replace(task_path / ".state.lock") print(f"Claimed task '{args.task}' for agent '{args.agent}' — phase: {phase}") print(f"Lock expires: {expires.isoformat()}") return 0 def cmd_release(args): task_path = _task_dir(args.task, args.project) if not task_path.exists(): print(f"ERROR: Task '{args.task}' not found in {task_path.parent}") return 2 config = _parse_agent_config(args.project) if config["mode"] != "multi-agent": print(f"Released task '{args.task}' (single-agent mode — no lock to release)") return 0 if not args.agent: print("ERROR: --agent is required in multi-agent mode") return 2 lock_file = task_path / ".state.lock" if not lock_file.exists(): print(f"WARN: Task '{args.task}' has no lock. Nothing to release.") return 0 content = lock_file.read_text().strip() lines = dict(l.split(": ", 1) for l in content.splitlines() if ": " in l) existing_agent = lines.get("agent", "unknown") if existing_agent != args.agent: print(f"ERROR: Task '{args.task}' is claimed by agent '{existing_agent}', not '{args.agent}'. Only the claiming agent can release.") return 1 lock_file.unlink() print(f"Released task '{args.task}' from agent '{args.agent}'") return 0 def cmd_next_available(args): config = _parse_agent_config(args.project) if config["mode"] != "multi-agent": print("Single-agent mode — use --list to see all tasks") return 0 if not args.agent: print("ERROR: --agent is required in multi-agent mode") return 2 agent_phases = config["agents"].get(args.agent, {}).get("phases", []) if not agent_phases: print(f"ERROR: Agent '{args.agent}' not found in Agent Configuration") return 2 tasks = _all_task_dirs(args.project) candidates = [] for name, path in tasks: phase = _read_state(path) if phase is None: continue base = _base_phase(phase) if phase in ("complete", "human_intervention"): continue if "*" not in agent_phases and base not in agent_phases: continue lock_file = path / ".state.lock" if lock_file.exists(): content = lock_file.read_text().strip() lines = dict(l.split(": ", 1) for l in content.splitlines() if ": " in l) expires_str = lines.get("expires", "") if expires_str: try: from datetime import datetime as dt expires = dt.fromisoformat(expires_str.replace("Z", "+00:00")) if datetime.now(timezone.utc) < expires: continue except Exception: pass priority = PHASE_PRIORITY.get(base, 0) candidates.append((name, base, phase, priority)) if not candidates: print(f"No tasks available for agent '{args.agent}'. All tasks are claimed by other agents, in phases this agent cannot work on, or completed.") return 0 candidates.sort(key=lambda x: -x[3]) best = candidates[0] print(f"Next available task for agent '{args.agent}':") print(f"Task: {best[0]}") print(f"Phase: {best[2]}") print(f"Phase priority: {best[3]} ({'high — close to completion' if best[3] >= 7 else 'medium' if best[3] >= 4 else 'low'})") print(f"Status: unclaimed") print(f"\nTo claim: python ~/.automaton/scripts/status.py --claim --task {best[0]} --agent {args.agent}") return 0 def cmd_available(args): config = _parse_agent_config(args.project) if config["mode"] != "multi-agent": print("Single-agent mode — use --list to see all tasks") return 0 if not args.agent: print("ERROR: --agent is required in multi-agent mode") return 2 agent_phases = config["agents"].get(args.agent, {}).get("phases", []) if not agent_phases: print(f"ERROR: Agent '{args.agent}' not found in Agent Configuration") return 2 tasks = _all_task_dirs(args.project) print(f"Available tasks for agent '{args.agent}':") idx = 1 for name, path in tasks: phase = _read_state(path) if phase is None: continue base = _base_phase(phase) if phase in ("complete", "human_intervention"): continue if "*" not in agent_phases and base not in agent_phases: continue lock_file = path / ".state.lock" status = "unclaimed" if lock_file.exists(): content = lock_file.read_text().strip() lines = dict(l.split(": ", 1) for l in content.splitlines() if ": " in l) existing_agent = lines.get("agent", "unknown") expires_str = lines.get("expires", "") status = f"claimed by '{existing_agent}' (expires: {expires_str})" try: from datetime import datetime as dt expires = dt.fromisoformat(expires_str.replace("Z", "+00:00")) if datetime.now(timezone.utc) >= expires: status = "expired lock — available" except Exception: pass print(f"{idx}. Task: {name} | Phase: {phase} | Status: {status}") idx += 1 if idx == 1: print("No tasks available.") return 0 def cmd_list_states(args): """Print all valid phase names.""" print("Valid phases:") for phase in VALID_PHASES: base = _base_phase(phase) approvals = " * requires approval" if base in APPROVAL_PHASES and phase == base else "" print(f" {phase}{approvals}") return 0 def main(): parser = argparse.ArgumentParser(description="Automaton status and enforcement script") parser.add_argument("--project", help="Project root directory (defaults to CWD)") parser.add_argument("--task", help="Task name") parser.add_argument("--agent", help="Agent ID (for multi-agent commands)") parser.add_argument("--transition", metavar="PHASE", help="Transition task to a new phase") parser.add_argument("--create-task", metavar="NAME", help="Create a new task") parser.add_argument("--approve", action="store_true", help="Approve current phase (for approval-gated phases)") parser.add_argument("--validate-folder", action="store_true", help="Validate task folder for out-of-order artifacts") parser.add_argument("--list", action="store_true", help="List all tasks") parser.add_argument("--audit", action="store_true", help="Audit all tasks for violations") parser.add_argument("--claim", action="store_true", help="Claim task for agent (multi-agent)") parser.add_argument("--release", action="store_true", help="Release task claim (multi-agent)") parser.add_argument("--next-available", action="store_true", help="Find next available task for agent") parser.add_argument("--available", action="store_true", help="List all available tasks for agent") parser.add_argument("--can-edit", action="store_true", help="Check if code edits are allowed. Without --task, checks if ANY task allows edits. With --task, checks specific task. With --file, also checks file scope.") parser.add_argument("--upgrade", action="store_true", help="Bootstrap .state files for pre-v2.0 tasks (use --task for single task, or omit for all)") parser.add_argument("--scope-check", action="store_true", help="Check if a file is in project scope") parser.add_argument("--file", help="File path for scope check or can-edit file scope check") parser.add_argument("--same-session", action="store_true", help="Check if task was created in current session") parser.add_argument("--list-states", action="store_true", help="List all valid phase names") parser.add_argument("--touch", action="store_true", help="Update .state mtime to reset stale-task timer without changing phase") parser.add_argument("--json", action="store_true", dest="json_output", help="Output machine-readable JSON on last line (for harness integration)") args = parser.parse_args() if args.create_task: return cmd_create_task(args) if args.approve: if not args.task: print("ERROR: --task is required for --approve") return 2 return cmd_approve(args) if args.transition: if not args.task: print("ERROR: --task is required for --transition") return 2 return cmd_transition(args) if args.validate_folder: if not args.task: print("ERROR: --task is required for --validate-folder") return 2 return cmd_validate_folder(args) if args.list: return cmd_list(args) if args.audit: return cmd_audit(args) if args.claim: if not args.task: print("ERROR: --task is required for --claim") return 2 return cmd_claim(args) if args.release: if not args.task: print("ERROR: --task is required for --release") return 2 return cmd_release(args) if args.next_available: return cmd_next_available(args) if args.available: return cmd_available(args) if args.can_edit: return cmd_can_edit(args) if args.upgrade: return cmd_upgrade(args) if args.scope_check: if not args.task or not args.file: print("ERROR: --task and --file are required for --scope-check") return 2 return cmd_scope_check(args) if args.same_session: if not args.task: print("ERROR: --task is required for --same-session") return 2 return cmd_same_session(args) if args.list_states: return cmd_list_states(args) if args.touch: if not args.task: print("ERROR: --task is required for --touch") return 2 return cmd_touch(args) if args.task: return cmd_show_task(args) print("ERROR: No command specified. Use --help for usage information.") return 2 if __name__ == "__main__": sys.exit(main())