🐛 fix(templates): enforce main loop progress tracking
Update Third-party Superpowers / Update thirdparty/skill snapshot (push) Successful in 1m20s
Update Third-party Superpowers / Update thirdparty/skill snapshot (push) Successful in 1m20s
replace the old select/record progress flow with a single main_loop claim/finish CLI. route template execution through main_loop only, remove the legacy plan_progress entry points, and update tests to enforce the new behavior.
This commit is contained in:
@@ -9,21 +9,25 @@ PLAN_STATUS_START = "<!-- plan-status:start -->"
|
||||
PLAN_STATUS_END = "<!-- plan-status:end -->"
|
||||
PLAN_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-.+\.md$")
|
||||
PLAN_LINE_RE = re.compile(
|
||||
r"^- \[(?P<check>[ xX])\] `(?P<plan>[^`]+)` (?P<status>done|blocked|pending|in-progress|skipped)(?:: (?P<note>.*))?$"
|
||||
r"^- \[(?P<check>[ xX])\] `(?P<plan>[^`]+)` "
|
||||
r"(?P<status>done|blocked|pending|in-progress|skipped)"
|
||||
r"(?:: (?P<note>.*))?$"
|
||||
)
|
||||
VALID_STATUSES = {"done", "blocked", "pending", "in-progress", "skipped"}
|
||||
FINISH_STATUSES = {"done", "blocked", "skipped"}
|
||||
ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$")
|
||||
|
||||
|
||||
def usage() -> str:
|
||||
return (
|
||||
"Usage:\n"
|
||||
" python scripts/plan_progress.py select -plans <dir> -progress <file>\n"
|
||||
" python scripts/plan_progress.py record -plan <path> -status <status> -progress <file> [-note <text>]\n"
|
||||
" python scripts/plan_progress.py -h\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"
|
||||
" python scripts/main_loop.py -h\n"
|
||||
"Options:\n"
|
||||
" -plans DIR\n"
|
||||
" -plan PATH\n"
|
||||
" -status done|blocked|pending|in-progress|skipped\n"
|
||||
" -status done|blocked|skipped\n"
|
||||
" -progress FILE\n"
|
||||
" -note TEXT\n"
|
||||
" -h, -help Show this help.\n"
|
||||
@@ -57,30 +61,18 @@ def normalize_plan_key(plan_value: str) -> str:
|
||||
return raw
|
||||
|
||||
|
||||
def normalize_note(note: str) -> str:
|
||||
return note.replace("\n", " ").replace("\r", " ").replace("`", "'").strip()
|
||||
|
||||
|
||||
def render_plan_line(plan_key: str, status: str, note: Optional[str]) -> str:
|
||||
checked = "x" if status == "done" else " "
|
||||
if status == "blocked":
|
||||
suffix = "blocked"
|
||||
if note:
|
||||
suffix += f": {note}"
|
||||
elif status == "pending":
|
||||
suffix = "pending"
|
||||
elif status == "in-progress":
|
||||
suffix = "in-progress"
|
||||
elif status == "skipped":
|
||||
suffix = "skipped"
|
||||
if note:
|
||||
suffix += f": {note}"
|
||||
else:
|
||||
suffix = "done"
|
||||
suffix = status
|
||||
if note:
|
||||
suffix += f": {note}"
|
||||
return f"- [{checked}] `{plan_key}` {suffix}"
|
||||
|
||||
|
||||
def normalize_note(note: str) -> str:
|
||||
cleaned = note.replace("\n", " ").replace("\r", " ").replace("`", "'").strip()
|
||||
return cleaned
|
||||
|
||||
|
||||
def list_plan_files(plans_dir: Path) -> list[str]:
|
||||
entries: list[str] = []
|
||||
for path in plans_dir.iterdir():
|
||||
@@ -88,11 +80,7 @@ def list_plan_files(plans_dir: Path) -> list[str]:
|
||||
continue
|
||||
if not PLAN_FILE_RE.match(path.name):
|
||||
continue
|
||||
try:
|
||||
rel = path.resolve().relative_to(plans_dir.resolve()).as_posix()
|
||||
except ValueError:
|
||||
rel = path.name
|
||||
entries.append(rel)
|
||||
entries.append(path.name)
|
||||
return sorted(entries)
|
||||
|
||||
|
||||
@@ -110,7 +98,9 @@ def find_block(lines: list[str]) -> Optional[tuple[int, int]]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_entries(lines: list[str], start_idx: int, end_idx: int) -> list[tuple[str, str, Optional[str], int]]:
|
||||
def parse_entries(
|
||||
lines: list[str], start_idx: int, end_idx: int
|
||||
) -> list[tuple[str, str, Optional[str], int]]:
|
||||
entries: list[tuple[str, str, Optional[str], int]] = []
|
||||
for idx in range(start_idx + 1, end_idx):
|
||||
line = lines[idx].strip()
|
||||
@@ -132,17 +122,13 @@ def render_progress_lines(plans: list[str]) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$")
|
||||
|
||||
|
||||
def parse_env_blocked_note(note: Optional[str]) -> Optional[tuple[str, str]]:
|
||||
"""Parse 'env:windows:Task2,Task4' format. Returns (env, tasks) or None."""
|
||||
if not note:
|
||||
return None
|
||||
match = ENV_BLOCKED_RE.match(note)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
return None
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
|
||||
def detect_env() -> Optional[str]:
|
||||
@@ -150,91 +136,124 @@ def detect_env() -> Optional[str]:
|
||||
return mapping.get(platform.system().lower())
|
||||
|
||||
|
||||
def select_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
|
||||
def load_progress_lines(progress_path: Path) -> list[str]:
|
||||
progress_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if progress_path.exists():
|
||||
return progress_path.read_text(encoding="utf-8").splitlines()
|
||||
return []
|
||||
|
||||
|
||||
def write_progress_lines(progress_path: Path, lines: list[str]) -> None:
|
||||
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def ensure_plan_block(
|
||||
lines: list[str], progress_path: Path, plan_keys: list[str]
|
||||
) -> tuple[list[str], int, int]:
|
||||
block = find_block(lines)
|
||||
if not block:
|
||||
lines = render_progress_lines(plan_keys)
|
||||
write_progress_lines(progress_path, lines)
|
||||
block = find_block(lines)
|
||||
if not block:
|
||||
raise ValueError("failed to create plan status block")
|
||||
return lines, block[0], block[1]
|
||||
|
||||
|
||||
def ensure_all_plans_present(
|
||||
lines: list[str], start_idx: int, end_idx: int, progress_path: Path, plan_keys: list[str]
|
||||
) -> list[tuple[str, str, Optional[str], int]]:
|
||||
entries = parse_entries(lines, start_idx, end_idx)
|
||||
existing = {plan_key for plan_key, _, _, _ in entries}
|
||||
missing = [plan_key for plan_key in plan_keys if plan_key not in existing]
|
||||
if missing:
|
||||
insert_lines = [render_plan_line(plan_key, "pending", None) for plan_key in missing]
|
||||
lines[end_idx:end_idx] = insert_lines
|
||||
write_progress_lines(progress_path, lines)
|
||||
end_idx += len(insert_lines)
|
||||
entries = parse_entries(lines, start_idx, end_idx)
|
||||
return entries
|
||||
|
||||
|
||||
def choose_claim_entry(
|
||||
entries: list[tuple[str, str, Optional[str], int]], current_env: Optional[str]
|
||||
) -> Optional[tuple[str, Optional[str], int]]:
|
||||
for plan_key, status, note, idx in entries:
|
||||
if status == "in-progress":
|
||||
return plan_key, note, idx
|
||||
|
||||
for plan_key, status, note, idx in entries:
|
||||
if status == "pending":
|
||||
return plan_key, note, idx
|
||||
|
||||
if current_env:
|
||||
for plan_key, status, note, idx in entries:
|
||||
if status != "blocked":
|
||||
continue
|
||||
env_info = parse_env_blocked_note(note)
|
||||
if env_info and env_info[0] == current_env:
|
||||
return plan_key, note, idx
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def claim_plan(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)
|
||||
if not plan_keys:
|
||||
return 2, "ERROR: no plan files found"
|
||||
|
||||
progress_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if progress_path.exists():
|
||||
lines = progress_path.read_text(encoding="utf-8").splitlines()
|
||||
else:
|
||||
lines = []
|
||||
lines = load_progress_lines(progress_path)
|
||||
try:
|
||||
lines, start_idx, end_idx = ensure_plan_block(lines, progress_path, plan_keys)
|
||||
except ValueError as exc:
|
||||
return 2, f"ERROR: {exc}"
|
||||
|
||||
block = find_block(lines)
|
||||
if not block:
|
||||
lines = render_progress_lines(plan_keys)
|
||||
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return 0, (plans_dir / plan_keys[0]).as_posix()
|
||||
entries = ensure_all_plans_present(lines, start_idx, end_idx, progress_path, plan_keys)
|
||||
chosen = choose_claim_entry(entries, detect_env())
|
||||
if not chosen:
|
||||
return 2, "ERROR: no claimable plans"
|
||||
|
||||
start_idx, end_idx = block
|
||||
entries = parse_entries(lines, start_idx, end_idx)
|
||||
existing = {plan for plan, _, _, _ in entries}
|
||||
missing = [plan for plan in plan_keys if plan not in existing]
|
||||
if missing:
|
||||
insert_lines = [render_plan_line(plan, "pending", None) for plan in missing]
|
||||
lines[end_idx:end_idx] = insert_lines
|
||||
end_idx += len(insert_lines)
|
||||
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
entries = parse_entries(lines, start_idx, end_idx)
|
||||
plan_key, note, idx = chosen
|
||||
lines[idx] = render_plan_line(plan_key, "in-progress", note)
|
||||
write_progress_lines(progress_path, lines)
|
||||
|
||||
for plan_key, status, note, _ in entries:
|
||||
if status in ("pending", "in-progress"):
|
||||
return 0, (plans_dir / plan_key).as_posix()
|
||||
|
||||
# Check for env-blocked Plans if current environment is detected
|
||||
current_env = detect_env()
|
||||
if current_env:
|
||||
for plan_key, status, note, _ in entries:
|
||||
if status == "blocked":
|
||||
env_info = parse_env_blocked_note(note)
|
||||
if env_info and env_info[0] == current_env:
|
||||
return 0, (plans_dir / plan_key).as_posix()
|
||||
|
||||
return 2, "ERROR: no pending plans"
|
||||
output = [f"PLAN={(plans_dir / plan_key).as_posix()}"]
|
||||
if note:
|
||||
output.append(f"NOTE={note}")
|
||||
return 0, "\n".join(output)
|
||||
|
||||
|
||||
def record_status(plan: str, status: str, progress_path: Path, note: Optional[str]) -> tuple[int, str]:
|
||||
if status not in VALID_STATUSES:
|
||||
def finish_plan(
|
||||
plan: str, status: str, progress_path: Path, note: Optional[str]
|
||||
) -> 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"
|
||||
|
||||
progress_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if progress_path.exists():
|
||||
lines = progress_path.read_text(encoding="utf-8").splitlines()
|
||||
else:
|
||||
lines = []
|
||||
|
||||
lines = load_progress_lines(progress_path)
|
||||
plan_key = normalize_plan_key(plan)
|
||||
block = find_block(lines)
|
||||
if not block:
|
||||
lines = render_progress_lines([plan_key])
|
||||
block = find_block(lines)
|
||||
if not block:
|
||||
return 2, "ERROR: failed to create plan status block"
|
||||
|
||||
start_idx, end_idx = block
|
||||
try:
|
||||
lines, start_idx, end_idx = ensure_plan_block(lines, progress_path, [plan_key])
|
||||
except ValueError as exc:
|
||||
return 2, f"ERROR: {exc}"
|
||||
|
||||
entries = parse_entries(lines, start_idx, end_idx)
|
||||
|
||||
rendered_note = None
|
||||
if status in ("blocked", "skipped") and note:
|
||||
rendered_note = normalize_note(note)
|
||||
|
||||
rendered_note = normalize_note(note) if note else None
|
||||
updated_line = render_plan_line(plan_key, status, rendered_note)
|
||||
updated = False
|
||||
|
||||
for entry_plan, _, _, idx in entries:
|
||||
if entry_plan == plan_key:
|
||||
lines[idx] = updated_line
|
||||
updated = True
|
||||
break
|
||||
write_progress_lines(progress_path, lines)
|
||||
return 0, updated_line
|
||||
|
||||
if not updated:
|
||||
lines[end_idx:end_idx] = [updated_line]
|
||||
|
||||
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
lines[end_idx:end_idx] = [updated_line]
|
||||
write_progress_lines(progress_path, lines)
|
||||
return 0, updated_line
|
||||
|
||||
|
||||
@@ -247,7 +266,7 @@ def main(argv: list[str]) -> int:
|
||||
return 0
|
||||
|
||||
mode = argv[0]
|
||||
if mode not in ("select", "record"):
|
||||
if mode not in {"claim", "finish"}:
|
||||
print(f"ERROR: unknown mode: {mode}", file=sys.stderr)
|
||||
print(usage(), file=sys.stderr)
|
||||
return 2
|
||||
@@ -262,14 +281,14 @@ def main(argv: list[str]) -> int:
|
||||
print(usage(), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if mode == "select":
|
||||
if mode == "claim":
|
||||
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 = select_plan(Path(plans), Path(progress))
|
||||
code, message = claim_plan(Path(plans), Path(progress))
|
||||
if code != 0:
|
||||
print(message, file=sys.stderr)
|
||||
return code
|
||||
@@ -284,7 +303,7 @@ def main(argv: list[str]) -> int:
|
||||
print("ERROR: -plan, -status, and -progress are required", file=sys.stderr)
|
||||
print(usage(), file=sys.stderr)
|
||||
return 2
|
||||
code, message = record_status(plan, status, Path(progress), note)
|
||||
code, message = finish_plan(plan, status, Path(progress), note)
|
||||
if code != 0:
|
||||
print(message, file=sys.stderr)
|
||||
return code
|
||||
Reference in New Issue
Block a user