multi-forge 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,1137 @@
1
+ #!/usr/bin/env python3
2
+ """Parse a walkthrough checklist into structured JSON with state tracking.
3
+
4
+ Provides deterministic bookkeeping so the agent never does arithmetic —
5
+ it only classifies (pass/fail/skip) while this script handles structure,
6
+ counting, and progress tracking.
7
+
8
+ Usage (read-only):
9
+ python3 walkthrough-state.py <checklist> index
10
+ python3 walkthrough-state.py <checklist> step 6.3
11
+ python3 walkthrough-state.py <checklist> summary
12
+
13
+ Usage (state management):
14
+ python3 walkthrough-state.py <checklist> init <state-file> [--mode M] [--force]
15
+ python3 walkthrough-state.py <checklist> record <state-file> <step_id> <results> [--force]
16
+ python3 walkthrough-state.py <checklist> var <state-file> set <key> <value>
17
+ python3 walkthrough-state.py <checklist> var <state-file> get <key>
18
+ python3 walkthrough-state.py <checklist> prereq-check <state-file> <step_id|section_id>
19
+ python3 walkthrough-state.py <checklist> report <state-file>
20
+ python3 walkthrough-state.py <checklist> validate <state-file> --from <step_id>
21
+ """
22
+
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ SECTION_RE = re.compile(r"^## (\S+?)\.?\s+(.+)")
33
+ SUBSECTION_RE = re.compile(r"^### (\S+)\s+(.+)")
34
+ ANNOTATION_RE = re.compile(r"^<!--\s*(.+?)\s*-->")
35
+ PREREQ_RE = re.compile(r"^<!--\s*prereq:\s*(.+?)\s*-->")
36
+ ASSERTION_RE = re.compile(r"^- \[ \]\s+(.+)")
37
+ VERSION_RE = re.compile(r"^<!--\s*version:\s*(.+?)\s*-->")
38
+ FENCE_RE = re.compile(r"^```(\w*)")
39
+
40
+ CHECKLIST_INDEX_RE = re.compile(r"^<!--\s*checklist:\s*index\s*-->")
41
+ INDEX_SECTION_RE = re.compile(r"^<!--\s*section:\s*(\S+)\s+(.+?)\s*-->")
42
+
43
+ RESULT_CODES = {"p": "pass", "f": "fail", "s": "skip"}
44
+ EXECUTION_ANNOTATIONS = {"auto", "human:confirm", "human:guided"}
45
+
46
+
47
+ def _primary_annotation(annotations: list[str]) -> str:
48
+ """Return the execution annotation for a step, ignoring modifier annotations."""
49
+ for annotation in annotations:
50
+ if annotation in EXECUTION_ANNOTATIONS:
51
+ return annotation
52
+ return "human:confirm"
53
+
54
+
55
+ def _parse_index_entries(index_lines: list[str]) -> list[tuple[str, str]]:
56
+ entries: list[tuple[str, str]] = []
57
+ for line in index_lines:
58
+ m = INDEX_SECTION_RE.match(line)
59
+ if not m:
60
+ continue
61
+ section_id = m.group(1)
62
+ relpath = m.group(2).strip()
63
+ entries.append((section_id, relpath))
64
+ return entries
65
+
66
+
67
+ def _next_nonblank_line(lines: list[str], start: int) -> tuple[int | None, str | None]:
68
+ for idx in range(start, len(lines)):
69
+ if lines[idx].strip():
70
+ return idx, lines[idx]
71
+ return None, None
72
+
73
+
74
+ def _parse_checklist_lines(lines: list[str], *, extract_version: bool) -> dict:
75
+ version = None
76
+ sections = []
77
+ current_section = None
78
+ current_sub = None
79
+ all_subs = []
80
+ # Prereqs seen outside a subsection body apply to the next ## heading.
81
+ pending_prereqs: list[str] = []
82
+
83
+ in_fence = False
84
+
85
+ for line_idx, line in enumerate(lines):
86
+ m = FENCE_RE.match(line)
87
+ if m:
88
+ if in_fence:
89
+ in_fence = False
90
+ continue
91
+ in_fence = True
92
+ if current_sub is not None:
93
+ lang = m.group(1) or None
94
+ current_sub["_collecting_code"] = True
95
+ current_sub["code_blocks"].append({"code": "", "runnable": lang == "bash"})
96
+ continue
97
+
98
+ if in_fence:
99
+ if current_sub is not None and current_sub.get("_collecting_code"):
100
+ block = current_sub["code_blocks"][-1]
101
+ if block["code"]:
102
+ block["code"] += "\n"
103
+ block["code"] += line
104
+ continue
105
+
106
+ # Prereq annotations are subsection-level when placed in the annotation block
107
+ # directly under a ### heading. Outside that block, they are only allowed
108
+ # immediately before a ## heading (section-level).
109
+ m = PREREQ_RE.match(line)
110
+ if m:
111
+ in_subsection_annotation_block = (
112
+ current_sub is not None
113
+ and not current_sub["instructions"]
114
+ and not current_sub["code_blocks"]
115
+ and not current_sub["assertions"]
116
+ )
117
+ if not in_subsection_annotation_block:
118
+ _, next_line = _next_nonblank_line(lines, line_idx + 1)
119
+ if next_line is not None and (PREREQ_RE.match(next_line) or SECTION_RE.match(next_line)):
120
+ pending_prereqs = list(
121
+ dict.fromkeys(pending_prereqs + [p.strip() for p in m.group(1).split(",") if p.strip()])
122
+ )
123
+ continue
124
+
125
+ if current_sub is not None:
126
+ print(
127
+ f"Error: misplaced prereq annotation inside subsection body: {current_sub['id']}",
128
+ file=sys.stderr,
129
+ )
130
+ print(
131
+ "Place it immediately below the subsection heading for a step-level prereq, "
132
+ "or immediately above the next ## heading for a section-level prereq.",
133
+ file=sys.stderr,
134
+ )
135
+ else:
136
+ print("Error: section-level prereq must appear immediately before a ## heading.", file=sys.stderr)
137
+ sys.exit(1)
138
+
139
+ if version is None:
140
+ if extract_version:
141
+ m = VERSION_RE.match(line)
142
+ if m:
143
+ version = m.group(1).strip()
144
+ continue
145
+
146
+ m = SECTION_RE.match(line)
147
+ if m:
148
+ current_section = {
149
+ "id": m.group(1),
150
+ "title": m.group(2).strip(),
151
+ "prereqs": pending_prereqs,
152
+ "subsections": [],
153
+ }
154
+ pending_prereqs = []
155
+ sections.append(current_section)
156
+ current_sub = None
157
+ continue
158
+
159
+ m = SUBSECTION_RE.match(line)
160
+ if m:
161
+ current_sub = {
162
+ "id": m.group(1),
163
+ "title": m.group(2).strip(),
164
+ "section_id": current_section["id"] if current_section else None,
165
+ "section_title": current_section["title"] if current_section else None,
166
+ "annotations": [],
167
+ "annotation": None,
168
+ "instructions": "",
169
+ "code_blocks": [],
170
+ "assertions": [],
171
+ }
172
+ if current_section is not None:
173
+ current_section["subsections"].append(current_sub)
174
+ all_subs.append(current_sub)
175
+ continue
176
+
177
+ if current_sub is None:
178
+ continue
179
+
180
+ m = ANNOTATION_RE.match(line)
181
+ if m and not current_sub["code_blocks"] and not current_sub["assertions"]:
182
+ current_sub["annotations"].append(m.group(1))
183
+ continue
184
+
185
+ m = ASSERTION_RE.match(line)
186
+ if m:
187
+ current_sub["assertions"].append(m.group(1))
188
+ continue
189
+
190
+ stripped = line.strip()
191
+ if stripped:
192
+ if current_sub["instructions"]:
193
+ current_sub["instructions"] += "\n"
194
+ current_sub["instructions"] += stripped
195
+
196
+ for sub in all_subs:
197
+ sub.pop("_collecting_code", None)
198
+ # Extract prereq annotations into a dedicated field
199
+ sub_prereqs: list[str] = []
200
+ non_prereq_annotations: list[str] = []
201
+ for ann in sub["annotations"]:
202
+ pm = PREREQ_RE.match(f"<!-- {ann} -->")
203
+ if pm:
204
+ sub_prereqs.extend(p.strip() for p in pm.group(1).split(","))
205
+ else:
206
+ non_prereq_annotations.append(ann)
207
+ sub["prereqs"] = sub_prereqs
208
+ sub["annotations"] = non_prereq_annotations
209
+ sub["annotation"] = _primary_annotation(non_prereq_annotations)
210
+
211
+ for section in sections:
212
+ section["assertion_count"] = sum(len(s["assertions"]) for s in section["subsections"])
213
+ for sub in section["subsections"]:
214
+ sub["assertion_count"] = len(sub["assertions"])
215
+
216
+ total = sum(s["assertion_count"] for s in sections)
217
+
218
+ return {
219
+ "version": version,
220
+ "total_assertions": total,
221
+ "sections": sections,
222
+ "_all_subs": all_subs,
223
+ }
224
+
225
+
226
+ def _parse_index_checklist(index_path: Path, index_lines: list[str]) -> dict:
227
+ version = None
228
+ for line in index_lines:
229
+ if version is None:
230
+ m = VERSION_RE.match(line)
231
+ if m:
232
+ version = m.group(1).strip()
233
+ continue
234
+
235
+ if version is None:
236
+ print(f"Error: index checklist missing version: {index_path}", file=sys.stderr)
237
+ print("Add: <!-- version: X.Y.Z -->", file=sys.stderr)
238
+ sys.exit(1)
239
+
240
+ entries = _parse_index_entries(index_lines)
241
+ if not entries:
242
+ print(f"Error: index checklist contains no section entries: {index_path}", file=sys.stderr)
243
+ print("Add one or more: <!-- section: <id> <relative_path> -->", file=sys.stderr)
244
+ sys.exit(1)
245
+
246
+ seen_ids: set[str] = set()
247
+ sections: list[dict] = []
248
+ all_subs: list[dict] = []
249
+
250
+ for section_id, relpath in entries:
251
+ if section_id in seen_ids:
252
+ print(f"Error: duplicate section id in index: {section_id}", file=sys.stderr)
253
+ sys.exit(1)
254
+ seen_ids.add(section_id)
255
+
256
+ section_path = index_path.parent / relpath
257
+ if not section_path.exists():
258
+ print(f"Error: section file not found for section {section_id}: {section_path}", file=sys.stderr)
259
+ sys.exit(1)
260
+
261
+ parsed = _parse_checklist_lines(section_path.read_text().splitlines(), extract_version=False)
262
+ if len(parsed["sections"]) != 1:
263
+ print(
264
+ f"Error: section file must contain exactly 1 section: {section_path}",
265
+ file=sys.stderr,
266
+ )
267
+ print(f" Found: {len(parsed['sections'])}", file=sys.stderr)
268
+ sys.exit(1)
269
+
270
+ section = parsed["sections"][0]
271
+ if section["id"] != section_id:
272
+ print(
273
+ f"Error: section id mismatch in {section_path}\n"
274
+ f" Index expects: {section_id}\n"
275
+ f" File declares: {section['id']}",
276
+ file=sys.stderr,
277
+ )
278
+ sys.exit(1)
279
+
280
+ sections.append(section)
281
+ all_subs.extend(parsed["_all_subs"])
282
+
283
+ for i, sub in enumerate(all_subs):
284
+ sub["next"] = all_subs[i + 1]["id"] if i + 1 < len(all_subs) else None
285
+
286
+ for section in sections:
287
+ section["assertion_count"] = sum(len(s["assertions"]) for s in section["subsections"])
288
+ for sub in section["subsections"]:
289
+ sub["assertion_count"] = len(sub["assertions"])
290
+
291
+ total = sum(s["assertion_count"] for s in sections)
292
+
293
+ return {
294
+ "version": version,
295
+ "total_assertions": total,
296
+ "sections": sections,
297
+ "_all_subs": all_subs,
298
+ }
299
+
300
+
301
+ def parse_checklist(path: str) -> dict:
302
+ """Parse a checklist markdown file (or checklist index) into structured data."""
303
+ p = Path(path)
304
+ lines = p.read_text().splitlines()
305
+ if any(CHECKLIST_INDEX_RE.match(line) for line in lines):
306
+ return _parse_index_checklist(p, lines)
307
+
308
+ data = _parse_checklist_lines(lines, extract_version=True)
309
+ for i, sub in enumerate(data["_all_subs"]):
310
+ sub["next"] = data["_all_subs"][i + 1]["id"] if i + 1 < len(data["_all_subs"]) else None
311
+ return data
312
+
313
+
314
+ # --- Read-only commands (no state file) ---
315
+
316
+
317
+ def cmd_index(data: dict) -> dict:
318
+ """Full index with sections, subsections, annotations, assertion counts."""
319
+ sections = []
320
+ for s in data["sections"]:
321
+ subs = []
322
+ for sub in s["subsections"]:
323
+ sub_entry: dict = {
324
+ "id": sub["id"],
325
+ "title": sub["title"],
326
+ "annotation": sub["annotation"],
327
+ "assertion_count": sub["assertion_count"],
328
+ }
329
+ if sub["prereqs"]:
330
+ sub_entry["prereqs"] = sub["prereqs"]
331
+ subs.append(sub_entry)
332
+ sec_entry: dict = {
333
+ "id": s["id"],
334
+ "title": s["title"],
335
+ "assertion_count": s["assertion_count"],
336
+ "subsections": subs,
337
+ }
338
+ if s.get("prereqs"):
339
+ sec_entry["prereqs"] = s["prereqs"]
340
+ sections.append(sec_entry)
341
+ return {
342
+ "version": data["version"],
343
+ "total_assertions": data["total_assertions"],
344
+ "sections": sections,
345
+ }
346
+
347
+
348
+ def cmd_step(data: dict, step_id: str) -> dict:
349
+ """Single step details."""
350
+ for sub in data["_all_subs"]:
351
+ if sub["id"] == step_id:
352
+ # Merge section-level and subsection-level prereqs
353
+ section_prereqs: list[str] = []
354
+ for s in data["sections"]:
355
+ if s["id"] == sub["section_id"]:
356
+ section_prereqs = s.get("prereqs", [])
357
+ break
358
+ merged_prereqs = list(dict.fromkeys(section_prereqs + sub.get("prereqs", [])))
359
+ result: dict = {
360
+ "id": sub["id"],
361
+ "title": sub["title"],
362
+ "section": f"{sub['section_id']}. {sub['section_title']}",
363
+ "annotation": sub["annotation"],
364
+ "annotations": sub["annotations"],
365
+ "instructions": sub["instructions"],
366
+ "code_blocks": sub["code_blocks"],
367
+ "assertions": sub["assertions"],
368
+ "assertion_count": len(sub["assertions"]),
369
+ "next": sub["next"],
370
+ }
371
+ if merged_prereqs:
372
+ result["prereqs"] = merged_prereqs
373
+ return result
374
+ print(f"Error: step '{step_id}' not found.", file=sys.stderr)
375
+ sys.exit(1)
376
+
377
+
378
+ def cmd_summary(data: dict) -> dict:
379
+ """Summary template with expected counts per section."""
380
+ sections = []
381
+ for s in data["sections"]:
382
+ sections.append(
383
+ {
384
+ "id": s["id"],
385
+ "title": s["title"],
386
+ "expected": s["assertion_count"],
387
+ }
388
+ )
389
+ return {
390
+ "total_assertions": data["total_assertions"],
391
+ "sections": sections,
392
+ }
393
+
394
+
395
+ # --- State management commands ---
396
+
397
+
398
+ def checklist_hash(path: str) -> str:
399
+ """SHA-256 hash of the checklist content (single file or index + section files)."""
400
+ p = Path(path)
401
+ lines = p.read_text().splitlines()
402
+
403
+ h = hashlib.sha256()
404
+ h.update(b"forge-checklist-hash-v1\n")
405
+
406
+ if any(CHECKLIST_INDEX_RE.match(line) for line in lines):
407
+ entries = _parse_index_entries(lines)
408
+ if not entries:
409
+ print(f"Error: index checklist contains no section entries: {p}", file=sys.stderr)
410
+ sys.exit(1)
411
+
412
+ seen_ids: set[str] = set()
413
+
414
+ h.update(b"type:index\n")
415
+ h.update(p.read_bytes())
416
+ for section_id, relpath in entries:
417
+ if section_id in seen_ids:
418
+ print(f"Error: duplicate section id in index: {section_id}", file=sys.stderr)
419
+ sys.exit(1)
420
+ seen_ids.add(section_id)
421
+
422
+ section_path = p.parent / relpath
423
+ if not section_path.exists():
424
+ print(f"Error: section file not found for section {section_id}: {section_path}", file=sys.stderr)
425
+ sys.exit(1)
426
+
427
+ h.update(b"\nsection\n")
428
+ h.update(section_id.encode("utf-8") + b"\n")
429
+ h.update(relpath.encode("utf-8") + b"\n")
430
+ h.update(section_path.read_bytes())
431
+ else:
432
+ h.update(b"type:single\n")
433
+ h.update(p.read_bytes())
434
+
435
+ return f"sha256:{h.hexdigest()}"
436
+
437
+
438
+ def step_hash(step: dict) -> str:
439
+ """Hash the structural content of a step that affects result validity.
440
+
441
+ Includes: ID, title, annotation, assertion texts (normalized).
442
+ Excludes: instructions, code blocks (presentation only).
443
+ """
444
+ h = hashlib.sha256()
445
+ h.update(b"forge-step-hash-v1\n")
446
+ h.update(step["id"].encode() + b"\n")
447
+ h.update(step["title"].strip().encode() + b"\n")
448
+ h.update((step.get("annotation") or "").encode() + b"\n")
449
+ for a in step["assertions"]:
450
+ h.update(a.strip().encode() + b"\n")
451
+ return h.hexdigest()
452
+
453
+
454
+ def _migrate_v1_to_v2(state: dict, data: dict, checklist_path: str) -> dict:
455
+ """Auto-migrate v1 state (global hash) to v2 (per-step hash).
456
+
457
+ If the v1 global hash matches the current checklist, step hashes are
458
+ computed with full confidence. If mismatched (checklist was edited since
459
+ init), step hashes are set to null (unverified).
460
+ """
461
+ old_global = state.get("checklist_hash")
462
+ current_global = checklist_hash(checklist_path) if old_global else None
463
+ trust = old_global is not None and old_global == current_global
464
+
465
+ for step_id, step_data in state.get("steps", {}).items():
466
+ if "hash" in step_data:
467
+ continue
468
+ if trust:
469
+ found = find_step(data, step_id)
470
+ step_data["hash"] = step_hash(found) if found else None
471
+ else:
472
+ step_data["hash"] = None
473
+
474
+ state.pop("checklist_hash", None)
475
+ state["schema_version"] = 2
476
+ return state
477
+
478
+
479
+ def read_state(path: str) -> dict:
480
+ """Read and return state JSON. Fail-closed with actionable errors."""
481
+ p = Path(path)
482
+ if not p.exists():
483
+ print(f"Error: state file not found: {path}", file=sys.stderr)
484
+ print("Run 'init' first to create the state file.", file=sys.stderr)
485
+ sys.exit(1)
486
+ try:
487
+ return json.loads(p.read_text())
488
+ except json.JSONDecodeError as e:
489
+ print(f"Error: state file is corrupt: {path}", file=sys.stderr)
490
+ print(f" {e}", file=sys.stderr)
491
+ print("Delete the file and run 'init' again.", file=sys.stderr)
492
+ sys.exit(1)
493
+
494
+
495
+ def write_state(path: str, state: dict) -> None:
496
+ """Atomic write: write to .tmp then os.replace."""
497
+ state["last_updated"] = datetime.now(timezone.utc).isoformat()
498
+ tmp = path + ".tmp"
499
+ Path(tmp).write_text(json.dumps(state, indent=2) + "\n")
500
+ os.replace(tmp, path)
501
+
502
+
503
+ def find_step(data: dict, step_id: str):
504
+ """Find a subsection by ID."""
505
+ for sub in data["_all_subs"]:
506
+ if sub["id"] == step_id:
507
+ return sub
508
+ return None
509
+
510
+
511
+ def find_section(data: dict, section_id: str):
512
+ """Find a section by ID."""
513
+ for section in data["sections"]:
514
+ if section["id"] == section_id:
515
+ return section
516
+ return None
517
+
518
+
519
+ def resolve_step_id(data: dict, raw_id: str) -> str:
520
+ """Resolve a possibly section-level ID to a subsection ID.
521
+
522
+ Accepts '3.1' (exact subsection), '3' (section -> first subsection),
523
+ or '3.0' (section.0 shorthand -> first subsection).
524
+ """
525
+ # Exact subsection match
526
+ if find_step(data, raw_id):
527
+ return raw_id
528
+
529
+ # Try section-level: '3' or '3.0' -> first subsection of section 3
530
+ section_id = raw_id.rsplit(".0", 1)[0] if raw_id.endswith(".0") else raw_id
531
+ section = find_section(data, section_id)
532
+ if section and section.get("subsections"):
533
+ return section["subsections"][0]["id"]
534
+
535
+ return raw_id # Return as-is; caller handles the error
536
+
537
+
538
+ def _current_run_scope(state: dict) -> Optional[str]:
539
+ """Return the current run scope, if the caller recorded one."""
540
+ return state.get("vars", {}).get("RUN_SCOPE")
541
+
542
+
543
+ def _section_status_keys(section_id: str) -> tuple[str, str]:
544
+ return f"SECTION_{section_id}_STATUS", f"SECTION_{section_id}_SCOPE"
545
+
546
+
547
+ def _section_state(data: dict, state: dict, section_id: str, *, run_scope: Optional[str] = None) -> dict:
548
+ """Classify a section using recorded step results in the current run scope."""
549
+ section = find_section(data, section_id)
550
+ if section is None:
551
+ raise ValueError(f"Unknown section: {section_id}")
552
+
553
+ missing_steps: list[str] = []
554
+ stale_steps: list[str] = []
555
+ has_failure = False
556
+ has_non_skip = False
557
+ has_recorded_steps = False
558
+
559
+ for sub in section["subsections"]:
560
+ step_data = state.get("steps", {}).get(sub["id"])
561
+ if step_data is None:
562
+ missing_steps.append(sub["id"])
563
+ continue
564
+
565
+ if run_scope is not None and step_data.get("scope") != run_scope:
566
+ stale_steps.append(sub["id"])
567
+ continue
568
+
569
+ results = step_data["results"]
570
+ has_recorded_steps = True
571
+ if any(result != "skip" for result in results):
572
+ has_non_skip = True
573
+ if any(result == "fail" for result in results):
574
+ has_failure = True
575
+
576
+ if stale_steps:
577
+ status = "stale_run"
578
+ elif missing_steps:
579
+ status = "not_run"
580
+ elif has_failure:
581
+ status = "failed"
582
+ elif has_recorded_steps and not has_non_skip:
583
+ status = "skipped"
584
+ else:
585
+ status = "passed"
586
+
587
+ return {
588
+ "status": status,
589
+ "missing_steps": missing_steps,
590
+ "stale_steps": stale_steps,
591
+ }
592
+
593
+
594
+ def _refresh_section_status_vars(data: dict, state: dict) -> None:
595
+ """Recompute derived SECTION_* vars from the currently valid step records."""
596
+ vars_dict = state.setdefault("vars", {})
597
+ for key in list(vars_dict):
598
+ if key.startswith("SECTION_") and (key.endswith("_STATUS") or key.endswith("_SCOPE")):
599
+ del vars_dict[key]
600
+
601
+ run_scope = _current_run_scope(state)
602
+ for section in data["sections"]:
603
+ section_state = _section_state(data, state, section["id"], run_scope=run_scope)
604
+ if section_state["status"] in {"passed", "failed"}:
605
+ status_key, scope_key = _section_status_keys(section["id"])
606
+ vars_dict[status_key] = section_state["status"]
607
+ if run_scope is not None:
608
+ vars_dict[scope_key] = run_scope
609
+
610
+
611
+ def cmd_init(data: dict, checklist_path: str, state_path: str, mode: str, force: bool) -> dict:
612
+ """Create initial state file."""
613
+ if Path(state_path).exists() and not force:
614
+ print(f"Error: state file already exists: {state_path}", file=sys.stderr)
615
+ print("Use --force to overwrite.", file=sys.stderr)
616
+ sys.exit(1)
617
+
618
+ first_step = data["_all_subs"][0]["id"] if data["_all_subs"] else None
619
+ total_steps = len(data["_all_subs"])
620
+
621
+ state = {
622
+ "schema_version": 2,
623
+ "checklist_version": data["version"],
624
+ "mode": mode,
625
+ "started_at": datetime.now(timezone.utc).isoformat(),
626
+ "last_updated": datetime.now(timezone.utc).isoformat(),
627
+ "current_step": first_step,
628
+ "vars": {},
629
+ "steps": {},
630
+ }
631
+
632
+ Path(state_path).parent.mkdir(parents=True, exist_ok=True)
633
+ write_state(state_path, state)
634
+
635
+ return {
636
+ "status": "initialized",
637
+ "sections": len(data["sections"]),
638
+ "steps": total_steps,
639
+ "assertions": data["total_assertions"],
640
+ "current_step": first_step,
641
+ }
642
+
643
+
644
+ def cmd_record(data: dict, checklist_path: str, state_path: str, step_id: str, results_csv: str, force: bool) -> dict:
645
+ """Record assertion results for a step."""
646
+ state = read_state(state_path)
647
+
648
+ # Auto-migrate v1 state files
649
+ if state.get("schema_version", 1) < 2:
650
+ state = _migrate_v1_to_v2(state, data, checklist_path)
651
+
652
+ # Find the step in the checklist
653
+ step = find_step(data, step_id)
654
+ if step is None:
655
+ print(f"Error: step '{step_id}' not found in checklist.", file=sys.stderr)
656
+ sys.exit(1)
657
+
658
+ # Reject overwrite
659
+ if step_id in state["steps"] and not force:
660
+ print(f"Error: step '{step_id}' already recorded. Use --force to overwrite.", file=sys.stderr)
661
+ sys.exit(1)
662
+
663
+ # Parse and validate results
664
+ codes = [c.strip() for c in results_csv.split(",")]
665
+ expected_count = len(step["assertions"])
666
+ if len(codes) != expected_count:
667
+ print(
668
+ f"Error: step '{step_id}' expects {expected_count} assertions, got {len(codes)} results.",
669
+ file=sys.stderr,
670
+ )
671
+ sys.exit(1)
672
+
673
+ results = []
674
+ for c in codes:
675
+ if c not in RESULT_CODES:
676
+ print(f"Error: invalid result code '{c}'. Use p (pass), f (fail), s (skip).", file=sys.stderr)
677
+ sys.exit(1)
678
+ results.append(RESULT_CODES[c])
679
+
680
+ # Update state with per-step hash and the current run scope (if any).
681
+ step_entry = {"results": results, "hash": step_hash(step)}
682
+ current_scope = _current_run_scope(state)
683
+ if current_scope is not None:
684
+ step_entry["scope"] = current_scope
685
+ state["steps"][step_id] = step_entry
686
+ state["current_step"] = step["next"]
687
+ _refresh_section_status_vars(data, state)
688
+ write_state(state_path, state)
689
+
690
+ # Compute progress for output
691
+ step_pass = sum(1 for r in results if r == "pass")
692
+ step_total = len(results)
693
+
694
+ # Section progress
695
+ section_id = step["section_id"]
696
+ section_expected = 0
697
+ section_recorded = 0
698
+ for s in data["sections"]:
699
+ if s["id"] == section_id:
700
+ section_expected = s["assertion_count"]
701
+ for sub in s["subsections"]:
702
+ if sub["id"] in state["steps"]:
703
+ section_recorded += len(state["steps"][sub["id"]]["results"])
704
+ break
705
+
706
+ # Overall progress (only count steps that exist in the current checklist)
707
+ checklist_ids = {sub["id"] for sub in data["_all_subs"]}
708
+ overall_recorded = sum(len(s["results"]) for sid, s in state["steps"].items() if sid in checklist_ids)
709
+ overall_total = data["total_assertions"]
710
+
711
+ return {
712
+ "step": step_id,
713
+ "step_results": f"{step_pass}/{step_total} pass",
714
+ "section_progress": f"{section_recorded}/{section_expected}",
715
+ "section_status": state["vars"].get(f"SECTION_{section_id}_STATUS"),
716
+ "overall_progress": f"{overall_recorded}/{overall_total}",
717
+ }
718
+
719
+
720
+ def cmd_var(state_path: str, action: str, key: str, value=None) -> dict:
721
+ """Store or retrieve a variable in state."""
722
+ state = read_state(state_path)
723
+
724
+ if action == "set":
725
+ if value is None:
726
+ print("Error: 'var set' requires a value.", file=sys.stderr)
727
+ sys.exit(1)
728
+ state["vars"][key] = value
729
+ write_state(state_path, state)
730
+ return {"action": "set", "key": key, "value": value}
731
+
732
+ elif action == "get":
733
+ if key not in state["vars"]:
734
+ return {"action": "get", "key": key, "exists": False}
735
+ return {"action": "get", "key": key, "value": state["vars"][key], "exists": True}
736
+
737
+ else:
738
+ print(f"Error: unknown var action '{action}'. Use 'set' or 'get'.", file=sys.stderr)
739
+ sys.exit(1)
740
+
741
+
742
+ def _step_prereq_status(state: dict, step_id: str, run_scope: Optional[str] = None) -> str:
743
+ """Check if a single step was completed in the current run scope."""
744
+ step_data = state.get("steps", {}).get(step_id)
745
+ if step_data is None:
746
+ return "not_run"
747
+ if run_scope is not None and step_data.get("scope") != run_scope:
748
+ return "stale_run"
749
+ results = step_data.get("results", [])
750
+ if any(result == "fail" for result in results):
751
+ return "failed"
752
+ if results and all(result == "skip" for result in results):
753
+ return "skipped"
754
+ return "passed"
755
+
756
+
757
+ def cmd_prereq_check(data: dict, state_path: str, step_id: str) -> dict:
758
+ """Check prerequisites for a step. Returns ok/missing/statuses."""
759
+ state = read_state(state_path)
760
+
761
+ # Find the step and its section
762
+ target_sub = None
763
+ target_section = None
764
+ for s in data["sections"]:
765
+ for sub in s["subsections"]:
766
+ if sub["id"] == step_id:
767
+ target_sub = sub
768
+ target_section = s
769
+ break
770
+ if target_sub:
771
+ break
772
+
773
+ if target_sub is None:
774
+ # Try as a section ID (e.g., "5" -> check first subsection's prereqs)
775
+ for s in data["sections"]:
776
+ if s["id"] == step_id:
777
+ target_section = s
778
+ if s["subsections"]:
779
+ target_sub = s["subsections"][0]
780
+ break
781
+
782
+ if target_section is None:
783
+ print(f"Error: step or section '{step_id}' not found.", file=sys.stderr)
784
+ sys.exit(1)
785
+
786
+ # Merge section + subsection prereqs
787
+ section_prereqs = target_section.get("prereqs", [])
788
+ sub_prereqs = target_sub.get("prereqs", []) if target_sub else []
789
+ all_prereqs = list(dict.fromkeys(section_prereqs + sub_prereqs))
790
+
791
+ if not all_prereqs:
792
+ return {"ok": True, "required": [], "missing": [], "blocking": [], "resolvable": [], "statuses": {}}
793
+
794
+ # Check each prereq against the current run scope.
795
+ run_scope = _current_run_scope(state)
796
+ statuses: dict[str, str] = {}
797
+ missing: list[str] = []
798
+ blocking: list[str] = []
799
+ for prereq_id in all_prereqs:
800
+ if "." in prereq_id:
801
+ # Subsection-level prereq (e.g., "3.2"): check if step was recorded
802
+ status = _step_prereq_status(state, prereq_id, run_scope)
803
+ else:
804
+ # Section-level prereq (e.g., "3"): check full section completion
805
+ section_state = _section_state(data, state, prereq_id, run_scope=run_scope)
806
+ status = section_state["status"]
807
+ statuses[prereq_id] = status
808
+ if status != "passed":
809
+ blocking.append(prereq_id)
810
+ if status == "not_run":
811
+ missing.append(prereq_id)
812
+
813
+ # For each missing step-level prereq, check if it's resolvable:
814
+ # its section prereqs are all satisfied, so the agent can run it immediately.
815
+ resolvable: list[str] = []
816
+ for prereq_id in missing:
817
+ if "." not in prereq_id:
818
+ continue # Section-level prereqs are too broad to auto-resolve
819
+ # Find the section this prereq step belongs to
820
+ prereq_step = find_step(data, prereq_id)
821
+ if prereq_step is None:
822
+ continue
823
+ prereq_section = find_section(data, prereq_step["section_id"])
824
+ if prereq_section is None:
825
+ continue
826
+ # Check if the prereq step's section prereqs are all satisfied
827
+ section_prereqs = prereq_section.get("prereqs", [])
828
+ all_section_prereqs_ok = True
829
+ for sp in section_prereqs:
830
+ if "." in sp:
831
+ sp_status = _step_prereq_status(state, sp, run_scope)
832
+ else:
833
+ sp_state = _section_state(data, state, sp, run_scope=run_scope)
834
+ sp_status = sp_state["status"]
835
+ if sp_status != "passed":
836
+ all_section_prereqs_ok = False
837
+ break
838
+ if all_section_prereqs_ok:
839
+ resolvable.append(prereq_id)
840
+
841
+ return {
842
+ "ok": len(blocking) == 0,
843
+ "required": all_prereqs,
844
+ "missing": missing,
845
+ "blocking": blocking,
846
+ "resolvable": resolvable,
847
+ "statuses": statuses,
848
+ }
849
+
850
+
851
+ def cmd_report(data: dict, checklist_path: str, state_path: str) -> dict:
852
+ """Generate final summary by joining state with checklist structure."""
853
+ state = read_state(state_path)
854
+
855
+ # Auto-migrate v1 state files
856
+ if state.get("schema_version", 1) < 2:
857
+ state = _migrate_v1_to_v2(state, data, checklist_path)
858
+ write_state(state_path, state)
859
+
860
+ # Per-step hash validation (fail-open: warn, don't exit)
861
+ changed_steps = []
862
+ unverified_steps = []
863
+ orphaned_steps = []
864
+
865
+ checklist_step_ids = {sub["id"] for sub in data["_all_subs"]}
866
+ for sid, sdata in state.get("steps", {}).items():
867
+ if sid not in checklist_step_ids:
868
+ orphaned_steps.append(sid)
869
+ continue
870
+ stored_hash = sdata.get("hash")
871
+ if stored_hash is None:
872
+ unverified_steps.append(sid)
873
+ continue
874
+ found = find_step(data, sid)
875
+ if found and step_hash(found) != stored_hash:
876
+ changed_steps.append({"id": sid, "reason": "step content changed since recorded"})
877
+
878
+ sections = []
879
+ total_pass = 0
880
+ total_fail = 0
881
+ total_skip = 0
882
+ failures = []
883
+ gaps = []
884
+
885
+ for s in data["sections"]:
886
+ s_pass = 0
887
+ s_fail = 0
888
+ s_skip = 0
889
+
890
+ for sub in s["subsections"]:
891
+ if sub["id"] not in state["steps"]:
892
+ gaps.append(sub["id"])
893
+ continue
894
+
895
+ results = state["steps"][sub["id"]]["results"]
896
+ for i, r in enumerate(results):
897
+ if r == "pass":
898
+ s_pass += 1
899
+ elif r == "fail":
900
+ s_fail += 1
901
+ failures.append(
902
+ {
903
+ "step": sub["id"],
904
+ "title": sub["title"],
905
+ "assertion_index": i,
906
+ "text": sub["assertions"][i] if i < len(sub["assertions"]) else "?",
907
+ }
908
+ )
909
+ elif r == "skip":
910
+ s_skip += 1
911
+
912
+ sections.append(
913
+ {
914
+ "id": s["id"],
915
+ "title": s["title"],
916
+ "expected": s["assertion_count"],
917
+ "pass": s_pass,
918
+ "fail": s_fail,
919
+ "skip": s_skip,
920
+ }
921
+ )
922
+ total_pass += s_pass
923
+ total_fail += s_fail
924
+ total_skip += s_skip
925
+
926
+ result = {
927
+ "total": {
928
+ "expected": data["total_assertions"],
929
+ "pass": total_pass,
930
+ "fail": total_fail,
931
+ "skip": total_skip,
932
+ },
933
+ "sections": sections,
934
+ "failures": failures,
935
+ "gaps": gaps,
936
+ "complete": len(gaps) == 0,
937
+ }
938
+
939
+ # Attach warnings (agent decides how to present these)
940
+ if changed_steps or unverified_steps or orphaned_steps:
941
+ result["warnings"] = {}
942
+ if changed_steps:
943
+ result["warnings"]["changed_steps"] = changed_steps
944
+ if unverified_steps:
945
+ result["warnings"]["unverified_steps"] = unverified_steps
946
+ if orphaned_steps:
947
+ result["warnings"]["orphaned_steps"] = orphaned_steps
948
+
949
+ return result
950
+
951
+
952
+ def cmd_validate(data: dict, checklist_path: str, state_path: str, from_step: str) -> dict:
953
+ """Pre-flight validation for resume. Checks hashes and clears stale future steps.
954
+
955
+ Steps before from_step: validate stored hash vs current checklist.
956
+ Steps at/after from_step: clear from state to prevent phantom progress.
957
+ Returns JSON with changed_steps, unverified_steps, cleared_steps.
958
+ """
959
+ state = read_state(state_path)
960
+
961
+ # Auto-migrate v1 state files
962
+ if state.get("schema_version", 1) < 2:
963
+ state = _migrate_v1_to_v2(state, data, checklist_path)
964
+
965
+ # Resolve section-level IDs (e.g., '3' or '3.0' -> '3.1')
966
+ from_step = resolve_step_id(data, from_step)
967
+
968
+ # Build step order from checklist
969
+ step_order = [sub["id"] for sub in data["_all_subs"]]
970
+ try:
971
+ from_index = step_order.index(from_step)
972
+ except ValueError:
973
+ print(f"Error: step '{from_step}' not found in checklist.", file=sys.stderr)
974
+ sys.exit(1)
975
+
976
+ before_steps = set(step_order[:from_index])
977
+ at_or_after_steps = set(step_order[from_index:])
978
+
979
+ changed_steps = []
980
+ unverified_steps = []
981
+ cleared_steps = []
982
+ orphaned_steps = []
983
+
984
+ all_checklist_ids = set(step_order)
985
+ for sid, sdata in list(state.get("steps", {}).items()):
986
+ # Orphaned steps (no longer in checklist): purge
987
+ if sid not in all_checklist_ids:
988
+ orphaned_steps.append(sid)
989
+ del state["steps"][sid]
990
+ continue
991
+
992
+ # Steps at/after resume point: clear to prevent phantom progress
993
+ if sid in at_or_after_steps:
994
+ cleared_steps.append(sid)
995
+ del state["steps"][sid]
996
+ continue
997
+
998
+ # Steps before resume point: validate hash
999
+ if sid in before_steps:
1000
+ stored_hash = sdata.get("hash")
1001
+ if stored_hash is None:
1002
+ unverified_steps.append(sid)
1003
+ continue
1004
+ found = find_step(data, sid)
1005
+ if found and step_hash(found) != stored_hash:
1006
+ changed_steps.append({"id": sid, "reason": "step content changed since recorded"})
1007
+
1008
+ # Update current_step to the resume point
1009
+ state["current_step"] = from_step
1010
+ _refresh_section_status_vars(data, state)
1011
+ write_state(state_path, state)
1012
+
1013
+ status = "ok"
1014
+ if changed_steps or unverified_steps:
1015
+ status = "warnings"
1016
+
1017
+ return {
1018
+ "status": status,
1019
+ "changed_steps": changed_steps,
1020
+ "unverified_steps": unverified_steps,
1021
+ "cleared_steps": cleared_steps,
1022
+ "orphaned_steps": orphaned_steps,
1023
+ }
1024
+
1025
+
1026
+ # --- CLI dispatch ---
1027
+
1028
+ COMMANDS = ["index", "step", "summary", "init", "record", "var", "prereq-check", "report", "validate"]
1029
+
1030
+
1031
+ def main():
1032
+ if len(sys.argv) < 3:
1033
+ print(f"Usage: {sys.argv[0]} <checklist> <command> [args...]", file=sys.stderr)
1034
+ print(f"Commands: {', '.join(COMMANDS)}", file=sys.stderr)
1035
+ sys.exit(1)
1036
+
1037
+ checklist_path = sys.argv[1]
1038
+ command = sys.argv[2]
1039
+ rest = sys.argv[3:]
1040
+
1041
+ if command not in COMMANDS:
1042
+ print(f"Error: unknown command '{command}'. Valid: {', '.join(COMMANDS)}", file=sys.stderr)
1043
+ sys.exit(1)
1044
+
1045
+ # Parse checklist (needed for all commands)
1046
+ data = parse_checklist(checklist_path)
1047
+
1048
+ # Read-only commands
1049
+ if command == "index":
1050
+ result = cmd_index(data)
1051
+
1052
+ elif command == "step":
1053
+ if not rest:
1054
+ print("Error: 'step' requires a step ID (e.g., 6.3)", file=sys.stderr)
1055
+ sys.exit(1)
1056
+ result = cmd_step(data, rest[0])
1057
+
1058
+ elif command == "summary":
1059
+ result = cmd_summary(data)
1060
+
1061
+ # State commands
1062
+ elif command == "init":
1063
+ force = "--force" in rest
1064
+ mode = "walkthrough"
1065
+ positional = []
1066
+ skip_next = False
1067
+ for i, arg in enumerate(rest):
1068
+ if skip_next:
1069
+ skip_next = False
1070
+ continue
1071
+ if arg == "--force":
1072
+ continue
1073
+ if arg == "--mode" and i + 1 < len(rest):
1074
+ mode = rest[i + 1]
1075
+ skip_next = True
1076
+ continue
1077
+ positional.append(arg)
1078
+ if not positional:
1079
+ print("Error: 'init' requires a state file path.", file=sys.stderr)
1080
+ sys.exit(1)
1081
+ state_path = positional[0]
1082
+ result = cmd_init(data, checklist_path, state_path, mode, force)
1083
+
1084
+ elif command == "record":
1085
+ if len(rest) < 3:
1086
+ print("Error: 'record' requires <state-file> <step_id> <results>", file=sys.stderr)
1087
+ sys.exit(1)
1088
+ state_path, step_id, results_csv = rest[0], rest[1], rest[2]
1089
+ force = "--force" in rest
1090
+ result = cmd_record(data, checklist_path, state_path, step_id, results_csv, force)
1091
+
1092
+ elif command == "var":
1093
+ if len(rest) < 3:
1094
+ print("Error: 'var' requires <state-file> set|get <key> [<value>]", file=sys.stderr)
1095
+ sys.exit(1)
1096
+ state_path, action, key = rest[0], rest[1], rest[2]
1097
+ value = rest[3] if len(rest) > 3 else None
1098
+ result = cmd_var(state_path, action, key, value)
1099
+
1100
+ elif command == "prereq-check":
1101
+ if len(rest) < 2:
1102
+ print("Error: 'prereq-check' requires <state-file> <step_id|section_id>", file=sys.stderr)
1103
+ sys.exit(1)
1104
+ result = cmd_prereq_check(data, rest[0], rest[1])
1105
+
1106
+ elif command == "report":
1107
+ if not rest:
1108
+ print("Error: 'report' requires a state file path.", file=sys.stderr)
1109
+ sys.exit(1)
1110
+ result = cmd_report(data, checklist_path, rest[0])
1111
+
1112
+ elif command == "validate":
1113
+ from_step = None
1114
+ positional = []
1115
+ skip_next = False
1116
+ for i, arg in enumerate(rest):
1117
+ if skip_next:
1118
+ skip_next = False
1119
+ continue
1120
+ if arg == "--from" and i + 1 < len(rest):
1121
+ from_step = rest[i + 1]
1122
+ skip_next = True
1123
+ continue
1124
+ positional.append(arg)
1125
+ if not positional:
1126
+ print("Error: 'validate' requires a state file path.", file=sys.stderr)
1127
+ sys.exit(1)
1128
+ if not from_step:
1129
+ print("Error: 'validate' requires --from <step_id>.", file=sys.stderr)
1130
+ sys.exit(1)
1131
+ result = cmd_validate(data, checklist_path, positional[0], from_step)
1132
+
1133
+ print(json.dumps(result, indent=2))
1134
+
1135
+
1136
+ if __name__ == "__main__":
1137
+ main()