2026-06-15 14:16:46 -04:00
#!/usr/bin/env python3
"""Automaton status and enforcement script.
Manages task state, validates phase transitions, enforces approval gates,
and provides enforcement hooks for agent tool integrations.
Integration Contract for Harnesses
------------------------------------
Before allowing any file edit, a harness MUST call:
python ~/.automaton/scripts/status.py --can-edit --project {project} [--file {path} ] [--json]
Exit code 0 = ALLOWED, exit code 1 = DENIED, exit code 2 = ERROR.
Modes:
1. --can-edit --project {p}
Is editing allowed on this project at all? Checks that at least one
task is in implement or doc_review phase.
2. --can-edit --project {p} --file {path}
Same as (1) but also checks that the file is within the project scope.
3. --can-edit --project {p} --task {t}
Is this specific task in an edit-allowed phase?
4. --can-edit --project {p} --task {t} --file {path}
Same as (3) but also checks file scope.
Add --json for machine-readable output on the last line.
Usage:
python status.py --task {name} Show task status
python status.py --list List all tasks
python status.py --create-task {name} Create a new task
python status.py --transition {phase} --task {n} Transition task phase
python status.py --approve --task {name} Approve current phase
python status.py --validate-folder --task {name} Validate task folder
python status.py --audit Audit all tasks
python status.py --claim --task {name} --agent {id} Claim task (multi-agent)
python status.py --release --task {name} --agent {id} Release task (multi-agent)
python status.py --next-available --agent {id} Find available work
python status.py --available --agent {id} List available work
python status.py --can-edit --project {p} [--task {name} ] [--file {path} ] Check if edits allowed (harness hook)
python status.py --scope-check --task {name} --file {path} Check file scope
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime , timezone
from pathlib import Path
from typing import Optional
AUTOMATON_DIR = Path . home () / ".automaton"
VALID_PHASES = [
"new" ,
"research" , "research:awaiting_approval" , "research:approved" ,
"decomposition" , "decomposition:awaiting_approval" , "decomposition:approved" ,
"design" , "design:awaiting_approval" , "design:approved" ,
"test_design" , "test_design:awaiting_approval" , "test_design:approved" ,
"implement" ,
2026-06-16 09:00:22 -04:00
"code_review" , "code_review:awaiting_approval" , "code_review:approved" ,
2026-06-15 14:16:46 -04:00
"bug_find" ,
"adversarial_bug_find" ,
"doc_review" ,
"referee" ,
"complete" ,
"human_intervention" ,
]
BASE_PHASES = [
"new" , "research" , "decomposition" , "design" , "test_design" ,
2026-06-16 09:00:22 -04:00
"implement" , "code_review" , "bug_find" , "adversarial_bug_find" ,
"doc_review" , "referee" , "complete" , "human_intervention" ,
2026-06-15 14:16:46 -04:00
]
2026-06-16 09:00:22 -04:00
APPROVAL_PHASES = { "research" , "decomposition" , "design" , "test_design" , "code_review" }
2026-06-15 14:16:46 -04:00
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" ],
2026-06-15 17:42:17 -04:00
"decomposition:approved" : [ "complete" ],
2026-06-15 14:16:46 -04:00
"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" ],
2026-06-16 09:00:22 -04:00
"implement" : [ "code_review" ],
"code_review" : [ "code_review:awaiting_approval" ],
"code_review:awaiting_approval" : [ "code_review:approved" ],
"code_review:approved" : [ "bug_find" ],
2026-06-15 14:16:46 -04:00
"bug_find" : [ "adversarial_bug_find" ],
"adversarial_bug_find" : [ "doc_review" ],
"doc_review" : [ "referee" ],
"referee" : [ "complete" , "human_intervention" ],
2026-06-15 17:42:17 -04:00
"human_intervention" : [ "referee" , "complete" ],
2026-06-15 14:16:46 -04:00
}
PHASE_REQUIRED_ARTIFACTS = {
"research" : "SPEC.md" ,
"decomposition" : "DECOMPOSITION.md" ,
"design" : "DESIGN.md" ,
"test_design" : "TEST_PLAN.md" ,
"implement" : "IMPLEMENTATION.md" ,
2026-06-16 09:00:22 -04:00
"code_review" : "CODE_REVIEW.md" ,
2026-06-15 14:16:46 -04:00
"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" ,
2026-06-16 09:00:22 -04:00
"IMPLEMENTATION.md" , "CODE_REVIEW.md" , "BUG_REPORT.md" ,
"ADVERSARIAL_BUG_REPORT.md" , "DOC_REVIEW.md" , "VERDICT.md" ],
2026-06-15 14:16:46 -04:00
"research" : [ "DESIGN.md" , "DECOMPOSITION.md" , "TEST_PLAN.md" ,
2026-06-16 09:00:22 -04:00
"IMPLEMENTATION.md" , "CODE_REVIEW.md" , "BUG_REPORT.md" ,
"ADVERSARIAL_BUG_REPORT.md" , "DOC_REVIEW.md" , "VERDICT.md" ],
2026-06-15 14:16:46 -04:00
"decomposition" : [ "DESIGN.md" , "TEST_PLAN.md" , "IMPLEMENTATION.md" ,
2026-06-16 09:00:22 -04:00
"CODE_REVIEW.md" , "BUG_REPORT.md" ,
"ADVERSARIAL_BUG_REPORT.md" , "DOC_REVIEW.md" , "VERDICT.md" ],
2026-06-15 14:16:46 -04:00
"design" : [ "DECOMPOSITION.md" , "TEST_PLAN.md" , "IMPLEMENTATION.md" ,
2026-06-16 09:00:22 -04:00
"CODE_REVIEW.md" , "BUG_REPORT.md" , "ADVERSARIAL_BUG_REPORT.md" ,
2026-06-15 14:16:46 -04:00
"DOC_REVIEW.md" , "VERDICT.md" ],
"test_design" : [ "DECOMPOSITION.md" , "IMPLEMENTATION.md" ,
2026-06-16 09:00:22 -04:00
"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" ,
2026-06-15 14:16:46 -04:00
"DOC_REVIEW.md" , "VERDICT.md" ],
2026-06-16 09:00:22 -04:00
"code_review" : [ "BUG_REPORT.md" , "ADVERSARIAL_BUG_REPORT.md" ,
"DOC_REVIEW.md" , "VERDICT.md" ],
2026-06-15 14:16:46 -04:00
"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" ,
2026-06-16 09:00:22 -04:00
".state.implementer" , "VRAM_CONFIG.md" , "PARENT_SPEC.md" , "REVIEW.md" }
2026-06-15 14:16:46 -04:00
PHASE_PRIORITY = {
2026-06-16 09:00:22 -04:00
"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 ,
2026-06-15 14:16:46 -04:00
}
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" ],
2026-06-16 09:00:22 -04:00
"code_review" : [ "Read code" , "Read SPEC.md" , "Read DESIGN.md" , "Read IMPLEMENTATION.md" , "Write CODE_REVIEW.md" ],
2026-06-15 14:16:46 -04:00
"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)" ],
2026-06-16 09:00:22 -04:00
"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" ],
2026-06-15 14:16:46 -04:00
"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" ,
2026-06-16 09:00:22 -04:00
"implement" : "code_review" ,
"code_review" : "bug_find" ,
2026-06-15 14:16:46 -04:00
"bug_find" : "adversarial_bug_find" ,
"adversarial_bug_find" : "doc_review" ,
"doc_review" : "referee" ,
"referee" : "complete or human_intervention" ,
}
def _base_phase ( phase : str ) -> str :
if ":" in phase :
return phase . split ( ":" )[ 0 ]
return phase
def _find_project_dir ( project : Optional [ str ] = None ) -> Path :
if project :
p = Path ( project ) . resolve ()
if ( p / ".automaton" ) . exists () or p == AUTOMATON_DIR :
return p
print ( f "WARNING: ' { project } ' has no .automaton/ directory. Tasks will be stored at { p / '.automaton' / 'tasks' } ." , file = sys . stderr )
return p
cwd = Path . cwd () . resolve ()
if cwd == AUTOMATON_DIR :
return AUTOMATON_DIR
if ( cwd / ".automaton" ) . exists ():
return cwd
if cwd . parent == AUTOMATON_DIR :
return AUTOMATON_DIR
print ( f "ERROR: Not in an automaton project directory (cwd= { cwd } ). "
f "Use --project to specify the project path, or run from a directory with .automaton/ or from ~/.automaton/." ,
file = sys . stderr )
sys . exit ( 1 )
def _task_dir ( task_name : str , project : Optional [ str ] = None ) -> Path :
project_dir = _find_project_dir ( project )
if project_dir == AUTOMATON_DIR :
base = AUTOMATON_DIR / "tasks"
else :
base = project_dir / ".automaton" / "tasks"
parts = task_name . split ( "/" )
if len ( parts ) > 1 :
parent = "/" . join ( parts [: - 1 ])
return base / parent / "subtasks" / parts [ - 1 ]
return base / task_name
def _all_task_dirs ( project : Optional [ str ] = None ) -> list [ tuple [ str , Path ]]:
project_dir = _find_project_dir ( project )
if project_dir == AUTOMATON_DIR :
base = AUTOMATON_DIR / "tasks"
else :
base = project_dir / ".automaton" / "tasks"
tasks = []
if not base . exists ():
return tasks
for entry in sorted ( base . iterdir ()):
if entry . is_dir () and not entry . name . startswith ( "." ):
tasks . append (( entry . name , entry ))
subtasks = entry / "subtasks"
if subtasks . exists ():
for sub in sorted ( subtasks . iterdir ()):
if sub . is_dir () and not sub . name . startswith ( "." ):
tasks . append (( f " { entry . name } / { sub . name } " , sub ))
return tasks
def _read_state ( task_path : Path ) -> Optional [ str ]:
state_file = task_path / ".state"
if state_file . exists ():
content = state_file . read_text () . strip ()
if content in VALID_PHASES :
return content
base = content . split ( ":" )[ 0 ] if ":" in content else content
if base in BASE_PHASES :
return content
return None
def _write_state ( task_path : Path , phase : str ) -> None :
tmp = task_path / ".state.tmp"
tmp . write_text ( f " { phase } \n " )
tmp . replace ( task_path / ".state" )
def _read_state_approvals ( task_path : Path ) -> list [ str ]:
approvals_file = task_path / ".state.approvals"
if approvals_file . exists ():
return approvals_file . read_text () . strip () . splitlines ()
return []
def _append_approval ( task_path : Path , phase : str , approver : str ) -> None :
approvals_file = task_path / ".state.approvals"
ts = datetime . now ( timezone . utc ) . isoformat ()
line = f " { phase } | { ts } | { approver } \n "
with open ( approvals_file , "a" ) as f :
f . write ( line )
def _infer_state_from_artifacts ( task_path : Path ) -> Optional [ str ]:
artifacts = {}
for name in [ "SPEC.md" , "DECOMPOSITION.md" , "DESIGN.md" , "TEST_PLAN.md" ,
2026-06-16 09:00:22 -04:00
"IMPLEMENTATION.md" , "CODE_REVIEW.md" , "BUG_REPORT.md" ,
"ADVERSARIAL_BUG_REPORT.md" , "DOC_REVIEW.md" , "VERDICT.md" ]:
2026-06-15 14:16:46 -04:00
f = task_path / name
if f . exists () and f . stat () . st_size > 0 :
artifacts [ name ] = True
if "VERDICT.md" in artifacts :
content = ( task_path / "VERDICT.md" ) . read_text ()
if "PASS" in content :
return "complete"
return "human_intervention"
if "DOC_REVIEW.md" in artifacts :
return "referee"
if "ADVERSARIAL_BUG_REPORT.md" in artifacts and "BUG_REPORT.md" in artifacts :
return "doc_review"
2026-06-15 14:56:33 -04:00
if "BUG_REPORT.md" in artifacts :
2026-06-15 14:16:46 -04:00
return "adversarial_bug_find"
2026-06-16 09:00:22 -04:00
if "CODE_REVIEW.md" in artifacts :
2026-06-15 14:16:46 -04:00
return "bug_find"
2026-06-16 09:00:22 -04:00
if "IMPLEMENTATION.md" in artifacts :
return "code_review"
2026-06-15 14:16:46 -04:00
if "TEST_PLAN.md" in artifacts :
return "implement"
if "DESIGN.md" in artifacts :
return "test_design"
if "DECOMPOSITION.md" in artifacts and "SPEC.md" in artifacts :
return "decomposition"
if "SPEC.md" in artifacts :
return "research"
return "new"
def _require_state ( task_path : Path , task_name : str ) -> Optional [ str ]:
"""Read .state file and refuse operations on tasks without one.
Returns the phase string if .state exists, or prints an error and returns None.
"""
phase = _read_state ( task_path )
if phase is None :
print ( f "ERROR: Task ' { task_name } ' has no .state file. This task was likely created before v2.0 state enforcement." )
print ( f " Run: python ~/.automaton/scripts/status.py --upgrade --task { task_name } --project <project-path>" )
print ( f " Or: python ~/.automaton/scripts/status.py --audit --project <project-path> (to upgrade all tasks at once)" )
return None
return phase
def _is_kebab_case ( name : str ) -> bool :
return bool ( re . match ( r '^[a-z0-9]+(-[a-z0-9]+)*$' , name ))
def _parse_agent_config ( project : Optional [ str ] = None ) -> dict :
project_dir = _find_project_dir ( project )
agent_file = project_dir / ".automaton" / ".agent.md" if project_dir != AUTOMATON_DIR else AUTOMATON_DIR / ".agent.md"
if not agent_file . exists ():
agent_file = AUTOMATON_DIR / ".agent.md"
config = { "mode" : "single-agent" , "agents" : {}, "lock_timeout" : "30m" }
if not agent_file . exists ():
return config
content = agent_file . read_text ()
in_agent_section = False
current_agent = None
for line in content . splitlines ():
stripped = line . strip ()
if stripped . startswith ( "## Agent Configuration" ):
in_agent_section = True
continue
if in_agent_section and stripped . startswith ( "## " ):
break
if not in_agent_section :
continue
if stripped . lower () . startswith ( "mode:" ):
config [ "mode" ] = stripped . split ( ":" , 1 )[ 1 ] . strip () . lower ()
elif stripped . lower () . startswith ( "lock timeout:" ):
config [ "lock_timeout" ] = stripped . split ( ":" , 1 )[ 1 ] . strip ()
elif stripped . startswith ( "- id:" ):
current_agent = stripped . split ( "id:" )[ 1 ] . strip ()
config [ "agents" ][ current_agent ] = { "phases" : [], "role" : "worker" }
elif current_agent and "phases:" in stripped . lower ():
phases_str = stripped . split ( ":" , 1 )[ 1 ] . strip ()
phases = [ p . strip () . strip ( "[]" ) for p in phases_str . split ( "," )]
config [ "agents" ][ current_agent ][ "phases" ] = phases
elif current_agent and "role:" in stripped . lower ():
config [ "agents" ][ current_agent ][ "role" ] = stripped . split ( ":" , 1 )[ 1 ] . strip ()
return config
def _is_multi_agent ( project : Optional [ str ] = None ) -> bool :
config = _parse_agent_config ( project )
return config . get ( "mode" ) == "multi-agent"
def _lock_timeout_seconds ( project : Optional [ str ] = None ) -> int :
config = _parse_agent_config ( project )
timeout_str = config . get ( "lock_timeout" , "30m" )
match = re . match ( r '(\d+)(m|h|s)' , timeout_str )
if not match :
return 1800
val , unit = int ( match . group ( 1 )), match . group ( 2 )
if unit == "h" :
return val * 3600
if unit == "m" :
return val * 60
return val
# --- Command implementations ---
def cmd_show_task ( args ):
task_path = _task_dir ( args . task , args . project )
if not task_path . exists ():
print ( f "ERROR: Task ' { args . task } ' not found in { task_path . parent } " )
return 2
phase = _require_state ( task_path , args . task )
if phase is None :
return 1
base = _base_phase ( phase )
allowed = ALLOWED_ACTIONS_MAP . get ( base , [])
forbidden = FORBIDDEN_ACTIONS_MAP . get ( base , [])
next_phase = NEXT_PHASE_MAP . get ( base , "—" )
next_artifact = PHASE_REQUIRED_ARTIFACTS . get ( base , "—" )
print ( f "Task: { args . task } " )
print ( f "Phase: { phase } (from .state)" )
print ( f "State file: { task_path / '.state' } " )
if phase in ( f " { p } :awaiting_approval" for p in APPROVAL_PHASES ):
print ( f "Approval: AWAITING — user sign-off required before proceeding" )
elif phase in ( f " { p } :approved" for p in APPROVAL_PHASES ):
print ( f "Approval: APPROVED — ready to transition to next phase" )
print ( f "Allowed actions:" )
for a in allowed :
print ( f " - { a } " )
print ( f "Forbidden actions:" )
for f in forbidden :
print ( f " - { f } " )
print ( f "Next artifact needed: { next_artifact } " )
print ( f "Next phase: { next_phase } " )
return 0
def cmd_list ( args ):
tasks = _all_task_dirs ( args . project )
if not tasks :
print ( "No tasks found." )
return 0
print ( f " { 'Task' : <35 } { 'Phase' : <30 } { 'Next Step' } " )
print ( "-" * 80 )
has_untracked = False
for name , path in tasks :
phase = _read_state ( path )
if phase is None :
print ( f " { name : <35 } { 'UNTRACKED (no .state)' : <30 } Run --upgrade --task { name } " )
has_untracked = True
continue
base = _base_phase ( phase )
next_step = NEXT_PHASE_MAP . get ( base , "—" )
print ( f " { name : <35 } { phase : <30 } { next_step } " )
if has_untracked :
print ( " \n NOTE: 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
2026-06-15 21:55:17 -04:00
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 " )
2026-06-15 14:16:46 -04:00
def cmd_transition ( args ):
task_path = _task_dir ( args . task , args . project )
if not task_path . exists ():
print ( f "ERROR: Task ' { args . task } ' not found in { task_path . parent } " )
return 2
current = _require_state ( task_path , args . task )
if current is None :
return 1
target = args . transition
base_current = _base_phase ( current )
if target not in VALID_PHASES :
print ( f "ERROR: Unknown phase ' { target } '. Valid phases: { ', ' . join ( VALID_PHASES ) } " )
return 2
allowed = LEGAL_TRANSITIONS . get ( current , [])
if target not in allowed :
allowed_str = ", " . join ( allowed ) if allowed else "(no legal transitions)"
print ( f "ERROR: Cannot transition from ' { current } ' to ' { target } '. Legal transitions from ' { current } ' are: { allowed_str } " )
return 1
if current . endswith ( ":awaiting_approval" ) and target != f " { base_current } :approved" :
print ( f "ERROR: Cannot transition from ' { current } ' to ' { target } '. Current phase is awaiting approval — use --approve to grant approval first." )
return 1
for phase_base , artifact in PHASE_REQUIRED_ARTIFACTS . items ():
if _base_phase ( target ) == phase_base or ( target . startswith ( phase_base + ":" )):
af = task_path / artifact
if current != target and not af . exists () or af . exists () and af . stat () . st_size == 0 :
pass
forbidden_in_folder = _check_forbidden_artifacts ( task_path , base_current )
if forbidden_in_folder :
print ( f "ERROR: Cannot transition to { target } phase. Found out-of-order artifacts:" )
for art , belongs_to in forbidden_in_folder :
print ( f " - { art } (belongs to { belongs_to } phase)" )
print ( "Remove out-of-order artifacts before transitioning." )
return 1
required = PHASE_REQUIRED_ARTIFACTS . get ( base_current )
if required and target != current :
# only check required artifact when leaving a phase
pass
if base_current in PHASE_REQUIRED_ARTIFACTS and base_current != "new" :
req = PHASE_REQUIRED_ARTIFACTS . get ( _base_phase ( current ))
if req :
af = task_path / req
if not af . exists () or af . stat () . st_size == 0 :
print ( f "ERROR: Cannot transition from ' { current } ' to ' { target } '. Required artifact ' { req } ' is missing or empty in task folder." )
return 1
2026-06-15 21:55:17 -04:00
if current == "human_intervention" and target == "complete" :
_auto_update_verdict_on_complete ( task_path )
2026-06-16 09:00:22 -04:00
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 " )
2026-06-15 14:16:46 -04:00
_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
2026-06-15 17:42:17 -04:00
current = _require_state ( task_path , args . task )
2026-06-15 14:16:46 -04:00
if current is None :
2026-06-15 17:42:17 -04:00
return 1
2026-06-15 14:16:46 -04:00
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 :
2026-06-15 14:56:33 -04:00
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
2026-06-15 14:16:46 -04:00
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" ,
2026-06-16 09:00:22 -04:00
"CODE_REVIEW.md" : "code_review" ,
2026-06-15 14:16:46 -04:00
"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
2026-06-15 17:42:17 -04:00
def _audit_category3 ( project_dir , tasks ):
"""Audit Category 3: Git-based unauthorized modification detection."""
import subprocess
# Collect active edit-allowed tasks
active_edit_tasks = set ()
for name , path in tasks :
phase = _read_state ( path )
if phase and _base_phase ( phase ) in ( "implement" , "doc_review" ):
active_edit_tasks . add ( name )
# Get uncommitted changes (working tree + staged)
try :
result = subprocess . run (
[ "git" , "diff" , "--name-only" , "HEAD" ],
capture_output = True , text = True , cwd = str ( project_dir ), timeout = 10
)
uncommitted = [ f . strip () for f in result . stdout . splitlines () if f . strip ()]
except Exception as e :
print ( f "[WARN] Failed to check git diff: { e } " )
return 0
try :
result = subprocess . run (
[ "git" , "diff" , "--cached" , "--name-only" , "HEAD" ],
capture_output = True , text = True , cwd = str ( project_dir ), timeout = 10
)
staged = [ f . strip () for f in result . stdout . splitlines () if f . strip ()]
except Exception :
staged = []
all_changed = set ( uncommitted + staged )
violations = 0
if not all_changed :
print ( "[PASS] No uncommitted modifications detected" )
return violations
# Exclude files inside task folders
task_names = { name for name , _ in tasks }
unauthorized = set ()
for changed_file in all_changed :
parts = Path ( changed_file ) . parts
is_in_task_folder = len ( parts ) >= 2 and parts [ 0 ] == "tasks" and parts [ 1 ] in task_names
if not is_in_task_folder :
unauthorized . add ( changed_file )
if not unauthorized :
print ( "[PASS] All uncommitted changes are within task folders — no unauthorized modifications" )
return violations
if not active_edit_tasks :
print ( "[FAIL] No task in implement or doc_review phase, but uncommitted changes exist outside task folders:" )
for f in sorted ( unauthorized ):
print ( f " - { f } " )
violations += 1
print ( f " To allow edits: create a task and transition to implement phase" )
else :
print ( f "[INFO] Active edit tasks: { ', ' . join ( sorted ( active_edit_tasks )) } " )
print ( "[INFO] Uncommitted changes outside task folders exist (may be authorized if within active task scope):" )
for f in sorted ( unauthorized ):
print ( f " - { f } " )
return violations
2026-06-15 14:16:46 -04:00
def cmd_audit ( args ):
project_dir = _find_project_dir ( args . project )
tasks = _all_task_dirs ( args . project )
if not tasks :
print ( "No tasks found." )
return 0
violations = 0
print ( f "Audit Report for { project_dir } \n " )
cat1_violations = []
cat2_violations = []
cat4_violations = []
for name , path in tasks :
state_file = path / ".state"
if not state_file . exists ():
phase = _infer_state_from_artifacts ( path )
cat4_violations . append (( name , phase or "unknown" ))
continue
phase = _read_state ( path )
if phase is None :
phase = _infer_state_from_artifacts ( path )
if phase is None :
phase = "unknown"
base = _base_phase ( phase )
forbidden = _check_forbidden_artifacts ( path , base )
if forbidden :
cat1_violations . append (( name , phase , forbidden ))
expected = PHASE_REQUIRED_ARTIFACTS . get ( base )
if expected :
af = path / expected
inconsistency = False
details = []
if base == "implement" and ( path / "IMPLEMENTATION.md" ) . exists () and ( path / "IMPLEMENTATION.md" ) . stat () . st_size == 0 :
inconsistency = True
details . append ( f "IMPLEMENTATION.md is empty but .state says { base } " )
2026-06-16 09:00:22 -04:00
if base in ( "bug_find" , "adversarial_bug_find" , "code_review" , "doc_review" , "referee" ) and not ( path / "IMPLEMENTATION.md" ) . exists ():
2026-06-15 14:16:46 -04:00
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 :
2026-06-15 17:42:17 -04:00
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)" )
2026-06-15 14:16:46 -04:00
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 " \n Upgraded: { bootstrapped } , Skipped (already had .state): { skipped } " )
return 0
def cmd_can_edit ( args ):
project_dir = _find_project_dir ( args . project )
if not args . task :
edit_tasks = []
tasks = _all_task_dirs ( args . project )
for name , path in tasks :
phase = _read_state ( path )
if phase is None :
continue
base = _base_phase ( phase )
if base in ( "implement" , "doc_review" ):
2026-06-15 21:55:17 -04:00
state_file = path / ".state"
state_mtime = state_file . stat () . st_mtime if state_file . exists () else 0
edit_tasks . append (( name , base , path , state_mtime ))
2026-06-15 14:16:46 -04:00
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 = []
2026-06-15 21:55:17 -04:00
for name , base , path , state_mtime in edit_tasks :
2026-06-15 14:16:46 -04:00
if str ( file_path ) . startswith ( proj_str ):
2026-06-15 21:55:17 -04:00
scope_tasks . append ({ "task" : name , "phase" : base , "state_mtime" : state_mtime })
2026-06-15 14:16:46 -04:00
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 ]
2026-06-15 21:55:17 -04:00
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
2026-06-15 14:16:46 -04:00
print ( f "ALLOWED: Task ' { primary [ 'task' ] } ' is in { primary [ 'phase' ] } phase and file ' { file_path } ' is within project ' { project_dir } '." )
if args . json_output :
2026-06-15 21:55:17 -04:00
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 ]}))
2026-06-15 14:16:46 -04:00
return 0
2026-06-15 21:55:17 -04:00
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
2026-06-15 14:16:46 -04:00
primary = edit_tasks [ 0 ]
print ( f "ALLOWED: Task ' { primary [ 0 ] } ' is in { primary [ 1 ] } phase — code edits are permitted." )
if args . json_output :
2026-06-15 21:55:17 -04:00
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 ]}))
2026-06-15 14:16:46 -04:00
return 0
task_path = _task_dir ( args . task , args . project )
if not task_path . exists ():
print ( f "ERROR: Task ' { args . task } ' not found" )
return 2
phase = _require_state ( task_path , args . task )
if phase is None :
return 1
base = _base_phase ( phase )
if args . file :
file_path = Path ( args . file ) . resolve ()
proj_str = str ( project_dir . resolve ())
auto_str = str ( AUTOMATON_DIR )
if project_dir == AUTOMATON_DIR :
if not str ( file_path ) . startswith ( auto_str ):
print ( f "OUT_OF_SCOPE: File ' { file_path } ' is outside the framework directory" )
if args . json_output :
print ( json . dumps ({ "allowed" : False , "reason" : "out_of_scope" , "task" : args . task , "phase" : base }))
return 1
else :
if not str ( file_path ) . startswith ( proj_str ):
print ( f "OUT_OF_SCOPE: File ' { file_path } ' is outside project ' { project_dir } '. Only framework project can modify framework files." )
if args . json_output :
print ( json . dumps ({ "allowed" : False , "reason" : "out_of_scope" , "task" : args . task , "phase" : base , "file" : str ( file_path )}))
return 1
if base in ( "implement" , "doc_review" ):
print ( f "ALLOWED: Task ' { args . task } ' is in { base } phase — code edits are permitted." )
if args . json_output :
print ( json . dumps ({ "allowed" : True , "reason" : "edit_phase" , "task" : args . task , "phase" : base }))
return 0
print ( f "DENIED: Task ' { args . task } ' is in { base } phase. Code edits require implement or doc_review phase." )
if args . json_output :
print ( json . dumps ({ "allowed" : False , "reason" : "wrong_phase" , "task" : args . task , "phase" : base , "allowed_phases" : [ "implement" , "doc_review" ]}))
return 1
2026-06-15 21:55:17 -04:00
def cmd_touch ( args ):
"""Update .state mtime to reset stale-task timer without changing phase."""
task_path = _task_dir ( args . task , args . project )
if not task_path . exists ():
print ( f "ERROR: Task ' { args . task } ' not found" )
return 2
state_file = task_path / ".state"
if not state_file . exists ():
print ( f "ERROR: Task ' { args . task } ' has no .state file. Run --upgrade first." )
return 1
import os
os . utime ( str ( state_file ), None )
phase = _read_state ( task_path )
print ( f "Touched task ' { args . task } ' (phase: { phase } ) — activity clock reset." )
return 0
2026-06-15 14:16:46 -04:00
def cmd_scope_check ( args ):
project_dir = _find_project_dir ( args . project )
file_path = Path ( args . file ) . resolve ()
proj_str = str ( project_dir . resolve ())
if str ( file_path ) . startswith ( proj_str ):
print ( f "IN_SCOPE: File ' { file_path } ' is within project ' { project_dir } '" )
return 0
if project_dir != AUTOMATON_DIR :
auto_str = str ( AUTOMATON_DIR )
if str ( file_path ) . startswith ( auto_str ):
print ( f "OUT_OF_SCOPE: File ' { file_path } ' is in the framework directory, but current project is ' { project_dir } '. Only framework project can modify framework files." )
return 1
print ( f "OUT_OF_SCOPE: File ' { file_path } ' is outside project ' { project_dir } '" )
return 1
def cmd_same_session ( args ):
task_path = _task_dir ( args . task , args . project )
if not task_path . exists ():
print ( f "ERROR: Task ' { args . task } ' not found" )
return 2
state_file = task_path / ".state"
if not state_file . exists ():
print ( f "DIFFERENT_SESSION: Task ' { args . task } ' has no .state file" )
return 0
import time
mtime = state_file . stat () . st_mtime
age_minutes = ( time . time () - mtime ) / 60
threshold = 30
if age_minutes < threshold :
print ( f "SAME_SESSION: Task ' { args . task } ' .state was modified { age_minutes : .0f } minutes ago (threshold: { threshold } min)" )
return 1
print ( f "DIFFERENT_SESSION: Task ' { args . task } ' .state was modified { age_minutes : .0f } minutes ago (threshold: { threshold } min)" )
return 0
def cmd_claim ( args ):
task_path = _task_dir ( args . task , args . project )
if not task_path . exists ():
print ( f "ERROR: Task ' { args . task } ' not found in { task_path . parent } " )
return 2
config = _parse_agent_config ( args . project )
if config [ "mode" ] != "multi-agent" :
print ( f "Claimed task ' { args . task } ' (single-agent mode — no lock needed)" )
return 0
if not args . agent :
print ( "ERROR: --agent is required in multi-agent mode" )
return 2
agent_phases = config [ "agents" ] . get ( args . agent , {}) . get ( "phases" , [])
if not agent_phases :
print ( f "ERROR: Agent ' { args . agent } ' not found in Agent Configuration" )
return 2
phase = _require_state ( task_path , args . task )
if phase is None :
return 1
base = _base_phase ( phase )
if "*" not in agent_phases and base not in agent_phases :
print ( f "ERROR: Agent ' { args . agent } ' is not configured for phase ' { base } '. Allowed phases: { ', ' . join ( agent_phases ) } " )
return 1
2026-06-16 09:00:22 -04:00
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
2026-06-15 14:16:46 -04:00
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 } \n phase: { phase } \n claimed: { now . isoformat () } \n expires: { 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 " \n To 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
2026-06-15 17:42:17 -04:00
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
2026-06-15 14:16:46 -04:00
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" )
2026-06-15 17:42:17 -04:00
parser . add_argument ( "--list-states" , action = "store_true" , help = "List all valid phase names" )
2026-06-15 21:55:17 -04:00
parser . add_argument ( "--touch" , action = "store_true" , help = "Update .state mtime to reset stale-task timer without changing phase" )
2026-06-15 14:16:46 -04:00
parser . add_argument ( "--json" , action = "store_true" , dest = "json_output" , help = "Output machine-readable JSON on last line (for harness integration)" )
args = parser . parse_args ()
if args . create_task :
return cmd_create_task ( args )
if args . approve :
if not args . task :
print ( "ERROR: --task is required for --approve" )
return 2
return cmd_approve ( args )
if args . transition :
if not args . task :
print ( "ERROR: --task is required for --transition" )
return 2
return cmd_transition ( args )
if args . validate_folder :
if not args . task :
print ( "ERROR: --task is required for --validate-folder" )
return 2
return cmd_validate_folder ( args )
if args . list :
return cmd_list ( args )
if args . audit :
return cmd_audit ( args )
if args . claim :
if not args . task :
print ( "ERROR: --task is required for --claim" )
return 2
return cmd_claim ( args )
if args . release :
if not args . task :
print ( "ERROR: --task is required for --release" )
return 2
return cmd_release ( args )
if args . next_available :
return cmd_next_available ( args )
if args . available :
return cmd_available ( args )
if args . can_edit :
return cmd_can_edit ( args )
if args . upgrade :
return cmd_upgrade ( args )
if args . scope_check :
if not args . task or not args . file :
print ( "ERROR: --task and --file are required for --scope-check" )
return 2
return cmd_scope_check ( args )
if args . same_session :
if not args . task :
print ( "ERROR: --task is required for --same-session" )
return 2
return cmd_same_session ( args )
2026-06-15 17:42:17 -04:00
if args . list_states :
return cmd_list_states ( args )
2026-06-15 21:55:17 -04:00
if args . touch :
if not args . task :
print ( "ERROR: --task is required for --touch" )
return 2
return cmd_touch ( args )
2026-06-15 14:16:46 -04:00
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 ())