Files
automaton/scripts/status.py
T
Lap Tran 4a2301b077
CI / build (push) Has been cancelled
Archive completed tasks, add cleanup commands, self-documenting dashboard UI
- Archive 79 completed framework-dev tasks from tasks/ -> tasks/complete/
- status.py: add --cleanup-done and --install-cleanup-schedule commands
- Add scripts/automaton-cleanup.sh for periodic task archiving
- Dashboard: rename 'Background' tab -> 'Agent', 'Cleanup' agent -> 'Completed Task Archiver', remove redundant group headers and pill badges, dim inactive agent placeholders
- .rules.md: add Self-Documenting UI Names rule
- New tests: test_cleanup_done.py, expanded test_app.py and test_task.py
2026-06-24 22:43:33 -04:00

2957 lines
123 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
python status.py --cleanup-done [--days 7] [--dry-run] Archive complete tasks older than N days into tasks/complete/
python status.py --install-cleanup-schedule [--days 7] [--interval 86400] Install daily cleanup job
"""
from __future__ import annotations
import argparse
import contextlib
import json
import os
import platform
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", ".state.lastedit", "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]
task_path = base / task_name
if not task_path.exists():
completed = base / "complete" / task_name
if completed.exists():
return completed
return task_path
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(".") and entry.name != "complete":
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 _parse_verdict_status_line(content: str) -> Optional[str]:
"""Parse verdict status from structured header lines only.
Looks for ``## Status: PASS/FAIL/NEEDS_REVIEW`` or ``- **Status**: PASS/FAIL/NEEDS_REVIEW``
header lines. Returns None if no structured header is found.
Deliberately does NOT do substring search across the full file, because
body text may mention status keywords without reflecting the actual verdict.
"""
for line in content.splitlines():
stripped = line.strip()
low = stripped.lower()
if low.startswith("## status") or low.startswith("- **status**"):
after_colon = stripped.split(":", 1)[-1].strip() if ":" in stripped else ""
for label in ("PASS", "FAIL", "NEEDS_REVIEW"):
if after_colon.upper() == label or label in after_colon.upper():
return label
return None
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()
verdict_status = _parse_verdict_status_line(content)
if verdict_status == "PASS":
return "complete"
if verdict_status in ("FAIL", "NEEDS_REVIEW"):
return "human_intervention"
# Verdict exists but status header unparseable — fall back to human review
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 "test_design"
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
# R8: refuse when a HALTED loop owns this task. A paused/running loop can
# still have its task transitioned via the runner's own --transition calls
# (which run as a subprocess and would just be re-checked against gates),
# but a human must clear the halt first.
owning_loop = _loop_owning_task(args.task, args.project)
if owning_loop is not None:
loop_state = _read_state_loop(owning_loop[1])
if loop_state and loop_state.get("status") == "halted":
print(f"ERROR: Task '{args.task}' is owned by loop '{owning_loop[0]}' which is HALTED "
f"(halt_reason={loop_state.get('halt_reason')}). Clear the halt with "
f"`--approve --loop {owning_loop[0]}` before transitioning this task.")
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")
if target == "complete":
tasks_root = task_path.parent
if task_path.name == "complete":
tasks_root = tasks_root.parent
complete_dir = tasks_root / "complete"
complete_dir.mkdir(parents=True, exist_ok=True)
dest = complete_dir / task_path.name
if dest.exists():
print(f"ERROR: Completed task '{args.task}' already exists at {dest}.")
return 1
task_path.rename(dest)
_write_state(dest, target)
print(f"Moved task directory to {dest}")
else:
_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_paths(project_dir, tasks):
"""Return changed paths outside task folders (data-only sibling of
_audit_category3; used by --audit --json)."""
import subprocess
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:
return []
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)
if not all_changed:
return []
task_names = {name for name, _ in tasks}
unauthorized = []
for changed_file in all_changed:
parts = Path(changed_file).parts
in_task = (len(parts) >= 2 and parts[0] == "tasks" and parts[1] in task_names)
if not in_task:
in_task = (len(parts) >= 3 and parts[0] == ".automaton"
and parts[1] == "tasks" and parts[2] in task_names)
if not in_task:
unauthorized.append(changed_file)
return sorted(unauthorized)
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
# Framework mode: paths like "tasks/mytask/..."
is_in_task_folder = (
len(parts) >= 2 and parts[0] == "tasks" and parts[1] in task_names
)
# Regular project mode: paths like ".automaton/tasks/mytask/..."
if not is_in_task_folder:
is_in_task_folder = (
len(parts) >= 3
and parts[0] == ".automaton"
and parts[1] == "tasks"
and parts[2] 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 _audit_loops_block(args) -> int:
"""R9: print the Loops audit block and return the violation count."""
loops = _all_loop_dirs(args.project)
if not loops:
print("[INFO] No loops found.")
return 0
import time as _ltime
now = _ltime.time()
v = 0
for name, path in loops:
state = _read_state_loop(path)
if state is None:
print(f"[FAIL] {name}: no .state.loop (UNTRACKED loop). Run --create-loop {name} or future --upgrade-loops.")
v += 1
continue
status = state.get("status", "unknown")
if status == "halted":
print(f"[WARN] {name}: HALTED (reason={state.get('halt_reason')}). "
f"Clear with: --approve --loop {name}")
v += 1
elif status == "paused":
print(f"[INFO] {name}: paused. Resume with --resume-loop {name}")
elif status == "running":
last_tick = state.get("last_tick_at")
if last_tick:
try:
age_min = (now - datetime.fromisoformat(last_tick).timestamp()) / 60
if age_min > 120:
print(f"[WARN] {name}: running but last tick was {age_min:.0f} min ago (stale).")
v += 1
else:
print(f"[PASS] {name}: running (last tick {age_min:.0f} min ago).")
except (ValueError, TypeError):
print(f"[PASS] {name}: running (unparseable last_tick_at).")
else:
print(f"[PASS] {name}: running (no ticks yet).")
current_task = state.get("current_task")
if current_task:
if not _task_dir(current_task, args.project).exists():
print(f"[FAIL] {name}: current_task '{current_task}' does not exist.")
v += 1
return v
def _audit_collect(args):
"""Pure data collection used by both --audit (human) and --audit --json.
Returns a dict: {"violations": [...], "loops": [...], "total_tasks": int,
"untracked_tasks": int, "human_violation_count": int}
Each violation: {"category": int, "severity": "high"|"med"|"low",
"task": str|None, "message": str, "resolved": False}
Each loop entry: {"name": str, "status": str, "halt_reason": str|None,
"current_task": str|None, "violation": bool, "message": str}
"""
project_dir = _find_project_dir(args.project)
tasks = _all_task_dirs(args.project)
violations = []
loops = []
untracked = 0
for name, path in tasks:
state_file = path / ".state"
if not state_file.exists():
untracked += 1
phase = _infer_state_from_artifacts(path) or "unknown"
violations.append({"category": 4, "severity": "high",
"task": name,
"message": f"no .state file (manually created or pre-v2.0 task) - inferred phase: {phase}",
"resolved": False})
continue
phase = _read_state(path) or _infer_state_from_artifacts(path) or "unknown"
base = _base_phase(phase)
forbidden = _check_forbidden_artifacts(path, base)
if forbidden:
items_desc = ", ".join(f"{a} ({p} phase artifact)" for a, p in forbidden)
violations.append({"category": 1, "severity": "high",
"task": name,
"message": f"out-of-order artifacts: {items_desc} (phase: {phase})",
"resolved": False})
expected = PHASE_REQUIRED_ARTIFACTS.get(base)
if expected:
af = path / expected
if base == "implement" and (path / "IMPLEMENTATION.md").exists() and (path / "IMPLEMENTATION.md").stat().st_size == 0:
violations.append({"category": 2, "severity": "med",
"task": name,
"message": f"IMPLEMENTATION.md is empty but .state says {base}",
"resolved": False})
elif base in ("bug_find", "adversarial_bug_find", "code_review", "doc_review", "referee") and not (path / "IMPLEMENTATION.md").exists():
violations.append({"category": 2, "severity": "high",
"task": name,
"message": f".state says {base} but IMPLEMENTATION.md is missing",
"resolved": False})
git_dir = project_dir / ".git"
if git_dir.exists():
cat3_paths = _audit_category3_paths(project_dir, tasks)
for path in cat3_paths:
violations.append({"category": 3, "severity": "med",
"task": None,
"message": f"uncommitted change outside task folders: {path}",
"resolved": False})
import time as _time
stuck_threshold = 60
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:
violations.append({"category": 5, "severity": "med",
"task": name,
"message": f"stuck at '{phase}' for {age_minutes:.0f} minutes (threshold: {stuck_threshold} min)",
"resolved": False})
loop_dirs = _all_loop_dirs(args.project)
for lname, lpath in loop_dirs:
lstate = _read_state_loop(lpath)
if lstate is None:
loops.append({"name": lname, "status": "untracked",
"halt_reason": None, "current_task": None,
"violation": True,
"message": "no .state.loop (UNTRACKED loop)"})
continue
lstatus = lstate.get("status", "unknown")
lhalt = lstate.get("halt_reason")
ltask = lstate.get("current_task")
is_violation = False
msg = ""
if lstatus == "halted":
is_violation = True
msg = f"HALTED (reason={lhalt})"
elif lstatus == "paused":
msg = "paused"
elif lstatus == "running":
last_tick = lstate.get("last_tick_at")
if last_tick:
try:
age_min = (_time.time() - datetime.fromisoformat(last_tick).timestamp()) / 60
if age_min > 120:
is_violation = True
msg = f"running but last tick was {age_min:.0f} min ago (stale)"
else:
msg = f"running (last tick {age_min:.0f} min ago)"
except (ValueError, TypeError):
msg = "running (unparseable last_tick_at)"
else:
msg = "running (no ticks yet)"
if ltask and not _task_dir(ltask, args.project).exists():
is_violation = True
msg = f"current_task '{ltask}' does not exist"
loops.append({"name": lname, "status": lstatus,
"halt_reason": lhalt, "current_task": ltask,
"violation": is_violation, "message": msg})
return {"violations": violations,
"loops": loops,
"total_tasks": len(tasks),
"untracked_tasks": untracked}
def cmd_audit(args):
project_dir = _find_project_dir(args.project)
tasks = _all_task_dirs(args.project)
json_mode = bool(getattr(args, "json_output", False))
if json_mode:
data = _audit_collect(args)
print(json.dumps(data))
return 1 if data["violations"] else 0
if not tasks:
# Still audit loops even when no tasks exist (R9).
print("=== Category 6: Loops ===")
violations = _audit_loops_block(args)
print("\n=== Summary ===")
print("0 tasks audited")
if violations:
print(f"{violations} violation(s) found")
return 1
print("No violations 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("\n=== Category 6: Loops ===")
violations += _audit_loops_block(args)
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 _get_edit_timestamp(task_path: Path) -> float:
"""Return the mtime to use for stale-task detection.
Uses ``.state.lastedit`` if it exists (updated by --can-edit on ALLOWED).
Falls back to ``.state`` mtime for backward compatibility.
"""
lastedit = task_path / ".state.lastedit"
if lastedit.exists():
try:
return lastedit.stat().st_mtime
except OSError:
pass
state_file = task_path / ".state"
if state_file.exists():
try:
return state_file.stat().st_mtime
except OSError:
pass
return 0
def _touch_lastedit(task_path: Path) -> None:
"""Update .state.lastedit timestamp to mark edit activity."""
lastedit = task_path / ".state.lastedit"
try:
lastedit.touch()
except OSError:
pass
def cmd_can_edit(args):
project_dir = _find_project_dir(args.project)
if getattr(args, "loop", None):
return cmd_can_edit_loop(args)
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"):
edit_mtime = _get_edit_timestamp(path)
edit_tasks.append((name, base, path, edit_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 + os.sep) or str(file_path) == proj_str:
scope_tasks.append({"task": name, "phase": base, "state_mtime": state_mtime, "path": path})
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]}))
_touch_lastedit(primary["path"])
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]}))
_touch_lastedit(primary[2])
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 + os.sep) or str(file_path) == 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 + os.sep) or str(file_path) == 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"):
import time as _time
edit_mtime = _get_edit_timestamp(task_path)
now = _time.time()
age_minutes = (now - edit_mtime) / 60
if age_minutes > 30:
print(f"DENIED: Task '{args.task}' has been in {base} 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": args.task, "stale_minutes": round(age_minutes)}))
return 1
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}))
_touch_lastedit(task_path)
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.lastedit 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
_touch_lastedit(task_path)
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 + os.sep) or str(file_path) == 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 + os.sep) or str(file_path) == 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 = _get_edit_timestamp(task_path)
age_minutes = (time.time() - mtime) / 60
threshold = 30
if age_minutes < threshold:
print(f"SAME_SESSION: Task '{args.task}' last edit activity {age_minutes:.0f} minutes ago (threshold: {threshold} min)")
return 1
print(f"DIFFERENT_SESSION: Task '{args.task}' last edit activity {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
# ============================================================================
# Loop management (v1 — task add-status-brakes)
#
# All loop-aware behavior lives inside status.py so harnesses cannot route
# around it. Loops are stored per-project under <project>/.automaton/loops/
# <name>/. The .state.loop file is the single source of truth for runtime
# state. Loops without .state.loop are UNTRACKED. See design/loops/technical.md.
# ============================================================================
LOOP_STATES = ("running", "halted", "paused", "complete")
LOOP_HALTS = (
"iterations_exhausted",
"budget_exhausted",
"verifier_failed",
"drift_detected",
"human_intervention",
)
LOOP_STATE_SCHEMA_VERSION = 1
LOOP_TICK_LOG_NAME = ".state.log"
LOOP_STATE_FILE = ".state.loop"
LOOP_CONFIG_FILE = "loop.json"
LOOP_WORKTREE_DIR = "worktree"
LOOP_TICK_SCRIPT_SH = "automaton-loop-tick.sh"
LOOP_TICK_SCRIPT_BAT = "automaton-loop-tick.bat"
LOOP_CONTEXT_FLOOR_KB = 16_000 # mirrors vram_detect.LOOP_MODE_CONTEXT_FLOOR_KB
CLEANUP_DEFAULT_DAYS = 7
CLEANUP_DEFAULT_INTERVAL_SEC = 86400 # daily
CLEANUP_TICK_SCRIPT_SH = "automaton-cleanup.sh"
CLEANUP_TICK_SCRIPT_BAT = "automaton-cleanup.bat"
CLEANUP_LABEL = "cleanup" # launchd label: com.automaton.cleanup
def _completed_task_age_days(task_path: Path) -> Optional[float]:
"""Days since the task last transitioned to its current phase, measured by
the ``.state`` file mtime (the moment ``_write_state`` recorded completion).
Returns None if ``.state`` is missing or unreadable."""
state_file = task_path / ".state"
if not state_file.exists():
return None
try:
mtime = state_file.stat().st_mtime
except OSError:
return None
return (datetime.now(timezone.utc).timestamp() - mtime) / 86400.0
def _launchagents_dir() -> Path:
"""LaunchAgents directory, overridable via AUTOMATON_LAUNCHAGENTS_DIR so
tests can redirect plist writes to a temp dir without touching the host."""
override = os.environ.get("AUTOMATON_LAUNCHAGENTS_DIR")
if override:
return Path(override)
return Path.home() / "Library" / "LaunchAgents"
def _tasks_root_for(task_path: Path) -> Path:
"""Return the tasks directory that should hold the ``complete/`` archive
for this task (handles subtask paths)."""
parent = task_path.parent
if parent.name == "complete":
return parent.parent
return parent
def _loops_dir(project: Optional[str] = None) -> Path:
project_dir = _find_project_dir(project)
if project_dir == AUTOMATON_DIR:
return AUTOMATON_DIR / "loops"
return project_dir / ".automaton" / "loops"
def _loop_dir(name: str, project: Optional[str] = None) -> Path:
if not _is_kebab_case(name):
raise ValueError(f"loop name '{name}' must be kebab-case")
return _loops_dir(project) / name
def _all_loop_dirs(project: Optional[str] = None) -> list[tuple[str, Path]]:
base = _loops_dir(project)
out: list[tuple[str, Path]] = []
if not base.exists():
return out
for entry in sorted(base.iterdir()):
if entry.is_dir() and not entry.name.startswith("."):
out.append((entry.name, entry))
return out
def _read_state_loop(loop_path: Path) -> Optional[dict]:
state_file = loop_path / LOOP_STATE_FILE
if not state_file.exists():
return None
try:
return json.loads(state_file.read_text())
except (OSError, json.JSONDecodeError):
return None
def _write_state_loop(loop_path: Path, state: dict) -> None:
state_file = loop_path / LOOP_STATE_FILE
tmp = loop_path / ".state.loop.tmp"
tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
tmp.replace(state_file)
def _initial_state_loop(name: str) -> dict:
return {
"schema_version": LOOP_STATE_SCHEMA_VERSION,
"name": name,
"status": "running",
"halt_reason": None,
"iteration_count": 0,
"resumed_count": 0,
"last_tick_at": None,
"last_verdict": None,
"score_history": [],
"current_task": None,
"worktree_branch": None,
"worktree_path": None,
}
def _read_loop_config(loop_path: Path) -> Optional[dict]:
cfg = loop_path / LOOP_CONFIG_FILE
if not cfg.exists():
return None
try:
return json.loads(cfg.read_text())
except (OSError, json.JSONDecodeError):
return None
def _append_tick_log(loop_path: Path, line: str) -> None:
log = loop_path / LOOP_TICK_LOG_NAME
ts = datetime.now(timezone.utc).isoformat()
with log.open("a", encoding="utf-8") as fh:
fh.write(f"[{ts}] {line}\n")
def _loop_owning_task(task_name: str, project: Optional[str] = None) -> Optional[tuple[str, Path]]:
"""Scan all loops for one whose current_task == task_name. Returns (name, path) or None."""
for name, path in _all_loop_dirs(project):
state = _read_state_loop(path)
if state and state.get("current_task") == task_name:
return (name, path)
return None
def _loop_untracked_hint(name: str) -> str:
return (f"ERROR: loop '{name}' has no {LOOP_STATE_FILE} (UNTRACKED). "
f"All --loop commands refuse. Run --create-loop (or future "
f"--upgrade-loops) to bootstrap.")
def _halt_loop(loop_path: Path, state: dict, reason: str, project: Optional[str]) -> None:
state["status"] = "halted"
state["halt_reason"] = reason
_write_state_loop(loop_path, state)
_append_tick_log(loop_path, f"HALT reason={reason}")
# Best-effort schedule disable — never fatal if it fails (no cron/plist on
# this platform, etc.). The .state.loop is the source of truth; the OS unit
# will read the state on next wake and self-skip.
try:
_disable_schedule(loop_path.name, project)
except Exception:
pass
def _claim_loop_task_impl(name: str, task_name: str, project: Optional[str]) -> int:
"""Implement --claim-loop-task.
Scans all loops to verify no OTHER loop already owns task_name.
If self owns it → exit 0 (idempotent).
If other loop owns it → prints 'task_already_claimed:{other}' to stderr, exit 2.
If nobody owns it → sets state['current_task'] = task_name, writes .state.loop, exit 0.
If loop is untracked → prints 'loop_untracked' to stderr, exit 2.
Must be called inside the claiming loop's _loop_lock to serialize writes.
"""
loop_path = _loop_dir(name, project)
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name), file=sys.stderr)
return 2
# Self-ownership check (idempotent)
if state.get("current_task") == task_name:
print("already_self_claimed")
return 0
# Cross-loop scan: check no OTHER running/paused loop owns this task
for other_name, other_path in _all_loop_dirs(project):
if other_name == name:
continue
other_state = _read_state_loop(other_path)
if other_state is None:
continue
other_status = other_state.get("status")
if other_status not in ("running", "paused"):
continue
if other_state.get("current_task") == task_name:
print(f"task_already_claimed:{other_name}", file=sys.stderr)
return 2
# Nobody owns it → claim it
state["current_task"] = task_name
_write_state_loop(loop_path, state)
print("OK")
return 0
def cmd_claim_loop_task(args) -> int:
name = args.claim_loop_task
task_name = args.task
if not task_name:
print("ERROR: --task is required for --claim-loop-task", file=sys.stderr)
return 2
if not name:
print("ERROR: --claim-loop-task requires a loop name", file=sys.stderr)
return 2
loop_path = _loop_dir(name, args.project)
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name), file=sys.stderr)
return 2
with _loop_lock(loop_path):
return _claim_loop_task_impl(name, task_name, args.project)
_LOOP_LOCK_ENV_BYPASS = "AUTOMATON_NO_LOOP_LOCK"
@contextlib.contextmanager
def _loop_lock(loop_path: Path, exclusive: bool = True):
"""Cross-process file lock on <loop_path>/.state.lock.
Used to serialize read-modify-write cycles on `.state.loop` across
concurrent ticks (two scheduler firings, or a tick vs `--approve` or
`--pause-loop`). Blocking acquire; no timeout (operators notice a wedged
tick via `--loop-list` stale `last_tick_at`). Per-loop granularity: the
`.state.lock` file lives in the loop's own dir, not the framework root.
NOT re-entrant across processes. When the runner invokes
`status.py --check-gate` as a subprocess while holding the parent's lock,
the subprocess would otherwise deadlock waiting on the same flock. To
avoid this, the runner sets $AUTOMATON_NO_LOOP_LOCK=1 in the subprocess
environment; `_loop_lock` sees that env var and becomes a no-op
(`yield` without flock), trusting the caller's outer lock to cover the
critical section. Manual CLI invocations don't set the env var, so they
lock normally.
NFS caveat: `flock` semantics differ on NFS-mounted loops. The loop dir
is documented to be local (project root or `~/.automaton`).
"""
if os.environ.get(_LOOP_LOCK_ENV_BYPASS) == "1":
yield
return
lock_file = loop_path / ".state.lock"
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
acquired = False
try:
if sys.platform == "win32":
import msvcrt
msvcrt.locking(fd, msvcrt.LK_LOCK if exclusive else msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
acquired = True
yield
finally:
if acquired:
if sys.platform == "win32":
import msvcrt
try:
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
except OSError:
pass
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def _disable_schedule(name: str, project: Optional[str]) -> None:
"""Best-effort schedule disable. Implemented per R6 — see _install_schedule."""
loop_path = _loop_dir(name, project)
system = platform.system()
if system == "Darwin":
plist = _launchagents_dir() / f"com.automaton.loop.{name}.plist"
if plist.exists():
plist.rename(plist.with_suffix(".plist.disabled"))
try:
subprocess.run(["launchctl", "unload", str(plist)],
capture_output=True, timeout=5, check=False)
except (OSError, subprocess.SubprocessError):
pass
elif system == "Linux":
try:
res = subprocess.run(["crontab", "-l"], capture_output=True, text=True,
timeout=5, check=False)
lines = res.stdout.splitlines() if res.returncode == 0 else []
kept = []
inside_block = False
for line in lines:
if line.strip() == f"# automaton-loop:{name}":
inside_block = True
continue
if inside_block and line.strip().startswith("# end automaton-loop:"):
inside_block = False
continue
if not inside_block:
kept.append(line)
subprocess.run(["crontab", "-"], input="\n".join(kept) + "\n",
capture_output=True, text=True, timeout=5, check=False)
except (OSError, subprocess.SubprocessError):
pass
elif system == "Windows":
subprocess.run(["schtasks", "/end", "/tn", f"AutomatonLoop_{name}"],
capture_output=True, timeout=5, check=False)
def _install_cron_block_generic(marker: str, stub_path: Path, interval: int) -> int:
"""Install (or re-install) an idempotent cron block delimited by
``# <marker>`` / ``# end <marker>``. Strips any prior block with the same
marker before appending the new one. Returns 0 on success, 2 on failure."""
minutes_interval = max(1, interval // 60)
try:
res = subprocess.run(["crontab", "-l"], capture_output=True, text=True,
timeout=5, check=False)
existing = res.stdout.splitlines() if res.returncode == 0 else []
except (OSError, subprocess.SubprocessError):
existing = []
kept = []
inside = False
for line in existing:
if line.strip() == f"# {marker}":
inside = True
continue
if inside and line.strip() == f"# end {marker}":
inside = False
continue
if not inside:
kept.append(line)
kept.append(f"# {marker}")
kept.append(f"*/{minutes_interval} * * * * {stub_path}")
kept.append(f"# end {marker}")
try:
subprocess.run(["crontab", "-"], input="\n".join(kept) + "\n",
capture_output=True, text=True, timeout=5, check=False)
except (OSError, subprocess.SubprocessError) as exc:
print(f"ERROR: failed to write crontab: {exc}")
return 2
return 0
def _install_cron_block(name: str, loop_path: Path, interval: int) -> int:
"""Install (or re-install) the cron block for a loop. Idempotent: strips
any prior block for the same loop name before appending the new one.
Returns 0 on success, 2 on crontab write failure.
"""
stub_path = loop_path / LOOP_TICK_SCRIPT_SH
return _install_cron_block_generic(f"automaton-loop:{name}", stub_path, interval)
def _enable_schedule(name: str, project: Optional[str]) -> None:
"""Inverse of _disable_schedule. Called from --resume-loop after pause clear."""
loop_path = _loop_dir(name, project)
system = platform.system()
if system == "Darwin":
plist = _launchagents_dir() / f"com.automaton.loop.{name}.plist"
disabled = plist.with_suffix(".plist.disabled")
if disabled.exists() and not plist.exists():
disabled.rename(plist)
elif system == "Linux":
stub = loop_path / LOOP_TICK_SCRIPT_SH
if not stub.exists():
print(f"WARNING: cannot re-enable schedule for '{name}': "
f"no tick stub at {stub}; run --install-schedule first.",
file=sys.stderr)
return
cfg = _read_loop_config(loop_path) or {}
raw_interval = cfg.get("schedule", {}).get("interval_seconds", 3600)
try:
interval = int(raw_interval)
except (TypeError, ValueError):
interval = 3600
_install_cron_block(name, loop_path, interval)
elif system == "Windows":
subprocess.run(["schtasks", "/run", "/tn", f"AutomatonLoop_{name}"],
capture_output=True, timeout=5, check=False)
# ---------------------------------------------------------------------------
# cmd_* functions for loop commands
# ---------------------------------------------------------------------------
def cmd_create_loop(args) -> int:
name = args.create_loop
template = args.from_template
if not _is_kebab_case(name):
print(f"ERROR: loop name '{name}' must be kebab-case")
return 2
templates_dir = AUTOMATON_DIR / "templates" / "loops"
template_dir = templates_dir / template
if not template_dir.exists() or not (template_dir / "loop.json").exists():
print(f"ERROR: template '{template}' not found at {template_dir}/loop.json")
return 2
loop_path = _loop_dir(name, args.project)
if loop_path.exists():
print(f"ERROR: loop '{name}' already exists at {loop_path}")
return 2
loop_path.mkdir(parents=True)
cfg_src = template_dir / "loop.json"
cfg_dst = loop_path / LOOP_CONFIG_FILE
cfg_dst.write_text(cfg_src.read_text())
# Patch the "name" field to match the actual loop name (templates use a placeholder).
try:
cfg = json.loads(cfg_dst.read_text())
if "name" not in cfg or cfg.get("name") != name:
cfg["name"] = name
cfg_dst.write_text(json.dumps(cfg, indent=2) + "\n")
except json.JSONDecodeError:
pass
_write_state_loop(loop_path, _initial_state_loop(name))
(loop_path / LOOP_TICK_LOG_NAME).write_text("")
print(f"Created loop '{name}' from template '{template}'.")
print(f"State: running. Install schedule with: --install-schedule {name}")
return 0
def cmd_install_schedule(args) -> int:
name = args.install_schedule
loop_path = _loop_dir(name, args.project)
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
cfg = _read_loop_config(loop_path)
if cfg is None:
print(f"ERROR: loop '{name}' has no {LOOP_CONFIG_FILE}; cannot install schedule")
return 2
interval = args.interval
if interval is None:
interval = int(cfg.get("schedule", {}).get("interval_seconds", 3600))
project_dir = _find_project_dir(args.project)
framework_dir = AUTOMATON_DIR
runner = framework_dir / "scripts" / "loop-runner.py"
project_root_str = str(project_dir)
framework_str = str(framework_dir)
# Generate the OS-unit tick stub (locked by technical.md §6).
if platform.system() == "Windows":
stub_path = loop_path / LOOP_TICK_SCRIPT_BAT
stub = (
"@echo off\r\n"
f"cd /d \"{project_root_str}\"\r\n"
f"python \"{runner}\" --mode tick --loop \"{name}\"\r\n"
)
else:
stub_path = loop_path / LOOP_TICK_SCRIPT_SH
stub = (
"#!/usr/bin/env bash\n"
f"cd \"{project_root_str}\"\n"
f"python3 \"{runner}\" --mode tick --loop \"{name}\"\n"
)
stub_path.write_text(stub)
if platform.system() != "Windows":
stub_path.chmod(0o755)
system = platform.system()
if system == "Darwin":
plist_dir = _launchagents_dir()
plist_dir.mkdir(parents=True, exist_ok=True)
plist_path = plist_dir / f"com.automaton.loop.{name}.plist"
plist = (
f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
f"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" "
f"\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n"
f"<plist version=\"1.0\">\n"
f"<dict>\n"
f" <key>Label</key><string>com.automaton.loop.{name}</string>\n"
f" <key>ProgramArguments</key>\n"
f" <array>\n"
f" <string>{stub_path}</string>\n"
f" </array>\n"
f" <key>StartInterval</key><integer>{interval}</integer>\n"
f" <key>RunAtLoad</key><false/>\n"
f"</dict>\n"
f"</plist>\n"
)
plist_path.write_text(plist)
print(f"Installed launchd unit: {plist_path}")
print(f"Interval: {interval}s. Tick stub: {stub_path}")
return 0
elif system == "Linux":
rc = _install_cron_block(name, loop_path, interval)
if rc == 0:
minutes_interval = max(1, interval // 60)
print(f"Installed crontab block (every {minutes_interval} min). Tick stub: {stub_path}")
return rc
elif system == "Windows":
minutes_interval = max(1, interval // 60)
subprocess.run(
["schtasks", "/create", "/tn", f"AutomatonLoop_{name}",
"/tr", str(stub_path), "/sc", "minute",
"/mo", str(minutes_interval), "/f"],
capture_output=True, timeout=10, check=False,
)
print(f"Installed schtasks unit (every {minutes_interval} min). Tick stub: {stub_path}")
return 0
else:
print(f"ERROR: unsupported platform '{system}' for --install-schedule")
return 2
def cmd_cleanup_done(args) -> int:
"""--cleanup-done [--days N] [--dry-run] [--json]
Move tasks whose ``.state`` base phase is ``complete`` and whose completion
age (measured by ``.state`` mtime) is >= --days into the ``tasks/complete/``
archive folder. Archived tasks are excluded from ``--list``/``--audit`` and
from the dashboard. Idempotent. ``--dry-run`` lists candidates without moving.
"""
days = args.days if args.days is not None else CLEANUP_DEFAULT_DAYS
if days < 0:
print("ERROR: --days must be non-negative")
return 2
tasks = _all_task_dirs(args.project)
moved = []
skipped = []
candidates = []
for name, path in tasks:
phase = _read_state(path)
if phase is None:
continue
if _base_phase(phase) != "complete":
continue
age = _completed_task_age_days(path)
if age is None:
continue
if age < days:
continue
candidates.append((name, path, age))
tasks_root = _tasks_root_for(path)
complete_dir = tasks_root / "complete"
dest = complete_dir / name
if dest.exists():
skipped.append({"name": name, "reason": "dest_exists", "age_days": round(age, 2)})
continue
if args.dry_run:
moved.append({"name": name, "dest": str(dest), "age_days": round(age, 2),
"dry_run": True})
continue
complete_dir.mkdir(parents=True, exist_ok=True)
try:
path.rename(dest)
except OSError as exc:
skipped.append({"name": name, "reason": f"rename_failed: {exc}",
"age_days": round(age, 2)})
continue
moved.append({"name": name, "dest": str(dest), "age_days": round(age, 2)})
verb = "would move" if args.dry_run else "moved"
for item in moved:
print(f"[{verb}] {item['name']} (age {item['age_days']}d) -> {item['dest']}")
for item in skipped:
print(f"[skipped] {item['name']} ({item['reason']}, age {item['age_days']}d)")
if args.json_output:
summary = {
"days": days, "dry_run": bool(args.dry_run),
"moved": [m["name"] for m in moved],
"skipped": skipped, "candidates": [c[0] for c in candidates],
}
print(json.dumps(summary))
return 0
def _cleanup_stub_path() -> Path:
return AUTOMATON_DIR / "scripts" / "automaton-cleanup.sh"
def cmd_install_cleanup_schedule(args) -> int:
"""--install-cleanup-schedule [--days N] [--interval SEC]
Install an OS scheduler unit that runs ``--cleanup-done`` periodically
(default daily). Mirrors ``--install-schedule`` but for the cleanup job,
which is framework-wide (project = the framework dir)."""
days = args.days if args.days is not None else CLEANUP_DEFAULT_DAYS
interval = args.interval if args.interval is not None else CLEANUP_DEFAULT_INTERVAL_SEC
project_dir = _find_project_dir(args.project)
status_script = AUTOMATON_DIR / "scripts" / "status.py"
project_arg = f"--project \"{project_dir}\"" if str(project_dir) != str(AUTOMATON_DIR) else ""
if platform.system() == "Windows":
stub_path = AUTOMATON_DIR / "scripts" / "automaton-cleanup.bat"
stub = (
"@echo off\r\n"
f"python \"{status_script}\" --cleanup-done --days {days} {project_arg}\r\n"
)
else:
stub_path = _cleanup_stub_path()
stub = (
"#!/usr/bin/env bash\n"
f"python3 \"{status_script}\" --cleanup-done --days {days}"
+ (f" {project_arg}\n" if project_arg else "\n")
)
stub_path.write_text(stub)
if platform.system() != "Windows":
stub_path.chmod(0o755)
system = platform.system()
if system == "Darwin":
plist_dir = _launchagents_dir()
plist_dir.mkdir(parents=True, exist_ok=True)
plist_path = plist_dir / f"com.automaton.cleanup.plist"
plist = (
f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
f"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" "
f"\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n"
f"<plist version=\"1.0\">\n"
f"<dict>\n"
f" <key>Label</key><string>com.automaton.cleanup</string>\n"
f" <key>ProgramArguments</key>\n"
f" <array>\n"
f" <string>{stub_path}</string>\n"
f" </array>\n"
f" <key>StartInterval</key><integer>{interval}</integer>\n"
f" <key>RunAtLoad</key><false/>\n"
f"</dict>\n"
f"</plist>\n"
)
plist_path.write_text(plist)
print(f"Installed launchd unit: {plist_path}")
print(f"Interval: {interval}s, cleanup threshold: {days}d. Stub: {stub_path}")
return 0
elif system == "Linux":
rc = _install_cron_block_generic("automaton-cleanup", stub_path, interval)
if rc == 0:
print(f"Installed crontab block (every {max(1, interval // 60)} min), "
f"threshold {days}d. Stub: {stub_path}")
return rc
elif system == "Windows":
minutes_interval = max(1, interval // 60)
subprocess.run(
["schtasks", "/create", "/tn", "AutomatonCleanup",
"/tr", str(stub_path), "/sc", "minute",
"/mo", str(minutes_interval), "/f"],
capture_output=True, timeout=10, check=False,
)
print(f"Installed schtasks unit (every {minutes_interval} min). Stub: {stub_path}")
return 0
else:
print(f"ERROR: unsupported platform '{system}' for --install-cleanup-schedule")
return 2
def _parse_launchd_plist(plist_path: Path) -> dict:
"""Minimal plist extractor for ProgramArguments, StartInterval, Label."""
import plistlib
try:
with plist_path.open("rb") as fh:
data = plistlib.load(fh)
return data
except (OSError, ValueError):
return {}
def _read_loop_runtime_state(name: str, project: Optional[str]) -> Optional[dict]:
try:
loop_path = _loop_dir(name, project)
except ValueError:
return None
return _read_state_loop(loop_path)
def _list_scheduled_jobs(project: Optional[str] = None) -> list[dict]:
"""Enumerate installed scheduled jobs (loops + cleanup) for the dashboard
'Background' tab. Each entry: ``{label, kind, status, next_run_seconds,
stub_path, runtime_state?}``. ``status`` ∈ {enabled, disabled, misconfigured}."""
jobs: list[dict] = []
system = platform.system()
def _launchd_label_kind(label: str) -> tuple[str, str]:
# returns (kind, name_or_empty)
if label.startswith("com.automaton.loop."):
return ("loop", label[len("com.automaton.loop."):])
if label in ("com.automaton.cleanup",):
return ("cleanup", "")
if label.startswith("com.automaton.cleanup."):
return ("cleanup", label[len("com.automaton.cleanup."):])
return ("unknown", label)
if system == "Darwin" or system == "Windows":
# On Windows we cannot easily enumerate schtasks entries here; on Darwin
# we scan LaunchAgents. (Windows listing is best-effort left to the
# dashboard surfacing 'unknown' for the tab.)
if system == "Darwin":
plist_dir = _launchagents_dir()
if plist_dir.exists():
for entry in sorted(plist_dir.iterdir()):
if not entry.name.startswith("com.automaton."):
continue
disabled = entry.name.endswith(".plist.disabled")
real = entry.with_suffix("") if disabled else entry
label = real.stem # filename without .plist
data = _parse_launchd_plist(real) if real.exists() else {}
interval = data.get("StartInterval")
stub = (data.get("ProgramArguments") or [None])[0]
kind, sub = _launchd_label_kind(label)
runtime = None
agent_type = None
if kind == "loop" and sub:
s = _read_loop_runtime_state(sub, project)
runtime = s
loop_dir = _loop_dir(sub, project)
cfg = _read_loop_config(loop_dir) or {}
ws = cfg.get("work_source") or {}
agent_type = ws.get("kind", "single")
status = "misconfigured" if not real.exists() else (
"disabled" if disabled else "enabled")
# flag stub pointing at a missing path
if stub and not Path(stub).exists():
status = "misconfigured"
jobs.append({
"label": label, "kind": kind, "name": sub,
"status": status, "next_run_seconds": interval,
"stub_path": stub, "runtime_state": runtime,
"agent_type": agent_type,
})
if system == "Linux":
try:
res = subprocess.run(["crontab", "-l"], capture_output=True, text=True,
timeout=5, check=False)
lines = res.stdout.splitlines() if res.returncode == 0 else []
except (OSError, subprocess.SubprocessError):
lines = []
marker = None
for line in lines:
s = line.strip()
if s.startswith("# automaton-loop:") or s.startswith("#automaton-loop:"):
marker = s.lstrip("#").strip()
name = marker.split("automaton-loop:")[1]
cfg = _read_loop_config(_loop_dir(name, project)) or {}
ws = cfg.get("work_source") or {}
agent_type = ws.get("kind", "single")
jobs.append({
"label": f"automaton-loop:{name}", "kind": "loop", "name": name,
"status": "enabled", "next_run_seconds": None,
"stub_path": None, "runtime_state": _read_loop_runtime_state(name, project),
"agent_type": agent_type,
})
elif s in ("# automaton-cleanup", "#automaton-cleanup"):
jobs.append({
"label": "automaton-cleanup", "kind": "cleanup", "name": "",
"status": "enabled", "next_run_seconds": None,
"stub_path": None, "runtime_state": None,
})
return jobs
def cmd_pause_loop(args) -> int:
name = args.pause_loop
loop_path = _loop_dir(name, args.project)
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
# Serialize read-modify-write against concurrent ticks / approve.
with _loop_lock(loop_path):
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
if state["status"] != "running" and state["status"] != "paused":
print(f"ERROR: loop '{name}' is in status '{state['status']}', "
f"cannot pause. Only 'running' can be paused.")
return 1
state["status"] = "paused"
_write_state_loop(loop_path, state)
_append_tick_log(loop_path, "PAUSED by user")
_disable_schedule(name, args.project)
print(f"Paused loop '{name}'. Schedule disabled. Resume with --resume-loop {name}")
return 0
def cmd_resume_loop(args) -> int:
name = args.resume_loop
loop_path = _loop_dir(name, args.project)
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
with _loop_lock(loop_path):
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
if state["status"] == "halted":
print(f"ERROR: loop '{name}' is HALTED (halt_reason={state['halt_reason']}). "
f"--resume-loop cannot clear halts. Use: --approve --loop {name}")
return 1
if state["status"] != "paused":
print(f"ERROR: loop '{name}' is in status '{state['status']}', "
f"cannot resume. Only 'paused' can be resumed.")
return 1
state["status"] = "running"
_write_state_loop(loop_path, state)
_append_tick_log(loop_path, "RESUMED by user")
_enable_schedule(name, args.project)
print(f"Resumed loop '{name}'. Schedule re-enabled.")
return 0
def cmd_approve_loop(args) -> int:
"""--approve --loop <name> — the only way to clear a halt. (D4)"""
name = args.loop
loop_path = _loop_dir(name, args.project)
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
with _loop_lock(loop_path):
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
if state["status"] != "halted":
print(f"ERROR: loop '{name}' is in status '{state['status']}', not 'halted'. "
f"--approve --loop only clears halts.")
return 1
state["status"] = "running"
state["halt_reason"] = None
state["resumed_count"] = int(state.get("resumed_count", 0)) + 1
_write_state_loop(loop_path, state)
_append_tick_log(loop_path, f"APPROVED by user (resumed_count={state['resumed_count']})")
_enable_schedule(name, args.project)
print(f"Approved loop '{name}'. Halt cleared. Resumed count: {state['resumed_count']}")
return 0
def _gate_loop_status(loop_path: Path, state: dict, cfg: dict) -> Optional[dict]:
if state["status"] != "running":
return {
"ok": False,
"reason": f"{state['status']}:{state.get('halt_reason') or ''}".rstrip(":"),
"halt_reason": state.get("halt_reason"),
"remaining_iterations": max(0, _loop_max_iterations(cfg) - int(state.get("iteration_count", 0))),
"remaining_budget_usd": None,
"task_phase": None,
"task_in_halt_loop": state["status"] == "halted",
"out_of_scope_files": [],
}
return None # running — no failure on this check
def _loop_max_iterations(cfg: Optional[dict]) -> int:
if cfg is None:
return 0
return int(cfg.get("brakes", {}).get("max_iterations", 0))
def _gate_iterations(state: dict, cfg: dict) -> Optional[dict]:
max_iter = _loop_max_iterations(cfg)
if max_iter <= 0:
return None # no cap configured
remaining = max_iter - int(state.get("iteration_count", 0))
if remaining <= 0:
return {
"ok": False,
"reason": "halted:iterations_exhausted",
"halt_reason": "iterations_exhausted",
"remaining_iterations": 0,
"remaining_budget_usd": None,
"task_phase": None,
"task_in_halt_loop": True,
"out_of_scope_files": [],
}
return None
def _gate_budget(state: dict, cfg: dict, loop_path: Path) -> Optional[dict]:
max_budget = cfg.get("brakes", {}).get("max_budget_usd")
if max_budget is None:
return None # informational only, remote-only per D3
# Best-effort: read from a cost.json the harness may have written.
cost_file = loop_path / "cost.json"
if cost_file.exists():
try:
spent = json.loads(cost_file.read_text()).get("spent_usd", 0.0)
except (OSError, json.JSONDecodeError):
spent = 0.0
else:
spent = 0.0
if spent >= float(max_budget):
return {
"ok": False,
"reason": "halted:budget_exhausted",
"halt_reason": "budget_exhausted",
"remaining_iterations": None,
"remaining_budget_usd": max(0.0, float(max_budget) - spent),
"task_phase": None,
"task_in_halt_loop": True,
"out_of_scope_files": [],
}
return None
def _gate_task_phase(state: dict, cfg: dict, project: Optional[str]) -> Optional[dict]:
current_task = state.get("current_task")
if not current_task:
return None
task_path = _task_dir(current_task, project)
if not task_path.exists():
return {
"ok": False,
"reason": "halted:human_intervention",
"halt_reason": "human_intervention",
"remaining_iterations": None,
"remaining_budget_usd": None,
"task_phase": None,
"task_in_halt_loop": True,
"out_of_scope_files": [],
}
task_state = _read_state(task_path)
if task_state == "human_intervention":
return {
"ok": False,
"reason": "halted:human_intervention",
"halt_reason": "human_intervention",
"remaining_iterations": None,
"remaining_budget_usd": None,
"task_phase": task_state,
"task_in_halt_loop": True,
"out_of_scope_files": [],
}
return None
def _base_branch(cfg: Optional[dict]) -> str:
if cfg is None:
return "main"
br = (cfg.get("blast_radius") or {}).get("base_branch")
if br is None:
return "main"
if not isinstance(br, str):
print(f"WARNING: blast_radius.base_branch={br!r} is not a string; "
f"coercing to {str(br)!r}", file=sys.stderr)
return str(br)
if br.strip() == "":
print(f"WARNING: blast_radius.base_branch is empty; "
f"falling back to 'main'", file=sys.stderr)
return "main"
return br
def _gate_worktree_drift(state: dict, cfg: dict, project: Optional[str]) -> Optional[dict]:
if not state.get("worktree_path"):
return None # --no-worktree mode — drift check N/A
worktree_path = Path(state["worktree_path"])
if not worktree_path.exists():
return None # worktree missing — runner will recreate; treat as no-drift
file_scope = cfg.get("blast_radius", {}).get("file_scope", [])
if not file_scope:
return None
base = _base_branch(cfg)
try:
res = subprocess.run(
["git", "diff", "--name-only", f"{base}...HEAD"],
cwd=worktree_path, capture_output=True, text=True, timeout=10, check=False,
)
if res.returncode != 0:
print(f"WARNING: could not run git diff for drift check: {res.stderr.strip()}",
file=sys.stderr)
return None # no-git environment — skip with warning, not halt
changed = [l.strip() for l in res.stdout.splitlines() if l.strip()]
except (OSError, subprocess.SubprocessError) as exc:
print(f"WARNING: drift check subprocess failed: {exc}", file=sys.stderr)
return None
out_of_scope: list[str] = []
for f in changed:
if not any(f.startswith(scope) for scope in file_scope):
out_of_scope.append(f)
if out_of_scope:
return {
"ok": False,
"reason": "halted:drift_detected",
"halt_reason": "drift_detected",
"remaining_iterations": None,
"remaining_budget_usd": None,
"task_phase": None,
"task_in_halt_loop": True,
"out_of_scope_files": out_of_scope,
}
return None
def _gate_score_plateau(state: dict, cfg: dict) -> Optional[dict]:
window = int(cfg.get("brakes", {}).get("score_plateau_window", 0))
if window <= 0:
return None
history = list(state.get("score_history", []))[-window:]
if len(history) < window:
return None
# Flat or monotonically non-increasing across the window.
if all(s <= history[0] for s in history[1:]):
return {
"ok": False,
"reason": "halted:verifier_failed",
"halt_reason": "verifier_failed",
"remaining_iterations": None,
"remaining_budget_usd": None,
"task_phase": None,
"task_in_halt_loop": True,
"out_of_scope_files": [],
}
return None
def cmd_check_gate(args) -> int:
name = args.check_gate
loop_path = _loop_dir(name, args.project)
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
cfg = _read_loop_config(loop_path) or {}
# Serialize read-evaluate-(maybe halt-write) against concurrent ticks /
# --pause / --approve. When invoked as a subprocess by the loop runner,
# the runner sets $AUTOMATON_NO_LOOP_LOCK=1 and `_loop_lock` becomes a
# no-op (the runner's outer `with _loop_lock` covers the full tick). When
# invoked standalone (manual CLI), env var is unset and this acquires the
# lock normally. Either way the critical section below is serialized.
with _loop_lock(loop_path):
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
gates = [
_gate_loop_status(loop_path, state, cfg),
_gate_iterations(state, cfg),
_gate_budget(state, cfg, loop_path),
_gate_task_phase(state, cfg, args.project),
_gate_worktree_drift(state, cfg, args.project),
_gate_score_plateau(state, cfg),
]
failure = next((g for g in gates if g is not None), None)
if failure is None:
out = {
"ok": True,
"reason": "running",
"halt_reason": None,
"remaining_iterations": max(0, _loop_max_iterations(cfg) - int(state.get("iteration_count", 0))),
"remaining_budget_usd": cfg.get("brakes", {}).get("max_budget_usd"),
"task_phase": _task_phase_for_loop(state, args.project),
"task_in_halt_loop": False,
"out_of_scope_files": [],
}
verdict = out
else:
verdict = failure
# Atomically halt the loop for halts that are non-transient (verifier
# plateau, drift). iterations_exhausted / budget_exhausted already
# fired via _gate_iterations/_gate_budget returning the failure dict;
# mark halted consistently.
if state["status"] != "halted" and failure.get("halt_reason"):
_halt_loop(loop_path, state, failure["halt_reason"], args.project)
# refresh state from disk after halt
state = _read_state_loop(loop_path) or state
if args.json_output:
print(json.dumps(verdict))
else:
print(f"loop: {name}")
print(f"ok: {verdict['ok']}")
print(f"reason: {verdict['reason']}")
if verdict.get("halt_reason"):
print(f"halt_reason: {verdict['halt_reason']}")
print(f"remaining_iterations: {verdict.get('remaining_iterations')}")
print(f"remaining_budget_usd: {verdict.get('remaining_budget_usd')}")
print(f"task_phase: {verdict.get('task_phase')}")
print(f"task_in_halt_loop: {verdict.get('task_in_halt_loop')}")
if verdict.get("out_of_scope_files"):
print(f"out_of_scope_files: {verdict['out_of_scope_files']}")
return 0 if verdict["ok"] else 1
def _task_phase_for_loop(state: dict, project: Optional[str]) -> Optional[str]:
current_task = state.get("current_task")
if not current_task:
return None
task_path = _task_dir(current_task, project)
return _read_state(task_path)
def cmd_can_continue(args) -> int:
name = args.can_continue
loop_path = _loop_dir(name, args.project)
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
out = {
"ok": state["status"] == "running",
"status": state["status"],
"halt_reason": state.get("halt_reason"),
"name": name,
}
if args.json_output:
print(json.dumps(out))
else:
print(f"loop: {name}")
print(f"status: {state['status']}")
print(f"ok: {out['ok']}")
if state.get("halt_reason"):
print(f"halt_reason: {state['halt_reason']}")
return 0 if out["ok"] else 1
def cmd_loop_list(args) -> int:
loops = _all_loop_dirs(args.project)
if not loops:
print("No loops found.")
return 0
print(f"{'NAME':<30} {'STATUS':<10} {'HALT_REASON':<24} {'TICKS':<6} {'RESUMES':<8}")
print("-" * 80)
for name, path in loops:
state = _read_state_loop(path) or {}
print(f"{name:<30} {state.get('status', 'UNTRACKED'):<10} "
f"{str(state.get('halt_reason') or '-'):<24} "
f"{int(state.get('iteration_count', 0)):<6} "
f"{int(state.get('resumed_count', 0)):<8}")
return 0
def cmd_version(args) -> int:
"""Print framework version from config.md's ## Framework Version section.
Exit 0 always (POSIX convention for --version)."""
config_path = AUTOMATON_DIR / "config.md"
version = None
if config_path.exists():
try:
text = config_path.read_text(encoding="utf-8")
except OSError:
text = ""
in_section = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith("## Framework Version"):
in_section = True
continue
if in_section and line.startswith("##"):
in_section = False
if not in_section:
continue
m = re.search(r"version\**\s*[:=]\s*\**\s*([0-9][\w.\-]*)", line, re.IGNORECASE)
if m:
version = m.group(1)
break
if version is None:
print("automaton (unknown version)")
else:
print(f"automaton {version}")
return 0
def cmd_can_edit_loop(args) -> int:
"""--can-edit --loop <name> [--loop-worktree] --file {path}
Checks the file falls inside this loop's declared blast radius. The harness
must pass the absolute path (resolved). With --loop-worktree the harness is
confirming it has already cd'd into the worktree and the path is
worktree-relative; we then check the path against the loop's file_scope as-is.
"""
name = args.loop
loop_path = _loop_dir(name, args.project)
state = _read_state_loop(loop_path)
if state is None:
print(_loop_untracked_hint(name))
return 2
cfg = _read_loop_config(loop_path) or {}
file_scope = cfg.get("blast_radius", {}).get("file_scope", [])
if not args.file:
print(f"ERROR: --file is required for --can-edit --loop")
return 2
file_path = args.file
if args.loop_worktree:
check_path = file_path
else:
check_path = str(Path(file_path).resolve())
project_dir = _find_project_dir(args.project)
proj_str = str(project_dir.resolve())
auto_str = str(AUTOMATON_DIR)
if not (check_path.startswith(proj_str + os.sep) or check_path == proj_str
or check_path.startswith(auto_str + os.sep) or check_path == auto_str):
print(f"DENIED: File '{check_path}' is outside project/framework root.")
if args.json_output:
print(json.dumps({"allowed": False, "reason": "out_of_root", "loop": name}))
return 1
in_scope = True
if file_scope:
in_scope = any(check_path == s or check_path.startswith(s + os.sep) or
check_path.startswith(s.rstrip("/") + "/")
for s in file_scope)
if not in_scope:
print(f"DENIED: File '{check_path}' is outside loop '{name}' blast radius ({file_scope}).")
if args.json_output:
print(json.dumps({"allowed": False, "reason": "out_of_scope",
"loop": name, "file_scope": file_scope,
"file": check_path}))
return 1
print(f"ALLOWED: File '{check_path}' is within loop '{name}' blast radius.")
if args.json_output:
print(json.dumps({"allowed": True, "reason": "in_scope",
"loop": name, "file_scope": file_scope, "file": check_path}))
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.lastedit 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)")
# ----- Loop engineering (v1) -----
parser.add_argument("--create-loop", metavar="NAME", help="Create a new loop from a template")
parser.add_argument("--from-template", metavar="TEMPLATE", default="ci-triage",
help="Template name for --create-loop (default: ci-triage)")
parser.add_argument("--install-schedule", metavar="NAME", help="Install OS scheduler unit for a loop")
parser.add_argument("--interval", type=int, help="Tick interval in seconds (for --install-schedule)")
parser.add_argument("--pause-loop", metavar="NAME", help="Pause a running loop")
parser.add_argument("--resume-loop", metavar="NAME", help="Resume a paused loop")
parser.add_argument("--loop", metavar="NAME", help="Loop name (used with --approve, --can-edit, --check-gate)")
parser.add_argument("--loop-worktree", action="store_true", help="With --can-edit --loop, check worktree scope instead of main tree")
parser.add_argument("--check-gate", metavar="NAME", help="Run all loop gate checks before a tick (runner pre-check)")
parser.add_argument("--can-continue", metavar="NAME", help="Cheap status check: is the loop runnable right now?")
parser.add_argument("--loop-list", action="store_true", help="List all loops and their status")
parser.add_argument("--claim-loop-task", metavar="NAME", help="Claim a task for this loop (cross-loop ownership check)")
parser.add_argument("--cleanup-done", action="store_true",
help="Move complete tasks older than --days into tasks/complete/ (age by .state mtime)")
parser.add_argument("--install-cleanup-schedule", action="store_true",
help="Install an OS scheduler unit that runs --cleanup-done periodically (default daily)")
parser.add_argument("--days", type=int, help="Cleanup age threshold in days (default 7, used with --cleanup-done / --install-cleanup-schedule)")
parser.add_argument("--dry-run", action="store_true", help="With --cleanup-done, list candidates without moving them")
parser.add_argument("--version", action="store_true", help="Print framework version and exit")
args = parser.parse_args()
if args.version:
return cmd_version(args)
if args.claim_loop_task:
return cmd_claim_loop_task(args)
if args.create_loop:
return cmd_create_loop(args)
if args.install_schedule:
return cmd_install_schedule(args)
if args.pause_loop:
return cmd_pause_loop(args)
if args.resume_loop:
return cmd_resume_loop(args)
if args.check_gate:
return cmd_check_gate(args)
if args.can_continue:
return cmd_can_continue(args)
if args.loop_list:
return cmd_loop_list(args)
if args.install_cleanup_schedule:
return cmd_install_cleanup_schedule(args)
if args.cleanup_done:
return cmd_cleanup_done(args)
if args.create_task:
return cmd_create_task(args)
if args.approve:
if args.loop:
return cmd_approve_loop(args)
if not args.task:
print("ERROR: --task (or --loop) 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())