Files
automaton/scripts/status.py
T

1363 lines
59 KiB
Python
Raw Normal View History

#!/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 <project-path>")
print(f" Or: python ~/.automaton/scripts/status.py --audit --project <project-path> (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 <project-path>' 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 <project-path>' 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 <project-path>' 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 <project-path>' 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())