@garygentry/feature-forge 0.3.1 → 0.3.2

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 (314) hide show
  1. package/adapters/claude/.feature-forge-bundle.json +1 -1
  2. package/adapters/claude/references/epic-manifest-schema.json +6 -1
  3. package/adapters/claude/references/forge-config-schema.json +1 -1
  4. package/adapters/claude/references/pipeline-state-schema.json +4 -2
  5. package/adapters/claude/references/shared-conventions.md +63 -0
  6. package/adapters/claude/references/stage-exit-protocol.md +344 -140
  7. package/adapters/claude/scripts/epic-manifest.py +413 -87
  8. package/adapters/claude/scripts/forge-bootstrap.py +57 -5
  9. package/adapters/claude/scripts/forge-session.py +3136 -139
  10. package/adapters/claude/scripts/validate-traceability.py +86 -5
  11. package/adapters/claude/skills/forge/SKILL.md +7 -6
  12. package/adapters/claude/skills/forge/references/pipeline-state-schema.json +4 -2
  13. package/adapters/claude/skills/forge/references/shared-conventions.md +63 -0
  14. package/adapters/claude/skills/forge/references/stage-exit-protocol.md +344 -140
  15. package/adapters/claude/skills/forge-0-epic/SKILL.md +7 -2
  16. package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +24 -24
  17. package/adapters/claude/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
  18. package/adapters/claude/skills/forge-0-epic/references/shared-conventions.md +63 -0
  19. package/adapters/claude/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
  20. package/adapters/claude/skills/forge-1-prd/SKILL.md +14 -3
  21. package/adapters/claude/skills/forge-1-prd/references/shared-conventions.md +63 -0
  22. package/adapters/claude/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
  23. package/adapters/claude/skills/forge-2-tech/SKILL.md +13 -3
  24. package/adapters/claude/skills/forge-2-tech/references/shared-conventions.md +63 -0
  25. package/adapters/claude/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
  26. package/adapters/claude/skills/forge-3-specs/SKILL.md +4 -2
  27. package/adapters/claude/skills/forge-3-specs/references/shared-conventions.md +63 -0
  28. package/adapters/claude/skills/forge-3-specs/references/spec-archetypes.md +9 -0
  29. package/adapters/claude/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
  30. package/adapters/claude/skills/forge-4-backlog/SKILL.md +16 -3
  31. package/adapters/claude/skills/forge-4-backlog/references/shared-conventions.md +63 -0
  32. package/adapters/claude/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
  33. package/adapters/claude/skills/forge-5-loop/SKILL.md +40 -42
  34. package/adapters/claude/skills/forge-5-loop/references/result-reporting.md +70 -31
  35. package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +4 -2
  36. package/adapters/claude/skills/forge-5-loop/references/shared-conventions.md +63 -0
  37. package/adapters/claude/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
  38. package/adapters/claude/skills/forge-6-docs/SKILL.md +48 -4
  39. package/adapters/claude/skills/forge-6-docs/references/shared-conventions.md +63 -0
  40. package/adapters/claude/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
  41. package/adapters/claude/skills/forge-fix/SKILL.md +85 -33
  42. package/adapters/claude/skills/forge-fix/references/shared-conventions.md +63 -0
  43. package/adapters/claude/skills/forge-fix/references/stage-exit-protocol.md +344 -140
  44. package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +1 -1
  45. package/adapters/claude/skills/forge-guide/references/shared-conventions.md +63 -0
  46. package/adapters/claude/skills/forge-verify/SKILL.md +73 -41
  47. package/adapters/claude/skills/forge-verify/references/findings-template.md +39 -49
  48. package/adapters/claude/skills/forge-verify/references/shared-conventions.md +63 -0
  49. package/adapters/claude/skills/forge-verify/references/stage-exit-protocol.md +472 -0
  50. package/adapters/claude/skills/forge-verify/references/verification-checklists/epic.md +4 -3
  51. package/adapters/claude/skills/forge-verify/references/verification-checklists/specs.md +2 -0
  52. package/adapters/codex/.feature-forge-bundle.json +1 -1
  53. package/adapters/codex/references/epic-manifest-schema.json +6 -1
  54. package/adapters/codex/references/forge-config-schema.json +1 -1
  55. package/adapters/codex/references/pipeline-state-schema.json +4 -2
  56. package/adapters/codex/references/shared-conventions.md +63 -0
  57. package/adapters/codex/references/stage-exit-protocol.md +344 -140
  58. package/adapters/codex/scripts/epic-manifest.py +413 -87
  59. package/adapters/codex/scripts/forge-bootstrap.py +57 -5
  60. package/adapters/codex/scripts/forge-session.py +3136 -139
  61. package/adapters/codex/scripts/validate-traceability.py +86 -5
  62. package/adapters/codex/skills/forge/SKILL.md +7 -6
  63. package/adapters/codex/skills/forge/references/pipeline-state-schema.json +4 -2
  64. package/adapters/codex/skills/forge/references/shared-conventions.md +63 -0
  65. package/adapters/codex/skills/forge/references/stage-exit-protocol.md +344 -140
  66. package/adapters/codex/skills/forge-0-epic/SKILL.md +7 -2
  67. package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +24 -24
  68. package/adapters/codex/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
  69. package/adapters/codex/skills/forge-0-epic/references/shared-conventions.md +63 -0
  70. package/adapters/codex/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
  71. package/adapters/codex/skills/forge-1-prd/SKILL.md +14 -3
  72. package/adapters/codex/skills/forge-1-prd/references/shared-conventions.md +63 -0
  73. package/adapters/codex/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
  74. package/adapters/codex/skills/forge-2-tech/SKILL.md +13 -3
  75. package/adapters/codex/skills/forge-2-tech/references/shared-conventions.md +63 -0
  76. package/adapters/codex/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
  77. package/adapters/codex/skills/forge-3-specs/SKILL.md +4 -2
  78. package/adapters/codex/skills/forge-3-specs/references/shared-conventions.md +63 -0
  79. package/adapters/codex/skills/forge-3-specs/references/spec-archetypes.md +9 -0
  80. package/adapters/codex/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
  81. package/adapters/codex/skills/forge-4-backlog/SKILL.md +16 -3
  82. package/adapters/codex/skills/forge-4-backlog/references/shared-conventions.md +63 -0
  83. package/adapters/codex/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
  84. package/adapters/codex/skills/forge-5-loop/SKILL.md +40 -42
  85. package/adapters/codex/skills/forge-5-loop/references/result-reporting.md +70 -31
  86. package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +4 -2
  87. package/adapters/codex/skills/forge-5-loop/references/shared-conventions.md +63 -0
  88. package/adapters/codex/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
  89. package/adapters/codex/skills/forge-6-docs/SKILL.md +48 -4
  90. package/adapters/codex/skills/forge-6-docs/references/shared-conventions.md +63 -0
  91. package/adapters/codex/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
  92. package/adapters/codex/skills/forge-fix/SKILL.md +84 -32
  93. package/adapters/codex/skills/forge-fix/references/shared-conventions.md +63 -0
  94. package/adapters/codex/skills/forge-fix/references/stage-exit-protocol.md +344 -140
  95. package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +1 -1
  96. package/adapters/codex/skills/forge-guide/references/shared-conventions.md +63 -0
  97. package/adapters/codex/skills/forge-verify/SKILL.md +72 -40
  98. package/adapters/codex/skills/forge-verify/references/findings-template.md +39 -49
  99. package/adapters/codex/skills/forge-verify/references/shared-conventions.md +63 -0
  100. package/adapters/codex/skills/forge-verify/references/stage-exit-protocol.md +472 -0
  101. package/adapters/codex/skills/forge-verify/references/verification-checklists/epic.md +4 -3
  102. package/adapters/codex/skills/forge-verify/references/verification-checklists/specs.md +2 -0
  103. package/adapters/copilot/.feature-forge-bundle.json +1 -1
  104. package/adapters/copilot/references/epic-manifest-schema.json +6 -1
  105. package/adapters/copilot/references/forge-config-schema.json +1 -1
  106. package/adapters/copilot/references/pipeline-state-schema.json +4 -2
  107. package/adapters/copilot/references/shared-conventions.md +63 -0
  108. package/adapters/copilot/references/stage-exit-protocol.md +344 -140
  109. package/adapters/copilot/scripts/epic-manifest.py +413 -87
  110. package/adapters/copilot/scripts/forge-bootstrap.py +57 -5
  111. package/adapters/copilot/scripts/forge-session.py +3136 -139
  112. package/adapters/copilot/scripts/validate-traceability.py +86 -5
  113. package/adapters/copilot/skills/forge/forge.md +7 -6
  114. package/adapters/copilot/skills/forge/references/pipeline-state-schema.json +4 -2
  115. package/adapters/copilot/skills/forge/references/shared-conventions.md +63 -0
  116. package/adapters/copilot/skills/forge/references/stage-exit-protocol.md +344 -140
  117. package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +7 -2
  118. package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +24 -24
  119. package/adapters/copilot/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
  120. package/adapters/copilot/skills/forge-0-epic/references/shared-conventions.md +63 -0
  121. package/adapters/copilot/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
  122. package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +14 -3
  123. package/adapters/copilot/skills/forge-1-prd/references/shared-conventions.md +63 -0
  124. package/adapters/copilot/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
  125. package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +13 -3
  126. package/adapters/copilot/skills/forge-2-tech/references/shared-conventions.md +63 -0
  127. package/adapters/copilot/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
  128. package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +4 -2
  129. package/adapters/copilot/skills/forge-3-specs/references/shared-conventions.md +63 -0
  130. package/adapters/copilot/skills/forge-3-specs/references/spec-archetypes.md +9 -0
  131. package/adapters/copilot/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
  132. package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +16 -3
  133. package/adapters/copilot/skills/forge-4-backlog/references/shared-conventions.md +63 -0
  134. package/adapters/copilot/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
  135. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +40 -42
  136. package/adapters/copilot/skills/forge-5-loop/references/result-reporting.md +70 -31
  137. package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +4 -2
  138. package/adapters/copilot/skills/forge-5-loop/references/shared-conventions.md +63 -0
  139. package/adapters/copilot/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
  140. package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +48 -4
  141. package/adapters/copilot/skills/forge-6-docs/references/shared-conventions.md +63 -0
  142. package/adapters/copilot/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
  143. package/adapters/copilot/skills/forge-fix/forge-fix.md +84 -32
  144. package/adapters/copilot/skills/forge-fix/references/shared-conventions.md +63 -0
  145. package/adapters/copilot/skills/forge-fix/references/stage-exit-protocol.md +344 -140
  146. package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +1 -1
  147. package/adapters/copilot/skills/forge-guide/references/shared-conventions.md +63 -0
  148. package/adapters/copilot/skills/forge-verify/forge-verify.md +72 -40
  149. package/adapters/copilot/skills/forge-verify/references/findings-template.md +39 -49
  150. package/adapters/copilot/skills/forge-verify/references/shared-conventions.md +63 -0
  151. package/adapters/copilot/skills/forge-verify/references/stage-exit-protocol.md +472 -0
  152. package/adapters/copilot/skills/forge-verify/references/verification-checklists/epic.md +4 -3
  153. package/adapters/copilot/skills/forge-verify/references/verification-checklists/specs.md +2 -0
  154. package/adapters/cursor/.feature-forge-bundle.json +1 -1
  155. package/adapters/cursor/references/epic-manifest-schema.json +6 -1
  156. package/adapters/cursor/references/forge-config-schema.json +1 -1
  157. package/adapters/cursor/references/pipeline-state-schema.json +4 -2
  158. package/adapters/cursor/references/shared-conventions.md +63 -0
  159. package/adapters/cursor/references/stage-exit-protocol.md +344 -140
  160. package/adapters/cursor/scripts/epic-manifest.py +413 -87
  161. package/adapters/cursor/scripts/forge-bootstrap.py +57 -5
  162. package/adapters/cursor/scripts/forge-session.py +3136 -139
  163. package/adapters/cursor/scripts/validate-traceability.py +86 -5
  164. package/adapters/cursor/skills/forge/forge.mdc +7 -6
  165. package/adapters/cursor/skills/forge/references/pipeline-state-schema.json +4 -2
  166. package/adapters/cursor/skills/forge/references/shared-conventions.md +63 -0
  167. package/adapters/cursor/skills/forge/references/stage-exit-protocol.md +344 -140
  168. package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +7 -2
  169. package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +24 -24
  170. package/adapters/cursor/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
  171. package/adapters/cursor/skills/forge-0-epic/references/shared-conventions.md +63 -0
  172. package/adapters/cursor/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
  173. package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +14 -3
  174. package/adapters/cursor/skills/forge-1-prd/references/shared-conventions.md +63 -0
  175. package/adapters/cursor/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
  176. package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +13 -3
  177. package/adapters/cursor/skills/forge-2-tech/references/shared-conventions.md +63 -0
  178. package/adapters/cursor/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
  179. package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +4 -2
  180. package/adapters/cursor/skills/forge-3-specs/references/shared-conventions.md +63 -0
  181. package/adapters/cursor/skills/forge-3-specs/references/spec-archetypes.md +9 -0
  182. package/adapters/cursor/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
  183. package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +16 -3
  184. package/adapters/cursor/skills/forge-4-backlog/references/shared-conventions.md +63 -0
  185. package/adapters/cursor/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
  186. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +40 -42
  187. package/adapters/cursor/skills/forge-5-loop/references/result-reporting.md +70 -31
  188. package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +4 -2
  189. package/adapters/cursor/skills/forge-5-loop/references/shared-conventions.md +63 -0
  190. package/adapters/cursor/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
  191. package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +48 -4
  192. package/adapters/cursor/skills/forge-6-docs/references/shared-conventions.md +63 -0
  193. package/adapters/cursor/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
  194. package/adapters/cursor/skills/forge-fix/forge-fix.mdc +84 -32
  195. package/adapters/cursor/skills/forge-fix/references/shared-conventions.md +63 -0
  196. package/adapters/cursor/skills/forge-fix/references/stage-exit-protocol.md +344 -140
  197. package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +1 -1
  198. package/adapters/cursor/skills/forge-guide/references/shared-conventions.md +63 -0
  199. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +72 -40
  200. package/adapters/cursor/skills/forge-verify/references/findings-template.md +39 -49
  201. package/adapters/cursor/skills/forge-verify/references/shared-conventions.md +63 -0
  202. package/adapters/cursor/skills/forge-verify/references/stage-exit-protocol.md +472 -0
  203. package/adapters/cursor/skills/forge-verify/references/verification-checklists/epic.md +4 -3
  204. package/adapters/cursor/skills/forge-verify/references/verification-checklists/specs.md +2 -0
  205. package/adapters/gemini/.feature-forge-bundle.json +1 -1
  206. package/adapters/gemini/gemini-extension.json +1 -1
  207. package/adapters/gemini/references/epic-manifest-schema.json +6 -1
  208. package/adapters/gemini/references/forge-config-schema.json +1 -1
  209. package/adapters/gemini/references/pipeline-state-schema.json +4 -2
  210. package/adapters/gemini/references/shared-conventions.md +63 -0
  211. package/adapters/gemini/references/stage-exit-protocol.md +344 -140
  212. package/adapters/gemini/scripts/epic-manifest.py +413 -87
  213. package/adapters/gemini/scripts/forge-bootstrap.py +57 -5
  214. package/adapters/gemini/scripts/forge-session.py +3136 -139
  215. package/adapters/gemini/scripts/validate-traceability.py +86 -5
  216. package/adapters/gemini/skills/forge/forge.md +7 -6
  217. package/adapters/gemini/skills/forge/references/pipeline-state-schema.json +4 -2
  218. package/adapters/gemini/skills/forge/references/shared-conventions.md +63 -0
  219. package/adapters/gemini/skills/forge/references/stage-exit-protocol.md +344 -140
  220. package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +7 -2
  221. package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +24 -24
  222. package/adapters/gemini/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
  223. package/adapters/gemini/skills/forge-0-epic/references/shared-conventions.md +63 -0
  224. package/adapters/gemini/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
  225. package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +14 -3
  226. package/adapters/gemini/skills/forge-1-prd/references/shared-conventions.md +63 -0
  227. package/adapters/gemini/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
  228. package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +13 -3
  229. package/adapters/gemini/skills/forge-2-tech/references/shared-conventions.md +63 -0
  230. package/adapters/gemini/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
  231. package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +4 -2
  232. package/adapters/gemini/skills/forge-3-specs/references/shared-conventions.md +63 -0
  233. package/adapters/gemini/skills/forge-3-specs/references/spec-archetypes.md +9 -0
  234. package/adapters/gemini/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
  235. package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +16 -3
  236. package/adapters/gemini/skills/forge-4-backlog/references/shared-conventions.md +63 -0
  237. package/adapters/gemini/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
  238. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +40 -42
  239. package/adapters/gemini/skills/forge-5-loop/references/result-reporting.md +70 -31
  240. package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +4 -2
  241. package/adapters/gemini/skills/forge-5-loop/references/shared-conventions.md +63 -0
  242. package/adapters/gemini/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
  243. package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +48 -4
  244. package/adapters/gemini/skills/forge-6-docs/references/shared-conventions.md +63 -0
  245. package/adapters/gemini/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
  246. package/adapters/gemini/skills/forge-fix/forge-fix.md +84 -32
  247. package/adapters/gemini/skills/forge-fix/references/shared-conventions.md +63 -0
  248. package/adapters/gemini/skills/forge-fix/references/stage-exit-protocol.md +344 -140
  249. package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +1 -1
  250. package/adapters/gemini/skills/forge-guide/references/shared-conventions.md +63 -0
  251. package/adapters/gemini/skills/forge-verify/forge-verify.md +72 -40
  252. package/adapters/gemini/skills/forge-verify/references/findings-template.md +39 -49
  253. package/adapters/gemini/skills/forge-verify/references/shared-conventions.md +63 -0
  254. package/adapters/gemini/skills/forge-verify/references/stage-exit-protocol.md +472 -0
  255. package/adapters/gemini/skills/forge-verify/references/verification-checklists/epic.md +4 -3
  256. package/adapters/gemini/skills/forge-verify/references/verification-checklists/specs.md +2 -0
  257. package/adapters/pi/.feature-forge-bundle.json +1 -1
  258. package/adapters/pi/references/epic-manifest-schema.json +6 -1
  259. package/adapters/pi/references/forge-config-schema.json +1 -1
  260. package/adapters/pi/references/pipeline-state-schema.json +4 -2
  261. package/adapters/pi/references/shared-conventions.md +63 -0
  262. package/adapters/pi/references/stage-exit-protocol.md +344 -140
  263. package/adapters/pi/scripts/epic-manifest.py +413 -87
  264. package/adapters/pi/scripts/forge-bootstrap.py +57 -5
  265. package/adapters/pi/scripts/forge-session.py +3136 -139
  266. package/adapters/pi/scripts/validate-traceability.py +86 -5
  267. package/adapters/pi/skills/forge/SKILL.md +7 -6
  268. package/adapters/pi/skills/forge/references/pipeline-state-schema.json +4 -2
  269. package/adapters/pi/skills/forge/references/shared-conventions.md +63 -0
  270. package/adapters/pi/skills/forge/references/stage-exit-protocol.md +344 -140
  271. package/adapters/pi/skills/forge-0-epic/SKILL.md +7 -2
  272. package/adapters/pi/skills/forge-0-epic/references/edit-mode.md +24 -24
  273. package/adapters/pi/skills/forge-0-epic/references/pipeline-state-schema.json +4 -2
  274. package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +63 -0
  275. package/adapters/pi/skills/forge-0-epic/references/stage-exit-protocol.md +344 -140
  276. package/adapters/pi/skills/forge-1-prd/SKILL.md +14 -3
  277. package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +63 -0
  278. package/adapters/pi/skills/forge-1-prd/references/stage-exit-protocol.md +344 -140
  279. package/adapters/pi/skills/forge-2-tech/SKILL.md +13 -3
  280. package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +63 -0
  281. package/adapters/pi/skills/forge-2-tech/references/stage-exit-protocol.md +344 -140
  282. package/adapters/pi/skills/forge-3-specs/SKILL.md +4 -2
  283. package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +63 -0
  284. package/adapters/pi/skills/forge-3-specs/references/spec-archetypes.md +9 -0
  285. package/adapters/pi/skills/forge-3-specs/references/stage-exit-protocol.md +344 -140
  286. package/adapters/pi/skills/forge-4-backlog/SKILL.md +16 -3
  287. package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +63 -0
  288. package/adapters/pi/skills/forge-4-backlog/references/stage-exit-protocol.md +344 -140
  289. package/adapters/pi/skills/forge-5-loop/SKILL.md +40 -42
  290. package/adapters/pi/skills/forge-5-loop/references/result-reporting.md +70 -31
  291. package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +4 -2
  292. package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +63 -0
  293. package/adapters/pi/skills/forge-5-loop/references/stage-exit-protocol.md +344 -140
  294. package/adapters/pi/skills/forge-6-docs/SKILL.md +48 -4
  295. package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +63 -0
  296. package/adapters/pi/skills/forge-6-docs/references/stage-exit-protocol.md +472 -0
  297. package/adapters/pi/skills/forge-fix/SKILL.md +84 -32
  298. package/adapters/pi/skills/forge-fix/references/shared-conventions.md +63 -0
  299. package/adapters/pi/skills/forge-fix/references/stage-exit-protocol.md +344 -140
  300. package/adapters/pi/skills/forge-guide/references/forge-config-schema.json +1 -1
  301. package/adapters/pi/skills/forge-guide/references/shared-conventions.md +63 -0
  302. package/adapters/pi/skills/forge-verify/SKILL.md +72 -40
  303. package/adapters/pi/skills/forge-verify/references/findings-template.md +39 -49
  304. package/adapters/pi/skills/forge-verify/references/shared-conventions.md +63 -0
  305. package/adapters/pi/skills/forge-verify/references/stage-exit-protocol.md +472 -0
  306. package/adapters/pi/skills/forge-verify/references/verification-checklists/epic.md +4 -3
  307. package/adapters/pi/skills/forge-verify/references/verification-checklists/specs.md +2 -0
  308. package/package.json +1 -1
  309. package/adapters/claude/skills/forge-verify/references/pipeline-state-schema.json +0 -191
  310. package/adapters/codex/skills/forge-verify/references/pipeline-state-schema.json +0 -191
  311. package/adapters/copilot/skills/forge-verify/references/pipeline-state-schema.json +0 -191
  312. package/adapters/cursor/skills/forge-verify/references/pipeline-state-schema.json +0 -191
  313. package/adapters/gemini/skills/forge-verify/references/pipeline-state-schema.json +0 -191
  314. package/adapters/pi/skills/forge-verify/references/pipeline-state-schema.json +0 -191
@@ -13,8 +13,10 @@ root navigator:
13
13
  [--config FILE] [--epic E] [--json]
14
14
  python3 forge-session.py check-epic-base --feature F [--specs-dir DIR] \
15
15
  [--config FILE] [--epic E] [--json]
16
- python3 forge-session.py stage-exit --feature F --stage S [--specs-dir DIR] \
17
- [--config FILE] [--epic E] [--next-feature N] [--host claude|generic] [--json]
16
+ python3 forge-session.py stage-exit --feature F --stage S [--owner direct|nested] \
17
+ [--outcome O] [--verify-mode M] [--served-stage S] \
18
+ [--verify-capability interactive|manual] [--specs-dir DIR] [--config FILE] \
19
+ [--epic E] [--next-feature N] [--host claude|generic|pi] [--json]
18
20
  python3 forge-session.py effective-config [--config FILE] [--schema PATH] [--json]
19
21
 
20
22
  Plus the `state-*` write verbs, which author `.pipeline-state.json` so no stage
@@ -36,6 +38,9 @@ has to hand-write the JSON (and therefore no stage has to read the state schema)
36
38
  [--rationale R] [--target-stage S] [--specs-dir DIR] [--epic E] [--json]
37
39
  python3 forge-session.py state-ecr --feature F --kind K --target T --rationale R \
38
40
  --raised-by S --blocks-current true|false [--specs-dir DIR] [--epic E] [--json]
41
+ python3 forge-session.py state-verify --feature F --stage S [--status ST] \
42
+ [--findings-file P] [--findings-count N] [--verified-stage-version N] \
43
+ [--commit-hash H] [--specs-dir DIR] [--epic E] [--json]
39
44
 
40
45
  `rank-features` scans the specs tree for feature-shaped directories (those that
41
46
  directly contain a `.pipeline-state.json`, in both the flat
@@ -125,6 +130,22 @@ record `status: "open"` — resolving an item is the target stage's job, never t
125
130
  recorder's — and both emit exactly the schema keys, because those two array item
126
131
  shapes set `additionalProperties: false`.
127
132
 
133
+ `state-verify` is the eighth verb and the one that stops forge-verify/forge-fix
134
+ hand-authoring a `forge-verify-*` entry. It writes exactly one transition of the
135
+ verification matrix — `auto-verify-pending` (durable automatic-verify debt),
136
+ `passed`, `findings-reported`, `findings-applied`, or `skipped` — against the
137
+ `forge-verify-{token}` key the `--stage` selects, and touches nothing else in the
138
+ document. A terminal result DELETES the scheduling keys rather than nulling them,
139
+ and `findings-applied` deliberately drops `verifiedStageVersion`: fixes landed but
140
+ nothing has re-verified them, so freshness stays unresolved until a later `passed`
141
+ write. Its second mode, `--commit-hash`, is the Commit-2 provenance follow-up for
142
+ an entry that already exists: it changes only that entry's `commitHash`, and the
143
+ hash must be a full 40 hex characters — an abbreviation is rejected rather than
144
+ expanded, and no path amends a commit. Legacy short hashes already recorded in
145
+ state keep loading unmigrated; nothing constrains `commitHash` in the schema.
146
+ Unlike the other verbs its `--json` echo is the written entry plus the
147
+ resolved state path, not the whole document, so a caller never re-reads state.
148
+
128
149
  3.10 baseline, Google-style docstrings, full type annotations, stdlib only —
129
150
  matching the conventions of `scripts/epic-manifest.py`.
130
151
 
@@ -138,12 +159,13 @@ from __future__ import annotations
138
159
  import argparse
139
160
  import json
140
161
  import os
162
+ import re
141
163
  import subprocess
142
164
  import sys
143
165
  import tempfile
144
166
  from datetime import datetime, timezone
145
167
  from pathlib import Path
146
- from typing import Callable, Final, TypedDict
168
+ from typing import Callable, Final, Literal, NoReturn, TypedDict, get_args
147
169
 
148
170
 
149
171
  # --------------------------------------------------------------------------- #
@@ -154,6 +176,15 @@ from typing import Callable, Final, TypedDict
154
176
  PIPELINE_STATE_FILENAME: Final = ".pipeline-state.json"
155
177
  #: Epic roots hold this (and no .pipeline-state.json) — never a feature.
156
178
  MANIFEST_FILENAME: Final = "epic-manifest.json"
179
+ #: Epic-scoped verification state, sibling to the manifest. NEVER a member's
180
+ #: .pipeline-state.json: epic verification is epic-scoped (REQ-SEC-01).
181
+ EPIC_STATE_FILENAME: Final = ".epic-state.json"
182
+
183
+ #: A safe bare name: one kebab-case token, no separator, no traversal. Same pattern
184
+ #: epic-manifest.py applies (the flat scripts share no import module), so the epic
185
+ #: target of a state write fails closed exactly where the canonical resolver does
186
+ #: (REQ-SEC-01).
187
+ SAFE_NAME_RE: Final = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
157
188
 
158
189
  #: The ordered production stages. This is the ONE place stage order lives.
159
190
  PRODUCTION_STAGES: Final[tuple[str, ...]] = (
@@ -203,6 +234,12 @@ VERIFY_TOKEN_BY_STAGE: Final[dict[str, str]] = {
203
234
  "forge-5-loop": "impl",
204
235
  }
205
236
 
237
+ #: The `--stage` domain for `state-verify`: forge-0-epic (whose verification lives
238
+ #: in the epic's own `.epic-state.json`) plus the five stages that carry a verify
239
+ #: token. forge-6-docs is excluded on purpose — it has no verification token, so
240
+ #: there is no `forge-verify-*` key for it to write.
241
+ VERIFY_STAGES: Final[tuple[str, ...]] = ("forge-0-epic", *VERIFY_TOKEN_BY_STAGE)
242
+
206
243
  #: A production stage status that counts as "done" for next-stage selection.
207
244
  _DONE_STATUS: Final = "complete"
208
245
  #: The authoritative forge-verify status vocabulary. SOURCE OF TRUTH:
@@ -211,14 +248,178 @@ _DONE_STATUS: Final = "complete"
211
248
  #: NOTE: epic-manifest.py keeps a byte-identical copy — flat, self-contained scripts have
212
249
  #: no shared import module (each is copied verbatim into per-agent adapter bundles).
213
250
  KNOWN_VERIFY_STATUSES: Final = frozenset(
214
- {"pending", "passed", "findings-reported", "findings-applied", "skipped"}
251
+ {
252
+ "pending",
253
+ "auto-verify-pending",
254
+ "passed",
255
+ "findings-reported",
256
+ "findings-applied",
257
+ "skipped",
258
+ }
215
259
  )
216
260
  #: Verify statuses that count as "resolved" (no outstanding verify needed). A STRICT
217
261
  #: subset of KNOWN_VERIFY_STATUSES — not collapsible into it (different meaning).
262
+ #: `auto-verify-pending` is deliberately ABSENT: owed-but-unrun debt is not resolved.
218
263
  _VERIFY_RESOLVED: Final = frozenset({"passed", "findings-applied", "skipped"})
219
264
  #: Per-process dedupe for the unknown-verify-status diagnostic (#148) so a single
220
265
  #: bogus status is flagged once, not once per verify_state() call in a command.
221
266
  _UNKNOWN_VERIFY_WARNED: set[str] = set()
267
+ #: Per-process dedupe for the auto-verify debt-metadata diagnostic, same reason.
268
+ _AUTO_VERIFY_DEBT_WARNED: set[str] = set()
269
+ #: The single normative sentence every read-side emitter uses for owed-but-unrun
270
+ #: automatic verification. One line naming the
271
+ #: subject, the served stage, and the retry command — never a state-file dump.
272
+ AUTO_PENDING_DIAGNOSTIC: Final = (
273
+ "{subject}: automatic verification is still pending for {stage}; "
274
+ "run {command} to resolve it."
275
+ )
276
+ #: The directive-facing form of the debt-metadata advisory (`warnings` entry 2).
277
+ #: The stderr twin lives in `_warn_auto_verify_debt_metadata`; this one also
278
+ #: names the subject and the host-translated retry command, because a `warnings` entry
279
+ #: must carry both the affected feature/stage/key AND the recovery action (REQ-OBS-02).
280
+ AUTO_VERIFY_DEBT_METADATA_DIAGNOSTIC: Final = (
281
+ "{subject}: {verify_key} is auto-verify-pending but its scheduledStageVersion "
282
+ "is missing or malformed (legacy or hand-edited state); the debt stays "
283
+ "outstanding — run {command} to resolve it and record a usable schedule."
284
+ )
285
+ #: The exact template for an `autoVerifyStages` key that names no
286
+ #: verify-capable stage. A typo there silently never takes effect, so the exit
287
+ #: says so — once per offending key, in sorted key order, on stderr, and
288
+ #: WITHOUT failing the exit: an ignored config key is an advisory, not a usage
289
+ #: error. `{valid}` is derived from `VERIFY_TOKEN_BY_STAGE` so the sentence cannot
290
+ #: drift from the domain it describes.
291
+ INVALID_AUTO_VERIFY_KEY_WARNING: Final = (
292
+ 'Warning: autoVerifyStages key "{key}" names no verify-capable stage; it is '
293
+ "ignored. Valid keys are {valid}."
294
+ )
295
+ #: The exact template for an epic edit-mode member whose live pipeline state
296
+ #: cannot be resolved. It is `warnings` entry 1 and the router's
297
+ #: ONE tolerant new case: the exit degrades DOWN to `forge-1-prd <member>` rather
298
+ #: than fabricating progress it could not read (REQ-PROD-06). The trailing sentence
299
+ #: is what makes the warning name both the affected feature and the recovery action
300
+ #: (REQ-OBS-02); `{reason}` is one of `EPIC_MEMBER_FALLBACK_REASONS`.
301
+ EPIC_MEMBER_FALLBACK_WARNING: Final = (
302
+ "Warning: {member}: pipeline state could not be resolved under epic {epic} "
303
+ "({reason}); routing to forge-1-prd. Run /skill:forge {member} to "
304
+ "inspect its state."
305
+ )
306
+ #: The closed reason domain for `EPIC_MEMBER_FALLBACK_WARNING`. No other
307
+ #: value may be substituted — `tests/test_stage_exit.py` asserts the literal.
308
+ EPIC_MEMBER_FALLBACK_REASONS: Final[tuple[str, ...]] = (
309
+ "missing",
310
+ "unreadable",
311
+ "malformed",
312
+ "not a member of this epic",
313
+ )
314
+
315
+
316
+ # --------------------------------------------------------------------------- #
317
+ # Stage-exit and verification domains
318
+ #
319
+ # The `Literal` aliases below are the SINGLE place each domain is written. The
320
+ # `Final` constants underneath are DERIVED from them with `get_args`, never
321
+ # hand-listed: `ruff check` does not verify Literal conformance, so a hand-copied
322
+ # second list would drift silently — the failure this repository has already been
323
+ # bitten by twice (tests/test_stage_constants_parity.py,
324
+ # tests/test_agent_targets_parity.py). Deriving removes the second list entirely.
325
+ # --------------------------------------------------------------------------- #
326
+
327
+ #: The seven stages that produce a pipeline artifact. forge-0-epic participates in
328
+ #: exit and verify routing but not the member production walk (PRODUCTION_STAGES).
329
+ ProductionStage = Literal[
330
+ "forge-0-epic",
331
+ "forge-1-prd",
332
+ "forge-2-tech",
333
+ "forge-3-specs",
334
+ "forge-4-backlog",
335
+ "forge-5-loop",
336
+ "forge-6-docs",
337
+ ]
338
+ #: Every skill that closes a stage through `stage-exit` — the seven production
339
+ #: stages plus the two branch skills.
340
+ ExitStage = Literal[
341
+ "forge-0-epic",
342
+ "forge-1-prd",
343
+ "forge-2-tech",
344
+ "forge-3-specs",
345
+ "forge-4-backlog",
346
+ "forge-5-loop",
347
+ "forge-6-docs",
348
+ "forge-verify",
349
+ "forge-fix",
350
+ ]
351
+ #: forge-verify's mode, which selects the production stage a diversion served.
352
+ VerifyMode = Literal["epic", "prd", "tech", "specs", "backlog", "impl"]
353
+ #: Who prints the terminal block for a branch exit.
354
+ ExitOwner = Literal["direct", "nested"]
355
+ #: Whether the host may run an interactive verify gate + clean-room dispatch.
356
+ VerifyCapability = Literal["interactive", "manual"]
357
+ #: The navigator/stage-exit freshness label for an artifact's verification.
358
+ VerifyStateLabel = Literal[
359
+ "fresh", "stale", "failing", "never", "auto-pending", "skipped", "none"
360
+ ]
361
+ #: The persisted verify-entry status vocabulary; mirrors KNOWN_VERIFY_STATUSES and
362
+ #: references/pipeline-state-schema.json's verifyEntry.status.enum.
363
+ VerifyStatus = Literal[
364
+ "pending",
365
+ "auto-verify-pending",
366
+ "passed",
367
+ "findings-reported",
368
+ "findings-applied",
369
+ "skipped",
370
+ ]
371
+ #: Which gate form a stage exit asks the caller to render.
372
+ VerifyGate = Literal["none", "standard", "manual-print"]
373
+
374
+ LoopOutcome = Literal["complete", "partial", "blocked", "needs-human", "deferred"]
375
+ DocsOutcome = Literal["complete", "blocked"]
376
+ VerifyOutcome = Literal["passed", "findings", "skipped", "failed"]
377
+ FixOutcome = Literal[
378
+ "no-findings",
379
+ "decisions",
380
+ "failed",
381
+ "applied",
382
+ "reverified",
383
+ "reverify-findings",
384
+ "deferred",
385
+ ]
386
+
387
+ #: Derived, never hand-listed — see the block comment above.
388
+ EXIT_STAGES: Final[tuple[str, ...]] = get_args(ExitStage)
389
+ #: The `state-verify --status` domain: every VerifyStatus a result write may record.
390
+ #: `pending` is excluded — it is the pre-existing generic/manual pending marker, not
391
+ #: a verification RESULT, and `auto-verify-pending` is the value that carries owed
392
+ #: automatic debt. Derived so the two lists cannot drift.
393
+ VERIFY_RESULT_STATUSES: Final[tuple[str, ...]] = tuple(
394
+ status for status in get_args(VerifyStatus) if status != "pending"
395
+ )
396
+ #: The stages whose exit carries a multi-way outcome, and each one's legal values.
397
+ #: Stages absent from this table take no `--outcome` at all.
398
+ EXIT_OUTCOMES: Final[dict[str, frozenset[str]]] = {
399
+ "forge-5-loop": frozenset(get_args(LoopOutcome)),
400
+ "forge-6-docs": frozenset(get_args(DocsOutcome)),
401
+ "forge-verify": frozenset(get_args(VerifyOutcome)),
402
+ "forge-fix": frozenset(get_args(FixOutcome)),
403
+ }
404
+ #: The one domain still written twice, because neither side is a subset of the
405
+ #: other: its keys MUST equal set(get_args(VerifyMode)) and its values MUST be a
406
+ #: subset of get_args(ProductionStage). tests/test_stage_constants_parity.py
407
+ #: asserts both. NOT collapsible into VERIFY_TOKEN_BY_STAGE's inverse — that map
408
+ #: has no `epic` mode and exists to name state keys, not to route stages.
409
+ VERIFY_MODE_TO_STAGE: Final[dict[str, str]] = {
410
+ "epic": "forge-0-epic",
411
+ "prd": "forge-1-prd",
412
+ "tech": "forge-2-tech",
413
+ "specs": "forge-3-specs",
414
+ "backlog": "forge-4-backlog",
415
+ "impl": "forge-5-loop",
416
+ }
417
+ #: The fixed final line of the NEXT-STEPS block. The stamp instructs the skill
418
+ #: to print the block verbatim as its absolute last output — nothing after this.
419
+ NEXT_STEPS_SENTINEL: Final = "─ forge: end of stage ─"
420
+ #: New non-null commit hashes are full 40-hex only. Loaded legacy short hashes stay
421
+ #: readable — this validates WRITES, and no schema constrains commitHash.
422
+ FULL_GIT_HASH_RE: Final = re.compile(r"[0-9a-fA-F]{40}")
222
423
 
223
424
  #: Default context window when the model can't be inferred and config is silent.
224
425
  _DEFAULT_WINDOW: Final = 200_000
@@ -253,6 +454,231 @@ class FeatureRow(TypedDict):
253
454
  verifyGate: str
254
455
 
255
456
 
457
+ class EpicReconcile(TypedDict, total=False):
458
+ """Existing epic backflow directive retained in expanded exits.
459
+
460
+ Present only for epic members; absent entirely for a standalone feature.
461
+ """
462
+
463
+ # True when backflow must run before the member may advance; False when it is
464
+ # merely advisable. Drives whether the exit blocks or only mentions it.
465
+ required: bool
466
+ # True to surface the reminder text in the rendered block. Independent of
467
+ # `required`: a required reconcile with `reminder: False` still blocks silently
468
+ # in `--json` consumers.
469
+ reminder: bool
470
+ # Host-rendered command that performs the reconcile. Already passed through
471
+ # `_host_command`; consumers print it verbatim and never re-translate it.
472
+ command: str
473
+ # Number of member changes awaiting backflow. 0 is meaningful — it means
474
+ # reconcile was evaluated and found nothing, distinct from the key being absent
475
+ # because the feature is not an epic member.
476
+ count: int
477
+ # Canonical (untranslated) production command demoted behind a blocking
478
+ # reconcile — rendered as the unfenced "After reconciling, continue the
479
+ # pipeline with: …" line and passed through `_host_command` at render time.
480
+ # Present only when `required: True`; None/absent otherwise. It is a COMMAND,
481
+ # never a user-supplied reason: the live writer sets it to `next_command`
482
+ # (scripts/forge-session.py) and `_next_steps_block` translates it for the
483
+ # host. Repurposing it to carry prose would send free text through
484
+ # `_host_command` and strip the blocking follow-up line of its source
485
+ # (REQ-COMPAT-01).
486
+ deferred: str | None
487
+
488
+
489
+ class StageExitDirectives(TypedDict, total=False):
490
+ """Machine-readable decisions emitted by `stage_exit`.
491
+
492
+ `total=False` throughout: a key's ABSENCE means "not applicable to this exit",
493
+ which is never the same as a present-but-null value. `servedStage: None` says
494
+ the exit resolved no served stage; a missing `servedStage` says the concept does
495
+ not apply. Consumers must distinguish the two.
496
+ """
497
+
498
+ # The stage whose exit this is — always one of EXIT_STAGES. Always present.
499
+ stage: str
500
+ # Human-readable noun for this stage's artifact, used by
501
+ # references/stage-exit-protocol.md's "{stageNoun}" slots (the auto-verify
502
+ # heading and the "Verify {stageNoun} now" gate label). Always present;
503
+ # STAGE_NOUN.get(stage, stage), so it defaults to the stage id when unmapped.
504
+ # Pre-existing key, retained verbatim for REQ-COMPAT-01.
505
+ stageNoun: str
506
+ # For a verify/fix branch exit, the production stage the diversion served and
507
+ # rejoins. None on a production-stage exit, which serves only itself.
508
+ servedStage: str | None
509
+ # Verify mode in play (`prd`, `tech`, `specs`, `backlog`, `impl`, `epic`), keyed
510
+ # by VERIFY_MODE_TO_STAGE. None when this exit is not a verify/fix exit.
511
+ verifyMode: str | None
512
+ # Terminal outcome for stages with a multi-way result. Must be a member of
513
+ # EXIT_OUTCOMES[stage] — consult that table rather than this comment,
514
+ # which is deliberately not a second copy of the domain. None for stages
515
+ # whose exit has a single outcome.
516
+ outcome: str | None
517
+ # Branch ownership for a verify/fix exit — ExitOwner, i.e. exactly "direct"
518
+ # (this call owns and prints the terminal block) or "nested" (an outer
519
+ # authoring stage owns it). REQUIRED for forge-verify/forge-fix and REJECTED
520
+ # for stages 0–6, which are always direct owners.
521
+ # None only on a production-stage exit, where the concept does not apply.
522
+ owner: str | None
523
+ # Who prints the terminal block. "self" — this caller renders exactly one
524
+ # sentinel-terminated block. "outer" — a nested invocation that must print
525
+ # nothing terminal, leaving ownership with the outermost authoring stage.
526
+ terminalOwnedBy: Literal["self", "outer"]
527
+ # Feature (or epic) name this exit concerns. Always present.
528
+ feature: str
529
+ # Resolved host: "claude", "pi", or "generic". Selects command syntax and
530
+ # fresh-session wording; never inferred downstream, always decided here.
531
+ host: str
532
+ # Whether the host may dispatch a clean-room verifier subagent —
533
+ # VerifyCapability, i.e. exactly "interactive" or "manual". A manual host
534
+ # receives verify-first ordering with copy-paste commands instead of an
535
+ # interactive gate; capable Pi is interactive, not manual (REQ-EXIT-07).
536
+ # "May", not "has the tool": a session that bars unsolicited dispatch but
537
+ # offers a question tool is interactive, since the gate's prompt makes the
538
+ # dispatch solicited. Only no-question-tool-and-no-dispatch is manual.
539
+ verifyCapability: str
540
+ # Current verification state of the served artifact, as classified by
541
+ # `verify_state` — including "auto-pending" for unrun scheduled verification.
542
+ verifyState: str
543
+ # Production stage the outstanding/owed verification belongs to — the value
544
+ # `pending_verify()` returns; mirrors FeatureRow.verifyStage so navigator rows
545
+ # and stage-exit JSON report the same thing. None when nothing is outstanding.
546
+ # DISTINCT from `servedStage`, which is branch-exit-only: on a production-stage
547
+ # exit `servedStage` is None while `verifyStage` names the stage the debt is
548
+ # owed on (REQ-OBS-01, REQ-DEBT-05).
549
+ verifyStage: str | None
550
+ # Which gate form to render, derived from verifyState and verifyCapability.
551
+ verifyGate: str
552
+ # Host-rendered verify command. Present whenever verification is reachable,
553
+ # even if it is not the primary action.
554
+ verifyCommand: str
555
+ # True when the caller must run in-stage verification before returning control.
556
+ # When True, the auto-verify-pending debt write has already been attempted —
557
+ # see `autoVerifyDebtRecorded` for whether it landed.
558
+ runInStageVerify: bool
559
+ # Effective autoVerify for THIS stage after applying autoVerifyStages overrides
560
+ # over the autoVerify default. Not the raw config value.
561
+ autoVerifyEffective: bool
562
+ # True whenever `runInStageVerify` is True — the scheduling boundary
563
+ # persists the auto-verify-pending marker BEFORE this payload exists, and a
564
+ # failed debt write raises UsageError with no payload at all. So
565
+ # `runInStageVerify: True` with `autoVerifyDebtRecorded: False` is UNREACHABLE;
566
+ # the field is carried so tests and downstream tools can assert that invariant
567
+ # rather than infer it. False with `runInStageVerify: False` simply means no
568
+ # debt was owed (REQ-DEBT-01/04, REQ-REL-02).
569
+ autoVerifyDebtRecorded: bool
570
+ # True when an autoFix chain may run unattended: autoFix configured, zero
571
+ # unresolved decision points, and a clean tree at the pre-scheduling snapshot.
572
+ autoFixEligible: bool
573
+ # Next production stage in pipeline order, or None at the end of the pipeline.
574
+ # Routing introspection only — never promote it over `primaryCommand`.
575
+ nextStage: str | None
576
+ # Host-rendered command for `nextStage`. Retained for compatibility; see the
577
+ # promotion rule below. None when `nextStage` is None.
578
+ nextCommand: str | None
579
+ # THE authoritative single action. While verification is unresolved this is the
580
+ # verify command — or the forge-fix command when a findings report is live at
581
+ # the current revision — never the downstream stage. The one fenced command in
582
+ # the rendered block. None only when the pipeline has no further action.
583
+ primaryCommand: str | None
584
+ # Post-verification guidance shown as prose, never fenced, so it cannot be
585
+ # mistaken for the primary action. None when there is nothing deferred.
586
+ deferredCommand: str | None
587
+ # Keys in autoVerifyStages that name no verify-capable stage — a config typo.
588
+ # Empty list means the config was checked and clean; the key is always present
589
+ # when config was read at all, so [] and absent differ. Each key renders as
590
+ # exactly:
591
+ # Warning: autoVerifyStages key "{key}" names no verify-capable stage; it is
592
+ # ignored. Valid keys are forge-1-prd, forge-2-tech, forge-3-specs,
593
+ # forge-4-backlog, forge-5-loop.
594
+ # Keys are rendered in sorted order, per the determinism rule
595
+ # (REQ-OBS-02, REQ-REL-01).
596
+ invalidAutoVerifyKeys: list[str]
597
+ # Whether the working directory is a git repository at all.
598
+ gitRepo: bool
599
+ # Clean-tree snapshot taken BEFORE the pending-debt write, so the sanctioned
600
+ # state mutation does not dirty its own precondition. None when `gitRepo` is
601
+ # False — unknown, not clean.
602
+ cleanTree: bool | None
603
+ # Human-readable non-fatal advisories, in a fixed deterministic order:
604
+ # (1) the epic-member unreadable-state fallback, (2) the legacy/malformed
605
+ # scheduledStageVersion metadata warning, (3) the scheduled-vs-current
606
+ # revision mismatch note. A LIST,
607
+ # not a string, because these are independently triggerable and can co-occur
608
+ # on one call; a single string would force an implementer to drop or
609
+ # concatenate them, and REQ-REL-01's byte-identical-output requirement needs a
610
+ # defined order to assert against. Mirrors RenderStatus.warnings,
611
+ # which is already a list. Empty list means checked and clean; the key is
612
+ # always present, so [] and absent differ. Each entry names its affected
613
+ # feature/stage/key AND the recovery action (REQ-OBS-02).
614
+ warnings: list[str]
615
+ # Epic backflow directive; see EpicReconcile. Absent for standalone features.
616
+ epicReconcile: EpicReconcile
617
+
618
+
619
+ class StageExitPayload(TypedDict):
620
+ """Serialized direct or nested exit result.
621
+
622
+ Total (not `total=False`): all three keys are always present, and a nested
623
+ exit carries explicit nulls rather than omitting them.
624
+ """
625
+
626
+ # Always populated, for both direct and nested exits.
627
+ directives: StageExitDirectives
628
+ # The rendered terminal block for a direct owner. MUST be None when
629
+ # `terminalOwnedBy == "outer"` — a nested caller has nothing to print.
630
+ nextSteps: str | None
631
+ # NEXT_STEPS_SENTINEL when this payload owns the terminal block, else None.
632
+ # When non-None, `nextSteps` ends with exactly this string and nothing follows
633
+ # it (REQ-EXIT-03). Carried explicitly so a consumer can verify termination
634
+ # without importing the constant.
635
+ sentinel: str | None
636
+
637
+
638
+ class VerifyEntry(TypedDict, total=False):
639
+ """Feature or epic verification state persisted by `state-verify`.
640
+
641
+ `total=False` is load-bearing: terminal writes DELETE the scheduling keys rather
642
+ than nulling them, so an absent `scheduledAt` means "not scheduled"
643
+ while a present-but-null one would be a malformed entry. Legacy entries written
644
+ before this feature simply lack the newer keys and load unmigrated
645
+ (REQ-DEBT-06).
646
+ """
647
+
648
+ # The entry's state. Always present on a written entry; a wholly absent entry
649
+ # means never verified, which is distinct from every value here.
650
+ status: VerifyStatus
651
+ # Path to the findings document, relative to the feature directory. Non-empty
652
+ # for `findings-reported`/`findings-applied`; absent otherwise.
653
+ findingsFile: str | None
654
+ # Findings count. 0 is legal and meaningful for `findings-reported` — verified
655
+ # with nothing found — and is not the same as the key being absent.
656
+ findingsCount: int | None
657
+ # UTC ISO-8601 timestamp of the terminal verification result. Absent while
658
+ # scheduling is pending.
659
+ verifiedAt: str | None
660
+ # UTC ISO-8601 timestamp set by `findings-applied`. Its presence alongside a
661
+ # deleted `verifiedStageVersion` is exactly what marks fixes-landed-but-
662
+ # unconfirmed.
663
+ fixedAt: str | None
664
+ # Full 40-character hash of the artifact commit for this entry, or null between
665
+ # commit 1 and commit 2 of the two-commit protocol. Never a short hash on a new
666
+ # write; legacy short hashes still READ (REQ-STATE-01/02).
667
+ commitHash: str | None
668
+ # Artifact revision this result verified — the production stage's `version` for
669
+ # a feature, the manifest `revision` for an epic. Deleted by `findings-applied`
670
+ # on purpose, so freshness stays unresolved until a later `passed` write.
671
+ verifiedStageVersion: int | None
672
+ # UTC ISO-8601 timestamp of the auto-verify schedule. Deleted (not nulled) by
673
+ # any terminal result.
674
+ scheduledAt: str | None
675
+ # Artifact revision current when verification was scheduled. Makes rescheduling
676
+ # idempotent — an identical revision does not rewrite the entry (REQ-REL-01) —
677
+ # and lets a read distinguish debt owed on the current artifact from debt
678
+ # stranded on an older one. Deleted by any terminal result.
679
+ scheduledStageVersion: int | None
680
+
681
+
256
682
  class UsageError(Exception):
257
683
  """A usage or I/O failure that must exit 2."""
258
684
 
@@ -376,18 +802,100 @@ def _warn_unknown_verify_status(stage_name: str, status: object) -> None:
376
802
  )
377
803
 
378
804
 
805
+ def _scheduled_stage_version(entry: dict) -> int | None:
806
+ """Return an ``auto-verify-pending`` entry's usable ``scheduledStageVersion``.
807
+
808
+ ``None`` when the field is absent, a bool, a non-integer, or below 1 — i.e.
809
+ legacy state written before the scheduling fields existed, or hand-edited
810
+ state. The caller stays ``auto-pending`` either way: unusable metadata is a
811
+ reason to warn, never a reason to forget the debt.
812
+ """
813
+ version = entry.get("scheduledStageVersion")
814
+ if isinstance(version, bool) or not isinstance(version, int) or version < 1:
815
+ return None
816
+ return version
817
+
818
+
819
+ def _warn_auto_verify_debt_metadata(verify_key: str) -> None:
820
+ """Flag an ``auto-verify-pending`` entry whose scheduled revision is unusable.
821
+
822
+ Without a recorded revision the debt cannot be compared against the current
823
+ artifact, so it can be neither discharged as fresh nor described as advanced.
824
+ It REMAINS outstanding — the alternative (degrading to ``never``) is exactly
825
+ the conflation REQ-DEBT-02 forbids — but the operator needs to know why the
826
+ row carries no revision detail, so say it once per process.
827
+ """
828
+ if verify_key in _AUTO_VERIFY_DEBT_WARNED:
829
+ return
830
+ _AUTO_VERIFY_DEBT_WARNED.add(verify_key)
831
+ print(
832
+ f"feature-forge: {verify_key} is auto-verify-pending but its "
833
+ "scheduledStageVersion is missing or malformed (legacy or hand-edited "
834
+ "state); the debt stays outstanding — re-run forge-verify to resolve it "
835
+ "and record a usable schedule",
836
+ file=sys.stderr,
837
+ )
838
+
839
+
840
+ def auto_pending_message(
841
+ subject: str,
842
+ stage: str,
843
+ command: str,
844
+ scheduled_version: int | None = None,
845
+ current_version: int | None = None,
846
+ ) -> str:
847
+ """Render the diagnostic for owed-but-unrun automatic verification.
848
+
849
+ Args:
850
+ subject: The feature or epic the debt belongs to.
851
+ stage: The served production stage the debt is owed on.
852
+ command: The host-translated forge-verify retry command.
853
+ scheduled_version: Revision the debt was recorded against, if usable.
854
+ current_version: The artifact's current revision, if known.
855
+
856
+ Returns:
857
+ One sentence, with both revision numbers appended when the recorded
858
+ schedule predates the current artifact. Never a state-file dump.
859
+ """
860
+ message = AUTO_PENDING_DIAGNOSTIC.format(
861
+ subject=subject, stage=stage, command=command
862
+ )
863
+ if (
864
+ scheduled_version is not None
865
+ and current_version is not None
866
+ and scheduled_version != current_version
867
+ ):
868
+ message += (
869
+ f" The artifact has advanced since it was scheduled "
870
+ f"(scheduled at revision {scheduled_version}, now at revision "
871
+ f"{current_version})."
872
+ )
873
+ return message
874
+
875
+
379
876
  def verify_state(state: dict) -> tuple[str | None, str]:
380
877
  """Classify verify freshness for the most-recently-completed stage.
381
878
 
382
879
  Returns ``(stage, state_label)`` where ``state_label`` is one of:
383
880
 
384
- - ``fresh`` — verify is resolved AND its ``verifiedStageVersion`` matches the
385
- stage's current ``version`` (so no re-verify is needed).
881
+ - ``fresh`` — the entry is ``passed`` AND its ``verifiedStageVersion`` matches
882
+ the stage's current ``version`` (so no re-verify is needed). ``passed`` is the
883
+ ONLY status that reaches ``fresh``: ``findings-applied`` and ``skipped`` are
884
+ resolved but never fresh, for the reasons given below.
386
885
  - ``stale`` — verify was resolved once, but the stage version has since moved
387
886
  (artifact revised) OR the entry predates the freshness ledger (no
388
- ``verifiedStageVersion``). A revised artifact must be re-verified.
887
+ ``verifiedStageVersion``), OR the entry is ``findings-applied``, which never
888
+ classifies ``fresh`` regardless of any version it carries (§4.2 step 4).
889
+ A revised artifact must be re-verified.
389
890
  - ``failing`` — verify ran and reported findings that are not yet applied
390
891
  (``findings-reported``).
892
+ - ``auto-pending`` — effective configuration scheduled unattended in-stage
893
+ verification and nothing has discharged it: the obligation is RECORDED and
894
+ owed. Deliberately distinct from ``never`` (nobody ever asked for it), from
895
+ manual ``pending`` work, and from every resolved label — a dropped
896
+ ``runInStageVerify`` directive is precisely what this makes visible (#163,
897
+ REQ-DEBT-02). Classified BEFORE the generic unresolved handling below, and
898
+ never downgraded when its scheduling metadata is missing or malformed.
391
899
  - ``never`` — the stage completed but verify has not run at all.
392
900
  - ``skipped`` — the user explicitly chose to proceed without verifying. A
393
901
  resolved, non-pending state: it is deliberately NOT re-offered or
@@ -398,9 +906,10 @@ def verify_state(state: dict) -> tuple[str | None, str]:
398
906
  is ``None``.
399
907
 
400
908
  Only the most-recent completed production stage is considered, matching the
401
- navigator's "verify before continuing" gate. Absent ``verifiedStageVersion``
402
- on a ``passed``/``findings-applied`` entry (legacy state) is deliberately
403
- treated as ``stale`` — verify rather than skip.
909
+ navigator's "verify before continuing" gate. A ``findings-applied`` entry is
910
+ treated as ``stale`` UNCONDITIONALLY — applying fixes is not verifying them —
911
+ and an absent ``verifiedStageVersion`` on a ``passed`` entry (legacy state) is
912
+ likewise ``stale``: verify rather than skip.
404
913
  """
405
914
  for stage in reversed(PRODUCTION_STAGES):
406
915
  if _stage_status(state, stage) != _DONE_STATUS:
@@ -410,11 +919,26 @@ def verify_state(state: dict) -> tuple[str | None, str]:
410
919
  continue # forge-6-docs has no verify step
411
920
  entry = _verify_entry(state, f"forge-verify-{token}")
412
921
  status = entry.get("status")
922
+ if status is not None and not isinstance(status, str):
923
+ # A torn or hand-edited entry can carry any JSON type here; an
924
+ # unhashable one would raise TypeError at the frozenset membership
925
+ # below, crashing the navigator on one bad file. Same answer as an
926
+ # absent entry — and the same #148 diagnostic as an unknown string,
927
+ # so the degradation is never silent.
928
+ _warn_unknown_verify_status(f"forge-verify-{token}", status)
929
+ return stage, "never"
413
930
  if status == "skipped":
414
931
  # An explicit skip is resolved and non-pending — preserve the user's
415
932
  # decision. It never goes stale (no recorded version to compare), so
416
933
  # the freshness check below deliberately does not apply.
417
934
  return stage, "skipped"
935
+ if status == "auto-verify-pending":
936
+ # Ordered ahead of the generic unresolved branch so recorded debt can
937
+ # never fall through to "never". Unusable metadata warns and stays
938
+ # owed; a superseded revision stays owed too.
939
+ if _scheduled_stage_version(entry) is None:
940
+ _warn_auto_verify_debt_metadata(f"forge-verify-{token}")
941
+ return stage, "auto-pending"
418
942
  if status not in _VERIFY_RESOLVED:
419
943
  if status == "findings-reported":
420
944
  return stage, "failing"
@@ -425,6 +949,15 @@ def verify_state(state: dict) -> tuple[str | None, str]:
425
949
  if status is not None and status not in KNOWN_VERIFY_STATUSES:
426
950
  _warn_unknown_verify_status(f"forge-verify-{token}", status)
427
951
  return stage, "never"
952
+ if status == "findings-applied":
953
+ # Applying fixes is not verifying them: §4.2 step 4 says `findings-applied`
954
+ # CLEARS freshness, and only a later `passed` restores it. The writer builds
955
+ # the entry without `verifiedStageVersion`, but the read side may not rely on
956
+ # that — REQ-DEBT-06 requires loading legacy state without migration, and a
957
+ # pre-writer entry can still carry the key. Without this guard such an entry
958
+ # reads `fresh`, `pending_verify` returns None, and the verification debt for
959
+ # a fixed-but-never-re-verified stage disappears silently.
960
+ return stage, "stale"
428
961
  verified_version = entry.get("verifiedStageVersion")
429
962
  stage_version = _stage_version(state, stage)
430
963
  if (
@@ -441,8 +974,11 @@ def pending_verify(state: dict) -> str | None:
441
974
  """Return the production stage whose verify is outstanding, if any.
442
975
 
443
976
  Outstanding means the most-recently-completed production stage's verify is not
444
- ``fresh`` (never run, reported findings, or gone stale after an artifact
445
- revision). An explicit ``skipped`` is treated as resolved (never outstanding).
977
+ ``fresh`` (never run, scheduled-but-unrun automatic verification, reported
978
+ findings, or gone stale after an artifact revision). An ``auto-pending`` stage
979
+ is returned like any other outstanding one — recorded debt is owed work, and
980
+ ``_VERIFY_RESOLVED`` deliberately excludes it.
981
+ An explicit ``skipped`` is treated as resolved (never outstanding).
446
982
  Surfaced so the navigator can offer "verify before continuing" as an
447
983
  alternative to advancing. Returns ``None`` when the latest stage is fresh,
448
984
  skipped, or there is nothing to verify.
@@ -474,6 +1010,13 @@ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
474
1010
  ``config`` is the loaded forge.config.json (or ``{}``); it drives the effective
475
1011
  ``autoVerify``/``autoFix`` per stage so the navigator can branch without
476
1012
  re-reading config.
1013
+
1014
+ A row whose verify classifies ``auto-pending`` carries recorded-but-undischarged
1015
+ automatic verification: ``verifyPending`` is True, ``verifyState`` is
1016
+ ``auto-pending``, and ``verifyCommand`` is non-null, so no consumer can read it
1017
+ as verification-complete. The named sentence goes to stderr (this is the
1018
+ one emitter that knows the feature name); stdout keeps the three stable JSON
1019
+ keys — ``verifyState``, ``verifyStage``, ``verifyCommand`` — and no prose.
477
1020
  """
478
1021
  config = config or {}
479
1022
  # Fail closed: only a literal JSON ``true`` enables artifact-mutating autoFix.
@@ -487,6 +1030,20 @@ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
487
1030
  vstage, vlabel = verify_state(state)
488
1031
  verify_pending = vstage is not None and vlabel not in ("fresh", "none", "skipped")
489
1032
  effective_auto_verify = auto_verify_for(config, vstage) if vstage else False
1033
+ verify_command = f"/skill:forge-verify {name}" if verify_pending else None
1034
+ if vlabel == "auto-pending" and vstage is not None and verify_command:
1035
+ token = VERIFY_TOKEN_BY_STAGE.get(vstage)
1036
+ entry = _verify_entry(state, f"forge-verify-{token}") if token else {}
1037
+ print(
1038
+ auto_pending_message(
1039
+ name,
1040
+ vstage,
1041
+ verify_command,
1042
+ _scheduled_stage_version(entry),
1043
+ _stage_version(state, vstage),
1044
+ ),
1045
+ file=sys.stderr,
1046
+ )
490
1047
  branch = state.get("branch")
491
1048
  updated = state.get("updatedAt")
492
1049
  rows.append({
@@ -502,7 +1059,7 @@ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
502
1059
  "nextStage": nxt,
503
1060
  "nextCommand": f"/skill:{nxt} {name}" if nxt else None,
504
1061
  "verifyPending": verify_pending,
505
- "verifyCommand": f"/skill:forge-verify {name}" if verify_pending else None,
1062
+ "verifyCommand": verify_command,
506
1063
  "verifyStage": vstage,
507
1064
  "verifyState": vlabel,
508
1065
  "autoVerify": effective_auto_verify,
@@ -611,17 +1168,67 @@ def _infer_window(model: str | None) -> int:
611
1168
  return _DEFAULT_WINDOW
612
1169
 
613
1170
 
614
- def _load_config(config_path: Path) -> dict:
615
- """Read forge.config.json into a dict, tolerating missing/corrupt files.
1171
+ #: mirrors ``load_json_with_duplicates``/``warn_duplicate_keys`` in scripts/forge-bootstrap.py
1172
+ def load_json_with_duplicates(path: Path) -> tuple[object, list[str]]:
1173
+ """Load JSON with last-key-wins values and ordered duplicate key names.
1174
+
1175
+ Args:
1176
+ path: UTF-8 JSON file to read.
1177
+
1178
+ Returns:
1179
+ The parsed JSON value and duplicate key names in deterministic decoder-hook
1180
+ order. A repeated occurrence is appended whenever its key was already seen
1181
+ in that same object. Objects at every nesting depth use the hook.
1182
+
1183
+ Raises:
1184
+ OSError: The path cannot be read as UTF-8 text.
1185
+ json.JSONDecodeError: The file is not valid JSON.
1186
+ """
1187
+ duplicate_keys: list[str] = []
1188
+
1189
+ def object_from_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
1190
+ result: dict[str, object] = {}
1191
+ for key, value in pairs:
1192
+ if key in result:
1193
+ duplicate_keys.append(key)
1194
+ result[key] = value
1195
+ return result
1196
+
1197
+ text = path.read_text(encoding="utf-8")
1198
+ value = json.loads(text, object_pairs_hook=object_from_pairs)
1199
+ return value, duplicate_keys
1200
+
616
1201
 
617
- A missing, unreadable, or non-object config downgrades to ``{}`` so callers
618
- read every key through absent-safe ``.get`` defaults.
1202
+ def warn_duplicate_keys(path: Path, duplicate_keys: list[str]) -> None:
1203
+ """Write one deterministic warning for each reported duplicate occurrence.
1204
+
1205
+ Args:
1206
+ path: Source file whose duplicate key was accepted.
1207
+ duplicate_keys: Ordered names returned by `load_json_with_duplicates`.
1208
+
1209
+ Raises:
1210
+ OSError: The process cannot write to stderr.
619
1211
  """
1212
+ for key in duplicate_keys:
1213
+ rendered_key = json.dumps(key, ensure_ascii=False)
1214
+ print(
1215
+ f"Warning: duplicate JSON key {rendered_key} in {path}; "
1216
+ "using the last value.",
1217
+ file=sys.stderr,
1218
+ )
1219
+
1220
+
1221
+ def _load_config(config_path: Path) -> dict:
1222
+ """Read config into a dict, warning on duplicates and tolerating bad input."""
620
1223
  try:
621
- config = json.loads(config_path.read_text(encoding="utf-8"))
1224
+ value, duplicate_keys = load_json_with_duplicates(config_path)
622
1225
  except (OSError, json.JSONDecodeError):
623
1226
  return {}
624
- return config if isinstance(config, dict) else {}
1227
+ try:
1228
+ warn_duplicate_keys(config_path, duplicate_keys)
1229
+ except OSError:
1230
+ pass # a diagnostic write failure must not break a total read path
1231
+ return value if isinstance(value, dict) else {}
625
1232
 
626
1233
 
627
1234
  def _config_value(config_path: Path, key: str):
@@ -653,11 +1260,15 @@ def invalid_auto_verify_keys(config: dict) -> list[str]:
653
1260
  An unknown/typo key (e.g. ``forge-1-prod``) would silently never take effect,
654
1261
  turning an intended off-switch into a no-op. Surfacing it lets the navigator
655
1262
  warn instead of failing quietly. Mirrors the schema's ``propertyNames.enum``.
1263
+
1264
+ Sorted, not insertion-ordered: every diagnostic list must be
1265
+ sorted before rendering, so two configs that differ only in key order produce
1266
+ byte-identical output.
656
1267
  """
657
1268
  stages = config.get("autoVerifyStages")
658
1269
  if not isinstance(stages, dict):
659
1270
  return []
660
- return [key for key in stages if key not in VERIFY_TOKEN_BY_STAGE]
1271
+ return sorted(key for key in stages if key not in VERIFY_TOKEN_BY_STAGE)
661
1272
 
662
1273
 
663
1274
  def context_usage(
@@ -1433,15 +2044,28 @@ def _print_check_epic_base(payload: dict) -> None:
1433
2044
  # Scripted Stage Exit
1434
2045
  # --------------------------------------------------------------------------- #
1435
2046
 
1436
- #: Authoring stages whose closing runs stage-exit (the loop keeps bespoke exits).
1437
- EXIT_STAGES: Final[tuple[str, ...]] = (
1438
- "forge-0-epic",
1439
- "forge-1-prd",
1440
- "forge-2-tech",
1441
- "forge-3-specs",
1442
- "forge-4-backlog",
2047
+ #: The seven production stages as a ROUTING domain. Deliberately not
2048
+ #: ``PRODUCTION_STAGES``, which is the six-stage member walk that excludes
2049
+ #: ``forge-0-epic``; derived from the shared ``ProductionStage`` alias so the two
2050
+ #: cannot drift.
2051
+ _EXIT_PRODUCTION_STAGES: Final[tuple[str, ...]] = get_args(ProductionStage)
2052
+
2053
+ #: The two direct branch skills — every exit stage that is not a production stage.
2054
+ #: Derived, so adding a branch skill to ``ExitStage`` lands here automatically.
2055
+ _BRANCH_STAGES: Final[tuple[str, ...]] = tuple(
2056
+ stage for stage in EXIT_STAGES if stage not in _EXIT_PRODUCTION_STAGES
1443
2057
  )
1444
2058
 
2059
+ #: Inverse of ``VERIFY_MODE_TO_STAGE``. The mapping is injective, so the inverse is
2060
+ #: total over its values; stages with no mode (``forge-6-docs``) are simply absent.
2061
+ _STAGE_TO_VERIFY_MODE: Final[dict[str, str]] = {
2062
+ stage: mode for mode, stage in VERIFY_MODE_TO_STAGE.items()
2063
+ }
2064
+
2065
+ #: The `--host` domain: command syntax and fresh-session wording only. A host NEVER
2066
+ #: implies a verification capability (REQ-EXIT-07).
2067
+ EXIT_HOSTS: Final[tuple[str, ...]] = ("claude", "generic", "pi")
2068
+
1445
2069
  #: Stage id -> the noun phrase gate wording uses (the old {stage} stamp slot).
1446
2070
  STAGE_NOUN: Final[dict[str, str]] = {
1447
2071
  "forge-0-epic": "the epic decomposition",
@@ -1458,49 +2082,225 @@ _EXIT_VERIFY_TOKEN: Final[dict[str, str]] = {
1458
2082
  "forge-0-epic": "epic",
1459
2083
  }
1460
2084
 
1461
- #: The stage each exit hands off to when pipeline state cannot say better.
2085
+ #: The stage each exit hands off to when pipeline state cannot say better. Also the
2086
+ #: production-successor table a branch exit walks from its RESOLVED SERVED stage:
2087
+ #: ``forge-6-docs`` is absent because the pipeline ends there — a docs exit routes to
2088
+ #: a completion action, never to a nonexistent stage 7.
1462
2089
  _EXIT_NEXT_STAGE: Final[dict[str, str]] = {
1463
2090
  "forge-0-epic": "forge-1-prd",
1464
2091
  "forge-1-prd": "forge-2-tech",
1465
2092
  "forge-2-tech": "forge-3-specs",
1466
2093
  "forge-3-specs": "forge-4-backlog",
1467
2094
  "forge-4-backlog": "forge-5-loop",
2095
+ "forge-5-loop": "forge-6-docs",
1468
2096
  }
1469
2097
 
1470
- #: The fixed final line of the NEXT-STEPS block. The stamp instructs the skill
1471
- #: to print the block verbatim as its absolute last output — nothing after this.
1472
- NEXT_STEPS_SENTINEL: Final = "─ forge: end of stage ─"
2098
+ #: The route each branch outcome takes. Every value is a COMPLETE map over
2099
+ #: ``EXIT_OUTCOMES[stage]``: REQ-ROUTE-05/06 require a terminus for every outcome and
2100
+ #: forbid a fall-through, so a missing key is a bug, not a default. The four kinds:
2101
+ #:
2102
+ #: ``successor`` rejoin the live production position after the served stage
2103
+ #: ``fix`` ``/skill:forge-fix FEATURE --served-stage SERVED``
2104
+ #: ``verify`` ``/skill:forge-verify FEATURE --served-stage SERVED``
2105
+ #: ``verify-if-owed`` ``verify`` while verification is still owed, else ``successor``
2106
+ #:
2107
+ #: Only ``successor`` advances. ``decisions``, ``failed``, ``deferred``, and
2108
+ #: ``reverify-findings`` are deliberately absent from it: unresolved work never
2109
+ #: reaches a production stage.
2110
+ _BRANCH_ROUTE_KIND: Final[dict[str, dict[str, str]]] = {
2111
+ "forge-verify": {
2112
+ "passed": "successor",
2113
+ "findings": "fix",
2114
+ "skipped": "successor",
2115
+ "failed": "verify",
2116
+ },
2117
+ "forge-fix": {
2118
+ "no-findings": "verify-if-owed",
2119
+ "decisions": "fix",
2120
+ "failed": "fix",
2121
+ # `applied` is NOT `reverified`: the writer clears `verifiedStageVersion`, so
2122
+ # re-verification is mandatory and this may never route to production.
2123
+ "applied": "verify",
2124
+ "reverified": "successor",
2125
+ "reverify-findings": "fix",
2126
+ "deferred": "fix",
2127
+ },
2128
+ }
2129
+
2130
+ #: The deterministic sentence each branch outcome renders inside its NEXT-STEPS block
2131
+ #: (``_next_steps_block(..., outcome_text=...)``). Every non-advancing outcome names
2132
+ #: the unresolved work explicitly, which is what is required of `decisions`,
2133
+ #: `failed`, and `deferred`, and what is required of a `failed` verification.
2134
+ _BRANCH_OUTCOME_TEXT: Final[dict[str, dict[str, str]]] = {
2135
+ "forge-verify": {
2136
+ "passed": (
2137
+ "Verification passed for {served} — the pipeline rejoins where the "
2138
+ "diversion left it."
2139
+ ),
2140
+ "findings": (
2141
+ "Verification reported findings for {served}. They are recorded and "
2142
+ "remain unresolved, so the pipeline does not advance until they are "
2143
+ "fixed and re-verification passes."
2144
+ ),
2145
+ "skipped": (
2146
+ "Verification for {served} was explicitly skipped and the skip is "
2147
+ "recorded, so the pipeline may continue."
2148
+ ),
2149
+ "failed": (
2150
+ "Verification for {served} could not run to a result — the dispatch, the "
2151
+ "check, or the state write failed. Nothing advances until it does: "
2152
+ "resolve the failure, then re-run the verification below."
2153
+ ),
2154
+ },
2155
+ "forge-fix": {
2156
+ "no-findings": (
2157
+ "No applicable findings were found for {served}, but its verification is "
2158
+ "still owed — the absence of applicable findings is not a pass, so "
2159
+ "verification runs before the pipeline advances."
2160
+ ),
2161
+ "decisions": (
2162
+ "The fix stopped on unresolved decisions for {served}. Answer them and "
2163
+ "re-run the fix below; the pipeline does not advance while they are open."
2164
+ ),
2165
+ "failed": (
2166
+ "The fix for {served} failed — a fix step, a validation, a commit, or a "
2167
+ "state write did not complete. The findings remain unresolved, so the "
2168
+ "pipeline does not advance; address the failure and re-run the fix below."
2169
+ ),
2170
+ "applied": (
2171
+ "Fixes were applied for {served}, but applied is not verified: the "
2172
+ "recorded freshness was cleared, so re-verification is mandatory before "
2173
+ "the pipeline advances."
2174
+ ),
2175
+ "reverified": (
2176
+ "Re-verification passed for {served} — the findings are resolved and the "
2177
+ "pipeline rejoins where the diversion left it."
2178
+ ),
2179
+ "reverify-findings": (
2180
+ "Re-verification reported further findings for {served}. They remain "
2181
+ "unresolved, so the pipeline does not advance."
2182
+ ),
2183
+ "deferred": (
2184
+ "Fix work for {served} was explicitly deferred. The findings remain "
2185
+ "UNRESOLVED — the pipeline does not advance until they are fixed and "
2186
+ "re-verification passes."
2187
+ ),
2188
+ },
2189
+ }
1473
2190
 
2191
+ #: `no-findings` is the one outcome whose terminus depends on live state, so it has a
2192
+ #: second sentence for the already-resolved case.
2193
+ _NO_FINDINGS_RESOLVED_TEXT: Final[str] = (
2194
+ "No applicable findings were found for {served}, and its verification is already "
2195
+ "resolved — the pipeline rejoins where the diversion left it."
2196
+ )
1474
2197
 
1475
- def _verify_state_for(state: dict, stage: str) -> str:
1476
- """Classify THIS stage's verify freshness (stage-scoped ``verify_state``).
1477
2198
 
1478
- Same labels as ``verify_state`` — fresh / stale / failing / never /
1479
- skipped / none — but for the given stage rather than the most-recently
1480
- completed one, because stage-exit runs inside the stage that just closed.
2199
+ def _classify_verify_entry(entry: dict, verify_key: str, current: int | None) -> str:
2200
+ """Label one ``forge-verify-*`` entry against the artifact revision it serves.
2201
+
2202
+ The revision-agnostic half of ``_verify_state_for``, factored out because an
2203
+ EPIC-scoped exit compares against the epic manifest's ``revision`` held in
2204
+ ``.epic-state.json``, not against a member production-stage ``version``
2205
+ (REQ-SEC-01). Both callers must apply identical rules, so there is
2206
+ one implementation rather than two that can drift.
2207
+
2208
+ Args:
2209
+ entry: The verify entry (``{}`` when absent).
2210
+ verify_key: The ``forge-verify-*`` key, named in the metadata diagnostic.
2211
+ current: The artifact's current revision, or None when it is unknown.
2212
+
2213
+ Returns:
2214
+ One of fresh / stale / failing / auto-pending / never / skipped.
1481
2215
  """
1482
- token = _EXIT_VERIFY_TOKEN.get(stage)
1483
- if token is None:
1484
- return "none"
1485
- entry = _verify_entry(state, f"forge-verify-{token}")
1486
2216
  status = entry.get("status")
2217
+ if status is not None and not isinstance(status, str):
2218
+ # Same guard as `verify_state`: an unhashable status from a torn or
2219
+ # hand-edited entry must classify, not raise at the frozenset
2220
+ # membership below — this label is read while closing a stage.
2221
+ return "never"
1487
2222
  if status == "skipped":
1488
2223
  return "skipped"
1489
2224
  if status == "findings-reported":
1490
2225
  return "failing"
2226
+ if status == "auto-verify-pending":
2227
+ # Ahead of the generic unresolved branch, exactly as in verify_state.
2228
+ if _scheduled_stage_version(entry) is None:
2229
+ _warn_auto_verify_debt_metadata(verify_key)
2230
+ return "auto-pending"
1491
2231
  if status not in _VERIFY_RESOLVED:
1492
2232
  return "never"
2233
+ if status == "findings-applied":
2234
+ # §4.2 step 4: applying fixes CLEARS freshness; only a later `passed` restores
2235
+ # it. The writer omits `verifiedStageVersion` on this status, but REQ-DEBT-06
2236
+ # requires loading legacy state without migration, so a pre-writer entry can
2237
+ # still carry the key — and would otherwise read `fresh` here. Mirrors the
2238
+ # identical guard in `verify_state` (§5.1).
2239
+ return "stale"
1493
2240
  verified_version = entry.get("verifiedStageVersion")
1494
- stage_version = _stage_version(state, stage)
1495
2241
  if (
1496
2242
  isinstance(verified_version, int)
1497
- and stage_version is not None
1498
- and verified_version == stage_version
2243
+ and current is not None
2244
+ and verified_version == current
1499
2245
  ):
1500
2246
  return "fresh"
1501
2247
  return "stale"
1502
2248
 
1503
2249
 
2250
+ def _verify_state_for(state: dict, stage: str) -> str:
2251
+ """Classify THIS stage's verify freshness (stage-scoped ``verify_state``).
2252
+
2253
+ Same labels as ``verify_state`` — fresh / stale / failing / auto-pending /
2254
+ never / skipped / none — but for the given stage rather than the
2255
+ most-recently completed one, because stage-exit runs inside the stage that
2256
+ just closed. ``auto-pending`` is classified identically here so stage-exit
2257
+ routing and the navigator ledger never disagree about owed debt (REQ-DEBT-05).
2258
+ """
2259
+ token = _EXIT_VERIFY_TOKEN.get(stage)
2260
+ if token is None:
2261
+ return "none"
2262
+ return _classify_verify_entry(
2263
+ _verify_entry(state, f"forge-verify-{token}"),
2264
+ f"forge-verify-{token}",
2265
+ _stage_version(state, stage),
2266
+ )
2267
+
2268
+
2269
+ def _epic_verify_context(specs_dir: Path, epic_name: str) -> tuple[dict, int | None]:
2270
+ """Read an epic's verification entry and manifest revision — tolerantly.
2271
+
2272
+ An epic's verification state lives in ``{specsDir}/{epic}/.epic-state.json``
2273
+ and its artifact revision is the sibling manifest's ``revision``. Neither ever
2274
+ comes from a member ``.pipeline-state.json``, and a member production-stage
2275
+ ``version`` is never the epic's revision (REQ-SEC-01). This
2276
+ is the READ half: it degrades to ``({}, None)`` on anything missing or
2277
+ malformed, matching stage-exit's "never crash a stage closing" posture. The
2278
+ strict, fail-closed resolution lives on the WRITE path
2279
+ (``_load_epic_state_for_write``).
2280
+
2281
+ A legacy manifest with no ``revision`` reads as logical ``1``, matching
2282
+ ``epic-manifest.py::load_manifest``; its bytes are not rewritten
2283
+ (REQ-DEBT-06).
2284
+
2285
+ Args:
2286
+ specs_dir: The configured specs directory.
2287
+ epic_name: The epic — what ``--feature`` carries on an epic-scoped exit.
2288
+
2289
+ Returns:
2290
+ ``(verify_entry, revision)``; ``revision`` is None when the manifest is
2291
+ missing or its revision unusable.
2292
+ """
2293
+ epic_dir = specs_dir / epic_name
2294
+ revision: int | None = None
2295
+ manifest = _read_state(epic_dir / MANIFEST_FILENAME)
2296
+ if manifest:
2297
+ raw = manifest.get("revision", 1)
2298
+ if isinstance(raw, int) and not isinstance(raw, bool) and raw >= 1:
2299
+ revision = raw
2300
+ entry = _verify_entry(_read_state(epic_dir / EPIC_STATE_FILENAME), "forge-verify-epic")
2301
+ return entry, revision
2302
+
2303
+
1504
2304
  def _resolve_feature_dir(specs_dir: Path, feature: str, epic: str | None) -> Path:
1505
2305
  """Best-effort feature dir (flat, else unique nested, else flat literal).
1506
2306
 
@@ -1522,6 +2322,97 @@ def _resolve_feature_dir(specs_dir: Path, feature: str, epic: str | None) -> Pat
1522
2322
  return flat
1523
2323
 
1524
2324
 
2325
+ def _same_named_candidates(specs_dir: Path, epic: str, member: str) -> list[Path]:
2326
+ """Directories OTHER than ``{specsDir}/{epic}/{member}`` carrying that name's state.
2327
+
2328
+ Read-only, and used only to tell "no such feature anywhere" apart from "that
2329
+ name belongs to someone else" when the selected epic does not contain the
2330
+ member. Sorted, so the ambiguity error it feeds is deterministic.
2331
+ """
2332
+ contained = specs_dir / epic / member
2333
+ out: list[Path] = []
2334
+ flat = specs_dir / member
2335
+ if flat != contained and (flat / PIPELINE_STATE_FILENAME).is_file():
2336
+ out.append(flat)
2337
+ if specs_dir.is_dir():
2338
+ out.extend(
2339
+ sorted(
2340
+ p
2341
+ for p in specs_dir.glob(f"*/{member}")
2342
+ if p != contained and (p / PIPELINE_STATE_FILENAME).is_file()
2343
+ )
2344
+ )
2345
+ return out
2346
+
2347
+
2348
+ def _epic_member_state(specs_dir: Path, epic: str, member: str) -> tuple[dict, str | None]:
2349
+ """Resolve ONE epic member's live pipeline state for edit-mode routing.
2350
+
2351
+ Identity containment comes first: the member is read from
2352
+ ``{specsDir}/{epic}/{member}`` and nowhere else, so a same-named flat feature
2353
+ or a member of a different epic can never be substituted for it (REQ-SEC-01).
2354
+ ``_assert_safe_name`` has already rejected traversal by the time this runs.
2355
+
2356
+ Progress is then TOLERATED rather than demanded: an absent, unreadable,
2357
+ malformed, or foreign-epic state yields a reason instead of an exception, so
2358
+ the caller can degrade DOWN to ``forge-1-prd`` with a named warning rather
2359
+ than crash a stage closing or infer progress it could not read (REQ-PROD-06).
2360
+ This is the one documented new tolerant case.
2361
+
2362
+ Identity itself still fails closed: a member that is not under the selected
2363
+ epic at all and whose bare name matches more than one other candidate cannot
2364
+ be pinned to a single feature, and guessing would route a DIFFERENT feature's
2365
+ pipeline (REQ-REL-02).
2366
+
2367
+ Args:
2368
+ specs_dir: The configured specs directory.
2369
+ epic: The selected epic — what ``--feature`` carries on an epic exit.
2370
+ member: The selected member (``--next-feature``), already name-checked.
2371
+
2372
+ Returns:
2373
+ ``(state, None)`` when the member's state resolved, else ``({}, reason)``
2374
+ where ``reason`` is a member of ``EPIC_MEMBER_FALLBACK_REASONS``.
2375
+
2376
+ Raises:
2377
+ UsageError: The member is not under the selected epic and its bare name is
2378
+ ambiguous across the specs tree (→ exit 2, no route guessed).
2379
+ """
2380
+ member_dir = specs_dir / epic / member
2381
+ state_path = member_dir / PIPELINE_STATE_FILENAME
2382
+ # ``exists`` rather than ``is_file``: something occupying the state file's name
2383
+ # that cannot be read as one is `unreadable`, not absent.
2384
+ if not state_path.exists():
2385
+ if member_dir.is_dir():
2386
+ # Contained, just not started yet — the creation-mode case.
2387
+ return {}, "missing"
2388
+ elsewhere = _same_named_candidates(specs_dir, epic, member)
2389
+ if len(elsewhere) > 1:
2390
+ listed = ", ".join(str(p) for p in elsewhere)
2391
+ raise UsageError(
2392
+ f"ambiguous member {member!r} for epic {epic}: it is not under "
2393
+ f"{member_dir} and {len(elsewhere)} other directories carry a state "
2394
+ f"file for that name ({listed}) — refusing to guess which feature to "
2395
+ f"route to. Re-run naming the epic that owns it."
2396
+ )
2397
+ return {}, "not a member of this epic" if elsewhere else "missing"
2398
+ try:
2399
+ raw = state_path.read_text(encoding="utf-8")
2400
+ except OSError:
2401
+ return {}, "unreadable"
2402
+ try:
2403
+ parsed = json.loads(raw)
2404
+ except json.JSONDecodeError:
2405
+ return {}, "malformed"
2406
+ if not isinstance(parsed, dict):
2407
+ return {}, "malformed"
2408
+ back_pointer = parsed.get("epic")
2409
+ if isinstance(back_pointer, str) and back_pointer != epic:
2410
+ # The file sits under this epic but claims another one. Trusting either
2411
+ # side would assert progress for a feature we cannot identify.
2412
+ return {}, "not a member of this epic"
2413
+ return parsed, None
2414
+
2415
+
1525
2416
  def _host_command(command: str, host: str) -> str:
1526
2417
  """Rewrite a `/skill:` slash command to the host's surface.
1527
2418
 
@@ -1534,34 +2425,57 @@ def _host_command(command: str, host: str) -> str:
1534
2425
 
1535
2426
 
1536
2427
  def _next_steps_block(
1537
- next_command: str, host: str, reconcile: dict | None = None
2428
+ primary_command: str,
2429
+ host: str,
2430
+ reconcile: dict | None = None,
2431
+ deferred_command: str | None = None,
2432
+ outcome_text: str | None = None,
1538
2433
  ) -> str:
1539
- """Render the sentinel-terminated NEXT-STEPS block for the given host.
2434
+ """Render one sentinel-terminated terminal block.
2435
+
2436
+ Args:
2437
+ primary_command: The sole fenced action.
2438
+ host: Command and fresh-session wording target.
2439
+ reconcile: Existing epic-backflow override metadata.
2440
+ deferred_command: Optional production action allowed only after the primary
2441
+ verification/recovery action succeeds.
2442
+ outcome_text: Optional deterministic loop/docs/branch outcome explanation.
2443
+
2444
+ Returns:
2445
+ A string whose final line is exactly `NEXT_STEPS_SENTINEL`.
1540
2446
 
1541
2447
  The Claude wording uses the literal ``/clear`` slash-command; the generic
1542
2448
  wording is host-neutral (matching the adapter build's host-term table, so
1543
2449
  a non-Claude bundle invoking ``--host generic`` never instructs a fake
1544
2450
  slash-command).
1545
2451
 
2452
+ ``deferred_command`` is the caller's signal that ``primary_command`` is a
2453
+ verification/recovery action standing in front of a production successor: it
2454
+ is rendered only as unfenced conditional prose, and the fresh-session wording
2455
+ follows the primary action instead of promising "the next stage below"
2456
+ (REQ-EXIT-06). It is NEVER fenced, so it cannot be mistaken for the
2457
+ primary action.
2458
+
1546
2459
  ``reconcile`` carries the epic-backflow routing (§Epic backflow in
1547
2460
  ``references/stage-exit-protocol.md``). When it marks a **blocking** request
1548
- (``required: true``), the fenced primary command becomes the epic reconcile
1549
- command and the normal next stage is demoted to a follow-up line. When it
1550
- marks only **non-blocking** requests (``reminder: true``), the fenced command
1551
- stays the normal next stage and a reminder line is appended. Either way the
1552
- added prose is host-neutral (no literal ``/clear``) so it survives verbatim
1553
- into a generic bundle.
2461
+ (``required: true``) AND the caller made the reconcile command primary, the
2462
+ fence carries it and the normal next stage is demoted to a follow-up line.
2463
+ When verification is still outstanding the caller keeps the verify command
2464
+ primary instead; the reconcile then becomes the FIRST deferred action, ahead
2465
+ of the ordinary production successor. When only **non-blocking**
2466
+ requests are present (``reminder: true``), a reminder line is appended.
2467
+ Either way the added prose is host-neutral (no literal ``/clear``) so it
2468
+ survives verbatim into a generic bundle.
1554
2469
  """
2470
+ verify_first = deferred_command is not None
1555
2471
  if host == "claude":
1556
2472
  clear_line = (
1557
2473
  "1. `/clear` — recommended unconditionally at this stage boundary; "
1558
2474
  "every artifact is on disk, so the work survives the clear. "
1559
2475
  "I can't `/clear` for you — you have to run it yourself."
1560
2476
  )
1561
- next_line = (
1562
- "2. Then start a fresh session and run the next stage below — or "
1563
- "re-run `/skill:forge` to let the navigator resume from disk."
1564
- )
2477
+ navigator = "`/skill:forge`"
2478
+ fresh_prefix = "2. Then start a fresh session and run"
1565
2479
  elif host == "pi":
1566
2480
  # Pi's fresh-session command is `/new` (not `/clear`); its slash-command
1567
2481
  # surface is `/skill:` (the fenced command below is rewritten to match).
@@ -1570,29 +2484,50 @@ def _next_steps_block(
1570
2484
  "artifact is on disk, so the work survives starting a fresh session. "
1571
2485
  "I can't run `/new` for you — you have to run it yourself."
1572
2486
  )
1573
- next_line = (
1574
- "2. Then, in the new session, run the next stage below — or re-run "
1575
- "`/skill:forge` to let the navigator resume from disk."
1576
- )
2487
+ navigator = "`/skill:forge`"
2488
+ fresh_prefix = "2. Then, in the new session, run"
1577
2489
  else:
1578
2490
  clear_line = (
1579
2491
  "1. Clear your session / start a fresh session — recommended "
1580
2492
  "unconditionally at this stage boundary; every artifact is on "
1581
2493
  "disk, so the work survives it."
1582
2494
  )
2495
+ navigator = None
2496
+ fresh_prefix = "2. Then start a fresh session and run"
2497
+ resume = (
2498
+ f"re-run {navigator} to let the navigator resume from disk."
2499
+ if navigator
2500
+ else "re-run the forge navigator skill to resume from disk."
2501
+ )
2502
+ # The primary actionable command goes in a fenced block so mobile/remote hosts
2503
+ # get a native copy button (inline code is not tap-to-copy). The CALLER decides
2504
+ # which command is primary (the renderer fences exactly what it
2505
+ # is given); the fence sits before the sentinel, so the sentinel remains the
2506
+ # absolute last line.
2507
+ fenced_command = _host_command(primary_command, host)
2508
+ if verify_first:
2509
+ # REQ-EXIT-06: the fresh-session guidance follows the PRIMARY action, and
2510
+ # must never tell the user to clear and run the production successor first.
2511
+ # It names what is actually FENCED: on a branch exit that is the fix standing
2512
+ # between recorded findings and the re-verification, not a verify
2513
+ # command. Every other case keeps the wording verbatim.
2514
+ action_noun = "fix" if "forge-fix " in fenced_command else "verification"
1583
2515
  next_line = (
1584
- "2. Then start a fresh session and run the next stage below — or "
1585
- "re-run the forge navigator skill to resume from disk."
2516
+ f"{fresh_prefix} the {action_noun} below — verification is still "
2517
+ "outstanding for this stage, so it comes before the next production "
2518
+ f"stage. Or {resume}"
1586
2519
  )
2520
+ else:
2521
+ next_line = f"{fresh_prefix} the next stage below — or {resume}"
1587
2522
  blocking = bool(reconcile and reconcile.get("required"))
1588
- # The primary actionable command goes in a fenced block so mobile/remote hosts
1589
- # get a native copy button (inline code is not tap-to-copy). For a blocking
1590
- # epic-change request the primary is the reconcile command; otherwise it is the
1591
- # normal next-stage command. The fence sits before the sentinel, so the
1592
- # sentinel remains the absolute last line.
1593
- fenced_command = _host_command(reconcile["command"] if blocking else next_command, host)
1594
- lines = ["**Next steps**", clear_line]
1595
- if blocking:
2523
+ reconcile_is_primary = bool(
2524
+ blocking and _host_command(reconcile["command"], host) == fenced_command
2525
+ )
2526
+ lines = ["**Next steps**"]
2527
+ if outcome_text:
2528
+ lines.append(outcome_text)
2529
+ lines.append(clear_line)
2530
+ if reconcile_is_primary:
1596
2531
  count = reconcile["count"]
1597
2532
  plural = "s" if count != 1 else ""
1598
2533
  lines.append(
@@ -1605,6 +2540,16 @@ def _next_steps_block(
1605
2540
  lines.append(next_line)
1606
2541
  lines.append("")
1607
2542
  lines.append(f"```\n{fenced_command}\n```")
2543
+ if blocking and not reconcile_is_primary:
2544
+ # Verification outranked the reconcile, so the reconcile is the FIRST
2545
+ # deferred action and the production successor stays subordinate to it.
2546
+ count = reconcile["count"]
2547
+ plural = "s" if count != 1 else ""
2548
+ lines.append(
2549
+ f"After verification passes, reconcile the epic first — {count} "
2550
+ f"blocking epic change request{plural} flagged: "
2551
+ f"`{_host_command(reconcile['command'], host)}`"
2552
+ )
1608
2553
  if blocking and reconcile.get("deferred"):
1609
2554
  deferred_cmd = _host_command(reconcile["deferred"], host)
1610
2555
  lines.append(f"After reconciling, continue the pipeline with: `{deferred_cmd}`")
@@ -1615,91 +2560,1181 @@ def _next_steps_block(
1615
2560
  f"You also flagged {count} epic change{plural} to reconcile when "
1616
2561
  f"convenient: `{_host_command(reconcile['command'], host)}`"
1617
2562
  )
2563
+ if verify_first and _host_command(deferred_command, host) != _host_command(
2564
+ (reconcile or {}).get("deferred") or "", host
2565
+ ):
2566
+ # Unfenced, conditional prose only. Suppressed when the
2567
+ # blocking reconcile above already demoted this same command, so one
2568
+ # command never appears twice in the deferred chain.
2569
+ lines.append(
2570
+ "After verification passes, continue with: "
2571
+ f"`{_host_command(deferred_command, host)}`"
2572
+ )
1618
2573
  lines.append(NEXT_STEPS_SENTINEL)
1619
2574
  return "\n".join(lines)
1620
2575
 
1621
2576
 
1622
- def stage_exit(
1623
- feature: str,
1624
- stage: str,
1625
- specs_dir: Path,
1626
- config_path: Path,
1627
- epic: str | None,
1628
- host: str,
1629
- next_feature: str | None,
1630
- ) -> dict:
1631
- """Compute the Scripted Stage Exit payload: DIRECTIVES + NEXT-STEPS block.
2577
+ def resolve_served_stage(
2578
+ served_stage: str | None,
2579
+ verify_mode: str | None,
2580
+ ) -> str:
2581
+ """Resolve one unambiguous production stage for a branch exit.
1632
2582
 
1633
- Directive semantics (the contract in ``references/stage-exit-protocol.md``):
2583
+ Args:
2584
+ served_stage: Explicit production stage supplied by the branch caller.
2585
+ verify_mode: Optional authoritative mode mapped by VERIFY_MODE_TO_STAGE.
1634
2586
 
1635
- - ``runInStageVerify`` — the effective auto-verify (per-stage override,
1636
- else global; strict-true) is on AND this stage's verify is not already
1637
- resolved (fresh/skipped). The skill then dispatches the clean-room
1638
- verify in-session (principle #2: verify before the clear).
1639
- - ``autoFixEligible`` — ``autoFix`` is strict-true AND the in-stage verify
1640
- runs AND the working tree is clean. Findings-level preconditions (zero
1641
- unresolved decisions) remain the skill's runtime check.
1642
- - ``verifyGate`` — ``none`` when verify is resolved or the in-stage run
1643
- covers it; ``standard`` when auto-verify is off and verification is
1644
- outstanding on a host with a question mechanism + clean-room path
1645
- (``--host claude``); ``manual-print`` for the same state on a generic
1646
- host (print ``verifyCommand`` instead of presenting the gate).
1647
- - ``nextStage``/``nextCommand`` — from pipeline state when it already
1648
- records this stage complete (first non-complete production stage), else
1649
- the fixed successor. ``--next-feature`` names the first actionable
1650
- feature for the epic handoff; without it the runtime placeholder
1651
- ``{first-actionable-feature}`` passes through for the skill to resolve.
1652
- - ``epicReconcile`` — present only when the exiting member carries
1653
- ``open`` ``epicChangeRequests`` (epic-backflow). ``required: true`` (any
1654
- ``blocksCurrent: true`` request) interposes a reconcile-first exit: the
1655
- NEXT-STEPS primary command becomes ``/skill:forge-0-epic {epic}``
1656
- and the normal next stage is deferred. Only non-blocking requests set
1657
- ``reminder: true`` and append a non-blocking reminder line. Absent when
1658
- there are no open requests (common path) or the epic name is unresolvable.
2587
+ Returns:
2588
+ A member of the shared ProductionStage domain.
1659
2589
 
1660
- Read-only, deterministic, exit 0 — errors degrade to defaults, never
1661
- crash a stage closing.
2590
+ Raises:
2591
+ UsageError: The explicit stage is invalid, mode is invalid, both inputs
2592
+ disagree, or neither input identifies a stage.
1662
2593
  """
1663
- config = _load_config(config_path)
1664
- feature_dir = _resolve_feature_dir(specs_dir, feature, epic)
1665
- state = _read_state(feature_dir / PIPELINE_STATE_FILENAME)
2594
+ if served_stage is not None and served_stage not in _EXIT_PRODUCTION_STAGES:
2595
+ raise UsageError(
2596
+ f"--served-stage {served_stage!r} is not a production stage; expected "
2597
+ f"one of {', '.join(_EXIT_PRODUCTION_STAGES)}"
2598
+ )
2599
+ if verify_mode is not None and verify_mode not in VERIFY_MODE_TO_STAGE:
2600
+ raise UsageError(
2601
+ f"--verify-mode {verify_mode!r} is not a known verify mode; expected "
2602
+ f"one of {', '.join(VERIFY_MODE_TO_STAGE)}"
2603
+ )
2604
+ if served_stage is not None and verify_mode is not None:
2605
+ mapped = VERIFY_MODE_TO_STAGE[verify_mode]
2606
+ if mapped != served_stage:
2607
+ # Both were supplied and they disagree. Name both flags and both
2608
+ # resolutions — picking one silently is exactly the guess REQ-ROUTE-03
2609
+ # forbids.
2610
+ raise UsageError(
2611
+ f"--served-stage {served_stage} conflicts with --verify-mode "
2612
+ f"{verify_mode} (which maps to {mapped}); supply one, or supply "
2613
+ "values that agree"
2614
+ )
2615
+ return served_stage
2616
+ if served_stage is not None:
2617
+ # Explicit stage takes precedence, and accepts any ProductionStage —
2618
+ # including forge-6-docs, which no verify mode maps to.
2619
+ return served_stage
2620
+ if verify_mode is not None:
2621
+ return VERIFY_MODE_TO_STAGE[verify_mode]
2622
+ raise UsageError(
2623
+ "forge-verify requires --served-stage or an unambiguous --verify-mode; "
2624
+ "rerun with the production stage this verification served"
2625
+ )
1666
2626
 
1667
- git_repo = _git_output(["rev-parse", "--git-dir"]) is not None
1668
- clean_tree: bool | None = None
1669
- if git_repo:
1670
- porcelain = _git_output(["status", "--porcelain"])
2627
+
2628
+ def _branch_route(
2629
+ stage: str,
2630
+ outcome: str,
2631
+ feature: str,
2632
+ served: str,
2633
+ successor_command: str | None,
2634
+ resolved: bool,
2635
+ ) -> tuple[str, str | None, str, bool]:
2636
+ """Route one verify/fix outcome back into the pipeline — the rejoin tables.
2637
+
2638
+ A verify or fix diversion must rejoin the production stage it SERVED rather than
2639
+ dropping the pipeline thread (issue #176), so the served stage is carried forward
2640
+ in every branch command this returns. Commands are canonical, pre-`_host_command`
2641
+ forms; the renderer translates them.
2642
+
2643
+ "Live successor" is the current production position, never a conversational
2644
+ assumption: `successor_command` is already the state-aware next production action
2645
+ after the served artifact, and it is None only at the end of the pipeline — a
2646
+ completed stage 6, which routes to the navigator completion action rather than a
2647
+ nonexistent stage 7.
2648
+
2649
+ Args:
2650
+ stage: `forge-verify` or `forge-fix`.
2651
+ outcome: A member of `EXIT_OUTCOMES[stage]`, already validated.
2652
+ feature: The feature (or epic) the diversion served.
2653
+ served: The resolved served production stage.
2654
+ successor_command: Canonical live-successor command, or None at pipeline end.
2655
+ resolved: Whether the served stage's verification is settled. Consulted only
2656
+ by `no-findings`, the one outcome whose terminus depends on live state.
2657
+
2658
+ Returns:
2659
+ `(primary_canonical, deferred_canonical, outcome_text, advancing)`.
2660
+ `deferred_canonical` is the demoted production successor, rendered only as
2661
+ unfenced prose, and is None whenever the primary command already advances.
2662
+
2663
+ Two rows carry a precondition this router does NOT re-check: `skipped` is valid
2664
+ only after the skip is persisted and `reverified` only after a passing state is
2665
+ recorded. Both are the CALLER's obligation — the branch skills
2666
+ write through `state-verify` before invoking this exit, and a fix
2667
+ that merely skips re-verification reports `deferred`, not `reverified`. Rejecting
2668
+ the outcome here would make a valid member of `EXIT_OUTCOMES[stage]` exit 2, which
2669
+ is a different contract from the one `stage_exit` validates.
2670
+ """
2671
+ kind = _BRANCH_ROUTE_KIND[stage][outcome]
2672
+ if kind == "verify-if-owed":
2673
+ template = (
2674
+ _NO_FINDINGS_RESOLVED_TEXT if resolved else _BRANCH_OUTCOME_TEXT[stage][outcome]
2675
+ )
2676
+ kind = "successor" if resolved else "verify"
2677
+ else:
2678
+ template = _BRANCH_OUTCOME_TEXT[stage][outcome]
2679
+ text = template.format(served=served)
2680
+
2681
+ if kind == "successor":
2682
+ return successor_command or f"/skill:forge {feature}", None, text, True
2683
+
2684
+ branch = "forge-fix" if kind == "fix" else "forge-verify"
2685
+ return (
2686
+ f"/skill:{branch} {feature} --served-stage {served}",
2687
+ successor_command,
2688
+ text,
2689
+ False,
2690
+ )
2691
+
2692
+
2693
+ #: Keys `_render_status` requires before it will route on a `render-status --json`
2694
+ #: payload. `RenderStatus` is TOTAL: every key is always present, so an
2695
+ #: empty `actionable` list is the answer "nothing is actionable" and a MISSING key
2696
+ #: means the helper is not the contract this router was built against — an
2697
+ #: actionable routing failure, never a silently-skipped check.
2698
+ _RENDER_STATUS_REQUIRED: Final[tuple[str, ...]] = (
2699
+ "epic",
2700
+ "features",
2701
+ "actionable",
2702
+ "rollup",
2703
+ "nextCommand",
2704
+ )
2705
+
2706
+ #: The bound on the one subprocess the docs exit path makes. Matches every other
2707
+ #: `subprocess.run` in this file (the git reads and the `forge-root.sh` resolver);
2708
+ #: without it a hung or pathological epic would stall stage closure with no
2709
+ #: diagnostic, defeating REQ-PERF-01.
2710
+ _RENDER_STATUS_TIMEOUT: Final = 10
2711
+
2712
+
2713
+ def _render_status_failure_detail(proc: subprocess.CompletedProcess) -> str:
2714
+ """Name WHY a nonzero ``render-status --json`` failed, in one deterministic line.
2715
+
2716
+ Its first stderr line when it wrote one (a missing/unreadable manifest exits 2
2717
+ that way), else the first validation finding from the JSON on stdout — which is
2718
+ the only place an invalid graph reports itself (it exits 1 with
2719
+ ``{"valid": false, "findings": [...]}`` and a silent stderr).
2720
+
2721
+ Args:
2722
+ proc: The completed ``render-status`` process.
2723
+
2724
+ Returns:
2725
+ A single-line detail, or ``""`` when the helper said nothing usable.
2726
+ """
2727
+ lines = proc.stderr.strip().splitlines()
2728
+ if lines:
2729
+ return lines[0]
2730
+ try:
2731
+ payload = json.loads(proc.stdout)
2732
+ except json.JSONDecodeError:
2733
+ return ""
2734
+ findings = payload.get("findings") if isinstance(payload, dict) else None
2735
+ if isinstance(findings, list) and findings and isinstance(findings[0], dict):
2736
+ message = findings[0].get("message")
2737
+ if isinstance(message, str):
2738
+ return f"first finding: {message}"
2739
+ return ""
2740
+
2741
+
2742
+ def _render_status(specs_dir: Path, epic: str) -> dict:
2743
+ """Read LIVE epic status from the sibling ``epic-manifest.py``.
2744
+
2745
+ The docs exit routes on the epic's real dependency/completion graph rather than
2746
+ re-deriving it here: dependency and completion derivation belong to
2747
+ ``epic-manifest.py``; duplicating them in this file is forbidden.
2748
+
2749
+ ``<bundle-root>`` is NOT a path this router may guess. ``forge-session.py`` is
2750
+ copied verbatim into six adapter bundles and runs from an arbitrary cwd, so the
2751
+ helper is resolved as a SIBLING of this file — the ``RUNTIME_HELPERS`` guarantee
2752
+ that ships them together, matching the existing ``_resolve_plugin_root``
2753
+ convention — and invoked with ``sys.executable`` rather than a bare ``python3``,
2754
+ which may be absent or a different interpreter than the one running this script.
2755
+
2756
+ Args:
2757
+ specs_dir: Configured specs directory, passed through to the helper.
2758
+ epic: The epic name; also the subject of every failure message.
2759
+
2760
+ Returns:
2761
+ The parsed ``RenderStatus`` dict.
2762
+
2763
+ Raises:
2764
+ UsageError: A missing sibling helper, a non-zero exit (which covers an
2765
+ invalid graph — ``render-status`` refuses to render one), a spawn
2766
+ failure, a timeout at the bound, unparseable stdout, or a missing or
2767
+ malformed required field. Every one is an actionable exit-2 routing
2768
+ failure that names the epic and the recovery command, so the caller
2769
+ emits no guessed member route and no sentinel (REQ-REL-02).
2770
+
2771
+ Reads only the bounded local manifest/member-state set — no network call and no
2772
+ repository-history scan (REQ-PERF-01).
2773
+ """
2774
+ helper = Path(__file__).resolve().parent / "epic-manifest.py"
2775
+
2776
+ def fail(reason: str) -> NoReturn:
2777
+ raise UsageError(
2778
+ f"cannot route the documentation exit for epic {epic!r}: {reason}. "
2779
+ f"Run /skill:forge-0-epic {epic} to inspect the epic and "
2780
+ "resolve it, then re-run this exit."
2781
+ )
2782
+
2783
+ if not helper.is_file():
2784
+ fail(f"the sibling epic-manifest.py is missing at {helper}")
2785
+ try:
2786
+ proc = subprocess.run(
2787
+ [
2788
+ sys.executable,
2789
+ str(helper),
2790
+ "render-status",
2791
+ epic,
2792
+ "--specs-dir",
2793
+ str(specs_dir),
2794
+ "--json",
2795
+ ],
2796
+ capture_output=True,
2797
+ text=True,
2798
+ timeout=_RENDER_STATUS_TIMEOUT,
2799
+ check=False,
2800
+ )
2801
+ except subprocess.TimeoutExpired:
2802
+ fail(f"render-status did not finish within {_RENDER_STATUS_TIMEOUT} seconds")
2803
+ except OSError as exc:
2804
+ fail(f"render-status could not be started ({exc})")
2805
+ if proc.returncode != 0:
2806
+ # An INVALID GRAPH exits 1 with its findings as JSON on stdout and nothing on
2807
+ # stderr, so quoting stderr alone would report a bare exit code for the one
2808
+ # failure the operator most needs named (REQ-OBS-02).
2809
+ detail = _render_status_failure_detail(proc)
2810
+ fail(f"render-status exited {proc.returncode}{f' ({detail})' if detail else ''}")
2811
+ try:
2812
+ status = json.loads(proc.stdout)
2813
+ except json.JSONDecodeError as exc:
2814
+ fail(f"render-status did not emit parseable JSON ({exc})")
2815
+ if not isinstance(status, dict):
2816
+ fail("render-status emitted a non-object JSON payload")
2817
+ missing = [key for key in _RENDER_STATUS_REQUIRED if key not in status]
2818
+ if missing:
2819
+ fail(f"render-status omitted required field(s): {', '.join(missing)}")
2820
+ rollup = status["rollup"]
2821
+ if not isinstance(rollup, dict) or any(
2822
+ not isinstance(rollup.get(key), int) or isinstance(rollup.get(key), bool)
2823
+ for key in ("complete", "total")
2824
+ ):
2825
+ fail("render-status emitted a malformed rollup")
2826
+ if not isinstance(status["actionable"], list):
2827
+ fail("render-status emitted a malformed actionable list")
2828
+ if status["nextCommand"] is not None and not isinstance(status["nextCommand"], str):
2829
+ fail("render-status emitted a malformed nextCommand")
2830
+ return status
2831
+
2832
+
2833
+ #: The deterministic sentence each documentation terminus renders inside its
2834
+ #: NEXT-STEPS block. Every epic route names the epic; no `blocked` route claims the
2835
+ #: pipeline is complete. `{new_feature}`/`{new_epic}` are host-translated INLINE
2836
+ #: mentions: starting a new feature is allowed only as secondary unfenced text, and
2837
+ #: `_next_steps_block` fences exactly the primary command and nothing else.
2838
+ _DOCS_OUTCOME_TEXT: Final[dict[str, str]] = {
2839
+ "standalone-complete": (
2840
+ "Documentation is complete for {feature}, and with it the pipeline. The "
2841
+ "navigator command below is the authoritative completion action — it "
2842
+ "confirms the finished state from disk. Optionally, you can start a new "
2843
+ "feature with `{new_feature}` or group related work into an epic with "
2844
+ "`{new_epic}`; neither is required to finish here."
2845
+ ),
2846
+ "standalone-blocked": (
2847
+ "Documentation could not be completed for {feature}, so the pipeline is NOT "
2848
+ "complete. Only valid partial state was persisted. Run the navigator below "
2849
+ "to see what remains and recover from there."
2850
+ ),
2851
+ "epic-actionable": (
2852
+ "Documentation is complete for {feature}. Epic {epic} has more work that can "
2853
+ "be started now ({complete}/{total} members complete), so the pipeline "
2854
+ "continues with the next actionable member below."
2855
+ ),
2856
+ "epic-blocked-members": (
2857
+ "Documentation is complete for {feature}, but no member of epic {epic} is "
2858
+ "actionable right now ({complete}/{total} members complete) — the remaining "
2859
+ "work is blocked by unmet dependencies. Open the epic dashboard below to see "
2860
+ "what is holding it up."
2861
+ ),
2862
+ "epic-complete": (
2863
+ "Documentation is complete for {feature}, and every member of epic {epic} is "
2864
+ "now complete ({complete}/{total}). Open the epic dashboard below for its "
2865
+ "completion view."
2866
+ ),
2867
+ "epic-blocked": (
2868
+ "Documentation could not be completed for {feature}, so neither this feature "
2869
+ "nor epic {epic} is complete. Only valid partial state was persisted. Open "
2870
+ "the epic dashboard below to see the epic's live state and recover from there."
2871
+ ),
2872
+ }
2873
+
2874
+
2875
+ def _docs_route(
2876
+ feature: str, epic: str | None, specs_dir: Path, outcome: str, host: str
2877
+ ) -> tuple[str, str | None, str, bool]:
2878
+ """Route the documentation exit — the live-state table.
2879
+
2880
+ For an epic member the route comes from the live ``render-status`` payload, so a
2881
+ Step-1 snapshot taken before docs state changed is never trusted: an actionable
2882
+ next member routes to that member's own live command, and anything else (blocked
2883
+ remaining work, or every member complete) routes to the epic dashboard, which is
2884
+ also the dashboard's completion view. A ``blocked`` docs outcome routes to
2885
+ recovery and NEVER claims pipeline completion.
2886
+
2887
+ Args:
2888
+ feature: The feature whose documentation stage is closing.
2889
+ epic: The owning epic, or None for a standalone feature.
2890
+ specs_dir: Configured specs directory.
2891
+ outcome: `complete` or `blocked`, already validated.
2892
+ host: Host surface, used only to translate the INLINE secondary mentions —
2893
+ the primary command is translated by the renderer.
2894
+
2895
+ Returns:
2896
+ `(primary_canonical, deferred_canonical, outcome_text, advancing)`, matching
2897
+ `_branch_route`. `deferred_canonical` is always None: a docs terminus has no
2898
+ production successor to demote, because the pipeline ends here.
2899
+
2900
+ A ``blocked`` epic exit deliberately does NOT call ``render-status``: its route is
2901
+ fixed at the epic dashboard regardless of what the live graph says, and a broken
2902
+ epic graph is precisely the state in which the recovery route must stay reachable
2903
+ rather than converting into a second failure.
2904
+ """
2905
+ if epic is None:
2906
+ text = _DOCS_OUTCOME_TEXT[
2907
+ "standalone-complete" if outcome == "complete" else "standalone-blocked"
2908
+ ].format(
2909
+ feature=feature,
2910
+ new_feature=_host_command("/skill:forge-1-prd <new-feature>", host),
2911
+ new_epic=_host_command("/skill:forge-0-epic <new-epic>", host),
2912
+ )
2913
+ return f"/skill:forge {feature}", None, text, False
2914
+
2915
+ dashboard = f"/skill:forge-0-epic {epic}"
2916
+ if outcome == "blocked":
2917
+ text = _DOCS_OUTCOME_TEXT["epic-blocked"].format(feature=feature, epic=epic)
2918
+ return dashboard, None, text, False
2919
+
2920
+ status = _render_status(specs_dir, epic)
2921
+ rollup = status["rollup"]
2922
+ fields = {
2923
+ "feature": feature,
2924
+ "epic": epic,
2925
+ "complete": rollup["complete"],
2926
+ "total": rollup["total"],
2927
+ }
2928
+ next_command = status["nextCommand"]
2929
+ if status["actionable"] and next_command:
2930
+ return next_command, None, _DOCS_OUTCOME_TEXT["epic-actionable"].format(**fields), True
2931
+ # Nothing actionable. Under the current derivation that coincides with "every
2932
+ # member complete" (a valid graph is acyclic, so an incomplete member always has
2933
+ # an actionable ancestor), but the two cases are named separately and the
2934
+ # rollup is the observable that tells them apart — so the blocked wording stays
2935
+ # reachable if a future derivation admits an unactionable incomplete member. Both
2936
+ # route to the same epic command either way; only the explanation differs.
2937
+ key = "epic-complete" if rollup["complete"] >= rollup["total"] else "epic-blocked-members"
2938
+ return dashboard, None, _DOCS_OUTCOME_TEXT[key].format(**fields), False
2939
+
2940
+
2941
+ #: The route each loop outcome takes. A COMPLETE map over
2942
+ #: ``EXIT_OUTCOMES["forge-5-loop"]``: REQ-PROD-01/02 require a deterministic resume or
2943
+ #: recovery action for every result, so a missing key is a bug, not a default.
2944
+ #:
2945
+ #: ``handoff`` verify-first implementation routing, then the live docs/epic handoff
2946
+ #: ``resume`` ``/skill:forge-5-loop FEATURE`` — state remains resumable
2947
+ #: ``recover`` ``/skill:forge FEATURE`` — the deterministic diagnostic action
2948
+ #:
2949
+ #: Only ``handoff`` (i.e. ``complete``) may reach a production stage. A runner's
2950
+ #: successful process exit is NOT by itself ``complete``: the final backlog state
2951
+ #: selects the outcome, and that selection is the skill's job.
2952
+ _LOOP_ROUTE_KIND: Final[dict[str, str]] = {
2953
+ "complete": "handoff",
2954
+ "partial": "resume",
2955
+ "deferred": "resume",
2956
+ "blocked": "recover",
2957
+ "needs-human": "recover",
2958
+ }
2959
+
2960
+ #: The deterministic sentence each NON-complete loop outcome renders inside its
2961
+ #: NEXT-STEPS block. Every one names the resume or recovery action and states that
2962
+ #: nothing downstream is ready — no wording here may imply that documentation, or any
2963
+ #: other downstream production stage, can start (REQ-PROD-02).
2964
+ _LOOP_OUTCOME_TEXT: Final[dict[str, str]] = {
2965
+ "partial": (
2966
+ "The loop stopped for {feature} with backlog items still pending — the "
2967
+ "iteration limit was reached before every item was done. The recorded state "
2968
+ "is resumable and nothing downstream is ready: run the loop again below to "
2969
+ "continue from where it stopped."
2970
+ ),
2971
+ "deferred": (
2972
+ "The loop explicitly deferred items for {feature} — the runner gave up on "
2973
+ "them after retries rather than finishing them, so they were left for "
2974
+ "another pass. The recorded state is resumable and nothing downstream is "
2975
+ "ready: run the loop again below to pick the deferred items back up."
2976
+ ),
2977
+ "blocked": (
2978
+ "The loop is blocked for {feature} — one or more backlog items could not be "
2979
+ "completed. Nothing downstream is ready. Run the navigator below to see the "
2980
+ "live pipeline state from disk and choose how to recover."
2981
+ ),
2982
+ "needs-human": (
2983
+ "The loop stopped for {feature} on a decision only a human can make — one or "
2984
+ "more items asked a question it could not answer, and they were set aside. "
2985
+ "Nothing downstream is ready until those decisions are made. Run the "
2986
+ "navigator below to see the live pipeline state from disk and recover from "
2987
+ "there."
2988
+ ),
2989
+ }
2990
+
2991
+ #: The `complete` preamble, selected by where the handoff actually lands. The epic
2992
+ #: rows name the epic and its live rollup, so the operator can see WHY the handoff is
2993
+ #: this member's own documentation rather than another member (or vice versa).
2994
+ _LOOP_COMPLETE_TEXT: Final[dict[str, str]] = {
2995
+ "standalone": "Every backlog item is done for {feature}.",
2996
+ "epic-next-member": (
2997
+ "Every backlog item is done for {feature}, and the live status of epic "
2998
+ "{epic} ({complete}/{total} members complete) puts the next actionable work "
2999
+ "below."
3000
+ ),
3001
+ "epic-complete-docs": (
3002
+ "Every backlog item is done for {feature}, and every member of epic {epic} "
3003
+ "is now complete ({complete}/{total}) — documentation is the next step below."
3004
+ ),
3005
+ "epic-dashboard": (
3006
+ "Every backlog item is done for {feature}, and no member of epic {epic} is "
3007
+ "actionable right now ({complete}/{total} members complete). Open the epic "
3008
+ "dashboard below for its live state."
3009
+ ),
3010
+ }
3011
+
3012
+ #: Appended to the `complete` preamble. REQ-EXIT-06/REQ-PROD-02: while implementation
3013
+ #: verification is unresolved it is THE action and the handoff is demoted to unfenced
3014
+ #: prose, so documentation never becomes primary before a pass or an explicit skip.
3015
+ _LOOP_COMPLETE_OUTSTANDING: Final[str] = (
3016
+ " Implementation verification is still outstanding, so it comes first — nothing "
3017
+ "downstream becomes the primary action until it passes or is explicitly skipped."
3018
+ )
3019
+ _LOOP_COMPLETE_SETTLED: Final[str] = (
3020
+ " Its implementation verification is settled, so the pipeline continues with the "
3021
+ "action below."
3022
+ )
3023
+ _LOOP_COMPLETE_FINDINGS: Final[str] = (
3024
+ " Implementation verification already ran at this revision and reported findings, "
3025
+ "so applying them comes first — nothing downstream becomes the primary action "
3026
+ "until a re-verify passes or the verification is explicitly skipped."
3027
+ )
3028
+
3029
+ #: The outcome sentence a loop or documentation exit renders when a blocking
3030
+ #: epic change request DISPLACES its live continuation. Both route tables above name
3031
+ #: that continuation "below"; once the fence carries the reconcile instead, the claim is
3032
+ #: false, so the sentence is REPLACED rather than corrected after the fact. The displaced
3033
+ #: command is deliberately not named here — the block's own "After reconciling, continue
3034
+ #: the pipeline with" line is its single authoritative mention, so the two can never
3035
+ #: disagree. Only used when the displaced command actually differs from the reconcile:
3036
+ #: a route that already lands on the epic keeps its own accurate wording.
3037
+ _RECONCILE_FIRST_TEXT: Final[dict[str, str]] = {
3038
+ "forge-5-loop": (
3039
+ "Every backlog item is done for {feature} and its implementation verification "
3040
+ "is settled, but {count} blocking epic change request{plural} recorded against "
3041
+ "epic {epic} must be reconciled first. Proceeding would build on a "
3042
+ "decomposition that is about to change, so the reconcile below comes before "
3043
+ "the continuation named under it."
3044
+ ),
3045
+ "forge-6-docs": (
3046
+ "Documentation is complete for {feature}, but {count} blocking epic change "
3047
+ "request{plural} recorded against epic {epic} must be reconciled first. "
3048
+ "Handing off would build the next member on a decomposition that is about to "
3049
+ "change, so the reconcile below comes before the continuation named under it."
3050
+ ),
3051
+ }
3052
+
3053
+
3054
+ def _promote_reconcile(
3055
+ stage: str,
3056
+ epic_reconcile: dict,
3057
+ feature: str,
3058
+ epic_name: object,
3059
+ primary_canonical: str,
3060
+ deferred_canonical: str | None,
3061
+ outcome_text: str | None,
3062
+ advancing: bool,
3063
+ ) -> tuple[str, str | None, str | None]:
3064
+ """Reconcile-first promotion for the loop and documentation routes.
3065
+
3066
+ Both routes compute their real primary from LIVE state (``render-status``), long
3067
+ after ``epicReconcile["deferred"]`` was seeded from the successor table. That seed
3068
+ is the wrong continuation for these two stages — for the loop it names this
3069
+ feature's own documentation, which the route deliberately did not choose, and for
3070
+ documentation it is None because the pipeline has no stage after it. So the
3071
+ continuation is re-derived here from the route's own result, never from the
3072
+ successor table (REQ-ROUTE-05/06: the live thread is what must survive).
3073
+
3074
+ Args:
3075
+ stage: `forge-5-loop` or `forge-6-docs` — selects the replacement wording.
3076
+ epic_reconcile: The blocking reconcile directive, MUTATED in place.
3077
+ feature: The exiting feature.
3078
+ epic_name: The epic the reconcile is recorded against.
3079
+ primary_canonical: The route's own primary command.
3080
+ deferred_canonical: The route's own deferred continuation, if any.
3081
+ outcome_text: The route's own outcome sentence.
3082
+ advancing: Whether the route's primary advances the pipeline.
3083
+
3084
+ Returns:
3085
+ `(primary_canonical, deferred_canonical, outcome_text)` after promotion.
3086
+
3087
+ A route whose primary IS the epic command (the dashboard handoffs) is not
3088
+ displaced by a reconcile that names the same command: promoting it would leave a
3089
+ "continue the pipeline with" line pointing back at the fence, so its own accurate
3090
+ wording and an absent continuation are kept instead.
3091
+ """
3092
+ reconcile_command = epic_reconcile["command"]
3093
+ if not advancing:
3094
+ # Verification (or a recovery action) outranks the reconcile, so the reconcile
3095
+ # is the FIRST deferred action and the route's own continuation follows it.
3096
+ # Handing the renderer the same command the caller deferred is what collapses
3097
+ # the two conditional lines into one.
3098
+ epic_reconcile["deferred"] = deferred_canonical
3099
+ return primary_canonical, deferred_canonical, outcome_text
3100
+ if primary_canonical == reconcile_command:
3101
+ epic_reconcile["deferred"] = None
3102
+ return primary_canonical, None, outcome_text
3103
+ epic_reconcile["deferred"] = primary_canonical
3104
+ count = epic_reconcile["count"]
3105
+ return (
3106
+ reconcile_command,
3107
+ None,
3108
+ _RECONCILE_FIRST_TEXT[stage].format(
3109
+ feature=feature,
3110
+ epic=epic_name,
3111
+ count=count,
3112
+ plural="s" if count != 1 else "",
3113
+ ),
3114
+ )
3115
+
3116
+
3117
+ def _loop_route(
3118
+ outcome: str,
3119
+ feature: str,
3120
+ epic: str | None,
3121
+ specs_dir: Path,
3122
+ successor_command: str | None,
3123
+ resolved: bool,
3124
+ verify_canonical: str,
3125
+ fix_canonical: str | None,
3126
+ ) -> tuple[str, str | None, str, bool]:
3127
+ """Route one loop result — the outcome table.
3128
+
3129
+ Every outcome lands on a deterministic action. Only ``complete`` may reach a
3130
+ production stage, and even then documentation is not primary until implementation
3131
+ verification passes or is explicitly skipped (REQ-PROD-02, REQ-EXIT-06). The four
3132
+ non-complete outcomes route to the loop resume (``partial``/``deferred``) or to
3133
+ the navigator (``blocked``/``needs-human``); their caller has already stripped the
3134
+ production successor, so no directive and no rendered line can imply that
3135
+ documentation is ready.
3136
+
3137
+ Args:
3138
+ outcome: A member of `EXIT_OUTCOMES["forge-5-loop"]`, already validated.
3139
+ feature: The feature whose loop stage is closing.
3140
+ epic: The owning epic, or None for a standalone feature.
3141
+ specs_dir: Configured specs directory.
3142
+ successor_command: Canonical live-successor command (documentation), or None.
3143
+ resolved: Whether the implementation verification is settled.
3144
+ verify_canonical: Canonical implementation-verify command.
3145
+ fix_canonical: Canonical forge-fix command when a findings report is live
3146
+ at the current revision (see ``live_findings_report`` in ``stage_exit``),
3147
+ else None. A live report outranks a fresh verify on the ``complete``
3148
+ handoff: findings already exist at this exact revision, so the fenced
3149
+ action is applying them, exactly as on a production re-exit.
3150
+
3151
+ Returns:
3152
+ `(primary_canonical, deferred_canonical, outcome_text, advancing)`, matching
3153
+ `_branch_route` and `_docs_route`.
3154
+
3155
+ For a completed EPIC MEMBER the handoff is delegated to the live
3156
+ ``render-status`` payload rather than re-deriving dependency or completion logic
3157
+ here, preserving the epic handoff this stage already performed:
3158
+ an actionable member routes to the epic's own live next command; nothing
3159
+ actionable with every member complete routes to this member's documentation; and
3160
+ anything else opens the epic dashboard. The ``total > 0`` guard is what stops an
3161
+ EMPTY epic's ``0/0`` from reading as complete. A helper failure is the same
3162
+ actionable ``UsageError`` the documentation exit raises, so a broken epic graph
3163
+ surfaces instead of being guessed around. A NON-complete outcome never calls the
3164
+ helper: a resume or recovery action must stay reachable exactly when the epic's
3165
+ own state is the thing that is broken.
3166
+ """
3167
+ kind = _LOOP_ROUTE_KIND[outcome]
3168
+ if kind != "handoff":
3169
+ primary = (
3170
+ f"/skill:forge-5-loop {feature}"
3171
+ if kind == "resume"
3172
+ else f"/skill:forge {feature}"
3173
+ )
3174
+ return primary, None, _LOOP_OUTCOME_TEXT[outcome].format(feature=feature), False
3175
+
3176
+ handoff = successor_command or f"/skill:forge {feature}"
3177
+ fields: dict[str, object] = {"feature": feature, "epic": epic}
3178
+ key = "standalone"
3179
+ if epic is not None:
3180
+ status = _render_status(specs_dir, epic)
3181
+ rollup = status["rollup"]
3182
+ fields["complete"] = rollup["complete"]
3183
+ fields["total"] = rollup["total"]
3184
+ next_command = status["nextCommand"]
3185
+ if status["actionable"] and next_command:
3186
+ handoff, key = next_command, "epic-next-member"
3187
+ elif rollup["total"] > 0 and rollup["complete"] >= rollup["total"]:
3188
+ # Nothing left to start and every member complete: the epic's remaining
3189
+ # work is this member's documentation, which `handoff` already names.
3190
+ key = "epic-complete-docs"
3191
+ else:
3192
+ handoff, key = f"/skill:forge-0-epic {epic}", "epic-dashboard"
3193
+
3194
+ if resolved:
3195
+ tail = _LOOP_COMPLETE_SETTLED
3196
+ elif fix_canonical is not None:
3197
+ tail = _LOOP_COMPLETE_FINDINGS
3198
+ else:
3199
+ tail = _LOOP_COMPLETE_OUTSTANDING
3200
+ text = _LOOP_COMPLETE_TEXT[key].format(**fields) + tail
3201
+ if resolved:
3202
+ return handoff, None, text, True
3203
+ if fix_canonical is not None:
3204
+ # A live findings report outranks a fresh verify, exactly as on a
3205
+ # production re-exit: the fenced action is the fix, the handoff is demoted.
3206
+ return fix_canonical, handoff, text, False
3207
+ # Verify-first ordering, applied to the loop's own handoff rather than to
3208
+ # the fixed successor: the verification is fenced and the handoff is demoted.
3209
+ return verify_canonical, handoff, text, False
3210
+
3211
+
3212
+ def _debt_metadata_warnings(
3213
+ entry: dict,
3214
+ verify_key: str | None,
3215
+ stage: str,
3216
+ subject: str,
3217
+ verify_command: str,
3218
+ current: int | None,
3219
+ ) -> list[str]:
3220
+ """Entries 2 and 3 of the ``warnings`` order, for owed automatic verification.
3221
+
3222
+ Entry 2 is the legacy/malformed ``scheduledStageVersion`` advisory;
3223
+ entry 3 is the scheduled-vs-current revision mismatch note. They are
3224
+ mutually exclusive by construction — a mismatch is only detectable once the
3225
+ recorded revision is usable — but the order is fixed regardless so a later
3226
+ entry can be added without re-deriving it.
3227
+
3228
+ Takes the already-resolved entry and revision rather than re-deriving them
3229
+ from a member state document: on an epic-scoped exit both come from
3230
+ ``.epic-state.json`` and the manifest revision, which a member state cannot
3231
+ supply (REQ-SEC-01).
3232
+
3233
+ Args:
3234
+ entry: The verify entry the exit routed from (``{}`` when absent).
3235
+ verify_key: Its ``forge-verify-*`` key, or None for a tokenless stage.
3236
+ stage: The production stage the debt is owed on.
3237
+ subject: The feature or epic to name.
3238
+ verify_command: The host-translated retry command.
3239
+ current: The artifact's current revision, or None when unknown.
3240
+ """
3241
+ if verify_key is None or entry.get("status") != "auto-verify-pending":
3242
+ return []
3243
+ scheduled = _scheduled_stage_version(entry)
3244
+ if scheduled is None:
3245
+ return [
3246
+ AUTO_VERIFY_DEBT_METADATA_DIAGNOSTIC.format(
3247
+ subject=subject, verify_key=verify_key, command=verify_command
3248
+ )
3249
+ ]
3250
+ if current is not None and scheduled != current:
3251
+ return [
3252
+ auto_pending_message(subject, stage, verify_command, scheduled, current)
3253
+ ]
3254
+ return []
3255
+
3256
+
3257
+ def _schedule_auto_verify_debt(
3258
+ specs_dir: Path, feature: str, epic: str | None, stage: str, verify_key: str
3259
+ ) -> None:
3260
+ """Persist `auto-verify-pending` for `stage` — the scheduling boundary.
3261
+
3262
+ Called immediately BEFORE `stage_exit` returns a payload carrying
3263
+ ``runInStageVerify: true``, never after, so there is no window in which the
3264
+ model is told to verify while nothing on disk records that it was owed
3265
+ (REQ-DEBT-01, REQ-REL-03). The transition itself is `cmd_state_verify`'s —
3266
+ `_load_verify_target` selects the target and `_verify_result_entry` builds the
3267
+ entry — so a scheduled marker is byte-identical to one written through the CLI.
3268
+
3269
+ Idempotent by target revision (REQ-REL-01): an entry already
3270
+ `auto-verify-pending` at the current revision returns without calling
3271
+ `_commit_state`, so `scheduledAt`, top-level `updatedAt`, and the file bytes
3272
+ are all untouched. A newer revision supersedes the older marker with exactly
3273
+ one write. The caller's `resolved` and live-report checks are what keep a
3274
+ fresh terminal entry, an explicit `skipped`, or a `findings-reported` entry
3275
+ at the current revision from ever reaching this function — the last because
3276
+ a write here REPLACES the entry and would delete its report metadata
3277
+ (REQ-EXIT-04).
3278
+
3279
+ Unlike the `state-verify` CLI, a target whose artifact revision is unknown
3280
+ (no recorded `version`, or an epic with no readable manifest) records the debt
3281
+ with a null `scheduledStageVersion` rather than refusing: the obligation is
3282
+ real either way, and an unusable schedule is already classified as
3283
+ `auto-pending` plus a warning. Forgetting the debt because its revision is
3284
+ unknown is the REQ-DEBT-02 conflation, and refusing would turn a routine stage
3285
+ closing into an exit 2.
3286
+
3287
+ Args:
3288
+ specs_dir: The configured specs directory.
3289
+ feature: The feature name, or the EPIC name for an epic-scoped exit.
3290
+ epic: The owning epic for a member, else None.
3291
+ stage: The production stage the debt is owed on (`forge-0-epic` for an
3292
+ epic-scoped exit).
3293
+ verify_key: The `forge-verify-*` key to write.
3294
+
3295
+ Raises:
3296
+ UsageError: Unsafe/ambiguous/unresolvable target, corrupt state, or an
3297
+ atomic-write failure (→ exit 2, no payload and no dispatch directive).
3298
+ """
3299
+ is_epic_target = stage == "forge-0-epic"
3300
+ state_path, state, epic_revision = _load_verify_target(
3301
+ specs_dir, feature, epic, is_epic_target
3302
+ )
3303
+ if is_epic_target:
3304
+ current = epic_revision
3305
+ else:
3306
+ version = _stage_version(state, stage)
3307
+ current = (
3308
+ version
3309
+ if isinstance(version, int) and not isinstance(version, bool) and version >= 1
3310
+ else None
3311
+ )
3312
+ prior = _verify_entry(state, verify_key)
3313
+ if (
3314
+ prior.get("status") == "auto-verify-pending"
3315
+ and _scheduled_stage_version(prior) == current
3316
+ ):
3317
+ return
3318
+ state.setdefault("stages", {})[verify_key] = _verify_result_entry(
3319
+ "auto-verify-pending", prior, current, None, None, _now_iso()
3320
+ )
3321
+ _commit_state(state_path, state)
3322
+
3323
+
3324
+ def stage_exit(
3325
+ feature: str,
3326
+ stage: str,
3327
+ specs_dir: Path,
3328
+ config_path: Path,
3329
+ epic: str | None,
3330
+ host: str,
3331
+ next_feature: str | None,
3332
+ served_stage: str | None = None,
3333
+ verify_mode: str | None = None,
3334
+ outcome: str | None = None,
3335
+ owner: str | None = None,
3336
+ verify_capability: str = "manual",
3337
+ ) -> StageExitPayload:
3338
+ """Compute a deterministic stage-exit payload.
3339
+
3340
+ Args:
3341
+ feature: Safe feature name, or epic name for an epic-scoped exit.
3342
+ stage: One member of `EXIT_STAGES`.
3343
+ specs_dir: Configured specs directory.
3344
+ config_path: Path to `forge.config.json`.
3345
+ epic: Owning epic for a nested member, otherwise None.
3346
+ host: Command-rendering host: `claude`, `pi`, or `generic`.
3347
+ next_feature: Explicit epic handoff member, when applicable.
3348
+ served_stage: Production stage served by direct verify/fix.
3349
+ verify_mode: Verify mode used to infer `served_stage` when unique.
3350
+ outcome: Required stage-specific outcome for loop/docs/verify/fix.
3351
+ owner: Required for verify/fix: `direct` or `nested`.
3352
+ verify_capability: `interactive` only when both question and clean-room
3353
+ verifier dispatch capabilities exist; otherwise `manual`. Dispatch
3354
+ capability is permission, not tool presence: a dispatch permitted
3355
+ only once the user has asked is still `interactive`, because the
3356
+ `standard` gate's own prompt supplies that request.
3357
+
3358
+ Returns:
3359
+ A JSON-serializable `StageExitPayload` dictionary.
3360
+
3361
+ Raises:
3362
+ UsageError: Unsafe or ambiguous identity, unsupported stage/outcome,
3363
+ missing ownership/served-stage metadata, or conflicting inference.
3364
+
3365
+ Directive semantics (the contract in ``references/stage-exit-protocol.md``):
3366
+
3367
+ - ``runInStageVerify`` — the effective auto-verify (per-stage override,
3368
+ else global; strict-true) is on AND this stage's verify is not already
3369
+ resolved (fresh/skipped) AND no findings report exists at the current
3370
+ revision (that state routes to forge-fix instead — scheduling over the
3371
+ report would delete its metadata, REQ-EXIT-04). The skill then dispatches
3372
+ the clean-room verify in-session (principle #2: verify before the clear).
3373
+ - ``autoVerifyDebtRecorded`` — the ``auto-verify-pending`` marker for this
3374
+ stage is durably on disk. Written BEFORE this payload exists, so
3375
+ a failed write raises ``UsageError`` and returns no payload at all and
3376
+ ``runInStageVerify: True`` with ``autoVerifyDebtRecorded: False`` is
3377
+ unreachable. Scheduling is idempotent by target revision: a repeat at the
3378
+ same revision touches neither ``scheduledAt`` nor top-level ``updatedAt``.
3379
+ - ``autoFixEligible`` — ``autoFix`` is strict-true AND the in-stage verify
3380
+ runs AND the working tree is clean. Findings-level preconditions (zero
3381
+ unresolved decisions) remain the skill's runtime check. Its clean-tree
3382
+ snapshot is taken BEFORE the debt write, so that sanctioned control-plane
3383
+ mutation cannot dirty its own precondition.
3384
+ - ``verifyState``/``warnings``/``cleanTree`` — all PRE-mutation snapshots:
3385
+ they describe the state the routing decision was made from, which is why a
3386
+ first exit reports ``never`` while the debt it just recorded reads
3387
+ ``auto-pending`` on the next one. Only ``autoVerifyDebtRecorded`` reports
3388
+ the write.
3389
+ - ``verifyGate`` — ``none`` when verify is resolved (including a tokenless
3390
+ stage), the in-stage run covers it, or a live findings report routes to
3391
+ forge-fix (the fenced fix IS the one action — a "verify now?" prompt
3392
+ beside it would be a second, contradictory ask); ``standard`` when
3393
+ auto-verify is off, verification is outstanding, and the CALLER declared
3394
+ ``--verify-capability interactive``; ``manual-print`` for the same state
3395
+ under ``manual`` (print ``verifyCommand`` instead of presenting the gate).
3396
+ Never a function of ``--host``: capable Pi is ``standard`` and incapable
3397
+ Claude is ``manual-print`` (REQ-EXIT-07).
3398
+ - ``primaryCommand``/``deferredCommand`` — the verify-first pair. While
3399
+ verification is unresolved ``primaryCommand`` is the verify command — or
3400
+ the forge-fix command when a findings report is live at the current
3401
+ revision — and is the ONLY fenced command; ``deferredCommand`` names the
3402
+ production successor as unfenced conditional prose. ``nextCommand`` stays
3403
+ compatibility/routing metadata and never overrides ``primaryCommand``
3404
+ (REQ-EXIT-06).
3405
+ - ``nextStage``/``nextCommand`` — from pipeline state when it already
3406
+ records this stage complete (first non-complete production stage), else
3407
+ the fixed successor. ``--next-feature`` names the first actionable
3408
+ feature for the epic handoff; without it an epic exit hands back to the
3409
+ epic dashboard rather than naming a member it cannot resolve.
3410
+ With it, the handoff is derived from THAT member's live state via
3411
+ ``next_stage``: a progressed member resumes where it actually is,
3412
+ a fully complete member hands back to the epic dashboard, and a member
3413
+ whose state cannot be resolved falls back to ``forge-1-prd`` with
3414
+ ``warnings`` entry 1 naming it.
3415
+ - ``epicReconcile`` — present only when the exiting member carries
3416
+ ``open`` ``epicChangeRequests`` (epic-backflow). ``required: true`` (any
3417
+ ``blocksCurrent: true`` request) interposes a reconcile-first exit: the
3418
+ NEXT-STEPS primary command becomes ``/skill:forge-0-epic {epic}``
3419
+ and the normal next stage is deferred. Only non-blocking requests set
3420
+ ``reminder: true`` and append a non-blocking reminder line. Absent when
3421
+ there are no open requests (common path) or the epic name is unresolvable.
3422
+ - ``servedStage``/``verifyMode``/``outcome``/``owner``/``terminalOwnedBy`` —
3423
+ branch metadata. A production exit serves only itself, so ``servedStage``
3424
+ is None there; ``verifyStage`` is the DISTINCT value ``pending_verify``
3425
+ returns, naming the stage outstanding verification is owed on.
3426
+ On a branch exit the outcome table in ``_branch_route`` — not
3427
+ verify-first ordering — supplies ``primaryCommand``: a diversion rejoins
3428
+ the production stage it served, and every recovery/defer route carries
3429
+ ``--served-stage`` forward so the thread is never dropped (issue #176).
3430
+ - ``forge-5-loop`` — routed by its required ``--outcome``. ``complete``
3431
+ keeps verify-first ordering in front of the documentation/epic-member handoff,
3432
+ which for a member is delegated to the live ``render-status`` payload rather
3433
+ than re-derived here. The other four outcomes route to the loop resume
3434
+ (``partial``/``deferred``) or the navigator (``blocked``/``needs-human``) and
3435
+ suppress every downstream signal: ``nextStage``/``nextCommand`` are None,
3436
+ ``runInStageVerify`` is False, no debt is scheduled, and ``verifyGate`` is
3437
+ ``none`` — a loop still in flight has no finished implementation to verify and
3438
+ nothing downstream may read as ready (REQ-PROD-02).
3439
+ - ``forge-6-docs`` — the documentation terminus is decided by LIVE epic
3440
+ state, never by the successor table: for an epic member the adjacent
3441
+ ``epic-manifest.py render-status`` supplies the next actionable member's own
3442
+ command, and anything else routes to the epic dashboard. A ``blocked``
3443
+ outcome routes to recovery and never claims completion. Any helper failure
3444
+ is an actionable ``UsageError`` — exit 2 with no payload, so no guessed
3445
+ member command and no sentinel can escape (REQ-REL-02).
3446
+ - ``warnings`` — non-fatal advisories in the documented fixed order. Always
3447
+ present; ``[]`` means checked-and-clean, which is not the same as absent.
3448
+
3449
+ Read-only and deterministic. Syntactic validation fails closed with
3450
+ ``UsageError`` (exit 2, no payload and no sentinel); everything after it
3451
+ degrades to defaults rather than crashing a stage closing.
3452
+ """
3453
+ # ---- Deterministic validation order ----------------------------------- #
3454
+ # 1. Safe names and containment, before any strict filesystem access.
3455
+ _assert_safe_name(feature, "--feature")
3456
+ if epic is not None:
3457
+ _assert_safe_name(epic, "--epic")
3458
+ if next_feature is not None:
3459
+ _assert_safe_name(next_feature, "--next-feature")
3460
+
3461
+ # 2. The stage domain itself.
3462
+ if stage not in EXIT_STAGES:
3463
+ raise UsageError(
3464
+ f"unsupported --stage {stage!r}; expected one of {', '.join(EXIT_STAGES)}"
3465
+ )
3466
+
3467
+ # 3./4. Stages 0-4 reject an outcome; loop/docs/verify/fix require their own.
3468
+ # argparse cannot express a different enum per stage, so the domain check is here.
3469
+ allowed_outcomes = EXIT_OUTCOMES.get(stage)
3470
+ if allowed_outcomes is None:
3471
+ if outcome is not None:
3472
+ raise UsageError(
3473
+ f"--outcome is not accepted for {stage}; its exit is state-driven "
3474
+ "and has a single outcome"
3475
+ )
3476
+ elif outcome is None:
3477
+ raise UsageError(
3478
+ f"{stage} requires --outcome; expected one of "
3479
+ f"{', '.join(sorted(allowed_outcomes))}"
3480
+ )
3481
+ elif outcome not in allowed_outcomes:
3482
+ raise UsageError(
3483
+ f"--outcome {outcome!r} is not valid for {stage}; expected one of "
3484
+ f"{', '.join(sorted(allowed_outcomes))}"
3485
+ )
3486
+
3487
+ # 5. Ownership: required for the branch skills, rejected for stages 0-6.
3488
+ if stage in _BRANCH_STAGES:
3489
+ if owner is None:
3490
+ raise UsageError(
3491
+ f"{stage} requires --owner direct (this call prints the terminal "
3492
+ "block) or --owner nested (an outer stage owns it)"
3493
+ )
3494
+ if owner not in get_args(ExitOwner):
3495
+ raise UsageError(
3496
+ f"--owner {owner!r} is not valid; expected direct or nested"
3497
+ )
3498
+ elif owner is not None:
3499
+ raise UsageError(
3500
+ f"--owner is not accepted for {stage}; only forge-verify and forge-fix "
3501
+ "carry branch ownership, and stages 0-6 are always direct owners"
3502
+ )
3503
+
3504
+ # 6. Host and capability, independently. A host NEVER implies a capability.
3505
+ if host not in EXIT_HOSTS:
3506
+ raise UsageError(
3507
+ f"unknown --host {host!r}; expected one of {', '.join(EXIT_HOSTS)}"
3508
+ )
3509
+ if verify_capability not in get_args(VerifyCapability):
3510
+ raise UsageError(
3511
+ f"unknown --verify-capability {verify_capability!r}; expected "
3512
+ f"{' or '.join(get_args(VerifyCapability))}"
3513
+ )
3514
+
3515
+ # 7./8. Served stage for branch exits; branch-only flags rejected elsewhere.
3516
+ if stage in _BRANCH_STAGES:
3517
+ resolved_served: str | None = resolve_served_stage(served_stage, verify_mode)
3518
+ else:
3519
+ if served_stage is not None or verify_mode is not None:
3520
+ raise UsageError(
3521
+ "--served-stage and --verify-mode are branch-only; "
3522
+ f"{stage} is a production stage and serves only itself"
3523
+ )
3524
+ resolved_served = None
3525
+ if next_feature is not None and stage != "forge-0-epic":
3526
+ raise UsageError(
3527
+ f"--next-feature is accepted only for forge-0-epic, not {stage}"
3528
+ )
3529
+
3530
+ # A loop that did not complete has NO production successor and owes no
3531
+ # implementation verification yet. Everything downstream is suppressed below —
3532
+ # `nextStage`/`nextCommand`, the epic-reconcile deferred line, the in-stage
3533
+ # verify chain, its debt write, and the verify gate — because each of them
3534
+ # would assert that the implementation is finished enough to move on, which is
3535
+ # exactly the readiness claim REQ-PROD-02 forbids.
3536
+ loop_incomplete = stage == "forge-5-loop" and outcome != "complete"
3537
+
3538
+ config = _load_config(config_path)
3539
+ invalid_keys = invalid_auto_verify_keys(config)
3540
+ for key in invalid_keys: # already sorted; advisory, never fatal
3541
+ print(
3542
+ INVALID_AUTO_VERIFY_KEY_WARNING.format(
3543
+ key=key, valid=", ".join(VERIFY_TOKEN_BY_STAGE)
3544
+ ),
3545
+ file=sys.stderr,
3546
+ )
3547
+ feature_dir = _resolve_feature_dir(specs_dir, feature, epic)
3548
+ state = _read_state(feature_dir / PIPELINE_STATE_FILENAME)
3549
+
3550
+ # Epic edit-mode: resolve the SELECTED member's live progress here, before
3551
+ # the scheduling boundary below, so an ambiguous identity exits 2 without having
3552
+ # mutated anything. `--next-feature` is accepted only for `forge-0-epic` (step 1),
3553
+ # so this is exactly the epic edit-mode selection. Read-only: no candidate state
3554
+ # file is opened for writing on this path.
3555
+ member_state: dict = {}
3556
+ member_reason: str | None = None
3557
+ if next_feature is not None:
3558
+ member_state, member_reason = _epic_member_state(specs_dir, feature, next_feature)
3559
+
3560
+ # The clean-tree snapshot is taken HERE, before the sanctioned debt write
3561
+ # below, so the pending marker cannot dirty its own precondition.
3562
+ # Every other directive is likewise a pre-mutation snapshot; only
3563
+ # `autoVerifyDebtRecorded` describes what the write did.
3564
+ git_repo = _git_output(["rev-parse", "--git-dir"]) is not None
3565
+ clean_tree: bool | None = None
3566
+ if git_repo:
3567
+ porcelain = _git_output(["status", "--porcelain"])
1671
3568
  clean_tree = porcelain is None or porcelain == ""
1672
3569
 
1673
- verify_label = _verify_state_for(state, stage)
1674
- resolved = verify_label in ("fresh", "skipped")
1675
- effective_auto_verify = auto_verify_for(config, stage)
1676
- run_in_stage = effective_auto_verify and not resolved
3570
+ # A branch exit routes from the production stage it SERVED, never from itself:
3571
+ # `forge-verify` has no artifact, no verify token, and no successor of its own.
3572
+ # For a production exit the two are the same stage, so stages 0-4 are unchanged.
3573
+ route_stage = resolved_served if resolved_served is not None else stage
3574
+
3575
+ # Verification context for the routed stage. An epic-scoped route reads
3576
+ # `.epic-state.json` and the manifest revision DIRECTLY — never
3577
+ # `_resolve_feature_dir`, never a member stage version (REQ-SEC-01).
3578
+ verify_token = _EXIT_VERIFY_TOKEN.get(route_stage)
3579
+ verify_key = f"forge-verify-{verify_token}" if verify_token else None
3580
+ if route_stage == "forge-0-epic":
3581
+ # EPIC-scoped: the entry and the revision come from `.epic-state.json` and the
3582
+ # manifest, never from a member stage version, so this branch cannot route
3583
+ # through the stage-scoped helper below. `forge-0-epic` always has a token.
3584
+ verify_entry, verify_current = _epic_verify_context(specs_dir, feature)
3585
+ verify_label = _classify_verify_entry(verify_entry, verify_key, verify_current)
3586
+ else:
3587
+ verify_entry = _verify_entry(state, verify_key) if verify_key else {}
3588
+ verify_current = _stage_version(state, route_stage) if verify_key else None
3589
+ # Classify through `_verify_state_for`, the designated stage-exit routing
3590
+ # classifier, rather than re-deriving its two steps inline. The inline copy
3591
+ # left `_verify_state_for` with no runtime
3592
+ # caller, so `tests/test_auto_verify.py` could pin routing labels through a
3593
+ # function the CLI never executed. It repeats the `_EXIT_VERIFY_TOKEN` lookup
3594
+ # and `_classify_verify_entry` call above and returns "none" for a tokenless
3595
+ # stage (forge-6-docs), where there is no verification to owe.
3596
+ verify_label = _verify_state_for(state, route_stage)
3597
+ # ``none`` is resolved for routing purposes: no verify command is promoted.
3598
+ resolved = verify_label in ("fresh", "skipped", "none")
3599
+ # A findings report AT THE CURRENT revision is live evidence, not owed debt.
3600
+ # Scheduling over it would REPLACE the entry (`_verify_result_entry` builds
3601
+ # replacements, not patches) and delete `findingsFile`/`findingsCount` —
3602
+ # the same REQ-EXIT-04 clobber the branch-exit guard below forbids, reached
3603
+ # instead from a production re-exit. The outstanding obligation is the FIX,
3604
+ # so this exit routes to forge-fix and never re-schedules; a report left
3605
+ # behind by a since-revised artifact is superseded normally.
3606
+ reported_version = verify_entry.get("verifiedStageVersion")
3607
+ live_findings_report = (
3608
+ verify_label == "failing"
3609
+ and isinstance(reported_version, int)
3610
+ and not isinstance(reported_version, bool)
3611
+ and verify_current is not None
3612
+ and reported_version == verify_current
3613
+ )
3614
+ effective_auto_verify = auto_verify_for(config, route_stage)
3615
+ # A BRANCH exit is already inside the verification diversion, so it never owes
3616
+ # an in-stage verify chain and never schedules debt. Without this a
3617
+ # `forge-verify --outcome findings` exit would both direct a re-dispatch of
3618
+ # itself and overwrite the `findings-reported` entry it had just written with
3619
+ # a fresh `auto-verify-pending` marker, losing the report (REQ-EXIT-04).
3620
+ # Branch rejoin routing belongs to the outcome tables, not this boundary.
3621
+ run_in_stage = (
3622
+ effective_auto_verify
3623
+ and not resolved
3624
+ and not live_findings_report
3625
+ and stage not in _BRANCH_STAGES
3626
+ and not loop_incomplete
3627
+ )
1677
3628
  auto_fix_eligible = (
1678
3629
  config.get("autoFix") is True and run_in_stage and clean_tree is True
1679
3630
  )
1680
- if resolved or effective_auto_verify:
3631
+
3632
+ # ---- Scheduling boundary ---------------------------------------------- #
3633
+ # The debt lands BEFORE the payload exists, so a crash between here and the
3634
+ # dispatch leaves durable state exposing the obligation, and a failed write
3635
+ # raises UsageError with no payload at all — `runInStageVerify: True` with
3636
+ # `autoVerifyDebtRecorded: False` is therefore unreachable.
3637
+ auto_verify_debt_recorded = False
3638
+ if run_in_stage and verify_key is not None:
3639
+ _schedule_auto_verify_debt(specs_dir, feature, epic, route_stage, verify_key)
3640
+ auto_verify_debt_recorded = True
3641
+ # Priority table. The gate is a pure function of the verification state
3642
+ # and the caller's declared capability: `--host` selects command syntax and
3643
+ # fresh-session wording ONLY. A capable Pi session gets `standard`; an
3644
+ # incapable Claude session gets `manual-print` (REQ-EXIT-07). Whether the
3645
+ # caller needed user consent to dispatch is the CALLER's determination
3646
+ # and is invisible here — a consent-required caller sends
3647
+ # `interactive` and gets `standard`, which is the intended path.
3648
+ #
3649
+ # A BRANCH exit is already inside the diversion and its outcome table
3650
+ # names the one action to take, so there is nothing left to gate: offering
3651
+ # "verify now?" beside a fenced fix command would be a second, contradictory
3652
+ # ask. The table's `verify` routes ARE the verification prompt.
3653
+ #
3654
+ # A non-complete loop outcome is gateless for the same reason it never
3655
+ # schedules debt: there is no finished implementation to verify, so offering
3656
+ # "verify now?" beside a fenced loop resume would ask for a verification of
3657
+ # work that is still in flight.
3658
+ # A live findings report is likewise gateless: the fenced forge-fix route IS
3659
+ # the one action, and a "verify now?" prompt beside it would be a second,
3660
+ # contradictory ask for a verification that already ran at this revision.
3661
+ if (
3662
+ resolved
3663
+ or run_in_stage
3664
+ or live_findings_report
3665
+ or stage in _BRANCH_STAGES
3666
+ or loop_incomplete
3667
+ ):
1681
3668
  verify_gate = "none"
1682
- elif host == "claude":
3669
+ elif verify_capability == "interactive":
1683
3670
  verify_gate = "standard"
1684
3671
  else:
1685
3672
  verify_gate = "manual-print"
1686
3673
 
1687
- next_stage_id = _EXIT_NEXT_STAGE.get(stage)
3674
+ next_stage_id = _EXIT_NEXT_STAGE.get(route_stage)
1688
3675
  state_next = next_stage(state)
1689
3676
  if (
1690
- stage in PRODUCTION_STAGES
3677
+ route_stage in PRODUCTION_STAGES
1691
3678
  and state_next is not None
1692
- and PRODUCTION_STAGES.index(state_next) > PRODUCTION_STAGES.index(stage)
3679
+ and PRODUCTION_STAGES.index(state_next) > PRODUCTION_STAGES.index(route_stage)
1693
3680
  ):
1694
3681
  # State records this stage complete AND its walk lands beyond it —
1695
3682
  # trust it (it skips stages already completed out of order). A missing
1696
3683
  # or behind-the-stage walk (state not yet flushed, corrupt file) falls
1697
3684
  # back to the fixed successor, never to an earlier stage.
1698
3685
  next_stage_id = state_next
1699
- next_arg = next_feature or (
1700
- "{first-actionable-feature}" if stage == "forge-0-epic" else feature
1701
- )
1702
- next_command = f"/skill:{next_stage_id} {next_arg}" if next_stage_id else None
3686
+ # Keyed off the ROUTED stage, so a branch exit that served the epic decomposition
3687
+ # hands off the same way the epic's own exit does. Identical to the previous
3688
+ # behavior for every production exit, where `route_stage is stage`.
3689
+ if route_stage == "forge-0-epic" and next_feature is None:
3690
+ # An epic exit that names no concrete member has nothing to hand off to.
3691
+ # The dashboard is the same non-fabrication answer given to a named member
3692
+ # that has finished every production stage: never invent a member, and
3693
+ # never print a template the user cannot run.
3694
+ next_stage_id = None
3695
+ next_command = f"/skill:forge-0-epic {feature}"
3696
+ else:
3697
+ next_arg = next_feature or feature
3698
+ next_command = (
3699
+ f"/skill:{next_stage_id} {next_arg}" if next_stage_id else None
3700
+ )
3701
+
3702
+ # ---- Epic edit-mode live member routing (issue #175) -------------------- #
3703
+ # The fixed `forge-0-epic -> forge-1-prd` successor above is a CREATION-mode
3704
+ # answer: a member that has just been decomposed has no completed production
3705
+ # stage, so PRD is right. In edit mode the selected member may be anywhere in
3706
+ # the pipeline, and sending it back to PRD would ask for work already done.
3707
+ # The live position comes from the member's own state via `next_stage`, never
3708
+ # from the epic's state, the successor table, or conversational context.
3709
+ epic_member_warning: str | None = None
3710
+ if next_feature is not None:
3711
+ if member_reason is not None:
3712
+ # Degrade DOWN, never up: an unreadable member cannot be assumed to
3713
+ # have progressed, and inferring a later stage would fabricate the
3714
+ # very progress this exit failed to read (REQ-PROD-06).
3715
+ epic_member_warning = EPIC_MEMBER_FALLBACK_WARNING.format(
3716
+ member=next_feature, epic=feature, reason=member_reason
3717
+ )
3718
+ next_stage_id = "forge-1-prd"
3719
+ next_command = f"/skill:forge-1-prd {next_feature}"
3720
+ else:
3721
+ member_next = next_stage(member_state)
3722
+ if member_next is None:
3723
+ # Every production stage is complete. There is no stage 7 to
3724
+ # fabricate, so the handoff is the epic dashboard itself.
3725
+ next_stage_id = None
3726
+ next_command = f"/skill:forge-0-epic {feature}"
3727
+ else:
3728
+ next_stage_id = member_next
3729
+ next_command = f"/skill:{member_next} {next_feature}"
3730
+
3731
+ if loop_incomplete:
3732
+ # The pipeline has no next production stage from here, exactly as it has
3733
+ # none after `forge-6-docs`. Cleared BEFORE the epic-backflow block below,
3734
+ # so a blocking reconcile's `deferred` line cannot re-introduce
3735
+ # `/skill:forge-6-docs` as text the loop resume did not earn.
3736
+ next_stage_id = None
3737
+ next_command = None
1703
3738
 
1704
3739
  # Epic backflow routing: an exiting member may carry epic-level change requests
1705
3740
  # (recorded by forge-1-prd/forge-2-tech). A `blocksCurrent: true` request means
@@ -1709,6 +3744,15 @@ def stage_exit(
1709
3744
  # The epic name comes from the `--epic` arg or the state's `epic` back-pointer.
1710
3745
  epic_reconcile: dict | None = None
1711
3746
  epic_name = epic or state.get("epic")
3747
+ # The epic a documentation or completed-loop exit routes against:
3748
+ # the explicit `--epic`, else the state's back-pointer. A back-pointer is
3749
+ # untrusted on-disk data, so it is name-checked here rather than reaching the
3750
+ # helper's argv (REQ-SEC-01); an unusable value degrades to the standalone route
3751
+ # rather than crashing a stage closing. `--epic` itself was already validated in
3752
+ # step 1.
3753
+ route_epic = (
3754
+ epic_name if isinstance(epic_name, str) and SAFE_NAME_RE.match(epic_name) else None
3755
+ )
1712
3756
  open_requests = [
1713
3757
  r
1714
3758
  for r in state.get("epicChangeRequests", [])
@@ -1732,38 +3776,184 @@ def stage_exit(
1732
3776
  "count": len(open_requests),
1733
3777
  }
1734
3778
 
3779
+ # ---- Verify-first primary routing ------------------------------------- #
3780
+ # While verification is unresolved the verify command is THE action — except
3781
+ # under a live findings report, whose one action is the forge-fix that applies
3782
+ # it. Either way the production successor is demoted to unfenced conditional
3783
+ # prose. No path may fence or recommend the deferred production command first
3784
+ # (REQ-EXIT-06).
3785
+ verify_canonical = f"/skill:forge-verify {feature}"
3786
+ # The one action a live findings report promotes, on every route that can
3787
+ # reach it: findings already exist at this exact revision, so re-dispatching
3788
+ # verify would only restate them. The served stage is carried so the fix
3789
+ # rejoins this production thread.
3790
+ fix_canonical = f"/skill:forge-fix {feature} --served-stage {route_stage}"
3791
+ verify_command = _host_command(verify_canonical, host)
3792
+ blocking_reconcile = bool(epic_reconcile and epic_reconcile.get("required"))
3793
+ primary_canonical: str | None
3794
+ deferred_canonical: str | None
3795
+ outcome_text: str | None = None
3796
+ if stage in _BRANCH_STAGES:
3797
+ # The outcome table alone decides a branch terminus — verify-first
3798
+ # ordering does not apply, because the branch IS the verification work.
3799
+ primary_canonical, deferred_canonical, outcome_text, advancing = _branch_route(
3800
+ stage,
3801
+ outcome,
3802
+ feature,
3803
+ resolved_served,
3804
+ next_command,
3805
+ resolved,
3806
+ )
3807
+ if advancing and blocking_reconcile:
3808
+ # An advancing rejoin is subject to the same reconcile-first rule as a
3809
+ # production exit; a non-advancing one already outranks the reconcile.
3810
+ primary_canonical = epic_reconcile["command"]
3811
+ deferred_canonical = None
3812
+ elif stage == "forge-5-loop":
3813
+ # Every loop result gets a deterministic resume or recovery action.
3814
+ # `complete` keeps verify-first ordering (the table applies it to its own
3815
+ # handoff); the other four never reach a production stage at all.
3816
+ primary_canonical, deferred_canonical, outcome_text, advancing = _loop_route(
3817
+ outcome,
3818
+ feature,
3819
+ route_epic,
3820
+ specs_dir,
3821
+ next_command,
3822
+ resolved,
3823
+ verify_canonical,
3824
+ fix_canonical if live_findings_report else None,
3825
+ )
3826
+ if blocking_reconcile:
3827
+ # Same reconcile-first rule as every other advancing route — but the
3828
+ # continuation carried forward is the LOOP's, not the successor table's
3829
+ # documentation stage, which this route deliberately did not choose.
3830
+ primary_canonical, deferred_canonical, outcome_text = _promote_reconcile(
3831
+ stage,
3832
+ epic_reconcile,
3833
+ feature,
3834
+ epic_name,
3835
+ primary_canonical,
3836
+ deferred_canonical,
3837
+ outcome_text,
3838
+ advancing,
3839
+ )
3840
+ elif stage == "forge-6-docs":
3841
+ # The documentation terminus is decided by LIVE epic state, not by
3842
+ # the successor table — the pipeline ends here, so there is no next stage
3843
+ # to fence and no verification to put first (docs is tokenless).
3844
+ primary_canonical, deferred_canonical, outcome_text, advancing = _docs_route(
3845
+ feature,
3846
+ route_epic,
3847
+ specs_dir,
3848
+ outcome,
3849
+ host,
3850
+ )
3851
+ if blocking_reconcile:
3852
+ # Same reconcile-first rule as an advancing branch rejoin: handing off to
3853
+ # the next member would build it on a decomposition that is about to
3854
+ # change. A non-advancing docs route already lands on the epic itself. The
3855
+ # successor table has no entry for this stage, so its seeded continuation
3856
+ # is None — the live route's own primary is what must be carried forward.
3857
+ primary_canonical, deferred_canonical, outcome_text = _promote_reconcile(
3858
+ stage,
3859
+ epic_reconcile,
3860
+ feature,
3861
+ epic_name,
3862
+ primary_canonical,
3863
+ deferred_canonical,
3864
+ outcome_text,
3865
+ advancing,
3866
+ )
3867
+ elif live_findings_report:
3868
+ primary_canonical = fix_canonical
3869
+ deferred_canonical = next_command
3870
+ elif not resolved:
3871
+ primary_canonical = verify_canonical
3872
+ deferred_canonical = next_command
3873
+ elif blocking_reconcile:
3874
+ # Verification is settled, so the blocking reconcile is the primary
3875
+ # action and `epicReconcile["deferred"]` carries the demoted successor.
3876
+ primary_canonical = epic_reconcile["command"]
3877
+ deferred_canonical = None
3878
+ else:
3879
+ primary_canonical = next_command or "/skill:forge"
3880
+ deferred_canonical = None
3881
+
3882
+ # Fixed order: entry 1 is the epic-member unreadable-state fallback,
3883
+ # then the debt-metadata and revision-mismatch entries.
3884
+ warnings: list[str] = []
3885
+ if epic_member_warning is not None:
3886
+ warnings.append(epic_member_warning)
3887
+ warnings.extend(
3888
+ _debt_metadata_warnings(
3889
+ verify_entry, verify_key, route_stage, feature, verify_command, verify_current
3890
+ )
3891
+ )
3892
+
3893
+ # `owner == "nested"` means an outer authoring stage prints the terminal block.
3894
+ # The routing directives survive; the human-facing block does not exist at all,
3895
+ # so a nested chain can never emit a second sentinel (REQ-EXIT-03/04).
3896
+ nested = owner == "nested"
3897
+
1735
3898
  directives = {
1736
3899
  "stage": stage,
1737
3900
  "stageNoun": STAGE_NOUN.get(stage, stage),
3901
+ "servedStage": resolved_served,
3902
+ "verifyMode": _STAGE_TO_VERIFY_MODE.get(resolved_served or ""),
3903
+ "outcome": outcome,
3904
+ "owner": owner,
3905
+ "terminalOwnedBy": "outer" if nested else "self",
1738
3906
  "feature": feature,
1739
3907
  "runInStageVerify": run_in_stage,
1740
3908
  "verifyGate": verify_gate,
3909
+ "verifyCapability": verify_capability,
1741
3910
  "autoFixEligible": auto_fix_eligible,
1742
3911
  "verifyState": verify_label,
1743
- "verifyCommand": _host_command(f"/skill:forge-verify {feature}", host),
3912
+ "verifyStage": pending_verify(state),
3913
+ "verifyCommand": verify_command,
1744
3914
  "autoVerifyEffective": effective_auto_verify,
3915
+ "autoVerifyDebtRecorded": auto_verify_debt_recorded,
1745
3916
  "nextStage": next_stage_id,
1746
3917
  "nextCommand": _host_command(next_command, host) if next_command else next_command,
1747
- "invalidAutoVerifyKeys": invalid_auto_verify_keys(config),
3918
+ "primaryCommand": _host_command(primary_canonical, host),
3919
+ "deferredCommand": (
3920
+ _host_command(deferred_canonical, host) if deferred_canonical else None
3921
+ ),
3922
+ "invalidAutoVerifyKeys": invalid_keys,
3923
+ "warnings": warnings,
1748
3924
  "gitRepo": git_repo,
1749
3925
  "cleanTree": clean_tree,
1750
3926
  "host": host,
1751
3927
  }
1752
3928
  if epic_reconcile is not None:
1753
3929
  directives["epicReconcile"] = epic_reconcile
3930
+ if nested:
3931
+ return {"directives": directives, "nextSteps": None, "sentinel": None}
1754
3932
  return {
1755
3933
  "directives": directives,
1756
3934
  "nextSteps": _next_steps_block(
1757
- next_command or "/skill:forge", host, epic_reconcile
3935
+ primary_canonical,
3936
+ host,
3937
+ epic_reconcile,
3938
+ deferred_command=deferred_canonical,
3939
+ outcome_text=outcome_text,
1758
3940
  ),
1759
3941
  "sentinel": NEXT_STEPS_SENTINEL,
1760
3942
  }
1761
3943
 
1762
3944
 
1763
3945
  def _print_stage_exit(payload: dict) -> None:
1764
- """Print DIRECTIVES then the NEXT-STEPS block (the skill-facing form)."""
3946
+ """Print DIRECTIVES then the NEXT-STEPS block (the skill-facing form).
3947
+
3948
+ A NESTED branch payload carries ``nextSteps is None``: an outer authoring stage
3949
+ owns the terminal block, so this printer emits the directives and stops. Printing
3950
+ a terminal section here — even an empty one — is the ownership leak REQ-EXIT-04
3951
+ forbids.
3952
+ """
1765
3953
  print("DIRECTIVES:")
1766
3954
  print(json.dumps(payload["directives"], indent=2, ensure_ascii=False))
3955
+ if payload.get("nextSteps") is None:
3956
+ return
1767
3957
  print(
1768
3958
  "NEXT-STEPS (print this block verbatim as your absolute last output — "
1769
3959
  "nothing after the sentinel):"
@@ -2066,6 +4256,135 @@ def _load_state_for_write(
2066
4256
  return state_path, state
2067
4257
 
2068
4258
 
4259
+ def _assert_safe_name(name: str, label: str) -> None:
4260
+ """Reject a name that could steer a write outside ``{specsDir}/{name}``.
4261
+
4262
+ Args:
4263
+ name: The bare name supplied on the command line.
4264
+ label: The flag to name in the error (e.g. ``--feature``).
4265
+
4266
+ Raises:
4267
+ UsageError: Empty, absolute, separator-bearing, ``..``, or not a single
4268
+ kebab-case token (→ exit 2, nothing read or written).
4269
+ """
4270
+ if (
4271
+ not name
4272
+ or name == ".."
4273
+ or "/" in name
4274
+ or "\\" in name
4275
+ or os.path.isabs(name)
4276
+ or not SAFE_NAME_RE.match(name)
4277
+ ):
4278
+ raise UsageError(f"unsafe name {name!r} for {label}")
4279
+
4280
+
4281
+ def _load_epic_state_for_write(
4282
+ specs_dir: Path, epic_name: str, epic: str | None
4283
+ ) -> tuple[Path, dict, int]:
4284
+ """Resolve an EPIC's ``.epic-state.json`` and its manifest revision, for mutation.
4285
+
4286
+ The epic counterpart of ``_load_state_for_write``, and deliberately NOT a
4287
+ variant of it: epic verification is epic-scoped and must never resolve, read,
4288
+ create, or write a member's ``.pipeline-state.json`` (REQ-SEC-01). There is no
4289
+ fallback in either direction — an epic whose manifest is missing or whose
4290
+ identity disagrees is an error, not a feature lookup.
4291
+
4292
+ Resolution is strict where the member resolver is tolerant: the name must be a
4293
+ safe single token, the joined path must stay inside ``specs_dir`` after symlink
4294
+ resolution, ``epic-manifest.json`` must exist, and the manifest's own ``epic``
4295
+ value must equal ``epic_name``. The revision comes from the manifest, which is
4296
+ the canonical artifact version for epic freshness — never a member's
4297
+ production-stage version. A legacy manifest with no ``revision`` is
4298
+ presented as logical ``1`` here, matching ``epic-manifest.py::load_manifest``,
4299
+ and its bytes are not rewritten.
4300
+
4301
+ Args:
4302
+ specs_dir: The configured specs directory (``--specs-dir``).
4303
+ epic_name: The epic name — what ``--feature`` carries for this stage.
4304
+ epic: The ``--epic`` value, which must be absent or equal to ``epic_name``.
4305
+
4306
+ Returns:
4307
+ A ``(state_path, state, revision)`` tuple. ``state`` is the lazily created
4308
+ minimal shell (``epic`` + ``stages``) when no epic state exists yet.
4309
+
4310
+ Raises:
4311
+ UsageError: Conflicting ``--feature``/``--epic``, unsafe name, containment
4312
+ escape, missing/unparseable/non-object/identity-mismatched manifest,
4313
+ invalid manifest revision, or an unparseable/non-object epic state or
4314
+ ``stages`` value (→ exit 2, nothing written).
4315
+ """
4316
+ if epic is not None and epic != epic_name:
4317
+ raise UsageError(
4318
+ f"--stage forge-0-epic writes epic-scoped state, so --feature names the "
4319
+ f"epic: --feature {epic_name!r} and --epic {epic!r} disagree. Drop --epic "
4320
+ f"or make it match."
4321
+ )
4322
+ _assert_safe_name(epic_name, "--feature")
4323
+ base_real = specs_dir.resolve()
4324
+ epic_dir = (base_real / epic_name).resolve()
4325
+ if epic_dir != base_real and base_real not in epic_dir.parents:
4326
+ raise UsageError(
4327
+ f"resolved epic path escapes the specs dir: {specs_dir / epic_name}"
4328
+ )
4329
+
4330
+ manifest_path = epic_dir / MANIFEST_FILENAME
4331
+ if not manifest_path.is_file():
4332
+ raise UsageError(
4333
+ f"no epic manifest at {manifest_path} — --stage forge-0-epic verifies an "
4334
+ f"epic, and {epic_name!r} is not one. Nothing was written."
4335
+ )
4336
+ try:
4337
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
4338
+ except json.JSONDecodeError as exc:
4339
+ raise UsageError(f"{manifest_path} is not valid JSON ({exc})") from exc
4340
+ if not isinstance(manifest, dict):
4341
+ raise UsageError(f"{manifest_path} is not a JSON object")
4342
+ if manifest.get("epic") != epic_name:
4343
+ raise UsageError(
4344
+ f"{manifest_path} declares epic {manifest.get('epic')!r}, not "
4345
+ f"{epic_name!r}; refusing to write verification state for a mismatched "
4346
+ f"epic identity"
4347
+ )
4348
+ revision = _require_positive_int(
4349
+ manifest.get("revision", 1), f"{epic_name}/{MANIFEST_FILENAME} revision"
4350
+ )
4351
+
4352
+ state_path = epic_dir / EPIC_STATE_FILENAME
4353
+ if state_path.exists():
4354
+ try:
4355
+ state = json.loads(state_path.read_text(encoding="utf-8"))
4356
+ except json.JSONDecodeError as exc:
4357
+ raise UsageError(
4358
+ f"{state_path} exists but is not valid JSON ({exc}); refusing to "
4359
+ f"overwrite it. Fix or move the file, then re-run."
4360
+ ) from exc
4361
+ if not isinstance(state, dict):
4362
+ raise UsageError(
4363
+ f"{state_path} is not a JSON object; refusing to overwrite it."
4364
+ )
4365
+ recorded = state.get("epic")
4366
+ if recorded is not None and recorded != epic_name:
4367
+ raise UsageError(
4368
+ f"{state_path} records epic {recorded!r}, not {epic_name!r}; "
4369
+ f"refusing to overwrite it."
4370
+ )
4371
+ stages = state.get("stages")
4372
+ if stages is not None and not isinstance(stages, dict):
4373
+ raise UsageError(
4374
+ f"{state_path} has a non-object 'stages' value ({type(stages).__name__}); "
4375
+ f"refusing to overwrite it."
4376
+ )
4377
+ else:
4378
+ state = {}
4379
+ # Seed the minimal state shape in its documented key order. ``updatedAt`` is
4380
+ # a placeholder: every caller stamps it through ``_commit_state`` immediately
4381
+ # before the single atomic replacement, so the null never reaches disk.
4382
+ state.setdefault("epic", epic_name)
4383
+ state.setdefault("updatedAt", None)
4384
+ state.setdefault("stages", {})
4385
+ return state_path, state, revision
4386
+
4387
+
2069
4388
  def _commit_state(state_path: Path, state: dict) -> dict:
2070
4389
  """Refresh ``updatedAt`` and write ``state`` atomically; return it for echo.
2071
4390
 
@@ -2073,7 +4392,11 @@ def _commit_state(state_path: Path, state: dict) -> dict:
2073
4392
  always refreshed on a successful write and the write is atomic.
2074
4393
 
2075
4394
  Args:
2076
- state_path: The resolved ``.pipeline-state.json`` path.
4395
+ state_path: The resolved state-file path — a feature's
4396
+ ``.pipeline-state.json``, or an epic's ``.epic-state.json``. The helper
4397
+ is target-agnostic: it stamps and writes whatever document it is given,
4398
+ so an epic write reuses the same atomic mechanism without
4399
+ going anywhere near the member resolver.
2077
4400
  state: The mutated state dict.
2078
4401
 
2079
4402
  Returns:
@@ -2166,10 +4489,18 @@ def cmd_state_artifact(
2166
4489
  The mutated state dict (for the --json echo).
2167
4490
 
2168
4491
  Raises:
2169
- UsageError: Unknown feature directory, unparseable state file, or a
2170
- failed atomic write (→ exit 2).
4492
+ UsageError: A ``--path`` that is empty, absolute, ``..``-bearing,
4493
+ control-character-bearing, or escaping the feature directory; an
4494
+ unknown feature directory, an unparseable state file, or a failed
4495
+ atomic write (→ exit 2).
2171
4496
  """
2172
4497
  state_path, state = _load_state_for_write(specs_dir, feature, epic)
4498
+ # Containment is checked against the resolved feature dir, which only the load
4499
+ # produces; every path is validated before any of them is appended, so a
4500
+ # rejected value in a repeated --path list leaves the file untouched.
4501
+ target_dir = state_path.parent
4502
+ for path in paths:
4503
+ _validated_findings_file(path, target_dir, label="--path")
2173
4504
  entry = _stage_entry(state, stage)
2174
4505
  artifacts = entry.setdefault("artifacts", [])
2175
4506
  for path in paths:
@@ -2282,7 +4613,8 @@ def cmd_state_complete(
2282
4613
  Sets ONLY ``commitHash``, leaving status/version/artifacts intact. Guarded
2283
4614
  on the stage already being ``complete``, so a typo'd ``--stage`` cannot
2284
4615
  write a lone ``{"commitHash": …}`` entry (which would violate
2285
- ``stageEntry``'s ``required: ["status"]``) at exit 0.
4616
+ ``stageEntry``'s ``required: ["status"]``) at exit 0. The value must be a
4617
+ full 40-hex object hash (REQ-STATE-01), checked before anything is loaded.
2286
4618
  2. ``resumable`` — the failed-Commit-1 revert (`references/shared-conventions.md`
2287
4619
  L245). Records ONLY ``status = "in-progress"`` plus the ``updatedAt``
2288
4620
  refresh: no completedAt, no version bump, no basedOnVersions/artifacts
@@ -2308,7 +4640,8 @@ def cmd_state_complete(
2308
4640
  based_on: Parsed ``{upstreamStage: version}`` provenance map.
2309
4641
  artifacts: Final canonical artifact path list for this stage.
2310
4642
  commit_hash: If given, record it as the stage's commitHash (Commit 2);
2311
- else set commitHash to None (Commit 1).
4643
+ else set commitHash to None (Commit 1). Full 40-hex only on a new
4644
+ write — an abbreviation is rejected rather than expanded.
2312
4645
  specs_dir: Specs directory.
2313
4646
  epic: Owning epic name, or None.
2314
4647
  status: Terminal status to record — "complete" (the default when the flag
@@ -2325,6 +4658,7 @@ def cmd_state_complete(
2325
4658
 
2326
4659
  Raises:
2327
4660
  UsageError: Contradictory ``--resumable --status complete``, a
4661
+ ``--version`` below 1, a short or non-hex ``--commit-hash``, a
2328
4662
  ``--commit-hash`` follow-up against a stage that is not complete, an
2329
4663
  unknown feature directory, an unparseable state file, or a failed
2330
4664
  atomic write (→ exit 2).
@@ -2333,6 +4667,14 @@ def cmd_state_complete(
2333
4667
  raise UsageError(
2334
4668
  "--resumable implies --status in-progress; do not pass --status complete"
2335
4669
  )
4670
+ # The write path must not accept a version the read path refuses; checked before
4671
+ # the state file is loaded for mutation, so a rejection touches nothing.
4672
+ _require_positive_int(version, "--version")
4673
+ if commit_hash is not None:
4674
+ # Branch 1's first act: full 40-hex only, validated BEFORE the
4675
+ # state file is loaded for mutation and long before _commit_state. Legacy
4676
+ # short hashes already recorded in state keep loading unmigrated.
4677
+ _assert_full_commit_hash(commit_hash)
2336
4678
  state_path, state = _load_state_for_write(specs_dir, feature, epic)
2337
4679
  entry = _stage_entry(state, stage)
2338
4680
  cascaded: list[str] = []
@@ -2542,6 +4884,539 @@ def cmd_state_ecr(
2542
4884
  return _commit_state(state_path, state)
2543
4885
 
2544
4886
 
4887
+ def _require_positive_int(value: object, label: str) -> int:
4888
+ """Return ``value`` as a positive int, or raise ``UsageError``.
4889
+
4890
+ ``bool`` is rejected explicitly: it is an ``int`` subclass, so ``True`` would
4891
+ otherwise sail through as version 1 and record a freshness ledger entry for an
4892
+ artifact revision that never existed.
4893
+
4894
+ Args:
4895
+ value: The candidate revision/version.
4896
+ label: The flag or field name to name in the error.
4897
+
4898
+ Returns:
4899
+ The validated positive integer.
4900
+
4901
+ Raises:
4902
+ UsageError: Not an int, a bool, or below 1 (→ exit 2).
4903
+ """
4904
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
4905
+ raise UsageError(f"{label} must be a positive integer; got {value!r}")
4906
+ return value
4907
+
4908
+
4909
+ def _validated_findings_file(
4910
+ value: str, target_dir: Path, label: str = "--findings-file"
4911
+ ) -> str:
4912
+ """Return ``value`` if it is a safe relative path inside ``target_dir``.
4913
+
4914
+ ``findingsFile`` is defined as relative to the
4915
+ feature directory, and downstream consumers (forge-fix selecting the report)
4916
+ follow the stored value verbatim. So it gets the same fail-closed containment
4917
+ treatment as the write target itself (REQ-SEC-01): an absolute path, a ``..``
4918
+ segment, a NUL/control character, or a symlinked escape is rejected BEFORE any
4919
+ mutation rather than persisted for a later reader to resolve.
4920
+
4921
+ The same containment contract governs every stored path a caller asserts is
4922
+ inside the feature directory, so the flag being validated is a parameter: the
4923
+ diagnostic must name the flag the user actually passed.
4924
+
4925
+ Args:
4926
+ value: The candidate path, as supplied on the command line.
4927
+ target_dir: The resolved feature (or epic) directory it must sit inside.
4928
+ label: The flag to name in the error.
4929
+
4930
+ Returns:
4931
+ The value unchanged, once validated.
4932
+
4933
+ Raises:
4934
+ UsageError: Empty, absolute, ``..``-bearing, control-character-bearing, or
4935
+ escaping the target directory (→ exit 2).
4936
+ """
4937
+ if not value:
4938
+ raise UsageError(f"{label} must not be empty")
4939
+ bad = next((ch for ch in value if ord(ch) < 32 or ord(ch) == 127), None)
4940
+ if bad is not None:
4941
+ raise UsageError(
4942
+ f"{label} contains a control character ({bad!r}); "
4943
+ f"expected a plain relative path"
4944
+ )
4945
+ candidate = Path(value)
4946
+ if candidate.is_absolute():
4947
+ raise UsageError(
4948
+ f"{label} {value!r} is absolute; it must be relative to the "
4949
+ f"feature directory ({target_dir})"
4950
+ )
4951
+ if ".." in candidate.parts:
4952
+ raise UsageError(
4953
+ f"{label} {value!r} contains a '..' segment; it must stay inside "
4954
+ f"the feature directory ({target_dir})"
4955
+ )
4956
+ root = target_dir.resolve()
4957
+ resolved = (target_dir / candidate).resolve()
4958
+ if resolved == root or root not in resolved.parents:
4959
+ raise UsageError(
4960
+ f"{label} {value!r} escapes the feature directory ({target_dir}); "
4961
+ f"refusing to record it"
4962
+ )
4963
+ return value
4964
+
4965
+
4966
+ def _current_artifact_version(state: dict, stage: str) -> int:
4967
+ """Return the artifact revision a verify result is being recorded against.
4968
+
4969
+ For a feature target that is the selected production stage's ``version``. A
4970
+ result other than ``skipped`` cannot be recorded without it: `passed` and
4971
+ `findings-reported` write it into the freshness ledger, and
4972
+ `auto-verify-pending` writes it as the revision the debt is owed on.
4973
+
4974
+ Args:
4975
+ state: The loaded state document.
4976
+ stage: The production stage the verify entry serves.
4977
+
4978
+ Returns:
4979
+ The stage's current positive-integer version.
4980
+
4981
+ Raises:
4982
+ UsageError: The stage has no recorded (or no valid) ``version`` (→ exit 2).
4983
+ """
4984
+ version = _stage_version(state, stage)
4985
+ if version is None:
4986
+ raise UsageError(
4987
+ f"{stage} has no recorded version in this feature's state, so there is no "
4988
+ f"artifact revision to verify against; run state-complete for {stage} first"
4989
+ )
4990
+ return _require_positive_int(version, f"{stage}.version")
4991
+
4992
+
4993
+ def _assert_full_commit_hash(commit_hash: object) -> None:
4994
+ """Reject a ``--commit-hash`` that is not exactly 40 hexadecimal characters.
4995
+
4996
+ REQ-STATE-01 constrains WRITES, not reads. New provenance is a
4997
+ full ``git rev-parse HEAD`` object hash; an abbreviation is rejected rather than
4998
+ expanded, because expanding one would mean shelling out to Git from a script
4999
+ whose whole contract is bounded local file reads. Caller case is preserved —
5000
+ the regex accepts either case and nothing normalizes it.
5001
+
5002
+ Nothing constrains the schema, so a legacy short hash already recorded in state
5003
+ keeps loading through ``_read_state``, ``_load_state_for_write``, the manifest
5004
+ status readers, the navigator, and stage exit unmigrated (REQ-STATE-02).
5005
+
5006
+ Args:
5007
+ commit_hash: The supplied value, typed loosely so a non-string reaching the
5008
+ callable in-process is refused here rather than at serialization time.
5009
+
5010
+ Raises:
5011
+ UsageError: The value is not a 40-character hex string (→ exit 2, before
5012
+ any load-for-mutation and always before ``_commit_state``).
5013
+ """
5014
+ if isinstance(commit_hash, str) and FULL_GIT_HASH_RE.fullmatch(commit_hash):
5015
+ return
5016
+ raise UsageError(
5017
+ f"--commit-hash must be the full 40-character Git object hash "
5018
+ f"(`git rev-parse HEAD`); got {commit_hash!r}. An abbreviation is rejected "
5019
+ f"rather than expanded. Nothing was written."
5020
+ )
5021
+
5022
+
5023
+ def _load_verify_target(
5024
+ specs_dir: Path, feature: str, epic: str | None, is_epic_target: bool
5025
+ ) -> tuple[Path, dict, int | None]:
5026
+ """Resolve the state document ``state-verify`` will mutate — epic or feature.
5027
+
5028
+ An epic target NEVER falls back to the member writer, and a member target never
5029
+ reaches the epic root: the two resolvers are disjoint (REQ-SEC-01). Both result
5030
+ mode and commit-2 mode go through here, so neither can drift onto the other's
5031
+ resolver.
5032
+
5033
+ Args:
5034
+ specs_dir: The configured specs directory.
5035
+ feature: The feature name, or the epic name for an epic target.
5036
+ epic: The owning epic for a member, else None.
5037
+ is_epic_target: True when ``--stage forge-0-epic`` selected the epic root.
5038
+
5039
+ Returns:
5040
+ ``(state_path, state, revision)``. ``revision`` is the epic's manifest
5041
+ revision for an epic target, and None for a feature target (whose artifact
5042
+ version is read per-stage out of its own state).
5043
+
5044
+ Raises:
5045
+ UsageError: Any resolution or load failure (→ exit 2, nothing written).
5046
+ """
5047
+ if is_epic_target:
5048
+ return _load_epic_state_for_write(specs_dir, feature, epic)
5049
+ state_path, state = _load_state_for_write(specs_dir, feature, epic)
5050
+ return state_path, state, None
5051
+
5052
+
5053
+ def _verify_result_entry(
5054
+ status: str,
5055
+ prior: dict,
5056
+ current: int | None,
5057
+ findings_file: str | None,
5058
+ findings_count: int | None,
5059
+ now: str,
5060
+ ) -> dict:
5061
+ """Build the replacement ``forge-verify-*`` entry for one result transition.
5062
+
5063
+ Each status REPLACES the entry rather than patching it, which is what makes the
5064
+ the "clear …" rules exact: a terminal write cannot leave a stale
5065
+ ``scheduledAt``/``scheduledStageVersion`` behind, and the keys are DELETED
5066
+ rather than nulled (``VerifyEntry`` is ``total=False``, so absent means "not
5067
+ scheduled" while present-but-null would be malformed). ``findings-applied`` is
5068
+ the one status that carries prior state forward — the report metadata — and it
5069
+ deliberately writes no ``verifiedStageVersion``: fixes landed, nothing
5070
+ re-verified them, so freshness stays unresolved until a later ``passed``.
5071
+ ``passed`` may record NEW attached-report metadata of its own (the
5072
+ advisory-only and escalation-acceptance rules in ``cmd_state_verify``).
5073
+
5074
+ Args:
5075
+ status: The validated result status.
5076
+ prior: The existing entry (``{}`` when absent).
5077
+ current: The current artifact revision, or None for ``skipped``.
5078
+ findings_file: Validated relative report path, when supplied.
5079
+ findings_count: Validated non-negative count, when supplied.
5080
+ now: The shared ISO-8601 timestamp for this write.
5081
+
5082
+ Returns:
5083
+ The complete new entry dict.
5084
+ """
5085
+ if status == "auto-verify-pending":
5086
+ return {
5087
+ "status": status,
5088
+ "scheduledAt": now,
5089
+ "scheduledStageVersion": current,
5090
+ "commitHash": None,
5091
+ }
5092
+ if status == "passed":
5093
+ entry: dict = {"status": status}
5094
+ if findings_file is not None:
5095
+ # An attached report — advisory-only, or residual findings the user
5096
+ # explicitly accepted at the escalation gate — resolves as `passed`
5097
+ # so it never routes to forge-fix, while the report stays attached
5098
+ # for later pickup. A bare zero count records no report keys, keeping
5099
+ # the plain "verified clean" shape byte-identical to before.
5100
+ entry["findingsFile"] = findings_file
5101
+ entry["findingsCount"] = findings_count
5102
+ entry["verifiedAt"] = now
5103
+ entry["verifiedStageVersion"] = current
5104
+ entry["commitHash"] = None
5105
+ return entry
5106
+ if status == "findings-reported":
5107
+ return {
5108
+ "status": status,
5109
+ "findingsFile": findings_file,
5110
+ "findingsCount": findings_count,
5111
+ "verifiedAt": now,
5112
+ "verifiedStageVersion": current,
5113
+ "commitHash": None,
5114
+ }
5115
+ if status == "findings-applied":
5116
+ entry: dict = {"status": status}
5117
+ for key in ("findingsFile", "findingsCount"):
5118
+ if key in prior:
5119
+ entry[key] = prior[key]
5120
+ entry["fixedAt"] = now
5121
+ entry["commitHash"] = None
5122
+ return entry
5123
+ return {"status": status, "commitHash": None} # skipped
5124
+
5125
+
5126
+ def cmd_state_verify(
5127
+ feature: str,
5128
+ stage: str,
5129
+ specs_dir: Path,
5130
+ epic: str | None,
5131
+ status: str | None = None,
5132
+ findings_file: str | None = None,
5133
+ findings_count: int | None = None,
5134
+ verified_stage_version: int | None = None,
5135
+ commit_hash: str | None = None,
5136
+ ) -> dict:
5137
+ """Write one verify result transition or one provenance follow-up.
5138
+
5139
+ Args:
5140
+ feature: The feature name, or the EPIC name when `stage == "forge-0-epic"`.
5141
+ Resolved through the same path-safety and containment rules as every
5142
+ other state write.
5143
+ stage: The production stage this verify entry serves — one of
5144
+ `VERIFY_MODE_TO_STAGE`'s values, or `"forge-0-epic"` for an epic-target
5145
+ write. Selects `stages["forge-verify-{suffix}"]`.
5146
+ specs_dir: Root of the specs tree, as configured by `specsDir`.
5147
+ epic: Epic name when `feature` is a member, else None. REQUIRED for members
5148
+ so the bare name is never resolved ambiguously. For
5149
+ `stage == "forge-0-epic"` it must be absent or equal to `feature`.
5150
+ status: Result mode. Mutually exclusive with `commit_hash`. Each status
5151
+ admits only the metadata below; everything else is refused before any
5152
+ write, so a contradictory call never lands a partial entry:
5153
+
5154
+ - `passed` — REQUIRES `verified_stage_version`. MAY carry an attached
5155
+ report (`findings_file` + `findings_count` together, count >= 1) in
5156
+ two protocol cases: an ADVISORY-ONLY report (no blocking
5157
+ `error`/`gap` findings), and residual findings the user explicitly
5158
+ ACCEPTED at the round-ledger escalation (recorded first as a
5159
+ `state-decision`; see "Escalation" in stage-exit-protocol.md).
5160
+ Either way the stage resolves without routing to forge-fix and the
5161
+ report stays attached. Half a pairing is refused: a file without a
5162
+ count, a positive count without a file, or a file with a zero
5163
+ count. Unaccepted blocking findings belong to `findings-reported`.
5164
+ - `findings-reported` — REQUIRES all three of `verified_stage_version`,
5165
+ `findings_file`, and a non-negative `findings_count`.
5166
+ - `findings-applied` — REFUSES `verified_stage_version`. Applying fixes
5167
+ is not verifying them, so this status deliberately CLEARS the recorded
5168
+ freshness and leaves the stage's verification outstanding until a later
5169
+ `passed` records a revision.
5170
+ - `skipped` and `auto-verify-pending` — accept none of the three.
5171
+
5172
+ `passed` and `findings-reported` additionally refuse a
5173
+ `verified_stage_version` that is stale against the served stage's current
5174
+ version. The persisted shape is `references/pipeline-state-schema.json`.
5175
+ findings_file: Path to the findings document, relative to and contained by
5176
+ the resolved feature/epic directory. Required by `findings-reported`,
5177
+ optional on `passed` (the attached-report cases above); rejected when
5178
+ absolute, containing `..`, or carrying NUL/control characters
5179
+ (REQ-SEC-01).
5180
+ findings_count: Number of findings in `findings_file`. Required alongside it.
5181
+ verified_stage_version: The served stage's `version` at verification time,
5182
+ feeding the navigator's freshness ledger. Cleared by
5183
+ `findings-applied`, which deliberately does not claim freshness.
5184
+ commit_hash: Commit-2 mode. Full 40-hex only, validated by
5185
+ `FULL_GIT_HASH_RE.fullmatch`; abbreviations are rejected rather than
5186
+ expanded. Mutually exclusive with `status`.
5187
+
5188
+ Returns:
5189
+ The emitted JSON result: the written verify entry plus the resolved target
5190
+ path, so the caller can report what landed without re-reading state.
5191
+
5192
+ Raises:
5193
+ UsageError: Mixed modes, invalid metadata, invalid hash, missing entry,
5194
+ unsafe/ambiguous target, or atomic write failure.
5195
+ """
5196
+ # --- Mode exclusivity, before anything is resolved or loaded. -------------
5197
+ if status is None and commit_hash is None:
5198
+ raise UsageError(
5199
+ "state-verify needs exactly one mode: --status <result> to record a "
5200
+ "verification transition, or --commit-hash <40-hex> to record Commit-2 "
5201
+ "provenance for an existing entry"
5202
+ )
5203
+ if status is not None and commit_hash is not None:
5204
+ raise UsageError(
5205
+ "--status and --commit-hash are mutually exclusive: a result write "
5206
+ "records commitHash null (Commit 1), and the hash lands in a separate "
5207
+ "commit-2 call"
5208
+ )
5209
+ if commit_hash is not None:
5210
+ # Commit-2 carries provenance for an entry that ALREADY exists, so every
5211
+ # result field must be absent: a hash arriving next to findings metadata
5212
+ # means the caller conflated the two writes.
5213
+ for label, value in (
5214
+ ("--findings-file", findings_file),
5215
+ ("--findings-count", findings_count),
5216
+ ("--verified-stage-version", verified_stage_version),
5217
+ ):
5218
+ if value is not None:
5219
+ raise UsageError(
5220
+ f"--commit-hash records provenance for an existing entry and "
5221
+ f"changes only its commitHash, so it does not accept {label}. "
5222
+ f"Record the result with --status first, commit, then re-run "
5223
+ f"with --commit-hash alone."
5224
+ )
5225
+ _assert_full_commit_hash(commit_hash)
5226
+ elif status not in VERIFY_RESULT_STATUSES:
5227
+ known = ", ".join(VERIFY_RESULT_STATUSES)
5228
+ raise UsageError(f"unknown --status {status!r}; expected one of {known}")
5229
+
5230
+ # --- Target selection: epic before the token map. --------------
5231
+ is_epic_target = stage == "forge-0-epic"
5232
+ if is_epic_target:
5233
+ verify_key = "forge-verify-epic"
5234
+ else:
5235
+ token = VERIFY_TOKEN_BY_STAGE.get(stage)
5236
+ if token is None:
5237
+ raise UsageError(
5238
+ f"{stage} has no verification token, so it has no forge-verify-* entry "
5239
+ f"to write; expected one of {', '.join(VERIFY_STAGES)}"
5240
+ )
5241
+ verify_key = f"forge-verify-{token}"
5242
+
5243
+ # --- Commit-2 provenance mode. ---------------------------------
5244
+ # Commit 1 recorded the result with `commitHash: null`; this second, targeted
5245
+ # write records the hash of THAT commit. Nothing here invokes Git, rewrites
5246
+ # history, or amends — the two commits stay two commits (REQ-STATE-04).
5247
+ if commit_hash is not None:
5248
+ state_path, state, _ = _load_verify_target(
5249
+ specs_dir, feature, epic, is_epic_target
5250
+ )
5251
+ entry = _verify_entry(state, verify_key)
5252
+ if not entry:
5253
+ raise UsageError(
5254
+ f"--commit-hash records provenance for an existing {verify_key} "
5255
+ f"entry, and {feature} has none. Record the verification result "
5256
+ f"with --status first, commit it, then re-run with --commit-hash."
5257
+ )
5258
+ # In place, so status, findings metadata, scheduling metadata, timestamps
5259
+ # and versions are all left exactly as Commit 1 wrote them.
5260
+ entry["commitHash"] = commit_hash
5261
+ written = _commit_state(state_path, state)
5262
+ return {
5263
+ "feature": feature,
5264
+ "stage": stage,
5265
+ "verifyKey": verify_key,
5266
+ "statePath": str(state_path),
5267
+ "entry": entry,
5268
+ "updatedAt": written["updatedAt"],
5269
+ }
5270
+
5271
+ # --- Metadata validation that needs no state. ------------------
5272
+ if verified_stage_version is not None:
5273
+ _require_positive_int(verified_stage_version, "--verified-stage-version")
5274
+ if findings_count is not None and (
5275
+ isinstance(findings_count, bool) or not isinstance(findings_count, int)
5276
+ ):
5277
+ raise UsageError(f"--findings-count must be an integer; got {findings_count!r}")
5278
+
5279
+ if status in ("auto-verify-pending", "skipped"):
5280
+ for label, value in (
5281
+ ("--findings-file", findings_file),
5282
+ ("--findings-count", findings_count),
5283
+ ("--verified-stage-version", verified_stage_version),
5284
+ ):
5285
+ if value is not None:
5286
+ raise UsageError(f"--status {status} does not accept {label}")
5287
+ elif status == "passed":
5288
+ if findings_file is not None and findings_count is None:
5289
+ raise UsageError(
5290
+ "--status passed with an advisory --findings-file requires "
5291
+ "--findings-count N (the number of advisory findings it lists)"
5292
+ )
5293
+ if findings_count is not None:
5294
+ if findings_count < 0:
5295
+ raise UsageError(
5296
+ f"--findings-count must not be negative; got {findings_count!r}"
5297
+ )
5298
+ if findings_count > 0 and findings_file is None:
5299
+ raise UsageError(
5300
+ f"--status passed with --findings-count {findings_count} requires "
5301
+ f"--findings-file <advisory report>: a positive count with no "
5302
+ f"report to read is unrecoverable. Blocking findings belong to "
5303
+ f"--status findings-reported instead."
5304
+ )
5305
+ if findings_count == 0 and findings_file is not None:
5306
+ raise UsageError(
5307
+ "--status passed with --findings-file requires --findings-count "
5308
+ ">= 1: an attached report claiming zero findings is "
5309
+ "self-contradictory — omit both for a clean pass"
5310
+ )
5311
+ if verified_stage_version is None:
5312
+ raise UsageError(
5313
+ "--status passed requires --verified-stage-version <current version>"
5314
+ )
5315
+ elif status == "findings-reported":
5316
+ if verified_stage_version is None:
5317
+ raise UsageError(
5318
+ "--status findings-reported requires --verified-stage-version "
5319
+ "<current version>"
5320
+ )
5321
+ if findings_file is None:
5322
+ raise UsageError(
5323
+ "--status findings-reported requires --findings-file <path relative "
5324
+ "to the feature directory>"
5325
+ )
5326
+ if findings_count is None:
5327
+ raise UsageError("--status findings-reported requires --findings-count N")
5328
+ if findings_count < 0:
5329
+ raise UsageError(
5330
+ f"--findings-count must not be negative; got {findings_count!r}"
5331
+ )
5332
+ elif verified_stage_version is not None: # findings-applied
5333
+ raise UsageError(
5334
+ "--status findings-applied does not accept --verified-stage-version: "
5335
+ "applying fixes deliberately CLEARS freshness, so only a later "
5336
+ "--status passed may record a verified revision"
5337
+ )
5338
+
5339
+ state_path, state, epic_revision = _load_verify_target(
5340
+ specs_dir, feature, epic, is_epic_target
5341
+ )
5342
+ target_dir = state_path.parent
5343
+ if findings_file is not None:
5344
+ _validated_findings_file(findings_file, target_dir)
5345
+
5346
+ if status == "skipped":
5347
+ current = None
5348
+ elif is_epic_target:
5349
+ # The epic's artifact revision is the manifest revision — never a member's
5350
+ # production-stage version.
5351
+ current = epic_revision
5352
+ else:
5353
+ current = _current_artifact_version(state, stage)
5354
+ if status in ("passed", "findings-reported") and verified_stage_version != current:
5355
+ at = (
5356
+ f"{feature}'s manifest is at revision {current}"
5357
+ if is_epic_target
5358
+ else f"{stage} is at version {current}"
5359
+ )
5360
+ raise UsageError(
5361
+ f"--verified-stage-version {verified_stage_version} is stale: {at}. "
5362
+ f"Re-run verification against the current artifact."
5363
+ )
5364
+
5365
+ prior = _verify_entry(state, verify_key)
5366
+ if status == "auto-verify-pending" and prior.get("status") == "findings-reported":
5367
+ # `_verify_result_entry` REPLACES the entry, so scheduling over a report
5368
+ # for the current revision would delete its `findingsFile`/`findingsCount`
5369
+ # and break the later `findings-applied` precondition (REQ-EXIT-04's
5370
+ # forbidden clobber, reached through the CLI instead of a branch exit).
5371
+ # A report against a since-revised artifact is superseded normally.
5372
+ reported = prior.get("verifiedStageVersion")
5373
+ if (
5374
+ isinstance(reported, int)
5375
+ and not isinstance(reported, bool)
5376
+ and current is not None
5377
+ and reported == current
5378
+ ):
5379
+ raise UsageError(
5380
+ f"--status auto-verify-pending would replace {verify_key}'s "
5381
+ f"findings-reported entry for the current revision and delete its "
5382
+ f"report metadata ({prior.get('findingsFile')!r}, "
5383
+ f"findingsCount {prior.get('findingsCount')!r}). Apply the report "
5384
+ f"via forge-fix (--status findings-applied) or re-verify to a "
5385
+ f"terminal status; scheduling is valid only after the artifact "
5386
+ f"is revised."
5387
+ )
5388
+ if status == "findings-applied":
5389
+ if prior.get("status") not in ("findings-reported", "findings-applied"):
5390
+ raise UsageError(
5391
+ f"--status findings-applied requires an existing {verify_key} entry "
5392
+ f"with status findings-reported (or findings-applied); found "
5393
+ f"{prior.get('status')!r}"
5394
+ )
5395
+ for label, key, supplied in (
5396
+ ("--findings-file", "findingsFile", findings_file),
5397
+ ("--findings-count", "findingsCount", findings_count),
5398
+ ):
5399
+ if supplied is not None and supplied != prior.get(key):
5400
+ raise UsageError(
5401
+ f"{label} {supplied!r} does not match the recorded report "
5402
+ f"({key}: {prior.get(key)!r}); fix the value or omit the flag"
5403
+ )
5404
+
5405
+ entry = _verify_result_entry(
5406
+ status, prior, current, findings_file, findings_count, _now_iso()
5407
+ )
5408
+ state.setdefault("stages", {})[verify_key] = entry
5409
+ written = _commit_state(state_path, state)
5410
+ return {
5411
+ "feature": feature,
5412
+ "stage": stage,
5413
+ "verifyKey": verify_key,
5414
+ "statePath": str(state_path),
5415
+ "entry": entry,
5416
+ "updatedAt": written["updatedAt"],
5417
+ }
5418
+
5419
+
2545
5420
  def _print_state_enter(state: dict) -> None:
2546
5421
  """Print the one-line human summary for `state-enter`."""
2547
5422
  print(f"entered {state['currentStage']} (in-progress) for {state['feature']}")
@@ -2596,6 +5471,31 @@ def _print_state_decision(state: dict) -> None:
2596
5471
  print(f"deferred decision recorded (raisedBy {routing})")
2597
5472
 
2598
5473
 
5474
+ def _print_state_verify(result: dict, commit_hash: str | None = None) -> None:
5475
+ """Print the one-line human summary for `state-verify` (one per mode).
5476
+
5477
+ Takes the verb's RESULT dict (entry + resolved path), not a state document —
5478
+ `state-verify` is the one verb whose echo is the written entry rather than the
5479
+ whole file. Commit-2 mode gets its own line: reporting the untouched status
5480
+ would read as if the result had just been re-written.
5481
+ """
5482
+ entry = result["entry"]
5483
+ if commit_hash is not None:
5484
+ print(f"recorded {result['verifyKey']} commitHash: {commit_hash}")
5485
+ return
5486
+ detail = ""
5487
+ if entry.get("findingsFile"):
5488
+ detail = f" ({entry.get('findingsCount')} in {entry['findingsFile']})"
5489
+ elif entry.get("scheduledStageVersion") is not None:
5490
+ detail = f" (scheduled at v{entry['scheduledStageVersion']})"
5491
+ elif entry.get("verifiedStageVersion") is not None:
5492
+ detail = f" (v{entry['verifiedStageVersion']})"
5493
+ print(
5494
+ f"recorded {result['verifyKey']} = {entry['status']} for "
5495
+ f"{result['feature']}{detail}"
5496
+ )
5497
+
5498
+
2599
5499
  def _print_state_ecr(state: dict) -> None:
2600
5500
  """Print the one-line human summary for `state-ecr` (the item appended)."""
2601
5501
  item = state["epicChangeRequests"][-1]
@@ -2640,7 +5540,15 @@ def _print_rank_table(rows: list[FeatureRow], counts: dict[str, int]) -> None:
2640
5540
  nxt = row["nextCommand"] or "complete"
2641
5541
  print(f" {marker} {label}: {row['currentStage']} — next: {nxt}")
2642
5542
  if row["verifyPending"]:
2643
- print(f" (verify available: {row['verifyCommand']})")
5543
+ # Owed automatic verification is an obligation, not an offer — the
5544
+ # full diagnostic sentence went to stderr, so keep this line honest
5545
+ # rather than repeating it (REQ-DEBT-02).
5546
+ offer = (
5547
+ "automatic verification owed"
5548
+ if row["verifyState"] == "auto-pending"
5549
+ else "verify available"
5550
+ )
5551
+ print(f" ({offer}: {row['verifyCommand']})")
2644
5552
 
2645
5553
 
2646
5554
  def _print_context(usage: dict) -> None:
@@ -2656,8 +5564,29 @@ def _print_context(usage: dict) -> None:
2656
5564
  )
2657
5565
 
2658
5566
 
5567
+ class _ErrorPrefixParser(argparse.ArgumentParser):
5568
+ """An argparse parser whose failures use this CLI's ``Error: ...`` exit-2 form.
5569
+
5570
+ ``stage-exit`` carries two contracts that argparse cannot satisfy together out
5571
+ of the box: its enum flags MUST be registered with typed ``choices`` drawn from
5572
+ the shared literal domains, AND any invalid input must print
5573
+ ``Error: <actionable message>`` to stderr and return exit 2 with no payload and
5574
+ no sentinel. Stock argparse leads with ``usage:``, so the reconciliation lives
5575
+ here rather than in a hand-rolled second validation pass that would drift from
5576
+ the ``choices`` it duplicates.
5577
+
5578
+ ``parse_args`` runs before ``main``'s ``UsageError`` handler, so this exits
5579
+ directly instead of raising. ``add_subparsers`` defaults ``parser_class`` to
5580
+ ``type(self)``, so every subcommand inherits the same form — matching the
5581
+ ``UsageError`` path they already share.
5582
+ """
5583
+
5584
+ def error(self, message: str) -> NoReturn: # noqa: D102 - argparse override
5585
+ self.exit(2, f"Error: {message}\nTry '{self.prog} --help' for usage.\n")
5586
+
5587
+
2659
5588
  def main() -> int:
2660
- parser = argparse.ArgumentParser(prog="forge-session.py", description=__doc__)
5589
+ parser = _ErrorPrefixParser(prog="forge-session.py", description=__doc__)
2661
5590
  sub = parser.add_subparsers(dest="cmd", required=True)
2662
5591
 
2663
5592
  p_rank = sub.add_parser("rank-features", help="Rank active features by recency")
@@ -2712,13 +5641,29 @@ def main() -> int:
2712
5641
  p_exit.add_argument("--feature", required=True,
2713
5642
  help="Feature name (the epic name for forge-0-epic)")
2714
5643
  p_exit.add_argument("--stage", required=True, choices=EXIT_STAGES,
2715
- help="The just-completed authoring stage")
5644
+ help="The just-completed stage (or branch skill)")
5645
+ p_exit.add_argument("--served-stage", default=None, dest="served_stage",
5646
+ choices=_EXIT_PRODUCTION_STAGES,
5647
+ help="Production stage a verify/fix diversion served")
5648
+ p_exit.add_argument("--verify-mode", default=None, dest="verify_mode",
5649
+ choices=tuple(VERIFY_MODE_TO_STAGE),
5650
+ help="Verify mode; maps to --served-stage when unique")
5651
+ # No argparse `choices`: the accepted outcome domain differs per stage, which
5652
+ # argparse cannot express. `stage_exit` validates it against EXIT_OUTCOMES.
5653
+ p_exit.add_argument("--outcome", default=None,
5654
+ help="Stage-specific outcome (loop/docs/verify/fix only)")
5655
+ p_exit.add_argument("--owner", default=None, choices=get_args(ExitOwner),
5656
+ help="Branch terminal ownership (forge-verify/forge-fix only)")
5657
+ p_exit.add_argument("--verify-capability", default="manual",
5658
+ dest="verify_capability", choices=get_args(VerifyCapability),
5659
+ help="interactive only with BOTH a question mechanism and "
5660
+ "permitted clean-room verifier dispatch")
2716
5661
  p_exit.add_argument("--specs-dir", default="./specs", help="Specs directory")
2717
5662
  p_exit.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
2718
5663
  p_exit.add_argument("--epic", default=None, help="Epic name for a nested member")
2719
5664
  p_exit.add_argument("--next-feature", default=None, dest="next_feature",
2720
5665
  help="First actionable feature (epic handoff next-command arg)")
2721
- p_exit.add_argument("--host", default="claude", choices=("claude", "generic", "pi"),
5666
+ p_exit.add_argument("--host", default="claude", choices=EXIT_HOSTS,
2722
5667
  help="Host wording for the NEXT-STEPS block")
2723
5668
  p_exit.add_argument("--json", action="store_true", dest="json_output")
2724
5669
 
@@ -2842,6 +5787,34 @@ def main() -> int:
2842
5787
  p_ecr.add_argument("--epic", default=None, help="Epic name for a nested member")
2843
5788
  p_ecr.add_argument("--json", action="store_true", dest="json_output")
2844
5789
 
5790
+ p_ver = sub.add_parser(
5791
+ "state-verify", help="Write one forge-verify-* transition (result or provenance)"
5792
+ )
5793
+ p_ver.add_argument("--feature", required=True,
5794
+ help="Feature name (the EPIC name for --stage forge-0-epic)")
5795
+ p_ver.add_argument("--stage", required=True, choices=VERIFY_STAGES,
5796
+ help="The production stage this verify entry serves "
5797
+ "(forge-6-docs has no verification token)")
5798
+ p_ver.add_argument("--status", default=None, choices=VERIFY_RESULT_STATUSES,
5799
+ help="Result mode: the transition to record "
5800
+ "(mutually exclusive with --commit-hash)")
5801
+ p_ver.add_argument("--findings-file", default=None, dest="findings_file",
5802
+ metavar="PATH",
5803
+ help="Findings document, relative to and contained by the "
5804
+ "feature directory (required by findings-reported)")
5805
+ p_ver.add_argument("--findings-count", type=int, default=None,
5806
+ dest="findings_count", metavar="N",
5807
+ help="Number of findings in --findings-file (0 is meaningful)")
5808
+ p_ver.add_argument("--verified-stage-version", type=int, default=None,
5809
+ dest="verified_stage_version", metavar="N",
5810
+ help="The served stage's current version, for the freshness "
5811
+ "ledger (rejected by findings-applied)")
5812
+ p_ver.add_argument("--commit-hash", default=None, dest="commit_hash",
5813
+ help="Commit-2 provenance mode: the full 40-hex Commit-1 hash")
5814
+ p_ver.add_argument("--specs-dir", default="./specs", help="Specs directory")
5815
+ p_ver.add_argument("--epic", default=None, help="Epic name for a nested member")
5816
+ p_ver.add_argument("--json", action="store_true", dest="json_output")
5817
+
2845
5818
  args = parser.parse_args()
2846
5819
 
2847
5820
  try:
@@ -2925,6 +5898,11 @@ def main() -> int:
2925
5898
  args.epic,
2926
5899
  args.host,
2927
5900
  args.next_feature,
5901
+ args.served_stage,
5902
+ args.verify_mode,
5903
+ args.outcome,
5904
+ args.owner,
5905
+ args.verify_capability,
2928
5906
  )
2929
5907
  if args.json_output:
2930
5908
  print(json.dumps(payload, indent=2, ensure_ascii=False))
@@ -3023,6 +6001,25 @@ def main() -> int:
3023
6001
  _emit(payload, args.json_output, _print_state_ecr)
3024
6002
  return 0
3025
6003
 
6004
+ if args.cmd == "state-verify":
6005
+ payload = cmd_state_verify(
6006
+ args.feature,
6007
+ args.stage,
6008
+ Path(args.specs_dir),
6009
+ args.epic,
6010
+ status=args.status,
6011
+ findings_file=args.findings_file,
6012
+ findings_count=args.findings_count,
6013
+ verified_stage_version=args.verified_stage_version,
6014
+ commit_hash=args.commit_hash,
6015
+ )
6016
+ _emit(
6017
+ payload,
6018
+ args.json_output,
6019
+ lambda result: _print_state_verify(result, args.commit_hash),
6020
+ )
6021
+ return 0
6022
+
3026
6023
  raise UsageError(f"unknown command: {args.cmd}")
3027
6024
  except UsageError as exc:
3028
6025
  print(f"Error: {exc}", file=sys.stderr)