Fix verdict parsing + plug stale-task enforcement hole
CI / build (push) Has been cancelled

- task.py: remove naive substring fallback from parse_verdict_status(),
  only parse ## Status: header; no header → ambiguous (REFEREE)
- status.py: add _auto_update_verdict_on_complete() — when transitioning
  human_intervention→complete, auto-update VERDICT.md to PASS
- status.py: add stale-task detection to --can-edit — deny edits if
  all edit tasks have .state mtime >30 min old (reason: stale_task)
- status.py: add --touch command to reset task activity clock
- guard plugin: handle stale_task reason with specific error message
- Update test to match new verdict parsing behavior
This commit is contained in:
2026-06-15 21:55:17 -04:00
parent cc412a51bb
commit 21f16b7da2
18 changed files with 124 additions and 20 deletions
+76 -5
View File
@@ -465,6 +465,30 @@ def cmd_create_task(args):
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():
@@ -509,6 +533,8 @@ def cmd_transition(args):
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)
_write_state(task_path, target)
print(f"Transitioned task '{args.task}' from '{current}' to '{target}'.")
return 0
@@ -887,7 +913,9 @@ def cmd_can_edit(args):
continue
base = _base_phase(phase)
if base in ("implement", "doc_review"):
edit_tasks.append((name, base, path))
state_file = path / ".state"
state_mtime = state_file.stat().st_mtime if state_file.exists() else 0
edit_tasks.append((name, base, path, state_mtime))
if not edit_tasks:
print("DENIED: No tasks in implement or doc_review phase. Create a task and transition it to implement before editing files.")
if args.json_output:
@@ -898,9 +926,9 @@ def cmd_can_edit(args):
proj_str = str(project_dir.resolve())
scope_tasks = []
out_of_scope = []
for name, base, path in edit_tasks:
for name, base, path, state_mtime in edit_tasks:
if str(file_path).startswith(proj_str):
scope_tasks.append({"task": name, "phase": base})
scope_tasks.append({"task": name, "phase": base, "state_mtime": state_mtime})
else:
out_of_scope.append({"task": name, "phase": base, "file": str(file_path)})
if not scope_tasks:
@@ -909,14 +937,34 @@ def cmd_can_edit(args):
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": primary, "all_edit_tasks": scope_tasks}))
print(json.dumps({"allowed": True, "reason": "edit_task_in_scope", "primary_task": {"task": primary["task"], "phase": primary["phase"]}, "all_edit_tasks": [{"task": t["task"], "phase": t["phase"]} for t in scope_tasks]}))
return 0
import time as _time
now = _time.time()
max_state_age = max(mtime for _, _, _, mtime in edit_tasks)
age_minutes = (now - max_state_age) / 60
latest = max(edit_tasks, key=lambda t: t[3])
if age_minutes > 30:
print(f"DENIED: Task '{latest[0]}' has been in {latest[1]} phase for {age_minutes:.0f} minutes (stale). Create a new task for new work.")
if args.json_output:
print(json.dumps({"allowed": False, "reason": "stale_task", "stale_task": latest[0], "stale_minutes": round(age_minutes), "all_edit_tasks": [{"task": n, "phase": b} for n, b, _, _ in edit_tasks]}))
return 1
primary = edit_tasks[0]
print(f"ALLOWED: Task '{primary[0]}' is in {primary[1]} phase — code edits are permitted.")
if args.json_output:
print(json.dumps({"allowed": True, "reason": "edit_task", "primary_task": {"task": primary[0], "phase": primary[1]}, "all_edit_tasks": [{"task": n, "phase": b} for n, b, _ in edit_tasks]}))
print(json.dumps({"allowed": True, "reason": "edit_task", "primary_task": {"task": primary[0], "phase": primary[1]}, "all_edit_tasks": [{"task": n, "phase": b} for n, b, _, _ in edit_tasks]}))
return 0
task_path = _task_dir(args.task, args.project)
@@ -954,6 +1002,23 @@ def cmd_can_edit(args):
return 1
def cmd_touch(args):
"""Update .state mtime to reset stale-task timer without changing phase."""
task_path = _task_dir(args.task, args.project)
if not task_path.exists():
print(f"ERROR: Task '{args.task}' not found")
return 2
state_file = task_path / ".state"
if not state_file.exists():
print(f"ERROR: Task '{args.task}' has no .state file. Run --upgrade first.")
return 1
import os
os.utime(str(state_file), None)
phase = _read_state(task_path)
print(f"Touched task '{args.task}' (phase: {phase}) — activity clock reset.")
return 0
def cmd_scope_check(args):
project_dir = _find_project_dir(args.project)
file_path = Path(args.file).resolve()
@@ -1199,6 +1264,7 @@ def main():
parser.add_argument("--file", help="File path for scope check or can-edit file scope check")
parser.add_argument("--same-session", action="store_true", help="Check if task was created in current session")
parser.add_argument("--list-states", action="store_true", help="List all valid phase names")
parser.add_argument("--touch", action="store_true", help="Update .state mtime to reset stale-task timer without changing phase")
parser.add_argument("--json", action="store_true", dest="json_output", help="Output machine-readable JSON on last line (for harness integration)")
args = parser.parse_args()
@@ -1254,6 +1320,11 @@ def main():
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.")