feat(main_loop): harden plan execution state

Validate required Plan Meta before claims, record claim ownership, and persist verification evidence on completion.

Document conditional skill triggers without adding them to automatic main-loop dispatch.
This commit is contained in:
csh
2026-05-29 15:42:03 +08:00
parent 3023aef8a0
commit eaa061fd2b
3 changed files with 528 additions and 34 deletions
+192 -17
View File
@@ -1,8 +1,11 @@
#!/usr/bin/env python3
from contextlib import contextmanager
from datetime import datetime, timezone
import getpass
import os
import platform
import re
import socket
import sys
import threading
import time
@@ -31,6 +34,22 @@ PLAN_LINE_RE = re.compile(
)
FINISH_STATUSES = {"done", "blocked", "skipped"}
ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$")
PLAN_META_REQUIRED_FIELDS = (
"Plan Group",
"Parent Plan",
"Verification Scope",
"Verification Gate",
)
WORKFLOW_STATE_KEYS = {
"phase",
"spec",
"plan",
"executor",
"constraints",
"claimed_by",
"claimed_at",
"verification",
}
WORKFLOW_PHASES = {
"brainstorming",
"planning",
@@ -48,7 +67,8 @@ def usage() -> str:
"Usage:\n"
" python scripts/main_loop.py claim -plans <dir> -progress <file>\n"
" python scripts/main_loop.py finish -plan <path> -status <status> "
"-progress <file> [-note <text>]\n"
"-progress <file> [-note <text>] [-verified <text>]\n"
" python scripts/main_loop.py status -plans <dir> -progress <file>\n"
" python scripts/main_loop.py record -progress <file> -phase <phase> "
"[-spec <path>] [-plan <path>] [-executor <name>] "
"[-constraints <csv>]\n"
@@ -63,6 +83,8 @@ def usage() -> str:
" -executor NAME\n"
" -constraints CSV\n"
" -note TEXT\n"
" -owner NAME\n"
" -verified TEXT\n"
" -h, -help Show this help.\n"
)
@@ -98,6 +120,24 @@ def normalize_note(note: str) -> str:
return note.replace("\n", " ").replace("\r", " ").replace("`", "'").strip()
def now_utc() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
def default_claim_owner() -> str:
owner = os.environ.get("PLAYBOOK_MAIN_LOOP_OWNER")
if owner:
return normalize_note(owner)
host = socket.gethostname() or "unknown-host"
try:
user = getpass.getuser()
except Exception: # pragma: no cover
user = "unknown-user"
return f"{user}@{host}:{os.getpid()}"
def render_plan_line(plan_key: str, status: str, note: Optional[str]) -> str:
checked = "x" if status == "done" else " "
suffix = status
@@ -188,18 +228,23 @@ def render_workflow_state_lines(
plan: Optional[str] = None,
executor: Optional[str] = None,
constraints: Optional[str] = None,
claimed_by: Optional[str] = None,
claimed_at: Optional[str] = None,
verification: Optional[str] = None,
) -> list[str]:
lines = [WORKFLOW_STATE_START]
if phase:
lines.append(f"phase: {phase}")
if spec:
lines.append(f"spec: {spec}")
if plan:
lines.append(f"plan: {plan}")
if executor:
lines.append(f"executor: {executor}")
if constraints:
lines.append(f"constraints: {constraints}")
for key, value in (
("phase", phase),
("spec", spec),
("plan", plan),
("executor", executor),
("constraints", constraints),
("claimed_by", claimed_by),
("claimed_at", claimed_at),
("verification", verification),
):
if value:
lines.append(f"{key}: {value}")
lines.append(WORKFLOW_STATE_END)
return lines
@@ -213,7 +258,7 @@ def parse_workflow_state(
if ": " not in line:
continue
key, value = line.split(": ", 1)
if key in {"phase", "spec", "plan", "executor", "constraints"}:
if key in WORKFLOW_STATE_KEYS:
state[key] = value
return state
@@ -348,9 +393,15 @@ def update_workflow_state(
plan: Optional[str] = None,
executor: Optional[str] = None,
constraints: Optional[str] = None,
claimed_by: Optional[str] = None,
claimed_at: Optional[str] = None,
verification: Optional[str] = None,
clear_keys: tuple[str, ...] = (),
) -> list[str]:
lines, start_idx, end_idx = ensure_workflow_state_block(lines)
state = parse_workflow_state(lines, start_idx, end_idx)
for key in clear_keys:
state.pop(key, None)
if phase is not None:
state["phase"] = phase
if spec is not None:
@@ -361,12 +412,21 @@ def update_workflow_state(
state["executor"] = executor
if constraints is not None:
state["constraints"] = constraints
if claimed_by is not None:
state["claimed_by"] = claimed_by
if claimed_at is not None:
state["claimed_at"] = claimed_at
if verification is not None:
state["verification"] = verification
lines[start_idx : end_idx + 1] = render_workflow_state_lines(
state.get("phase"),
state.get("spec"),
state.get("plan"),
state.get("executor"),
state.get("constraints"),
state.get("claimed_by"),
state.get("claimed_at"),
state.get("verification"),
)
return lines
@@ -399,6 +459,27 @@ def filter_existing_entries(
return [entry for entry in entries if entry[0] in available]
def validate_plan_meta(plan_path: Path) -> list[str]:
text = plan_path.read_text(encoding="utf-8")
missing: list[str] = []
if not re.search(r"(?im)^##\s+Plan Meta\s*$", text):
missing.append("Plan Meta")
for field in PLAN_META_REQUIRED_FIELDS:
pattern = rf"(?im)^\s*[-*]\s+(?:\*\*)?{re.escape(field)}(?:\*\*)?\s*:"
if not re.search(pattern, text):
missing.append(field)
return missing
def validate_plan_files(plans_dir: Path, plan_keys: list[str]) -> Optional[str]:
for plan_key in plan_keys:
missing = validate_plan_meta(plans_dir / plan_key)
if missing:
fields = ", ".join(missing)
return f"ERROR: {plan_key} missing required Plan Meta fields: {fields}"
return None
def choose_claim_entry(
entries: list[tuple[str, str, Optional[str], int]],
current_env: Optional[str],
@@ -427,13 +508,18 @@ def choose_claim_entry(
return None
def claim_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
def claim_plan(
plans_dir: Path, progress_path: Path, owner: Optional[str] = None
) -> tuple[int, str]:
if not plans_dir.is_dir():
return 2, f"ERROR: plans dir not found: {plans_dir}"
plan_keys = list_plan_files(plans_dir)
if not plan_keys:
return 2, "ERROR: no plan files found"
plan_error = validate_plan_files(plans_dir, plan_keys)
if plan_error:
return 2, plan_error
with locked_progress(progress_path):
lines = load_progress_lines(progress_path)
@@ -458,6 +544,9 @@ def claim_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
lines,
phase="executing",
plan=(plans_dir / plan_key).as_posix(),
claimed_by=normalize_note(owner) if owner else default_claim_owner(),
claimed_at=now_utc(),
clear_keys=("verification",),
)
write_progress_lines(progress_path, lines)
@@ -468,12 +557,18 @@ def claim_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
def finish_plan(
plan: str, status: str, progress_path: Path, note: Optional[str]
plan: str,
status: str,
progress_path: Path,
note: Optional[str],
verified: Optional[str] = None,
) -> tuple[int, str]:
if status not in FINISH_STATUSES:
return 2, f"ERROR: invalid status: {status}"
if not plan:
return 2, "ERROR: plan is required"
if verified and status != "done":
return 2, "ERROR: -verified is only valid with -status done"
plan_key = normalize_plan_key(plan)
with locked_progress(progress_path):
@@ -488,6 +583,15 @@ def finish_plan(
entries = parse_entries(lines, start_idx, end_idx)
rendered_note = normalize_note(note) if note else None
rendered_verified = normalize_note(verified) if verified else None
verification_clear_keys = () if rendered_verified else ("verification",)
if rendered_verified:
verified_note = f"verified: {rendered_verified}"
rendered_note = (
f"{verified_note}; note: {rendered_note}"
if rendered_note
else verified_note
)
updated_line = render_plan_line(plan_key, status, rendered_note)
workflow_phase = {
"done": "done",
@@ -502,6 +606,8 @@ def finish_plan(
lines,
phase=workflow_phase,
plan=f"docs/superpowers/plans/{plan_key}",
verification=rendered_verified,
clear_keys=verification_clear_keys,
)
write_progress_lines(progress_path, lines)
return 0, updated_line
@@ -511,11 +617,64 @@ def finish_plan(
lines,
phase=workflow_phase,
plan=f"docs/superpowers/plans/{plan_key}",
verification=rendered_verified,
clear_keys=verification_clear_keys,
)
write_progress_lines(progress_path, lines)
return 0, updated_line
def status_report(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
if not plans_dir.is_dir():
return 2, f"ERROR: plans dir not found: {plans_dir}"
plan_keys = list_plan_files(plans_dir)
lines = load_progress_lines(progress_path)
block = find_block(lines)
entries: list[tuple[str, str, Optional[str], int]] = []
if block:
entries = filter_existing_entries(parse_entries(lines, *block), plan_keys)
entry_by_plan = {plan_key: (status, note) for plan_key, status, note, _ in entries}
counts = {status: 0 for status in ("pending", "in-progress", "done", "blocked", "skipped")}
rows: list[str] = []
for plan_key in plan_keys:
status, note = entry_by_plan.get(plan_key, ("pending", None))
counts[status] += 1
suffix = f": {note}" if note else ""
rows.append(f"PLAN {plan_key} {status}{suffix}")
state: dict[str, str] = {}
workflow_block = find_named_block(lines, WORKFLOW_STATE_START, WORKFLOW_STATE_END)
if workflow_block:
state = parse_workflow_state(lines, *workflow_block)
output = [
"STATUS "
f"total={len(plan_keys)} "
f"pending={counts['pending']} "
f"in-progress={counts['in-progress']} "
f"done={counts['done']} "
f"blocked={counts['blocked']} "
f"skipped={counts['skipped']}"
]
current_parts = [
f"{key}={state[key]}"
for key in (
"phase",
"plan",
"claimed_by",
"claimed_at",
"verification",
)
if key in state
]
if current_parts:
output.append("CURRENT " + " ".join(current_parts))
output.extend(rows)
return 0, "\n".join(output)
def record_workflow_state(
progress_path: Path,
phase: str,
@@ -543,7 +702,7 @@ def main(argv: list[str]) -> int:
return 0
mode = argv[0]
if mode not in {"claim", "finish", "record"}:
if mode not in {"claim", "finish", "record", "status"}:
print(f"ERROR: unknown mode: {mode}", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
@@ -561,11 +720,26 @@ def main(argv: list[str]) -> int:
if mode == "claim":
plans = flags.get("-plans")
progress = flags.get("-progress")
owner = flags.get("-owner")
if not plans or not progress:
print("ERROR: -plans and -progress are required", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
code, message = claim_plan(Path(plans), Path(progress))
code, message = claim_plan(Path(plans), Path(progress), owner)
if code != 0:
print(message, file=sys.stderr)
return code
print(message)
return 0
if mode == "status":
plans = flags.get("-plans")
progress = flags.get("-progress")
if not plans or not progress:
print("ERROR: -plans and -progress are required", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
code, message = status_report(Path(plans), Path(progress))
if code != 0:
print(message, file=sys.stderr)
return code
@@ -596,11 +770,12 @@ def main(argv: list[str]) -> int:
status = flags.get("-status")
progress = flags.get("-progress")
note = flags.get("-note")
verified = flags.get("-verified")
if not plan or not status or not progress:
print("ERROR: -plan, -status, and -progress are required", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
code, message = finish_plan(plan, status, Path(progress), note)
code, message = finish_plan(plan, status, Path(progress), note, verified)
if code != 0:
print(message, file=sys.stderr)
return code