@garygentry/feature-forge 0.3.2 → 0.3.4

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 (384) hide show
  1. package/adapters/claude/.feature-forge-bundle.json +1 -1
  2. package/adapters/claude/agents/forge-verifier.md +3 -1
  3. package/adapters/claude/references/decisions/single-writer-threat-model.md +53 -0
  4. package/adapters/claude/references/epic-state-schema.json +50 -0
  5. package/adapters/claude/references/forge-config-schema.json +18 -0
  6. package/adapters/claude/references/forge-decisions-schema.json +33 -0
  7. package/adapters/claude/references/pipeline-state-schema.json +34 -2
  8. package/adapters/claude/references/ralph-loop-contract.md +6 -3
  9. package/adapters/claude/references/shared-conventions.md +15 -4
  10. package/adapters/claude/references/stage-exit-protocol.md +55 -11
  11. package/adapters/claude/scripts/epic-manifest.py +82 -4
  12. package/adapters/claude/scripts/fix-sweep.py +1180 -0
  13. package/adapters/claude/scripts/forge-session.py +1124 -26
  14. package/adapters/claude/skills/forge/SKILL.md +5 -5
  15. package/adapters/claude/skills/forge/references/pipeline-state-schema.json +34 -2
  16. package/adapters/claude/skills/forge/references/shared-conventions.md +15 -4
  17. package/adapters/claude/skills/forge/references/stage-exit-protocol.md +55 -11
  18. package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +5 -1
  19. package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +5 -0
  20. package/adapters/claude/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  21. package/adapters/claude/skills/forge-0-epic/references/shared-conventions.md +15 -4
  22. package/adapters/claude/skills/forge-0-epic/references/stage-exit-protocol.md +55 -11
  23. package/adapters/claude/skills/forge-1-prd/SKILL.md +3 -1
  24. package/adapters/claude/skills/forge-1-prd/references/shared-conventions.md +15 -4
  25. package/adapters/claude/skills/forge-1-prd/references/stage-exit-protocol.md +55 -11
  26. package/adapters/claude/skills/forge-2-tech/SKILL.md +5 -1
  27. package/adapters/claude/skills/forge-2-tech/references/shared-conventions.md +15 -4
  28. package/adapters/claude/skills/forge-2-tech/references/stage-exit-protocol.md +55 -11
  29. package/adapters/claude/skills/forge-3-specs/SKILL.md +5 -1
  30. package/adapters/claude/skills/forge-3-specs/references/shared-conventions.md +15 -4
  31. package/adapters/claude/skills/forge-3-specs/references/stage-exit-protocol.md +55 -11
  32. package/adapters/claude/skills/forge-4-backlog/SKILL.md +41 -3
  33. package/adapters/claude/skills/forge-4-backlog/references/shared-conventions.md +15 -4
  34. package/adapters/claude/skills/forge-4-backlog/references/stage-exit-protocol.md +55 -11
  35. package/adapters/claude/skills/forge-5-loop/SKILL.md +36 -36
  36. package/adapters/claude/skills/forge-5-loop/references/agent-selection.md +16 -0
  37. package/adapters/claude/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  38. package/adapters/claude/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  39. package/adapters/claude/skills/forge-5-loop/references/result-reporting.md +40 -11
  40. package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +22 -4
  41. package/adapters/claude/skills/forge-5-loop/references/shared-conventions.md +15 -4
  42. package/adapters/claude/skills/forge-5-loop/references/stage-exit-protocol.md +55 -11
  43. package/adapters/claude/skills/forge-6-docs/SKILL.md +29 -6
  44. package/adapters/claude/skills/forge-6-docs/references/shared-conventions.md +15 -4
  45. package/adapters/claude/skills/forge-6-docs/references/stage-exit-protocol.md +55 -11
  46. package/adapters/claude/skills/forge-fix/SKILL.md +34 -0
  47. package/adapters/claude/skills/forge-fix/references/shared-conventions.md +15 -4
  48. package/adapters/claude/skills/forge-fix/references/stage-exit-protocol.md +55 -11
  49. package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +18 -0
  50. package/adapters/claude/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  51. package/adapters/claude/skills/forge-guide/references/shared-conventions.md +15 -4
  52. package/adapters/claude/skills/forge-verify/SKILL.md +10 -11
  53. package/adapters/claude/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  54. package/adapters/claude/skills/forge-verify/references/findings-template.md +30 -0
  55. package/adapters/claude/skills/forge-verify/references/shared-conventions.md +15 -4
  56. package/adapters/claude/skills/forge-verify/references/stage-exit-protocol.md +55 -11
  57. package/adapters/claude/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  58. package/adapters/claude/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  59. package/adapters/claude/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  60. package/adapters/codex/.feature-forge-bundle.json +1 -1
  61. package/adapters/codex/agents/forge-verifier.toml +3 -1
  62. package/adapters/codex/references/decisions/single-writer-threat-model.md +53 -0
  63. package/adapters/codex/references/epic-state-schema.json +50 -0
  64. package/adapters/codex/references/forge-config-schema.json +18 -0
  65. package/adapters/codex/references/forge-decisions-schema.json +33 -0
  66. package/adapters/codex/references/pipeline-state-schema.json +34 -2
  67. package/adapters/codex/references/process-overview.md +2 -2
  68. package/adapters/codex/references/ralph-loop-contract.md +6 -3
  69. package/adapters/codex/references/shared-conventions.md +44 -33
  70. package/adapters/codex/references/stage-exit-protocol.md +64 -20
  71. package/adapters/codex/scripts/epic-manifest.py +82 -4
  72. package/adapters/codex/scripts/fix-sweep.py +1180 -0
  73. package/adapters/codex/scripts/forge-session.py +1124 -26
  74. package/adapters/codex/skills/forge/SKILL.md +8 -8
  75. package/adapters/codex/skills/forge/references/pipeline-state-schema.json +34 -2
  76. package/adapters/codex/skills/forge/references/process-overview.md +2 -2
  77. package/adapters/codex/skills/forge/references/shared-conventions.md +44 -33
  78. package/adapters/codex/skills/forge/references/stage-exit-protocol.md +64 -20
  79. package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +14 -10
  80. package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  81. package/adapters/codex/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  82. package/adapters/codex/skills/forge-0-epic/references/shared-conventions.md +44 -33
  83. package/adapters/codex/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
  84. package/adapters/codex/skills/forge-1-prd/SKILL.md +3 -1
  85. package/adapters/codex/skills/forge-1-prd/references/shared-conventions.md +44 -33
  86. package/adapters/codex/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
  87. package/adapters/codex/skills/forge-2-tech/SKILL.md +5 -1
  88. package/adapters/codex/skills/forge-2-tech/references/shared-conventions.md +44 -33
  89. package/adapters/codex/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
  90. package/adapters/codex/skills/forge-3-specs/SKILL.md +5 -1
  91. package/adapters/codex/skills/forge-3-specs/references/shared-conventions.md +44 -33
  92. package/adapters/codex/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
  93. package/adapters/codex/skills/forge-4-backlog/SKILL.md +41 -3
  94. package/adapters/codex/skills/forge-4-backlog/references/shared-conventions.md +44 -33
  95. package/adapters/codex/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
  96. package/adapters/codex/skills/forge-5-loop/SKILL.md +36 -36
  97. package/adapters/codex/skills/forge-5-loop/references/agent-selection.md +17 -1
  98. package/adapters/codex/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  99. package/adapters/codex/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  100. package/adapters/codex/skills/forge-5-loop/references/result-reporting.md +40 -11
  101. package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +26 -8
  102. package/adapters/codex/skills/forge-5-loop/references/shared-conventions.md +44 -33
  103. package/adapters/codex/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
  104. package/adapters/codex/skills/forge-6-docs/SKILL.md +29 -6
  105. package/adapters/codex/skills/forge-6-docs/references/shared-conventions.md +44 -33
  106. package/adapters/codex/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
  107. package/adapters/codex/skills/forge-fix/SKILL.md +34 -0
  108. package/adapters/codex/skills/forge-fix/references/shared-conventions.md +44 -33
  109. package/adapters/codex/skills/forge-fix/references/stage-exit-protocol.md +64 -20
  110. package/adapters/codex/skills/forge-guide/SKILL.md +1 -1
  111. package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +18 -0
  112. package/adapters/codex/skills/forge-guide/references/process-overview.md +2 -2
  113. package/adapters/codex/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  114. package/adapters/codex/skills/forge-guide/references/shared-conventions.md +44 -33
  115. package/adapters/codex/skills/forge-init/SKILL.md +1 -1
  116. package/adapters/codex/skills/forge-verify/SKILL.md +11 -12
  117. package/adapters/codex/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  118. package/adapters/codex/skills/forge-verify/references/findings-template.md +32 -2
  119. package/adapters/codex/skills/forge-verify/references/shared-conventions.md +44 -33
  120. package/adapters/codex/skills/forge-verify/references/stage-exit-protocol.md +64 -20
  121. package/adapters/codex/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  122. package/adapters/codex/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  123. package/adapters/codex/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  124. package/adapters/codex/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  125. package/adapters/copilot/.feature-forge-bundle.json +1 -1
  126. package/adapters/copilot/agents/forge-verifier.md +3 -1
  127. package/adapters/copilot/references/decisions/single-writer-threat-model.md +53 -0
  128. package/adapters/copilot/references/epic-state-schema.json +50 -0
  129. package/adapters/copilot/references/forge-config-schema.json +18 -0
  130. package/adapters/copilot/references/forge-decisions-schema.json +33 -0
  131. package/adapters/copilot/references/pipeline-state-schema.json +34 -2
  132. package/adapters/copilot/references/process-overview.md +2 -2
  133. package/adapters/copilot/references/ralph-loop-contract.md +6 -3
  134. package/adapters/copilot/references/shared-conventions.md +44 -33
  135. package/adapters/copilot/references/stage-exit-protocol.md +64 -20
  136. package/adapters/copilot/scripts/epic-manifest.py +82 -4
  137. package/adapters/copilot/scripts/fix-sweep.py +1180 -0
  138. package/adapters/copilot/scripts/forge-session.py +1124 -26
  139. package/adapters/copilot/skills/forge/forge.md +8 -8
  140. package/adapters/copilot/skills/forge/references/pipeline-state-schema.json +34 -2
  141. package/adapters/copilot/skills/forge/references/process-overview.md +2 -2
  142. package/adapters/copilot/skills/forge/references/shared-conventions.md +44 -33
  143. package/adapters/copilot/skills/forge/references/stage-exit-protocol.md +64 -20
  144. package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +14 -10
  145. package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  146. package/adapters/copilot/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  147. package/adapters/copilot/skills/forge-0-epic/references/shared-conventions.md +44 -33
  148. package/adapters/copilot/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
  149. package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +3 -1
  150. package/adapters/copilot/skills/forge-1-prd/references/shared-conventions.md +44 -33
  151. package/adapters/copilot/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
  152. package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +5 -1
  153. package/adapters/copilot/skills/forge-2-tech/references/shared-conventions.md +44 -33
  154. package/adapters/copilot/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
  155. package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +5 -1
  156. package/adapters/copilot/skills/forge-3-specs/references/shared-conventions.md +44 -33
  157. package/adapters/copilot/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
  158. package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +41 -3
  159. package/adapters/copilot/skills/forge-4-backlog/references/shared-conventions.md +44 -33
  160. package/adapters/copilot/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
  161. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +36 -36
  162. package/adapters/copilot/skills/forge-5-loop/references/agent-selection.md +17 -1
  163. package/adapters/copilot/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  164. package/adapters/copilot/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  165. package/adapters/copilot/skills/forge-5-loop/references/result-reporting.md +40 -11
  166. package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +26 -8
  167. package/adapters/copilot/skills/forge-5-loop/references/shared-conventions.md +44 -33
  168. package/adapters/copilot/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
  169. package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +29 -6
  170. package/adapters/copilot/skills/forge-6-docs/references/shared-conventions.md +44 -33
  171. package/adapters/copilot/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
  172. package/adapters/copilot/skills/forge-fix/forge-fix.md +34 -0
  173. package/adapters/copilot/skills/forge-fix/references/shared-conventions.md +44 -33
  174. package/adapters/copilot/skills/forge-fix/references/stage-exit-protocol.md +64 -20
  175. package/adapters/copilot/skills/forge-guide/forge-guide.md +1 -1
  176. package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +18 -0
  177. package/adapters/copilot/skills/forge-guide/references/process-overview.md +2 -2
  178. package/adapters/copilot/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  179. package/adapters/copilot/skills/forge-guide/references/shared-conventions.md +44 -33
  180. package/adapters/copilot/skills/forge-init/forge-init.md +1 -1
  181. package/adapters/copilot/skills/forge-verify/forge-verify.md +11 -12
  182. package/adapters/copilot/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  183. package/adapters/copilot/skills/forge-verify/references/findings-template.md +32 -2
  184. package/adapters/copilot/skills/forge-verify/references/shared-conventions.md +44 -33
  185. package/adapters/copilot/skills/forge-verify/references/stage-exit-protocol.md +64 -20
  186. package/adapters/copilot/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  187. package/adapters/copilot/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  188. package/adapters/copilot/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  189. package/adapters/copilot/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  190. package/adapters/cursor/.feature-forge-bundle.json +1 -1
  191. package/adapters/cursor/agents/forge-verifier.mdc +3 -1
  192. package/adapters/cursor/references/decisions/single-writer-threat-model.md +53 -0
  193. package/adapters/cursor/references/epic-state-schema.json +50 -0
  194. package/adapters/cursor/references/forge-config-schema.json +18 -0
  195. package/adapters/cursor/references/forge-decisions-schema.json +33 -0
  196. package/adapters/cursor/references/pipeline-state-schema.json +34 -2
  197. package/adapters/cursor/references/process-overview.md +2 -2
  198. package/adapters/cursor/references/ralph-loop-contract.md +6 -3
  199. package/adapters/cursor/references/shared-conventions.md +44 -33
  200. package/adapters/cursor/references/stage-exit-protocol.md +64 -20
  201. package/adapters/cursor/scripts/epic-manifest.py +82 -4
  202. package/adapters/cursor/scripts/fix-sweep.py +1180 -0
  203. package/adapters/cursor/scripts/forge-session.py +1124 -26
  204. package/adapters/cursor/skills/forge/forge.mdc +8 -8
  205. package/adapters/cursor/skills/forge/references/pipeline-state-schema.json +34 -2
  206. package/adapters/cursor/skills/forge/references/process-overview.md +2 -2
  207. package/adapters/cursor/skills/forge/references/shared-conventions.md +44 -33
  208. package/adapters/cursor/skills/forge/references/stage-exit-protocol.md +64 -20
  209. package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +14 -10
  210. package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  211. package/adapters/cursor/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  212. package/adapters/cursor/skills/forge-0-epic/references/shared-conventions.md +44 -33
  213. package/adapters/cursor/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
  214. package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +3 -1
  215. package/adapters/cursor/skills/forge-1-prd/references/shared-conventions.md +44 -33
  216. package/adapters/cursor/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
  217. package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +5 -1
  218. package/adapters/cursor/skills/forge-2-tech/references/shared-conventions.md +44 -33
  219. package/adapters/cursor/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
  220. package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +5 -1
  221. package/adapters/cursor/skills/forge-3-specs/references/shared-conventions.md +44 -33
  222. package/adapters/cursor/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
  223. package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +41 -3
  224. package/adapters/cursor/skills/forge-4-backlog/references/shared-conventions.md +44 -33
  225. package/adapters/cursor/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
  226. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +36 -36
  227. package/adapters/cursor/skills/forge-5-loop/references/agent-selection.md +17 -1
  228. package/adapters/cursor/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  229. package/adapters/cursor/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  230. package/adapters/cursor/skills/forge-5-loop/references/result-reporting.md +40 -11
  231. package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +26 -8
  232. package/adapters/cursor/skills/forge-5-loop/references/shared-conventions.md +44 -33
  233. package/adapters/cursor/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
  234. package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +29 -6
  235. package/adapters/cursor/skills/forge-6-docs/references/shared-conventions.md +44 -33
  236. package/adapters/cursor/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
  237. package/adapters/cursor/skills/forge-fix/forge-fix.mdc +34 -0
  238. package/adapters/cursor/skills/forge-fix/references/shared-conventions.md +44 -33
  239. package/adapters/cursor/skills/forge-fix/references/stage-exit-protocol.md +64 -20
  240. package/adapters/cursor/skills/forge-guide/forge-guide.mdc +1 -1
  241. package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +18 -0
  242. package/adapters/cursor/skills/forge-guide/references/process-overview.md +2 -2
  243. package/adapters/cursor/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  244. package/adapters/cursor/skills/forge-guide/references/shared-conventions.md +44 -33
  245. package/adapters/cursor/skills/forge-init/forge-init.mdc +1 -1
  246. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +11 -12
  247. package/adapters/cursor/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  248. package/adapters/cursor/skills/forge-verify/references/findings-template.md +32 -2
  249. package/adapters/cursor/skills/forge-verify/references/shared-conventions.md +44 -33
  250. package/adapters/cursor/skills/forge-verify/references/stage-exit-protocol.md +64 -20
  251. package/adapters/cursor/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  252. package/adapters/cursor/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  253. package/adapters/cursor/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  254. package/adapters/cursor/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  255. package/adapters/gemini/.feature-forge-bundle.json +1 -1
  256. package/adapters/gemini/agents/forge-verifier.md +3 -1
  257. package/adapters/gemini/gemini-extension.json +1 -1
  258. package/adapters/gemini/references/decisions/single-writer-threat-model.md +53 -0
  259. package/adapters/gemini/references/epic-state-schema.json +50 -0
  260. package/adapters/gemini/references/forge-config-schema.json +18 -0
  261. package/adapters/gemini/references/forge-decisions-schema.json +33 -0
  262. package/adapters/gemini/references/pipeline-state-schema.json +34 -2
  263. package/adapters/gemini/references/process-overview.md +2 -2
  264. package/adapters/gemini/references/ralph-loop-contract.md +6 -3
  265. package/adapters/gemini/references/shared-conventions.md +44 -33
  266. package/adapters/gemini/references/stage-exit-protocol.md +64 -20
  267. package/adapters/gemini/scripts/epic-manifest.py +82 -4
  268. package/adapters/gemini/scripts/fix-sweep.py +1180 -0
  269. package/adapters/gemini/scripts/forge-session.py +1124 -26
  270. package/adapters/gemini/skills/forge/forge.md +8 -8
  271. package/adapters/gemini/skills/forge/references/pipeline-state-schema.json +34 -2
  272. package/adapters/gemini/skills/forge/references/process-overview.md +2 -2
  273. package/adapters/gemini/skills/forge/references/shared-conventions.md +44 -33
  274. package/adapters/gemini/skills/forge/references/stage-exit-protocol.md +64 -20
  275. package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +14 -10
  276. package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  277. package/adapters/gemini/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  278. package/adapters/gemini/skills/forge-0-epic/references/shared-conventions.md +44 -33
  279. package/adapters/gemini/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
  280. package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +3 -1
  281. package/adapters/gemini/skills/forge-1-prd/references/shared-conventions.md +44 -33
  282. package/adapters/gemini/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
  283. package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +5 -1
  284. package/adapters/gemini/skills/forge-2-tech/references/shared-conventions.md +44 -33
  285. package/adapters/gemini/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
  286. package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +5 -1
  287. package/adapters/gemini/skills/forge-3-specs/references/shared-conventions.md +44 -33
  288. package/adapters/gemini/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
  289. package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +41 -3
  290. package/adapters/gemini/skills/forge-4-backlog/references/shared-conventions.md +44 -33
  291. package/adapters/gemini/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
  292. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +36 -36
  293. package/adapters/gemini/skills/forge-5-loop/references/agent-selection.md +17 -1
  294. package/adapters/gemini/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  295. package/adapters/gemini/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  296. package/adapters/gemini/skills/forge-5-loop/references/result-reporting.md +40 -11
  297. package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +26 -8
  298. package/adapters/gemini/skills/forge-5-loop/references/shared-conventions.md +44 -33
  299. package/adapters/gemini/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
  300. package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +29 -6
  301. package/adapters/gemini/skills/forge-6-docs/references/shared-conventions.md +44 -33
  302. package/adapters/gemini/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
  303. package/adapters/gemini/skills/forge-fix/forge-fix.md +34 -0
  304. package/adapters/gemini/skills/forge-fix/references/shared-conventions.md +44 -33
  305. package/adapters/gemini/skills/forge-fix/references/stage-exit-protocol.md +64 -20
  306. package/adapters/gemini/skills/forge-guide/forge-guide.md +1 -1
  307. package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +18 -0
  308. package/adapters/gemini/skills/forge-guide/references/process-overview.md +2 -2
  309. package/adapters/gemini/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  310. package/adapters/gemini/skills/forge-guide/references/shared-conventions.md +44 -33
  311. package/adapters/gemini/skills/forge-init/forge-init.md +1 -1
  312. package/adapters/gemini/skills/forge-verify/forge-verify.md +11 -12
  313. package/adapters/gemini/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  314. package/adapters/gemini/skills/forge-verify/references/findings-template.md +32 -2
  315. package/adapters/gemini/skills/forge-verify/references/shared-conventions.md +44 -33
  316. package/adapters/gemini/skills/forge-verify/references/stage-exit-protocol.md +64 -20
  317. package/adapters/gemini/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  318. package/adapters/gemini/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  319. package/adapters/gemini/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  320. package/adapters/gemini/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  321. package/adapters/pi/.feature-forge-bundle.json +1 -1
  322. package/adapters/pi/agents/forge-verifier.md +3 -1
  323. package/adapters/pi/references/decisions/single-writer-threat-model.md +53 -0
  324. package/adapters/pi/references/epic-state-schema.json +50 -0
  325. package/adapters/pi/references/forge-config-schema.json +18 -0
  326. package/adapters/pi/references/forge-decisions-schema.json +33 -0
  327. package/adapters/pi/references/pipeline-state-schema.json +34 -2
  328. package/adapters/pi/references/process-overview.md +2 -2
  329. package/adapters/pi/references/ralph-loop-contract.md +6 -3
  330. package/adapters/pi/references/shared-conventions.md +33 -22
  331. package/adapters/pi/references/stage-exit-protocol.md +63 -19
  332. package/adapters/pi/scripts/epic-manifest.py +82 -4
  333. package/adapters/pi/scripts/fix-sweep.py +1180 -0
  334. package/adapters/pi/scripts/forge-session.py +1124 -26
  335. package/adapters/pi/skills/forge/SKILL.md +5 -5
  336. package/adapters/pi/skills/forge/references/pipeline-state-schema.json +34 -2
  337. package/adapters/pi/skills/forge/references/process-overview.md +2 -2
  338. package/adapters/pi/skills/forge/references/shared-conventions.md +33 -22
  339. package/adapters/pi/skills/forge/references/stage-exit-protocol.md +63 -19
  340. package/adapters/pi/skills/forge-0-epic/references/edit-mode.md +8 -4
  341. package/adapters/pi/skills/forge-0-epic/references/epic-manifest-subcommands.md +6 -1
  342. package/adapters/pi/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  343. package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +33 -22
  344. package/adapters/pi/skills/forge-0-epic/references/stage-exit-protocol.md +63 -19
  345. package/adapters/pi/skills/forge-1-prd/SKILL.md +3 -1
  346. package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +33 -22
  347. package/adapters/pi/skills/forge-1-prd/references/stage-exit-protocol.md +63 -19
  348. package/adapters/pi/skills/forge-2-tech/SKILL.md +5 -1
  349. package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +33 -22
  350. package/adapters/pi/skills/forge-2-tech/references/stage-exit-protocol.md +63 -19
  351. package/adapters/pi/skills/forge-3-specs/SKILL.md +5 -1
  352. package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +33 -22
  353. package/adapters/pi/skills/forge-3-specs/references/stage-exit-protocol.md +63 -19
  354. package/adapters/pi/skills/forge-4-backlog/SKILL.md +41 -3
  355. package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +33 -22
  356. package/adapters/pi/skills/forge-4-backlog/references/stage-exit-protocol.md +63 -19
  357. package/adapters/pi/skills/forge-5-loop/SKILL.md +36 -36
  358. package/adapters/pi/skills/forge-5-loop/references/agent-selection.md +16 -0
  359. package/adapters/pi/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  360. package/adapters/pi/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  361. package/adapters/pi/skills/forge-5-loop/references/result-reporting.md +40 -11
  362. package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +25 -7
  363. package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +33 -22
  364. package/adapters/pi/skills/forge-5-loop/references/stage-exit-protocol.md +63 -19
  365. package/adapters/pi/skills/forge-6-docs/SKILL.md +29 -6
  366. package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +33 -22
  367. package/adapters/pi/skills/forge-6-docs/references/stage-exit-protocol.md +63 -19
  368. package/adapters/pi/skills/forge-fix/SKILL.md +34 -0
  369. package/adapters/pi/skills/forge-fix/references/shared-conventions.md +33 -22
  370. package/adapters/pi/skills/forge-fix/references/stage-exit-protocol.md +63 -19
  371. package/adapters/pi/skills/forge-guide/references/forge-config-schema.json +18 -0
  372. package/adapters/pi/skills/forge-guide/references/process-overview.md +2 -2
  373. package/adapters/pi/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  374. package/adapters/pi/skills/forge-guide/references/shared-conventions.md +33 -22
  375. package/adapters/pi/skills/forge-verify/SKILL.md +10 -11
  376. package/adapters/pi/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  377. package/adapters/pi/skills/forge-verify/references/findings-template.md +31 -1
  378. package/adapters/pi/skills/forge-verify/references/shared-conventions.md +33 -22
  379. package/adapters/pi/skills/forge-verify/references/stage-exit-protocol.md +63 -19
  380. package/adapters/pi/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  381. package/adapters/pi/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  382. package/adapters/pi/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  383. package/adapters/pi/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  384. package/package.json +1 -1
@@ -14,9 +14,9 @@ root navigator:
14
14
  python3 forge-session.py check-epic-base --feature F [--specs-dir DIR] \
15
15
  [--config FILE] [--epic E] [--json]
16
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]
17
+ [--outcome O] [--cause dependency-starvation] [--verify-mode M] \
18
+ [--served-stage S] [--verify-capability interactive|manual] [--specs-dir DIR] \
19
+ [--config FILE] [--epic E] [--next-feature N] [--host claude|generic|pi] [--json]
20
20
  python3 forge-session.py effective-config [--config FILE] [--schema PATH] [--json]
21
21
 
22
22
  Plus the `state-*` write verbs, which author `.pipeline-state.json` so no stage
@@ -41,6 +41,15 @@ has to hand-write the JSON (and therefore no stage has to read the state schema)
41
41
  python3 forge-session.py state-verify --feature F --stage S [--status ST] \
42
42
  [--findings-file P] [--findings-count N] [--verified-stage-version N] \
43
43
  [--commit-hash H] [--specs-dir DIR] [--epic E] [--json]
44
+ python3 forge-session.py decision-record --backlog-dir DIR --item ID [--item ID ...] \
45
+ --question Q (--answer A | --deferred) [--cluster CID] [--actor LABEL] \
46
+ [--state-dir NAME] [--config PATH] [--json]
47
+ python3 forge-session.py decision-list --backlog-dir DIR [--unapplied] \
48
+ [--state-dir NAME] [--config PATH] [--json]
49
+ python3 forge-session.py decision-apply --backlog-dir DIR --item ID [--actor LABEL] \
50
+ [--state-dir NAME] [--config PATH] [--json]
51
+ python3 forge-session.py backlog-topology (--items-json PATH | --items-stdin) \
52
+ [--cluster] [--json]
44
53
 
45
54
  `rank-features` scans the specs tree for feature-shaped directories (those that
46
55
  directly contain a `.pipeline-state.json`, in both the flat
@@ -158,8 +167,10 @@ from __future__ import annotations
158
167
 
159
168
  import argparse
160
169
  import json
170
+ import math
161
171
  import os
162
172
  import re
173
+ import socket
163
174
  import subprocess
164
175
  import sys
165
176
  import tempfile
@@ -240,8 +251,15 @@ VERIFY_TOKEN_BY_STAGE: Final[dict[str, str]] = {
240
251
  #: there is no `forge-verify-*` key for it to write.
241
252
  VERIFY_STAGES: Final[tuple[str, ...]] = ("forge-0-epic", *VERIFY_TOKEN_BY_STAGE)
242
253
 
243
- #: A production stage status that counts as "done" for next-stage selection.
254
+ #: The terminal status the completion writer records (and the commit-hash
255
+ #: follow-up requires) — NOT the whole "done for selection" set below.
244
256
  _DONE_STATUS: Final = "complete"
257
+ #: Production stage statuses that count as "done" for next-stage selection.
258
+ #: `skipped` is legal only on forge-6-docs (schema: `docsStageEntry`) — an
259
+ #: explicitly skipped documentation stage ends the pipeline without claiming
260
+ #: artifacts it never produced (#197). Selection treats the status as done
261
+ #: wherever it appears; the schema is what confines it to the docs stage.
262
+ _DONE_STATUSES: Final = frozenset({_DONE_STATUS, "skipped"})
245
263
  #: The authoritative forge-verify status vocabulary. SOURCE OF TRUTH:
246
264
  #: references/pipeline-state-schema.json (definitions.verifyEntry.properties.status.enum).
247
265
  #: A status outside this set is unrecognized and must not be silently interpreted (#148).
@@ -261,6 +279,15 @@ KNOWN_VERIFY_STATUSES: Final = frozenset(
261
279
  #: subset of KNOWN_VERIFY_STATUSES — not collapsible into it (different meaning).
262
280
  #: `auto-verify-pending` is deliberately ABSENT: owed-but-unrun debt is not resolved.
263
281
  _VERIFY_RESOLVED: Final = frozenset({"passed", "findings-applied", "skipped"})
282
+ #: Prior verify statuses a `skipped` result write may NOT replace (#203). Mirrors
283
+ #: epic-manifest.py's `_VERIFY_ORCH_COMPLETE`: these two statuses make an epic
284
+ #: member complete-for-orchestration, so silently replacing one with `skipped`
285
+ #: demoted the member out of the rollup and fabricated unmetDeps on every
286
+ #: dependent — the observed 5/6 → 1/6 collapse. A deferral over one of these
287
+ #: needs NO write: the recorded result already carries the outstanding state.
288
+ #: NOT the same set as `_VERIFY_RESOLVED` (`skipped` re-writing `skipped` is a
289
+ #: harmless idempotent refresh and stays legal).
290
+ _SKIP_PROTECTED_PRIOR: Final = frozenset({"passed", "findings-applied"})
264
291
  #: Per-process dedupe for the unknown-verify-status diagnostic (#148) so a single
265
292
  #: bogus status is flagged once, not once per verify_state() call in a command.
266
293
  _UNKNOWN_VERIFY_WARNED: set[str] = set()
@@ -371,8 +398,10 @@ VerifyStatus = Literal[
371
398
  #: Which gate form a stage exit asks the caller to render.
372
399
  VerifyGate = Literal["none", "standard", "manual-print"]
373
400
 
374
- LoopOutcome = Literal["complete", "partial", "blocked", "needs-human", "deferred"]
375
- DocsOutcome = Literal["complete", "blocked"]
401
+ LoopOutcome = Literal[
402
+ "complete", "partial", "blocked", "needs-human", "deferred", "resolved"
403
+ ]
404
+ DocsOutcome = Literal["complete", "blocked", "skipped"]
376
405
  VerifyOutcome = Literal["passed", "findings", "skipped", "failed"]
377
406
  FixOutcome = Literal[
378
407
  "no-findings",
@@ -414,6 +443,27 @@ VERIFY_MODE_TO_STAGE: Final[dict[str, str]] = {
414
443
  "backlog": "forge-4-backlog",
415
444
  "impl": "forge-5-loop",
416
445
  }
446
+ #: Token-set Jaccard edge threshold for ``cluster_blocked``: two blocked items whose
447
+ #: normalized blockedReason token sets score >= this join one systemic-cause cluster
448
+ #: candidate. Calibrated against a real one-cause-three-phrasings incident — the
449
+ #: binding pair clears 0.5 by only ~0.028, and tests/test_decision_clustering.py
450
+ #: vendors those strings verbatim so a threshold change that would re-split the
451
+ #: incident is caught. Under-clustering is the deliberately chosen failure direction:
452
+ #: the agent holds merge authority, so the scripted floor must never over-merge.
453
+ CLUSTER_JACCARD_THRESHOLD: Final[float] = 0.5
454
+ #: Advisory topology warn triggers for ``compute_topology`` — a single root whose
455
+ #: gated subtree is >= ceil(ratio * itemCount) items trips "single-root-fanout";
456
+ #: a dependsOn chain of >= ceil(ratio * itemCount) nodes trips "chain-depth".
457
+ #: math.ceil keeps the ratios the single source of the thresholds even if a
458
+ #: future ratio is non-half. Advisory only: no consumer blocks on them.
459
+ TOPOLOGY_FANOUT_WARN_RATIO: Final[float] = 0.5
460
+ TOPOLOGY_DEPTH_WARN_RATIO: Final[float] = 0.5
461
+ #: The forge-side capability threshold for the runner's `backlog answer` apply
462
+ #: surface: at or above this rauf version the recovery procedure applies answers
463
+ #: via `rauf backlog answer`; below it, it degrades to `rauf backlog unblock`.
464
+ #: It never hard-fails recovery, and it is NOT ``loopRunner.minRunnerVersion``
465
+ #: (the install floor in references/forge-config-schema.json, which stays 0.6.0).
466
+ RECOVERY_MIN_RUNNER_VERSION: Final[str] = "0.14.0"
417
467
  #: The fixed final line of the NEXT-STEPS block. The stamp instructs the skill
418
468
  #: to print the block verbatim as its absolute last output — nothing after this.
419
469
  NEXT_STEPS_SENTINEL: Final = "─ forge: end of stage ─"
@@ -746,9 +796,10 @@ def next_stage(state: dict) -> str | None:
746
796
  """Return the first production stage that is not yet complete (the next step).
747
797
 
748
798
  Walks ``PRODUCTION_STAGES`` in order and returns the first whose recorded
749
- status is not ``complete`` (a missing/pending/in-progress/stale stage all
750
- count as "not done"). Returns ``None`` when every production stage is
751
- complete (nothing left to run).
799
+ status is not in ``_DONE_STATUSES`` (a missing/pending/in-progress/stale
800
+ stage all count as "not done"; ``complete`` and a forge-6-docs ``skipped``
801
+ both count as done). Returns ``None`` when every production stage is done
802
+ (nothing left to run).
752
803
 
753
804
  This is the derived "what runs next" value — the single source of truth for
754
805
  the next stage. It is intentionally distinct from the stored
@@ -757,7 +808,7 @@ def next_stage(state: dict) -> str | None:
757
808
  ``currentStage``.
758
809
  """
759
810
  for stage in PRODUCTION_STAGES:
760
- if _stage_status(state, stage) != _DONE_STATUS:
811
+ if _stage_status(state, stage) not in _DONE_STATUSES:
761
812
  return stage
762
813
  return None
763
814
 
@@ -912,7 +963,7 @@ def verify_state(state: dict) -> tuple[str | None, str]:
912
963
  likewise ``stale``: verify rather than skip.
913
964
  """
914
965
  for stage in reversed(PRODUCTION_STAGES):
915
- if _stage_status(state, stage) != _DONE_STATUS:
966
+ if _stage_status(state, stage) not in _DONE_STATUSES:
916
967
  continue
917
968
  token = VERIFY_TOKEN_BY_STAGE.get(stage)
918
969
  if token is None:
@@ -1236,6 +1287,18 @@ def _config_value(config_path: Path, key: str):
1236
1287
  return _load_config(config_path).get(key)
1237
1288
 
1238
1289
 
1290
+ def _config_duplicate_keys(config_path: Path) -> list[str]:
1291
+ """Duplicate key names in the config file, for doctor's health report.
1292
+
1293
+ Empty on a missing/unreadable/invalid config — those conditions are
1294
+ reported by doctor's ``configExists`` field, not here.
1295
+ """
1296
+ try:
1297
+ return load_json_with_duplicates(config_path)[1]
1298
+ except (OSError, json.JSONDecodeError):
1299
+ return []
1300
+
1301
+
1239
1302
  def auto_verify_for(config: dict, stage: str) -> bool:
1240
1303
  """Return the effective auto-verify setting for ``stage``.
1241
1304
 
@@ -1468,6 +1531,7 @@ def doctor_report(specs_dir: Path, config_path: Path) -> dict:
1468
1531
  "counts": _counts(specs_dir),
1469
1532
  "features": features,
1470
1533
  "invalidAutoVerifyKeys": invalid_auto_verify_keys(config),
1534
+ "duplicateConfigKeys": _config_duplicate_keys(config_path),
1471
1535
  "rootSandbox": _root_sandbox_status(),
1472
1536
  }
1473
1537
 
@@ -1535,6 +1599,11 @@ def _print_doctor(report: dict) -> None:
1535
1599
  invalid = report.get("invalidAutoVerifyKeys") or []
1536
1600
  if invalid:
1537
1601
  print(" ! invalid autoVerifyStages keys (ignored): " + ", ".join(invalid))
1602
+ duplicates = report.get("duplicateConfigKeys") or []
1603
+ if duplicates:
1604
+ print(
1605
+ " ! duplicate config keys (last value wins): " + ", ".join(duplicates)
1606
+ )
1538
1607
  rs = report.get("rootSandbox") or {}
1539
1608
  if rs.get("isRoot"):
1540
1609
  if rs.get("isSandboxSet"):
@@ -2040,6 +2109,402 @@ def _print_check_epic_base(payload: dict) -> None:
2040
2109
  print(f" → switch to the epic's home branch: {payload['homeBranch'] or '(unknown)'}")
2041
2110
 
2042
2111
 
2112
+ # --------------------------------------------------------------------------- #
2113
+ # Dependency graph & blocked-item clustering
2114
+ # --------------------------------------------------------------------------- #
2115
+ # Pure, stdlib-only flat functions over the loop runner's item array (the
2116
+ # `listCommand` JSON the caller already holds) — same precedent as
2117
+ # rank-features/reconcile-branch, no class. Nothing here reads backlog.json off
2118
+ # disk: single data source, so every derived claim cites the runner's
2119
+ # authoritative counts. All ordering flows through _id_key, never dict/hash
2120
+ # iteration, which is what makes the output deterministic and testable.
2121
+
2122
+
2123
+ def _id_key(item_id: object) -> tuple[int, object]:
2124
+ """Deterministic sort key for backlog ids.
2125
+
2126
+ All-digit ids sort numerically ("2" before "10"); everything else sorts
2127
+ lexically, after the numeric block. Used everywhere an ordering must not
2128
+ depend on dict/hash iteration.
2129
+
2130
+ Args:
2131
+ item_id: A backlog item id (usually ``str``; coerced defensively).
2132
+
2133
+ Returns:
2134
+ A ``(bucket, value)`` tuple that is a total order across mixed id shapes.
2135
+ """
2136
+ s = str(item_id)
2137
+ return (0, int(s)) if s.isdigit() else (1, s)
2138
+
2139
+
2140
+ def _build_dep_index(
2141
+ items: list[dict],
2142
+ ) -> tuple[dict[str, dict], dict[str, list[str]], dict[str, list[str]]]:
2143
+ """Build the in-backlog dependency adjacency from ``dependsOn`` edges.
2144
+
2145
+ Edges pointing at ids **not present** in this backlog are dropped (an item
2146
+ whose only ``dependsOn`` targets are external is therefore a root).
2147
+
2148
+ Args:
2149
+ items: The runner's item array (each a dict with at least ``id``; optional
2150
+ ``dependsOn``, ``status``, ``blockedReason``).
2151
+
2152
+ Returns:
2153
+ ``(by_id, deps, dependents)`` where ``by_id`` maps id → item, ``deps`` maps
2154
+ id → the ids it depends on (in-backlog only), and ``dependents`` maps id →
2155
+ the ids that directly depend on it.
2156
+ """
2157
+ by_id = {str(it["id"]): it for it in items}
2158
+ deps: dict[str, list[str]] = {
2159
+ i: [str(d) for d in (by_id[i].get("dependsOn") or []) if str(d) in by_id]
2160
+ for i in by_id
2161
+ }
2162
+ dependents: dict[str, list[str]] = {i: [] for i in by_id}
2163
+ for i, ds in deps.items():
2164
+ for d in ds:
2165
+ dependents[d].append(i)
2166
+ return by_id, deps, dependents
2167
+
2168
+
2169
+ def _transitive_dependents(
2170
+ dependents: dict[str, list[str]],
2171
+ ) -> dict[str, set[str]]:
2172
+ """Memoized transitive-dependents (gated-subtree) closure for every node.
2173
+
2174
+ ``dependents[x]`` lists items that directly depend on ``x``; the returned map
2175
+ gives, for each item, the set of items that **transitively** depend on it — the
2176
+ gated subtree that item's completion would unblock ("gates").
2177
+
2178
+ Cycle-safe: a node re-encountered on the current DFS path contributes nothing
2179
+ and is not memoized (rauf rejects cycles upstream, so this only hardens against
2180
+ malformed input; it never fires on validated backlogs).
2181
+
2182
+ Args:
2183
+ dependents: The reverse adjacency from :func:`_build_dep_index`.
2184
+
2185
+ Returns:
2186
+ A map id → set of transitively-dependent ids. O(V + E) overall (each edge
2187
+ is walked once thanks to memoization).
2188
+ """
2189
+ memo: dict[str, set[str]] = {}
2190
+
2191
+ def visit(node: str, on_path: set[str]) -> set[str]:
2192
+ if node in memo:
2193
+ return memo[node]
2194
+ if node in on_path: # cycle guard — unreachable on validated backlogs
2195
+ return set()
2196
+ on_path.add(node)
2197
+ acc: set[str] = set()
2198
+ for child in dependents[node]:
2199
+ acc.add(child)
2200
+ acc |= visit(child, on_path)
2201
+ on_path.discard(node)
2202
+ memo[node] = acc
2203
+ return acc
2204
+
2205
+ for n in dependents:
2206
+ visit(n, set())
2207
+ return memo
2208
+
2209
+
2210
+ #: A token that is a pure number or item-id-shaped (``42``, ``req12``, ``t7``) —
2211
+ #: noise carrying no cause signal, dropped by _normalize_reason.
2212
+ _ID_SHAPED_TOKEN = re.compile(r"^(?:\d+|[a-z]*\d+)$")
2213
+
2214
+
2215
+ def _normalize_reason(text: str | None) -> set[str]:
2216
+ """Normalize a ``blockedReason`` into its comparison token set.
2217
+
2218
+ Lowercases, splits on any run of non-alphanumeric characters, and drops noise
2219
+ tokens — pure numbers and item-id-shaped tokens (``42``, ``req12``, ``t7``) —
2220
+ which carry no cause signal and would spuriously separate or merge reasons.
2221
+
2222
+ Args:
2223
+ text: The item's ``blockedReason`` (may be ``None``/empty).
2224
+
2225
+ Returns:
2226
+ The set of meaningful lowercased tokens (possibly empty).
2227
+ """
2228
+ tokens = re.split(r"[^a-z0-9]+", (text or "").lower())
2229
+ return {t for t in tokens if t and not _ID_SHAPED_TOKEN.match(t)}
2230
+
2231
+
2232
+ def _jaccard(a: set[str], b: set[str]) -> float:
2233
+ """Jaccard similarity |A∩B| / |A∪B| of two token sets.
2234
+
2235
+ Symmetric and order-insensitive. Two empty sets score ``0.0`` — an item with
2236
+ no meaningful reason tokens never clusters with anything.
2237
+
2238
+ Args:
2239
+ a: First token set.
2240
+ b: Second token set.
2241
+
2242
+ Returns:
2243
+ A similarity in ``[0.0, 1.0]``.
2244
+ """
2245
+ union = a | b
2246
+ return len(a & b) / len(union) if union else 0.0
2247
+
2248
+
2249
+ def cluster_blocked(items: list[dict]) -> list[dict]:
2250
+ """Cluster blocked items by ``blockedReason`` similarity.
2251
+
2252
+ Union-find over every pair of ``status == "blocked"`` items whose normalized
2253
+ token-set Jaccard is ``>= CLUSTER_JACCARD_THRESHOLD``. Each emitted component
2254
+ carries its member ids, the members' raw reasons, the shared token core, and
2255
+ the **union** of the members' gated subtrees for blast-radius framing.
2256
+ Components of size 1 are emitted too — the recovery procedure consolidates
2257
+ only components of >= 2, prompting singletons per item.
2258
+
2259
+ The result is the deterministic *substrate*: the agent may merge components it
2260
+ judges to share a cause (under-clustering is the deliberately chosen failure
2261
+ direction). It never reads disk; ``items`` is the runner's array.
2262
+
2263
+ Args:
2264
+ items: The runner's ``listCommand`` item array.
2265
+
2266
+ Returns:
2267
+ A list of cluster dicts, sorted by lowest member id:
2268
+ ``{clusterId, memberIds, memberReasons, sharedTokens, gatedIds, gatedCount}``.
2269
+ """
2270
+ by_id, _deps, dependents = _build_dep_index(items)
2271
+ gated = _transitive_dependents(dependents)
2272
+ blocked = sorted(
2273
+ (i for i, it in by_id.items() if it.get("status") == "blocked"),
2274
+ key=_id_key,
2275
+ )
2276
+ tokens = {i: _normalize_reason(by_id[i].get("blockedReason")) for i in blocked}
2277
+
2278
+ parent = {i: i for i in blocked}
2279
+
2280
+ def find(x: str) -> str:
2281
+ while parent[x] != x:
2282
+ parent[x] = parent[parent[x]] # path halving
2283
+ x = parent[x]
2284
+ return x
2285
+
2286
+ def union(a: str, b: str) -> None:
2287
+ ra, rb = find(a), find(b)
2288
+ if ra == rb:
2289
+ return
2290
+ lo, hi = sorted((ra, rb), key=_id_key) # lowest id is the component root
2291
+ parent[hi] = lo
2292
+
2293
+ for idx, a in enumerate(blocked):
2294
+ for b in blocked[idx + 1:]:
2295
+ if _jaccard(tokens[a], tokens[b]) >= CLUSTER_JACCARD_THRESHOLD:
2296
+ union(a, b)
2297
+
2298
+ groups: dict[str, list[str]] = {}
2299
+ for i in blocked:
2300
+ groups.setdefault(find(i), []).append(i)
2301
+
2302
+ clusters: list[dict] = []
2303
+ for root in sorted(groups, key=_id_key):
2304
+ members = sorted(groups[root], key=_id_key)
2305
+ shared = set.intersection(*(tokens[m] for m in members)) if members else set()
2306
+ union_gated: set[str] = set()
2307
+ for m in members:
2308
+ union_gated |= gated[m]
2309
+ union_gated -= set(members) # a member gating a sibling is not its own blast radius
2310
+ clusters.append(
2311
+ {
2312
+ "clusterId": "c" + members[0], # "c" + lowest member id: stable across runs
2313
+ "memberIds": members,
2314
+ "memberReasons": [by_id[m].get("blockedReason") or "" for m in members],
2315
+ "sharedTokens": sorted(shared),
2316
+ "gatedIds": sorted(union_gated, key=_id_key),
2317
+ "gatedCount": len(union_gated),
2318
+ }
2319
+ )
2320
+ return clusters
2321
+
2322
+
2323
+ def _max_chain_depth(by_id: dict[str, dict], deps: dict[str, list[str]]) -> int:
2324
+ """Longest ``dependsOn`` chain length (node count), memoized and cycle-safe.
2325
+
2326
+ Depth of a node = ``1 + max(depth(dep) …)`` over its in-backlog dependencies;
2327
+ the result is the maximum over all nodes. A node re-seen on the current path
2328
+ contributes ``0`` (cycle guard; unreachable on validated backlogs).
2329
+
2330
+ Args:
2331
+ by_id: id → item, from :func:`_build_dep_index`.
2332
+ deps: id → dependency ids, from :func:`_build_dep_index`.
2333
+
2334
+ Returns:
2335
+ The longest chain length; ``0`` for an empty backlog.
2336
+ """
2337
+ memo: dict[str, int] = {}
2338
+
2339
+ def depth(node: str, on_path: set[str]) -> int:
2340
+ if node in memo:
2341
+ return memo[node]
2342
+ if node in on_path: # cycle guard
2343
+ return 0
2344
+ on_path.add(node)
2345
+ d = 1 + max((depth(x, on_path) for x in deps[node]), default=0)
2346
+ on_path.discard(node)
2347
+ memo[node] = d
2348
+ return d
2349
+
2350
+ return max((depth(n, set()) for n in by_id), default=0)
2351
+
2352
+
2353
+ def compute_topology(items: list[dict]) -> dict:
2354
+ """Compute dependency-topology metrics + advisory warnings (REQ-TOPO-01..03).
2355
+
2356
+ Pure function over the runner's item array (single data source, decision
2357
+ V-007) — it never reads ``backlog.json`` off disk, so every derived count
2358
+ cites the runner's authoritative array (REQ-ATTR-01, REQ-OBS-01). Linear via
2359
+ the memoized DFS helpers above (REQ-PERF-01).
2360
+
2361
+ Args:
2362
+ items: The runner's ``listCommand`` item array. Each item may carry
2363
+ ``id``, ``dependsOn`` (list of ids), and ``status`` (``pending``/
2364
+ ``done``/``blocked``/…).
2365
+
2366
+ Returns:
2367
+ The ``backlog-topology`` output shape (without ``clusters`` — that is
2368
+ appended by the verb under ``--cluster``): ``{itemCount, rootCount,
2369
+ roots, maxChainDepth, selectable, starvation, warnings}``.
2370
+ """
2371
+ by_id, deps, dependents = _build_dep_index(items)
2372
+ item_count = len(by_id)
2373
+ gated = _transitive_dependents(dependents)
2374
+
2375
+ roots = [i for i in by_id if not deps[i]] # no in-backlog dependsOn edges
2376
+ roots_out = sorted(
2377
+ (
2378
+ {
2379
+ "id": r,
2380
+ "gatedCount": len(gated[r]),
2381
+ "gatedIds": sorted(gated[r], key=_id_key),
2382
+ }
2383
+ for r in roots
2384
+ ),
2385
+ key=lambda row: _id_key(row["id"]),
2386
+ )
2387
+
2388
+ max_depth = _max_chain_depth(by_id, deps)
2389
+
2390
+ selectable = sum(
2391
+ 1
2392
+ for i, it in by_id.items()
2393
+ if it.get("status") == "pending"
2394
+ and all(by_id[d].get("status") == "done" for d in deps[i])
2395
+ )
2396
+ pending = sum(1 for it in by_id.values() if it.get("status") == "pending")
2397
+
2398
+ fanout_threshold = math.ceil(TOPOLOGY_FANOUT_WARN_RATIO * item_count)
2399
+ depth_threshold = math.ceil(TOPOLOGY_DEPTH_WARN_RATIO * item_count)
2400
+
2401
+ # A trivial graph (0-1 items, or no dependsOn edges at all) has no topology
2402
+ # to warn about — a single node's depth of 1 would otherwise trip the
2403
+ # ceil(0.5 * 1) = 1 depth threshold on every one-item backlog.
2404
+ warnings: list[str] = []
2405
+ if item_count > 1 and any(deps[i] for i in by_id):
2406
+ if any(row["gatedCount"] >= fanout_threshold for row in roots_out):
2407
+ warnings.append("single-root-fanout")
2408
+ if max_depth >= depth_threshold:
2409
+ warnings.append("chain-depth")
2410
+
2411
+ starvation = None
2412
+ if selectable == 0 and pending > 0:
2413
+ starvation = {
2414
+ "starved": True,
2415
+ "blockingRoots": [
2416
+ {"id": row["id"], "gatedCount": row["gatedCount"]}
2417
+ for row in roots_out
2418
+ if row["gatedCount"] > 0 and by_id[row["id"]].get("status") != "done"
2419
+ ],
2420
+ }
2421
+
2422
+ return {
2423
+ "itemCount": item_count,
2424
+ "rootCount": len(roots),
2425
+ "roots": roots_out,
2426
+ "maxChainDepth": max_depth,
2427
+ "selectable": selectable,
2428
+ "starvation": starvation,
2429
+ "warnings": warnings,
2430
+ }
2431
+
2432
+
2433
+ def cmd_backlog_topology(items: list[dict], *, with_clusters: bool) -> dict:
2434
+ """Assemble the ``backlog-topology`` payload.
2435
+
2436
+ Args:
2437
+ items: The runner's ``listCommand`` item array.
2438
+ with_clusters: When true, append the ``clusters`` section.
2439
+
2440
+ Returns:
2441
+ The topology dict; with ``clusters`` appended iff ``with_clusters``.
2442
+ """
2443
+ result = compute_topology(items)
2444
+ if with_clusters:
2445
+ result["clusters"] = cluster_blocked(items)
2446
+ return result
2447
+
2448
+
2449
+ def _load_topology_items(args: argparse.Namespace) -> list[dict]:
2450
+ """Read and parse the runner item array for ``backlog-topology``.
2451
+
2452
+ Accepts either a top-level JSON array or an object with an ``items`` array
2453
+ (rauf ``backlog list --json`` emits the array; the object form is tolerated
2454
+ for forward-compatibility). All failures raise ``UsageError`` → exit 2,
2455
+ never a partial/guessed result. This is the ONLY input path for the
2456
+ topology verb — it never opens ``backlog.json`` off disk (single data
2457
+ source, decision V-007).
2458
+
2459
+ Args:
2460
+ args: Parsed namespace with ``items_stdin`` / ``items_json``.
2461
+
2462
+ Returns:
2463
+ The item list.
2464
+
2465
+ Raises:
2466
+ UsageError: unreadable ``--items-json``, invalid JSON, or a shape that is
2467
+ neither an array nor an object carrying an ``items`` array.
2468
+ """
2469
+ if args.items_stdin:
2470
+ raw = sys.stdin.read()
2471
+ else:
2472
+ try:
2473
+ raw = Path(args.items_json).read_text(encoding="utf-8")
2474
+ except OSError as exc:
2475
+ raise UsageError(f"cannot read --items-json {args.items_json}: {exc}") from exc
2476
+ try:
2477
+ data = json.loads(raw)
2478
+ except json.JSONDecodeError as exc:
2479
+ raise UsageError(f"invalid items JSON: {exc}") from exc
2480
+ items = data.get("items", []) if isinstance(data, dict) else data
2481
+ if not isinstance(items, list):
2482
+ raise UsageError("items JSON must be an array or an object with an 'items' array")
2483
+ return items
2484
+
2485
+
2486
+ def _print_topology(payload: dict) -> None:
2487
+ """Human-readable topology summary (machine consumers pass ``--json``)."""
2488
+ print(
2489
+ f"Topology: {payload['itemCount']} items, {payload['rootCount']} roots, "
2490
+ f"max chain depth {payload['maxChainDepth']}, selectable {payload['selectable']}"
2491
+ )
2492
+ for row in sorted(payload["roots"], key=lambda r: -r["gatedCount"]):
2493
+ print(f" root {row['id']} gates {row['gatedCount']} item(s)")
2494
+ for warning in payload["warnings"]:
2495
+ print(f" warning: {warning}")
2496
+ starvation = payload.get("starvation")
2497
+ if starvation:
2498
+ blocking = ", ".join(r["id"] for r in starvation["blockingRoots"])
2499
+ print(f" starved: no selectable item; blocking roots: {blocking}")
2500
+ for cluster in payload.get("clusters", []):
2501
+ members = ", ".join(cluster["memberIds"])
2502
+ print(
2503
+ f" cluster {cluster['clusterId']}: members {members} "
2504
+ f"(gates {cluster['gatedCount']} item(s))"
2505
+ )
2506
+
2507
+
2043
2508
  # --------------------------------------------------------------------------- #
2044
2509
  # Scripted Stage Exit
2045
2510
  # --------------------------------------------------------------------------- #
@@ -2869,6 +3334,36 @@ _DOCS_OUTCOME_TEXT: Final[dict[str, str]] = {
2869
3334
  "nor epic {epic} is complete. Only valid partial state was persisted. Open "
2870
3335
  "the epic dashboard below to see the epic's live state and recover from there."
2871
3336
  ),
3337
+ # The `skipped` variants (#197): same routes as `complete`, honest wording — a
3338
+ # deliberate skip closes the pipeline without any stage claiming artifacts it
3339
+ # never produced, and the state says `skipped`, not `complete`.
3340
+ "standalone-skipped": (
3341
+ "Documentation was deliberately skipped for {feature} and recorded as "
3342
+ "`skipped` in state, closing the pipeline without claiming docs that were "
3343
+ "never written. The navigator command below is the authoritative completion "
3344
+ "action — it confirms the finished state from disk. Docs can still be "
3345
+ "generated later by re-running `{docs_stage}`. Optionally, you can start a "
3346
+ "new feature with `{new_feature}` or group related work into an epic with "
3347
+ "`{new_epic}`; neither is required to finish here."
3348
+ ),
3349
+ "epic-actionable-skipped": (
3350
+ "Documentation was deliberately skipped for {feature} and recorded as "
3351
+ "`skipped` in state. Epic {epic} has more work that can be started now "
3352
+ "({complete}/{total} members complete), so the pipeline continues with the "
3353
+ "next actionable member below."
3354
+ ),
3355
+ "epic-blocked-members-skipped": (
3356
+ "Documentation was deliberately skipped for {feature} and recorded as "
3357
+ "`skipped` in state, but no member of epic {epic} is actionable right now "
3358
+ "({complete}/{total} members complete) — the remaining work is blocked by "
3359
+ "unmet dependencies. Open the epic dashboard below to see what is holding "
3360
+ "it up."
3361
+ ),
3362
+ "epic-complete-skipped": (
3363
+ "Documentation was deliberately skipped for {feature} and recorded as "
3364
+ "`skipped` in state, and every member of epic {epic} is now complete "
3365
+ "({complete}/{total}). Open the epic dashboard below for its completion view."
3366
+ ),
2872
3367
  }
2873
3368
 
2874
3369
 
@@ -2882,13 +3377,15 @@ def _docs_route(
2882
3377
  next member routes to that member's own live command, and anything else (blocked
2883
3378
  remaining work, or every member complete) routes to the epic dashboard, which is
2884
3379
  also the dashboard's completion view. A ``blocked`` docs outcome routes to
2885
- recovery and NEVER claims pipeline completion.
3380
+ recovery and NEVER claims pipeline completion. A ``skipped`` outcome (#197)
3381
+ takes exactly the routes ``complete`` takes — the pipeline still ends here —
3382
+ but its wording says the docs were deliberately skipped, never that they exist.
2886
3383
 
2887
3384
  Args:
2888
3385
  feature: The feature whose documentation stage is closing.
2889
3386
  epic: The owning epic, or None for a standalone feature.
2890
3387
  specs_dir: Configured specs directory.
2891
- outcome: `complete` or `blocked`, already validated.
3388
+ outcome: `complete`, `blocked`, or `skipped`, already validated.
2892
3389
  host: Host surface, used only to translate the INLINE secondary mentions —
2893
3390
  the primary command is translated by the renderer.
2894
3391
 
@@ -2903,12 +3400,11 @@ def _docs_route(
2903
3400
  rather than converting into a second failure.
2904
3401
  """
2905
3402
  if epic is None:
2906
- text = _DOCS_OUTCOME_TEXT[
2907
- "standalone-complete" if outcome == "complete" else "standalone-blocked"
2908
- ].format(
3403
+ text = _DOCS_OUTCOME_TEXT[f"standalone-{outcome}"].format(
2909
3404
  feature=feature,
2910
3405
  new_feature=_host_command("/skill:forge-1-prd <new-feature>", host),
2911
3406
  new_epic=_host_command("/skill:forge-0-epic <new-epic>", host),
3407
+ docs_stage=_host_command(f"/skill:forge-6-docs {feature}", host),
2912
3408
  )
2913
3409
  return f"/skill:forge {feature}", None, text, False
2914
3410
 
@@ -2917,6 +3413,7 @@ def _docs_route(
2917
3413
  text = _DOCS_OUTCOME_TEXT["epic-blocked"].format(feature=feature, epic=epic)
2918
3414
  return dashboard, None, text, False
2919
3415
 
3416
+ skip_suffix = "-skipped" if outcome == "skipped" else ""
2920
3417
  status = _render_status(specs_dir, epic)
2921
3418
  rollup = status["rollup"]
2922
3419
  fields = {
@@ -2927,7 +3424,12 @@ def _docs_route(
2927
3424
  }
2928
3425
  next_command = status["nextCommand"]
2929
3426
  if status["actionable"] and next_command:
2930
- return next_command, None, _DOCS_OUTCOME_TEXT["epic-actionable"].format(**fields), True
3427
+ return (
3428
+ next_command,
3429
+ None,
3430
+ _DOCS_OUTCOME_TEXT["epic-actionable" + skip_suffix].format(**fields),
3431
+ True,
3432
+ )
2931
3433
  # Nothing actionable. Under the current derivation that coincides with "every
2932
3434
  # member complete" (a valid graph is acyclic, so an incomplete member always has
2933
3435
  # an actionable ancestor), but the two cases are named separately and the
@@ -2935,7 +3437,7 @@ def _docs_route(
2935
3437
  # reachable if a future derivation admits an unactionable incomplete member. Both
2936
3438
  # route to the same epic command either way; only the explanation differs.
2937
3439
  key = "epic-complete" if rollup["complete"] >= rollup["total"] else "epic-blocked-members"
2938
- return dashboard, None, _DOCS_OUTCOME_TEXT[key].format(**fields), False
3440
+ return dashboard, None, _DOCS_OUTCOME_TEXT[key + skip_suffix].format(**fields), False
2939
3441
 
2940
3442
 
2941
3443
  #: The route each loop outcome takes. A COMPLETE map over
@@ -2953,6 +3455,7 @@ _LOOP_ROUTE_KIND: Final[dict[str, str]] = {
2953
3455
  "complete": "handoff",
2954
3456
  "partial": "resume",
2955
3457
  "deferred": "resume",
3458
+ "resolved": "resume",
2956
3459
  "blocked": "recover",
2957
3460
  "needs-human": "recover",
2958
3461
  }
@@ -2986,8 +3489,26 @@ _LOOP_OUTCOME_TEXT: Final[dict[str, str]] = {
2986
3489
  "navigator below to see the live pipeline state from disk and recover from "
2987
3490
  "there."
2988
3491
  ),
3492
+ "resolved": (
3493
+ "The needs-human stop for {feature} was resolved — the recorded decisions "
3494
+ "were applied and every affected item was verified, per item, to have left "
3495
+ "blocked/needsHuman, with the working tree clean. The recorded state is "
3496
+ "resumable and nothing downstream is ready: run the loop again below to "
3497
+ "continue from where it stopped."
3498
+ ),
2989
3499
  }
2990
3500
 
3501
+ #: The starvation variant of the `partial` next-steps sentence (REQ-ATTR-02): names
3502
+ #: the unblock path instead of the iteration limit, which was NOT the binding
3503
+ #: constraint. Selected only by ``--cause dependency-starvation`` (REQ-ATTR-04).
3504
+ _LOOP_PARTIAL_STARVED_TEXT: Final[str] = (
3505
+ "The loop stopped for {feature} with backlog items still pending, but the "
3506
+ "iteration limit was NOT the constraint — no pending item was selectable because "
3507
+ "unblocked root items gate the rest of the backlog. The recorded state is "
3508
+ "resumable and nothing downstream is ready: unblock the roots named in the "
3509
+ "starvation report above, then run the loop again below to continue."
3510
+ )
3511
+
2991
3512
  #: The `complete` preamble, selected by where the handoff actually lands. The epic
2992
3513
  #: rows name the epic and its live rollup, so the operator can see WHY the handoff is
2993
3514
  #: this member's own documentation rather than another member (or vice versa).
@@ -3043,8 +3564,10 @@ _RECONCILE_FIRST_TEXT: Final[dict[str, str]] = {
3043
3564
  "the continuation named under it."
3044
3565
  ),
3045
3566
  "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. "
3567
+ # "closed", not "complete": this wording also serves a `skipped` docs
3568
+ # outcome, which must never claim the docs exist (#197).
3569
+ "The documentation stage is closed for {feature}, but {count} blocking epic "
3570
+ "change request{plural} recorded against epic {epic} must be reconciled first. "
3048
3571
  "Handing off would build the next member on a decomposition that is about to "
3049
3572
  "change, so the reconcile below comes before the continuation named under it."
3050
3573
  ),
@@ -3123,6 +3646,7 @@ def _loop_route(
3123
3646
  resolved: bool,
3124
3647
  verify_canonical: str,
3125
3648
  fix_canonical: str | None,
3649
+ cause: str | None = None,
3126
3650
  ) -> tuple[str, str | None, str, bool]:
3127
3651
  """Route one loop result — the outcome table.
3128
3652
 
@@ -3147,6 +3671,10 @@ def _loop_route(
3147
3671
  else None. A live report outranks a fresh verify on the ``complete``
3148
3672
  handoff: findings already exist at this exact revision, so the fenced
3149
3673
  action is applying them, exactly as on a production re-exit.
3674
+ cause: The already-validated attribution annotation — only
3675
+ ``"dependency-starvation"`` with ``outcome == "partial"``, else None.
3676
+ Swaps the partial next-steps sentence for the starvation variant; the
3677
+ route itself is unchanged (partial stays a resume either way).
3150
3678
 
3151
3679
  Returns:
3152
3680
  `(primary_canonical, deferred_canonical, outcome_text, advancing)`, matching
@@ -3171,7 +3699,11 @@ def _loop_route(
3171
3699
  if kind == "resume"
3172
3700
  else f"/skill:forge {feature}"
3173
3701
  )
3174
- return primary, None, _LOOP_OUTCOME_TEXT[outcome].format(feature=feature), False
3702
+ if outcome == "partial" and cause == "dependency-starvation":
3703
+ text = _LOOP_PARTIAL_STARVED_TEXT.format(feature=feature)
3704
+ else:
3705
+ text = _LOOP_OUTCOME_TEXT[outcome].format(feature=feature)
3706
+ return primary, None, text, False
3175
3707
 
3176
3708
  handoff = successor_command or f"/skill:forge {feature}"
3177
3709
  fields: dict[str, object] = {"feature": feature, "epic": epic}
@@ -3334,6 +3866,7 @@ def stage_exit(
3334
3866
  outcome: str | None = None,
3335
3867
  owner: str | None = None,
3336
3868
  verify_capability: str = "manual",
3869
+ cause: str | None = None,
3337
3870
  ) -> StageExitPayload:
3338
3871
  """Compute a deterministic stage-exit payload.
3339
3872
 
@@ -3354,6 +3887,10 @@ def stage_exit(
3354
3887
  capability is permission, not tool presence: a dispatch permitted
3355
3888
  only once the user has asked is still `interactive`, because the
3356
3889
  `standard` gate's own prompt supplies that request.
3890
+ cause: Pending-attribution annotation (`dependency-starvation`), valid
3891
+ only with `--stage forge-5-loop --outcome partial` (REQ-ATTR-04).
3892
+ It swaps the partial next-steps sentence for the starvation variant
3893
+ and changes no routing.
3357
3894
 
3358
3895
  Returns:
3359
3896
  A JSON-serializable `StageExitPayload` dictionary.
@@ -3484,6 +4021,14 @@ def stage_exit(
3484
4021
  f"{', '.join(sorted(allowed_outcomes))}"
3485
4022
  )
3486
4023
 
4024
+ # --cause is a forge-5-loop/partial-only attribution annotation (REQ-ATTR-04).
4025
+ # argparse `choices` already restricts the value; this restricts the combination.
4026
+ if cause is not None and not (stage == "forge-5-loop" and outcome == "partial"):
4027
+ raise UsageError(
4028
+ "--cause dependency-starvation is valid only with "
4029
+ "--stage forge-5-loop --outcome partial"
4030
+ )
4031
+
3487
4032
  # 5. Ownership: required for the branch skills, rejected for stages 0-6.
3488
4033
  if stage in _BRANCH_STAGES:
3489
4034
  if owner is None:
@@ -3822,6 +4367,7 @@ def stage_exit(
3822
4367
  resolved,
3823
4368
  verify_canonical,
3824
4369
  fix_canonical if live_findings_report else None,
4370
+ cause,
3825
4371
  )
3826
4372
  if blocking_reconcile:
3827
4373
  # Same reconcile-first rule as every other advancing route — but the
@@ -4102,7 +4648,8 @@ def _write_state(state_path: Path, state: dict) -> None:
4102
4648
  the temp file onto the target. os.replace is atomic on POSIX within one
4103
4649
  filesystem, so an interrupted write never leaves a partial or corrupt state
4104
4650
  file. Concurrent multi-session mutation is out of scope (single writer
4105
- assumed, matching epic-manifest.py).
4651
+ assumed, matching epic-manifest.py; decision record:
4652
+ references/decisions/single-writer-threat-model.md, issue #180).
4106
4653
 
4107
4654
  Args:
4108
4655
  state_path: Destination path, e.g.
@@ -4708,6 +5255,62 @@ def cmd_state_complete(
4708
5255
  return echo
4709
5256
 
4710
5257
 
5258
+ def cmd_state_skip(feature: str, stage: str, specs_dir: Path, epic: str | None) -> dict:
5259
+ """Record a deliberate skip of the documentation stage (#197).
5260
+
5261
+ Writes a REPLACEMENT ``stages.forge-6-docs`` entry ``{"status": "skipped",
5262
+ "skippedAt": …, "commitHash": null}`` — the honest terminal for a feature
5263
+ that ships without architecture docs. ``skipped`` counts as done for
5264
+ next-stage selection (``_DONE_STATUSES``), so the pipeline reads
5265
+ ``complete: true`` / ``nextStage: null`` without any stage claiming
5266
+ artifacts it never produced.
5267
+
5268
+ Scoped to ``forge-6-docs`` on purpose: a skipped PRD or specs stage is a
5269
+ different and much worse proposition, so both the CLI (``choices``) and this
5270
+ callable refuse any other stage.
5271
+
5272
+ The skip must not DESTROY a record of docs that exist: a prior entry whose
5273
+ ``artifacts`` list is non-empty is refused. A prior ``complete`` entry with
5274
+ no recorded artifacts is exactly the dishonest workaround this verb replaces
5275
+ (``state-complete`` with no ``--artifact``), so it may be corrected to
5276
+ ``skipped`` — that is the sanctioned migration path for such state.
5277
+
5278
+ Args:
5279
+ feature: Feature name.
5280
+ stage: Must be ``"forge-6-docs"`` (kept explicit so the scoping shows up
5281
+ in every call site).
5282
+ specs_dir: Specs directory.
5283
+ epic: Owning epic name, or None.
5284
+
5285
+ Returns:
5286
+ The mutated state dict (for the --json echo).
5287
+
5288
+ Raises:
5289
+ UsageError: A stage other than forge-6-docs, a prior entry recording
5290
+ artifacts, an unknown feature directory, an unparseable state file,
5291
+ or a failed atomic write (→ exit 2).
5292
+ """
5293
+ if stage != "forge-6-docs":
5294
+ raise UsageError(
5295
+ f"state-skip is scoped to forge-6-docs; a skipped {stage} is not a "
5296
+ "representable pipeline state"
5297
+ )
5298
+ state_path, state = _load_state_for_write(specs_dir, feature, epic)
5299
+ prior = state.get("stages", {}).get(stage)
5300
+ if isinstance(prior, dict) and prior.get("artifacts"):
5301
+ raise UsageError(
5302
+ f"{stage} already records {len(prior['artifacts'])} artifact(s) "
5303
+ f"(status: {prior.get('status')!r}); skipping now would erase the "
5304
+ "record that docs exist. Re-run forge-6-docs to refresh them instead."
5305
+ )
5306
+ state.setdefault("stages", {})[stage] = {
5307
+ "status": "skipped",
5308
+ "skippedAt": _now_iso(),
5309
+ "commitHash": None,
5310
+ }
5311
+ return _commit_state(state_path, state)
5312
+
5313
+
4711
5314
  def cmd_state_branch(feature: str, branch: str, specs_dir: Path, epic: str | None) -> dict:
4712
5315
  """Set the top-level ``branch`` field.
4713
5316
 
@@ -4966,10 +5569,12 @@ def _validated_findings_file(
4966
5569
  def _current_artifact_version(state: dict, stage: str) -> int:
4967
5570
  """Return the artifact revision a verify result is being recorded against.
4968
5571
 
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
5572
+ For a feature target that is the selected production stage's ``version``.
5573
+ Only the statuses that consume it resolve it: `passed` and
4971
5574
  `findings-reported` write it into the freshness ledger, and
4972
5575
  `auto-verify-pending` writes it as the revision the debt is owed on.
5576
+ `skipped` and `findings-applied` never read it and skip the lookup, so both
5577
+ stay recordable on a completed stage with no recorded ``version``.
4973
5578
 
4974
5579
  Args:
4975
5580
  state: The loaded state document.
@@ -5074,7 +5679,8 @@ def _verify_result_entry(
5074
5679
  Args:
5075
5680
  status: The validated result status.
5076
5681
  prior: The existing entry (``{}`` when absent).
5077
- current: The current artifact revision, or None for ``skipped``.
5682
+ current: The current artifact revision, or None for ``skipped`` and
5683
+ ``findings-applied`` (which never consume it).
5078
5684
  findings_file: Validated relative report path, when supplied.
5079
5685
  findings_count: Validated non-negative count, when supplied.
5080
5686
  now: The shared ISO-8601 timestamp for this write.
@@ -5343,7 +5949,12 @@ def cmd_state_verify(
5343
5949
  if findings_file is not None:
5344
5950
  _validated_findings_file(findings_file, target_dir)
5345
5951
 
5346
- if status == "skipped":
5952
+ if status in ("skipped", "findings-applied"):
5953
+ # Neither status consumes the artifact revision: `skipped` records no
5954
+ # freshness, and `findings-applied` deliberately clears it (the entry is
5955
+ # built from the prior report plus `fixedAt`). Resolving it anyway would
5956
+ # make both unrecordable on a completed stage whose `version` was never
5957
+ # written — exactly the state that needs the recovery path (#202).
5347
5958
  current = None
5348
5959
  elif is_epic_target:
5349
5960
  # The epic's artifact revision is the manifest revision — never a member's
@@ -5363,6 +5974,20 @@ def cmd_state_verify(
5363
5974
  )
5364
5975
 
5365
5976
  prior = _verify_entry(state, verify_key)
5977
+ if status == "skipped" and prior.get("status") in _SKIP_PROTECTED_PRIOR:
5978
+ # The #203 demotion trap: `skipped` over a complete-for-orchestration
5979
+ # status silently dropped the member from its epic rollup and re-blocked
5980
+ # every dependent. Fail closed; a deferral needs no write at all.
5981
+ raise UsageError(
5982
+ f"--status skipped would demote {verify_key} from "
5983
+ f"{prior.get('status')!r}: that status counts as resolved (and, for an "
5984
+ f"epic member, complete-for-orchestration), so replacing it with "
5985
+ f"skipped would drop the member from its epic rollup and re-block its "
5986
+ f"dependents. A deferral needs no write — the recorded result already "
5987
+ f"stands. Re-run verification to refresh it, or record --status passed "
5988
+ f"(with the report attached) to accept residual findings. "
5989
+ f"Nothing was written."
5990
+ )
5366
5991
  if status == "auto-verify-pending" and prior.get("status") == "findings-reported":
5367
5992
  # `_verify_result_entry` REPLACES the entry, so scheduling over a report
5368
5993
  # for the current revision would delete its `findingsFile`/`findingsCount`
@@ -5453,6 +6078,14 @@ def _print_state_complete(
5453
6078
  )
5454
6079
 
5455
6080
 
6081
+ def _print_state_skip(state: dict, stage: str) -> None:
6082
+ """Print the one-line human summary for `state-skip`."""
6083
+ print(
6084
+ f"recorded {stage} as skipped for {state['feature']} "
6085
+ "(deliberate — no docs claimed)"
6086
+ )
6087
+
6088
+
5456
6089
  def _print_state_branch(state: dict) -> None:
5457
6090
  """Print the one-line human summary for `state-branch`."""
5458
6091
  print(f"recorded branch for {state['feature']}: {state['branch']}")
@@ -5506,6 +6139,338 @@ def _print_state_ecr(state: dict) -> None:
5506
6139
  )
5507
6140
 
5508
6141
 
6142
+ # --------------------------------------------------------------------------- #
6143
+ # Decision record (forge-decisions.json) — the decision-* verbs
6144
+ # --------------------------------------------------------------------------- #
6145
+
6146
+ #: The one persistent artifact this feature adds; only decision-* verbs write it.
6147
+ DECISIONS_FILENAME: Final[str] = "forge-decisions.json"
6148
+ #: Enum-locked at references/forge-decisions-schema.json; a bump is a breaking change.
6149
+ DECISIONS_SCHEMA_VERSION: Final[str] = "1"
6150
+
6151
+
6152
+ def _resolve_decisions_path(
6153
+ backlog_dir: Path,
6154
+ state_dir: str | None,
6155
+ config_path: Path,
6156
+ schema_path: Path,
6157
+ ) -> Path:
6158
+ """Resolve `{backlog_dir}/{stateDir}/forge-decisions.json`.
6159
+
6160
+ When ``state_dir`` is None, ``stateDir`` is taken from the effective loopRunner
6161
+ config (schema default ``.rauf``) via ``resolve_loop_runner`` — the same resolver
6162
+ the loop itself uses — so the record lands beside the runner's own state and is
6163
+ covered by the ``**/.rauf/*`` ignore rule with zero ``.gitignore`` edits.
6164
+
6165
+ Args:
6166
+ backlog_dir: The resolved backlog directory (e.g. ``specs/loop-recovery``).
6167
+ state_dir: An explicit state-dir name, or None to resolve from config.
6168
+ config_path: ``forge.config.json`` path (``_load_config`` tolerates absent).
6169
+ schema_path: ``forge-config-schema.json`` path (source of the default).
6170
+
6171
+ Returns:
6172
+ The resolved path to the decision record (its parent may not yet exist).
6173
+ """
6174
+ if state_dir is None:
6175
+ resolved = resolve_loop_runner(config_path, schema_path)
6176
+ state_dir = str(resolved["stateDir"])
6177
+ return backlog_dir / state_dir / DECISIONS_FILENAME
6178
+
6179
+
6180
+ def _read_decisions_for_write(path: Path, feature: str) -> dict:
6181
+ """Load the decisions document for mutation, or seed a fresh one on first write.
6182
+
6183
+ A MISSING file is the first-write case → return a fresh skeleton whose parent
6184
+ dir is created on commit. An UNPARSEABLE or non-object existing file is a HARD
6185
+ failure (exit 2) — a write path must not inherit ``_read_state``'s corrupt→{}
6186
+ tolerance, which would atomically replace a recoverable record with a
6187
+ near-empty one.
6188
+
6189
+ Args:
6190
+ path: The resolved decision-record path.
6191
+ feature: The feature label to stamp on a first write (backlog dir basename).
6192
+
6193
+ Returns:
6194
+ The loaded (or freshly-seeded) decisions document, ready to mutate.
6195
+
6196
+ Raises:
6197
+ UsageError: The existing file is unreadable/unparseable or not a JSON object.
6198
+ """
6199
+ if not path.exists():
6200
+ return {
6201
+ "schemaVersion": DECISIONS_SCHEMA_VERSION,
6202
+ "feature": feature,
6203
+ "createdAt": _now_iso(),
6204
+ "decisions": [],
6205
+ }
6206
+ try:
6207
+ parsed = json.loads(path.read_text(encoding="utf-8"))
6208
+ except (OSError, json.JSONDecodeError) as exc:
6209
+ raise UsageError(f"unparseable decision record at {path}: {exc}") from exc
6210
+ if not isinstance(parsed, dict):
6211
+ raise UsageError(f"decision record at {path} is not a JSON object")
6212
+ return parsed
6213
+
6214
+
6215
+ def _new_decision_entry(
6216
+ item_id: str,
6217
+ question: str,
6218
+ answer: str | None,
6219
+ deferred: bool,
6220
+ cluster_id: str | None,
6221
+ actor: str,
6222
+ ) -> dict:
6223
+ """Build one decision entry conforming to references/forge-decisions-schema.json.
6224
+
6225
+ Args:
6226
+ item_id: The backlog item the decision answers.
6227
+ question: The needs-human question text (original text on a deferral).
6228
+ answer: The operator's answer, or None for a deferral.
6229
+ deferred: True iff this is a deferral / cancel-early entry.
6230
+ cluster_id: Shared clusterId for a consolidated decision, or None.
6231
+ actor: The session/actor label for ``recordedBy`` (never user identity).
6232
+
6233
+ Returns:
6234
+ A dict carrying all eight required fields (``appliedAt``/``appliedBy`` null),
6235
+ plus ``clusterId`` when supplied.
6236
+ """
6237
+ entry: dict = {
6238
+ "itemId": item_id,
6239
+ "question": question,
6240
+ "answer": answer,
6241
+ "deferred": deferred,
6242
+ "decidedAt": _now_iso(),
6243
+ "recordedBy": actor,
6244
+ "appliedAt": None,
6245
+ "appliedBy": None,
6246
+ }
6247
+ if cluster_id is not None:
6248
+ entry["clusterId"] = cluster_id
6249
+ return entry
6250
+
6251
+
6252
+ def _default_actor() -> str:
6253
+ """Return the default recordedBy/appliedBy label: ``forge-5-loop@<host>``.
6254
+
6255
+ The host segment is a machine label, not a user identity (REQ-SEC-01).
6256
+ """
6257
+ return f"forge-5-loop@{socket.gethostname()}"
6258
+
6259
+
6260
+ def _unapplied_decisions(decisions: list[dict]) -> list[dict]:
6261
+ """Return the latest entry per itemId whose ``appliedAt`` is None (REQ-DEC-05).
6262
+
6263
+ Walks entries in stored (append) order keeping the LAST entry seen per itemId,
6264
+ then keeps only those still unapplied. Deferrals (never applied) are included
6265
+ (REQ-DEC-06); an item whose latest entry is applied drops out; a later
6266
+ per-item entry supersedes an earlier consolidated (clusterId) one for that item
6267
+ only (REQ-DEC-07). Output is sorted by itemId for deterministic reporting.
6268
+
6269
+ Args:
6270
+ decisions: The document's ``decisions`` array, in stored order.
6271
+
6272
+ Returns:
6273
+ The unapplied entries, one per item, sorted by ``itemId``.
6274
+ """
6275
+ latest: dict[str, dict] = {}
6276
+ for entry in decisions:
6277
+ latest[entry["itemId"]] = entry
6278
+ return [
6279
+ entry for _item_id, entry in sorted(latest.items())
6280
+ if entry.get("appliedAt") is None
6281
+ ]
6282
+
6283
+
6284
+ def cmd_decision_record(
6285
+ backlog_dir: Path,
6286
+ item_ids: list[str],
6287
+ question: str,
6288
+ answer: str | None,
6289
+ deferred: bool,
6290
+ cluster_id: str | None,
6291
+ actor: str,
6292
+ state_dir: str | None,
6293
+ config_path: Path,
6294
+ schema_path: Path,
6295
+ ) -> dict:
6296
+ """Append one needs-human decision entry per ``--item`` (append-only).
6297
+
6298
+ Records a decision at the moment it is collected (REQ-DEC-01), on EVERY branch:
6299
+ an answered decision (``--answer``), and a deferral or cancel-early
6300
+ (``--deferred`` → ``answer: null``, REQ-DEC-06). With ``--cluster`` the per-item
6301
+ entries of ONE consolidated decision share a ``clusterId`` (REQ-CLU-04) yet stay
6302
+ independently re-decidable (REQ-DEC-07). The file and its
6303
+ ``schemaVersion``/``feature``/``createdAt`` stamp are created on first write.
6304
+ Existing entries are never mutated (append-only).
6305
+
6306
+ Args:
6307
+ backlog_dir: The resolved backlog directory; its basename stamps ``feature``.
6308
+ item_ids: One or more backlog item ids; one entry is appended per id.
6309
+ question: The needs-human question text (original text on a deferral).
6310
+ answer: The operator's answer, or None for a deferral.
6311
+ deferred: True iff this is a deferral / cancel-early entry.
6312
+ cluster_id: Shared ``clusterId`` for a consolidated decision, or None.
6313
+ actor: Session/actor label for ``recordedBy`` (never user identity).
6314
+ state_dir: State-dir name override, or None to resolve from config.
6315
+ config_path: ``forge.config.json`` path (for the stateDir default).
6316
+ schema_path: ``forge-config-schema.json`` path (source of the default).
6317
+
6318
+ Returns:
6319
+ The mutated decisions document (for the ``--json`` echo).
6320
+
6321
+ Raises:
6322
+ UsageError: Missing backlog dir; both/neither of ``--answer``/``--deferred``;
6323
+ an unparseable existing record; or a failed atomic write (→ exit 2).
6324
+ """
6325
+ # Defense in depth: the argparse mutually-exclusive group rejects both/neither
6326
+ # first, but a direct call must fail the same way. Valid states are exactly
6327
+ # (answered, not deferred) or (deferred, no answer).
6328
+ if deferred == (answer is not None):
6329
+ raise UsageError("exactly one of --answer or --deferred is required")
6330
+ if not backlog_dir.is_dir():
6331
+ raise UsageError(f"no backlog directory at {backlog_dir}")
6332
+
6333
+ path = _resolve_decisions_path(backlog_dir, state_dir, config_path, schema_path)
6334
+ doc = _read_decisions_for_write(path, backlog_dir.resolve().name)
6335
+ for item_id in item_ids:
6336
+ doc["decisions"].append(
6337
+ _new_decision_entry(item_id, question, answer, deferred, cluster_id, actor)
6338
+ )
6339
+ path.parent.mkdir(parents=True, exist_ok=True)
6340
+ return _commit_state(path, doc)
6341
+
6342
+
6343
+ def cmd_decision_list(
6344
+ backlog_dir: Path,
6345
+ unapplied: bool,
6346
+ state_dir: str | None,
6347
+ config_path: Path,
6348
+ schema_path: Path,
6349
+ ) -> dict:
6350
+ """Read the decision record back — the full log, or the unapplied set.
6351
+
6352
+ With ``--unapplied`` returns the REQ-DEC-05 set (``_unapplied_decisions``).
6353
+ Without it, echoes the full on-disk document. A missing record returns an
6354
+ empty result at exit 0 (nothing recorded yet is not a failure). This verb
6355
+ never mutates the file; it parses an existing record **strictly** (exit 2 on
6356
+ corruption) for both the plain and ``--unapplied`` forms — it never
6357
+ downgrades a corrupt record to ``{}``.
6358
+
6359
+ Args:
6360
+ backlog_dir: The resolved backlog directory.
6361
+ unapplied: Return only the latest-unapplied-per-item set.
6362
+ state_dir: State-dir name override, or None to resolve from config.
6363
+ config_path: ``forge.config.json`` path (for the stateDir default).
6364
+ schema_path: ``forge-config-schema.json`` path (source of the default).
6365
+
6366
+ Returns:
6367
+ On a plain read: the full document ``{schemaVersion, feature, createdAt,
6368
+ updatedAt, decisions}`` (or ``{"decisions": []}`` when none recorded).
6369
+ On ``--unapplied``: a report view ``{"feature", "unapplied": [...],
6370
+ "count": N}`` (NOT the on-disk shape; it is never written).
6371
+
6372
+ Raises:
6373
+ UsageError: Missing backlog dir, or an unparseable existing record (→ exit 2).
6374
+ """
6375
+ if not backlog_dir.is_dir():
6376
+ raise UsageError(f"no backlog directory at {backlog_dir}")
6377
+ path = _resolve_decisions_path(backlog_dir, state_dir, config_path, schema_path)
6378
+
6379
+ if not path.exists():
6380
+ return {"feature": backlog_dir.resolve().name, "unapplied": [], "count": 0} \
6381
+ if unapplied else {"decisions": []}
6382
+
6383
+ try:
6384
+ doc = json.loads(path.read_text(encoding="utf-8"))
6385
+ except (OSError, json.JSONDecodeError) as exc:
6386
+ raise UsageError(f"unparseable decision record at {path}: {exc}") from exc
6387
+
6388
+ if not unapplied:
6389
+ return doc
6390
+ pending = _unapplied_decisions(doc.get("decisions", []))
6391
+ return {"feature": doc.get("feature"), "unapplied": pending, "count": len(pending)}
6392
+
6393
+
6394
+ def cmd_decision_apply(
6395
+ backlog_dir: Path,
6396
+ item_id: str,
6397
+ actor: str,
6398
+ state_dir: str | None,
6399
+ config_path: Path,
6400
+ schema_path: Path,
6401
+ ) -> dict:
6402
+ """Stamp ``appliedAt``/``appliedBy`` on the LATEST entry for ``item_id``.
6403
+
6404
+ Append-only mutation (REQ-DEC-07): only the most recent entry for the item is
6405
+ touched, and only its ``appliedAt`` (→ ``_now_iso()``) and ``appliedBy``
6406
+ (→ ``actor``) fields. Called by the Post-Run Recovery Procedure only AFTER
6407
+ the runner apply for the item succeeded, so the record's applied state
6408
+ tracks the runner's (REQ-UNB-01).
6409
+
6410
+ Args:
6411
+ backlog_dir: The resolved backlog directory.
6412
+ item_id: The backlog item whose latest decision to stamp applied.
6413
+ actor: The session/actor label for ``appliedBy``.
6414
+ state_dir: State-dir name override, or None to resolve from config.
6415
+ config_path: ``forge.config.json`` path (for the stateDir default).
6416
+ schema_path: ``forge-config-schema.json`` path (source of the default).
6417
+
6418
+ Returns:
6419
+ The mutated decisions document (for the ``--json`` echo).
6420
+
6421
+ Raises:
6422
+ UsageError: Missing backlog dir; no decision recorded for the item; the
6423
+ item's latest entry is already applied (nothing unapplied); an
6424
+ unparseable record; or a failed atomic write (→ exit 2).
6425
+ """
6426
+ if not backlog_dir.is_dir():
6427
+ raise UsageError(f"no backlog directory at {backlog_dir}")
6428
+ path = _resolve_decisions_path(backlog_dir, state_dir, config_path, schema_path)
6429
+ doc = _read_decisions_for_write(path, backlog_dir.resolve().name)
6430
+
6431
+ latest_index: int | None = None
6432
+ for index, entry in enumerate(doc["decisions"]):
6433
+ if entry["itemId"] == item_id:
6434
+ latest_index = index # keep the LAST match — stored order is chronological
6435
+ if latest_index is None:
6436
+ raise UsageError(f"no decision recorded for item {item_id!r}")
6437
+ entry = doc["decisions"][latest_index]
6438
+ if entry["appliedAt"] is not None:
6439
+ raise UsageError(
6440
+ f"latest decision for item {item_id!r} is already applied "
6441
+ f"(at {entry['appliedAt']}) — nothing unapplied"
6442
+ )
6443
+
6444
+ entry["appliedAt"] = _now_iso()
6445
+ entry["appliedBy"] = actor
6446
+ path.parent.mkdir(parents=True, exist_ok=True)
6447
+ return _commit_state(path, doc)
6448
+
6449
+
6450
+ def _print_decision_record(doc: dict) -> None:
6451
+ """One-line human summary for ``decision-record``."""
6452
+ print(f"decision recorded — {len(doc['decisions'])} entr"
6453
+ f"{'y' if len(doc['decisions']) == 1 else 'ies'} on record for {doc['feature']}")
6454
+
6455
+
6456
+ def _print_decision_list(view: dict) -> None:
6457
+ """One-line-per-entry human summary for ``decision-list``."""
6458
+ if "unapplied" in view:
6459
+ print(f"{view['count']} unapplied decision(s)")
6460
+ for entry in view["unapplied"]:
6461
+ kind = "deferred" if entry["deferred"] else "answered"
6462
+ print(f" {entry['itemId']}: {kind} — {entry['question']}")
6463
+ else:
6464
+ print(f"{len(view.get('decisions', []))} decision(s) on record")
6465
+
6466
+
6467
+ def _print_decision_apply(doc: dict) -> None:
6468
+ """One-line human summary naming the just-applied entry (max appliedAt)."""
6469
+ applied = [d for d in doc["decisions"] if d["appliedAt"] is not None]
6470
+ entry = max(applied, key=lambda d: d["appliedAt"])
6471
+ print(f"applied decision for item {entry['itemId']} ({entry['appliedBy']})")
6472
+
6473
+
5509
6474
  # --------------------------------------------------------------------------- #
5510
6475
  # CLI dispatch
5511
6476
  # --------------------------------------------------------------------------- #
@@ -5652,6 +6617,11 @@ def main() -> int:
5652
6617
  # argparse cannot express. `stage_exit` validates it against EXIT_OUTCOMES.
5653
6618
  p_exit.add_argument("--outcome", default=None,
5654
6619
  help="Stage-specific outcome (loop/docs/verify/fix only)")
6620
+ p_exit.add_argument(
6621
+ "--cause", default=None, dest="cause", choices=("dependency-starvation",),
6622
+ help="Pending-attribution cause; valid only with "
6623
+ "--stage forge-5-loop --outcome partial",
6624
+ )
5655
6625
  p_exit.add_argument("--owner", default=None, choices=get_args(ExitOwner),
5656
6626
  help="Branch terminal ownership (forge-verify/forge-fix only)")
5657
6627
  p_exit.add_argument("--verify-capability", default="manual",
@@ -5736,6 +6706,16 @@ def main() -> int:
5736
6706
  p_comp.add_argument("--epic", default=None, help="Epic name for a nested member")
5737
6707
  p_comp.add_argument("--json", action="store_true", dest="json_output")
5738
6708
 
6709
+ p_skip = sub.add_parser(
6710
+ "state-skip", help="Record forge-6-docs as deliberately skipped (#197)"
6711
+ )
6712
+ p_skip.add_argument("--feature", required=True, help="Feature name")
6713
+ p_skip.add_argument("--stage", required=True, choices=("forge-6-docs",),
6714
+ help="The stage being skipped (only forge-6-docs is skippable)")
6715
+ p_skip.add_argument("--specs-dir", default="./specs", help="Specs directory")
6716
+ p_skip.add_argument("--epic", default=None, help="Epic name for a nested member")
6717
+ p_skip.add_argument("--json", action="store_true", dest="json_output")
6718
+
5739
6719
  p_br = sub.add_parser("state-branch", help="Set the top-level branch field")
5740
6720
  p_br.add_argument("--feature", required=True, help="Feature name")
5741
6721
  p_br.add_argument("--branch", required=True, help="Branch name to record")
@@ -5815,6 +6795,74 @@ def main() -> int:
5815
6795
  p_ver.add_argument("--epic", default=None, help="Epic name for a nested member")
5816
6796
  p_ver.add_argument("--json", action="store_true", dest="json_output")
5817
6797
 
6798
+ p_drec = sub.add_parser(
6799
+ "decision-record", help="Append a needs-human decision entry (append-only)"
6800
+ )
6801
+ p_drec.add_argument("--backlog-dir", required=True, dest="backlog_dir",
6802
+ help="Resolved backlog directory (e.g. specs/loop-recovery)")
6803
+ p_drec.add_argument("--item", required=True, action="append", dest="item_ids",
6804
+ metavar="ID", help="Backlog item id (repeatable — one entry per id)")
6805
+ p_drec.add_argument("--question", required=True, help="The needs-human question text")
6806
+ _ans = p_drec.add_mutually_exclusive_group(required=True)
6807
+ _ans.add_argument("--answer", default=None, help="The operator's answer")
6808
+ _ans.add_argument("--deferred", action="store_true",
6809
+ help="Record a deferral / cancel-early (answer: null)")
6810
+ p_drec.add_argument("--cluster", default=None, dest="cluster_id", metavar="CID",
6811
+ help="Shared clusterId for one consolidated decision (REQ-CLU-04)")
6812
+ p_drec.add_argument("--actor", default=None,
6813
+ help="Session/actor label for recordedBy (default forge-5-loop@<host>)")
6814
+ p_drec.add_argument("--state-dir", default=None, dest="state_dir",
6815
+ help="State-dir name (default: effective loopRunner.stateDir)")
6816
+ p_drec.add_argument("--config", default="./forge.config.json",
6817
+ help="forge.config.json path")
6818
+ p_drec.add_argument("--json", action="store_true", dest="json_output")
6819
+
6820
+ p_dlist = sub.add_parser(
6821
+ "decision-list", help="Read the decision record (or the unapplied set)"
6822
+ )
6823
+ p_dlist.add_argument("--backlog-dir", required=True, dest="backlog_dir",
6824
+ help="Resolved backlog directory")
6825
+ p_dlist.add_argument("--unapplied", action="store_true",
6826
+ help="Return only the latest-unapplied-per-item set (REQ-DEC-05)")
6827
+ p_dlist.add_argument("--state-dir", default=None, dest="state_dir",
6828
+ help="State-dir name (default: effective loopRunner.stateDir)")
6829
+ p_dlist.add_argument("--config", default="./forge.config.json",
6830
+ help="forge.config.json path")
6831
+ p_dlist.add_argument("--json", action="store_true", dest="json_output")
6832
+
6833
+ p_dapply = sub.add_parser(
6834
+ "decision-apply", help="Mark the latest decision for an item applied"
6835
+ )
6836
+ p_dapply.add_argument("--backlog-dir", required=True, dest="backlog_dir",
6837
+ help="Resolved backlog directory")
6838
+ p_dapply.add_argument("--item", required=True, dest="item_id", metavar="ID",
6839
+ help="Backlog item whose latest decision to stamp applied")
6840
+ p_dapply.add_argument("--actor", default=None,
6841
+ help="Session/actor label for appliedBy (default forge-5-loop@<host>)")
6842
+ p_dapply.add_argument("--state-dir", default=None, dest="state_dir",
6843
+ help="State-dir name (default: effective loopRunner.stateDir)")
6844
+ p_dapply.add_argument("--config", default="./forge.config.json",
6845
+ help="forge.config.json path")
6846
+ p_dapply.add_argument("--json", action="store_true", dest="json_output")
6847
+
6848
+ p_topo = sub.add_parser(
6849
+ "backlog-topology",
6850
+ help="Dependency-topology metrics + advisory warnings over a runner item array",
6851
+ )
6852
+ topo_src = p_topo.add_mutually_exclusive_group(required=True)
6853
+ topo_src.add_argument(
6854
+ "--items-json", help="Path to the loopRunner listCommand JSON output"
6855
+ )
6856
+ topo_src.add_argument(
6857
+ "--items-stdin", action="store_true",
6858
+ help="Read the listCommand JSON from stdin",
6859
+ )
6860
+ p_topo.add_argument(
6861
+ "--cluster", action="store_true", dest="with_clusters",
6862
+ help="Append blocked-item clusters for consolidated prompts",
6863
+ )
6864
+ p_topo.add_argument("--json", action="store_true", dest="json_output")
6865
+
5818
6866
  args = parser.parse_args()
5819
6867
 
5820
6868
  try:
@@ -5903,6 +6951,7 @@ def main() -> int:
5903
6951
  args.outcome,
5904
6952
  args.owner,
5905
6953
  args.verify_capability,
6954
+ args.cause,
5906
6955
  )
5907
6956
  if args.json_output:
5908
6957
  print(json.dumps(payload, indent=2, ensure_ascii=False))
@@ -5960,6 +7009,17 @@ def main() -> int:
5960
7009
  )
5961
7010
  return 0
5962
7011
 
7012
+ if args.cmd == "state-skip":
7013
+ payload = cmd_state_skip(
7014
+ args.feature, args.stage, Path(args.specs_dir), args.epic
7015
+ )
7016
+ _emit(
7017
+ payload,
7018
+ args.json_output,
7019
+ lambda state: _print_state_skip(state, args.stage),
7020
+ )
7021
+ return 0
7022
+
5963
7023
  if args.cmd == "state-branch":
5964
7024
  payload = cmd_state_branch(
5965
7025
  args.feature, args.branch, Path(args.specs_dir), args.epic
@@ -6020,6 +7080,44 @@ def main() -> int:
6020
7080
  )
6021
7081
  return 0
6022
7082
 
7083
+ if args.cmd == "decision-record":
7084
+ payload = cmd_decision_record(
7085
+ Path(args.backlog_dir),
7086
+ args.item_ids,
7087
+ args.question,
7088
+ args.answer,
7089
+ args.deferred,
7090
+ args.cluster_id,
7091
+ args.actor or _default_actor(),
7092
+ args.state_dir,
7093
+ Path(args.config),
7094
+ _default_schema_path(),
7095
+ )
7096
+ _emit(payload, args.json_output, _print_decision_record)
7097
+ return 0
7098
+
7099
+ if args.cmd == "decision-list":
7100
+ payload = cmd_decision_list(
7101
+ Path(args.backlog_dir), args.unapplied, args.state_dir,
7102
+ Path(args.config), _default_schema_path(),
7103
+ )
7104
+ _emit(payload, args.json_output, _print_decision_list)
7105
+ return 0
7106
+
7107
+ if args.cmd == "decision-apply":
7108
+ payload = cmd_decision_apply(
7109
+ Path(args.backlog_dir), args.item_id, args.actor or _default_actor(),
7110
+ args.state_dir, Path(args.config), _default_schema_path(),
7111
+ )
7112
+ _emit(payload, args.json_output, _print_decision_apply)
7113
+ return 0
7114
+
7115
+ if args.cmd == "backlog-topology":
7116
+ items = _load_topology_items(args)
7117
+ payload = cmd_backlog_topology(items, with_clusters=args.with_clusters)
7118
+ _emit(payload, args.json_output, _print_topology)
7119
+ return 0
7120
+
6023
7121
  raise UsageError(f"unknown command: {args.cmd}")
6024
7122
  except UsageError as exc:
6025
7123
  print(f"Error: {exc}", file=sys.stderr)