CI / build (push) Has been cancelled
- Insert code_review phase between implement and bug_find - Approval gate: code_review:awaiting_approval → code_review:approved - Read-only phase — no edits, no fixes, no returning to implement - Reviewer≠implementer: .state.implementer tracking + --claim enforcement - Structured CODE_REVIEW.md: spec compliance, design conformance, quality scorecard, items found (severity/category/location/resolution), test coverage - Updated status.py (10 data structures), dashboard (4 files), prompts (3 files), agent routing, tests (6 new test classes, 19 new tests)
1363 lines
59 KiB
Python
Executable File
1363 lines
59 KiB
Python
Executable File
#!/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()) |