@garygentry/feature-forge 0.2.14 → 0.3.1

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 (486) hide show
  1. package/README.md +6 -3
  2. package/adapters/GENERATION-REPORT.md +20 -0
  3. package/adapters/claude/.feature-forge-bundle.json +1 -1
  4. package/adapters/claude/agents/forge-verifier.md +1 -1
  5. package/adapters/claude/references/forge-config-schema.json +2 -2
  6. package/adapters/claude/references/pipeline-state-schema.json +1 -1
  7. package/adapters/claude/references/shared-conventions.md +62 -12
  8. package/adapters/claude/references/stage-exit-protocol.md +14 -4
  9. package/adapters/claude/references/vendor-construct-inventory.md +1 -1
  10. package/adapters/claude/scripts/epic-manifest.py +49 -6
  11. package/adapters/claude/scripts/forge-root.sh +47 -3
  12. package/adapters/claude/scripts/forge-session.py +1179 -9
  13. package/adapters/claude/scripts/validate-traceability.py +6 -1
  14. package/adapters/claude/skills/forge/SKILL.md +11 -5
  15. package/adapters/claude/skills/forge/references/pipeline-state-schema.json +1 -1
  16. package/adapters/claude/skills/forge/references/shared-conventions.md +62 -12
  17. package/adapters/claude/skills/forge/references/stage-exit-protocol.md +14 -4
  18. package/adapters/claude/skills/forge-0-epic/SKILL.md +1 -1
  19. package/adapters/claude/skills/forge-0-epic/references/pipeline-state-schema.json +1 -1
  20. package/adapters/claude/skills/forge-0-epic/references/shared-conventions.md +62 -12
  21. package/adapters/claude/skills/forge-0-epic/references/stage-exit-protocol.md +14 -4
  22. package/adapters/claude/skills/forge-1-prd/SKILL.md +25 -8
  23. package/adapters/claude/skills/forge-1-prd/references/shared-conventions.md +62 -12
  24. package/adapters/claude/skills/forge-1-prd/references/stage-exit-protocol.md +14 -4
  25. package/adapters/claude/skills/forge-2-tech/SKILL.md +25 -7
  26. package/adapters/claude/skills/forge-2-tech/references/shared-conventions.md +62 -12
  27. package/adapters/claude/skills/forge-2-tech/references/stage-exit-protocol.md +14 -4
  28. package/adapters/claude/skills/forge-3-specs/SKILL.md +24 -7
  29. package/adapters/claude/skills/forge-3-specs/references/shared-conventions.md +62 -12
  30. package/adapters/claude/skills/forge-3-specs/references/stage-exit-protocol.md +14 -4
  31. package/adapters/claude/skills/forge-4-backlog/SKILL.md +23 -7
  32. package/adapters/claude/skills/forge-4-backlog/references/shared-conventions.md +62 -12
  33. package/adapters/claude/skills/forge-4-backlog/references/stage-exit-protocol.md +14 -4
  34. package/adapters/claude/skills/forge-5-loop/SKILL.md +25 -25
  35. package/adapters/claude/skills/forge-5-loop/references/agent-selection.md +116 -0
  36. package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +14 -107
  37. package/adapters/claude/skills/forge-5-loop/references/shared-conventions.md +62 -12
  38. package/adapters/claude/skills/forge-5-loop/references/stage-exit-protocol.md +14 -4
  39. package/adapters/claude/skills/forge-6-docs/SKILL.md +11 -5
  40. package/adapters/claude/skills/forge-6-docs/references/shared-conventions.md +62 -12
  41. package/adapters/claude/skills/forge-fix/references/shared-conventions.md +62 -12
  42. package/adapters/claude/skills/forge-fix/references/stage-exit-protocol.md +14 -4
  43. package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +2 -2
  44. package/adapters/claude/skills/forge-guide/references/shared-conventions.md +62 -12
  45. package/adapters/claude/skills/forge-verify/SKILL.md +22 -12
  46. package/adapters/claude/skills/forge-verify/references/findings-template.md +157 -0
  47. package/adapters/claude/skills/forge-verify/references/pipeline-state-schema.json +1 -1
  48. package/adapters/claude/skills/forge-verify/references/shared-conventions.md +62 -12
  49. package/adapters/claude/skills/forge-verify/references/verification-checklists/backlog.md +97 -0
  50. package/adapters/claude/skills/forge-verify/references/verification-checklists/epic.md +79 -0
  51. package/adapters/claude/skills/forge-verify/references/verification-checklists/impl.md +48 -0
  52. package/adapters/claude/skills/forge-verify/references/verification-checklists/prd.md +31 -0
  53. package/adapters/claude/skills/forge-verify/references/verification-checklists/specs.md +64 -0
  54. package/adapters/claude/skills/forge-verify/references/verification-checklists/tech.md +35 -0
  55. package/adapters/codex/.feature-forge-bundle.json +1 -1
  56. package/adapters/codex/agents/forge-verifier.toml +1 -1
  57. package/adapters/codex/references/forge-config-schema.json +2 -2
  58. package/adapters/codex/references/pipeline-state-schema.json +1 -1
  59. package/adapters/codex/references/shared-conventions.md +62 -12
  60. package/adapters/codex/references/stage-exit-protocol.md +14 -4
  61. package/adapters/codex/references/vendor-construct-inventory.md +1 -1
  62. package/adapters/codex/scripts/epic-manifest.py +49 -6
  63. package/adapters/codex/scripts/forge-root.sh +47 -3
  64. package/adapters/codex/scripts/forge-session.py +1179 -9
  65. package/adapters/codex/scripts/validate-traceability.py +6 -1
  66. package/adapters/codex/skills/forge/SKILL.md +11 -5
  67. package/adapters/codex/skills/forge/references/pipeline-state-schema.json +1 -1
  68. package/adapters/codex/skills/forge/references/shared-conventions.md +62 -12
  69. package/adapters/codex/skills/forge/references/stage-exit-protocol.md +14 -4
  70. package/adapters/codex/skills/forge-0-epic/SKILL.md +1 -1
  71. package/adapters/codex/skills/forge-0-epic/references/pipeline-state-schema.json +1 -1
  72. package/adapters/codex/skills/forge-0-epic/references/shared-conventions.md +62 -12
  73. package/adapters/codex/skills/forge-0-epic/references/stage-exit-protocol.md +14 -4
  74. package/adapters/codex/skills/forge-1-prd/SKILL.md +25 -8
  75. package/adapters/codex/skills/forge-1-prd/references/shared-conventions.md +62 -12
  76. package/adapters/codex/skills/forge-1-prd/references/stage-exit-protocol.md +14 -4
  77. package/adapters/codex/skills/forge-2-tech/SKILL.md +25 -7
  78. package/adapters/codex/skills/forge-2-tech/references/shared-conventions.md +62 -12
  79. package/adapters/codex/skills/forge-2-tech/references/stage-exit-protocol.md +14 -4
  80. package/adapters/codex/skills/forge-3-specs/SKILL.md +24 -7
  81. package/adapters/codex/skills/forge-3-specs/references/shared-conventions.md +62 -12
  82. package/adapters/codex/skills/forge-3-specs/references/stage-exit-protocol.md +14 -4
  83. package/adapters/codex/skills/forge-4-backlog/SKILL.md +23 -7
  84. package/adapters/codex/skills/forge-4-backlog/references/shared-conventions.md +62 -12
  85. package/adapters/codex/skills/forge-4-backlog/references/stage-exit-protocol.md +14 -4
  86. package/adapters/codex/skills/forge-5-loop/SKILL.md +25 -25
  87. package/adapters/codex/skills/forge-5-loop/references/agent-selection.md +116 -0
  88. package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +14 -107
  89. package/adapters/codex/skills/forge-5-loop/references/shared-conventions.md +62 -12
  90. package/adapters/codex/skills/forge-5-loop/references/stage-exit-protocol.md +14 -4
  91. package/adapters/codex/skills/forge-6-docs/SKILL.md +11 -5
  92. package/adapters/codex/skills/forge-6-docs/references/shared-conventions.md +62 -12
  93. package/adapters/codex/skills/forge-fix/references/shared-conventions.md +62 -12
  94. package/adapters/codex/skills/forge-fix/references/stage-exit-protocol.md +14 -4
  95. package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +2 -2
  96. package/adapters/codex/skills/forge-guide/references/shared-conventions.md +62 -12
  97. package/adapters/codex/skills/forge-verify/SKILL.md +22 -12
  98. package/adapters/codex/skills/forge-verify/references/findings-template.md +157 -0
  99. package/adapters/codex/skills/forge-verify/references/pipeline-state-schema.json +1 -1
  100. package/adapters/codex/skills/forge-verify/references/shared-conventions.md +62 -12
  101. package/adapters/codex/skills/forge-verify/references/verification-checklists/backlog.md +97 -0
  102. package/adapters/codex/skills/forge-verify/references/verification-checklists/epic.md +79 -0
  103. package/adapters/codex/skills/forge-verify/references/verification-checklists/impl.md +48 -0
  104. package/adapters/codex/skills/forge-verify/references/verification-checklists/prd.md +31 -0
  105. package/adapters/codex/skills/forge-verify/references/verification-checklists/specs.md +64 -0
  106. package/adapters/codex/skills/forge-verify/references/verification-checklists/tech.md +35 -0
  107. package/adapters/copilot/.feature-forge-bundle.json +1 -1
  108. package/adapters/copilot/agents/forge-verifier.md +1 -1
  109. package/adapters/copilot/references/forge-config-schema.json +2 -2
  110. package/adapters/copilot/references/pipeline-state-schema.json +1 -1
  111. package/adapters/copilot/references/shared-conventions.md +62 -12
  112. package/adapters/copilot/references/stage-exit-protocol.md +14 -4
  113. package/adapters/copilot/references/vendor-construct-inventory.md +1 -1
  114. package/adapters/copilot/scripts/epic-manifest.py +49 -6
  115. package/adapters/copilot/scripts/forge-root.sh +47 -3
  116. package/adapters/copilot/scripts/forge-session.py +1179 -9
  117. package/adapters/copilot/scripts/validate-traceability.py +6 -1
  118. package/adapters/copilot/skills/forge/forge.md +11 -5
  119. package/adapters/copilot/skills/forge/references/pipeline-state-schema.json +1 -1
  120. package/adapters/copilot/skills/forge/references/shared-conventions.md +62 -12
  121. package/adapters/copilot/skills/forge/references/stage-exit-protocol.md +14 -4
  122. package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +1 -1
  123. package/adapters/copilot/skills/forge-0-epic/references/pipeline-state-schema.json +1 -1
  124. package/adapters/copilot/skills/forge-0-epic/references/shared-conventions.md +62 -12
  125. package/adapters/copilot/skills/forge-0-epic/references/stage-exit-protocol.md +14 -4
  126. package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +25 -8
  127. package/adapters/copilot/skills/forge-1-prd/references/shared-conventions.md +62 -12
  128. package/adapters/copilot/skills/forge-1-prd/references/stage-exit-protocol.md +14 -4
  129. package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +25 -7
  130. package/adapters/copilot/skills/forge-2-tech/references/shared-conventions.md +62 -12
  131. package/adapters/copilot/skills/forge-2-tech/references/stage-exit-protocol.md +14 -4
  132. package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +24 -7
  133. package/adapters/copilot/skills/forge-3-specs/references/shared-conventions.md +62 -12
  134. package/adapters/copilot/skills/forge-3-specs/references/stage-exit-protocol.md +14 -4
  135. package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +23 -7
  136. package/adapters/copilot/skills/forge-4-backlog/references/shared-conventions.md +62 -12
  137. package/adapters/copilot/skills/forge-4-backlog/references/stage-exit-protocol.md +14 -4
  138. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +25 -25
  139. package/adapters/copilot/skills/forge-5-loop/references/agent-selection.md +116 -0
  140. package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +14 -107
  141. package/adapters/copilot/skills/forge-5-loop/references/shared-conventions.md +62 -12
  142. package/adapters/copilot/skills/forge-5-loop/references/stage-exit-protocol.md +14 -4
  143. package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +11 -5
  144. package/adapters/copilot/skills/forge-6-docs/references/shared-conventions.md +62 -12
  145. package/adapters/copilot/skills/forge-fix/references/shared-conventions.md +62 -12
  146. package/adapters/copilot/skills/forge-fix/references/stage-exit-protocol.md +14 -4
  147. package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +2 -2
  148. package/adapters/copilot/skills/forge-guide/references/shared-conventions.md +62 -12
  149. package/adapters/copilot/skills/forge-verify/forge-verify.md +22 -12
  150. package/adapters/copilot/skills/forge-verify/references/findings-template.md +157 -0
  151. package/adapters/copilot/skills/forge-verify/references/pipeline-state-schema.json +1 -1
  152. package/adapters/copilot/skills/forge-verify/references/shared-conventions.md +62 -12
  153. package/adapters/copilot/skills/forge-verify/references/verification-checklists/backlog.md +97 -0
  154. package/adapters/copilot/skills/forge-verify/references/verification-checklists/epic.md +79 -0
  155. package/adapters/copilot/skills/forge-verify/references/verification-checklists/impl.md +48 -0
  156. package/adapters/copilot/skills/forge-verify/references/verification-checklists/prd.md +31 -0
  157. package/adapters/copilot/skills/forge-verify/references/verification-checklists/specs.md +64 -0
  158. package/adapters/copilot/skills/forge-verify/references/verification-checklists/tech.md +35 -0
  159. package/adapters/cursor/.feature-forge-bundle.json +1 -1
  160. package/adapters/cursor/agents/forge-verifier.mdc +1 -1
  161. package/adapters/cursor/references/forge-config-schema.json +2 -2
  162. package/adapters/cursor/references/pipeline-state-schema.json +1 -1
  163. package/adapters/cursor/references/shared-conventions.md +62 -12
  164. package/adapters/cursor/references/stage-exit-protocol.md +14 -4
  165. package/adapters/cursor/references/vendor-construct-inventory.md +1 -1
  166. package/adapters/cursor/scripts/epic-manifest.py +49 -6
  167. package/adapters/cursor/scripts/forge-root.sh +47 -3
  168. package/adapters/cursor/scripts/forge-session.py +1179 -9
  169. package/adapters/cursor/scripts/validate-traceability.py +6 -1
  170. package/adapters/cursor/skills/forge/forge.mdc +11 -5
  171. package/adapters/cursor/skills/forge/references/pipeline-state-schema.json +1 -1
  172. package/adapters/cursor/skills/forge/references/shared-conventions.md +62 -12
  173. package/adapters/cursor/skills/forge/references/stage-exit-protocol.md +14 -4
  174. package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +1 -1
  175. package/adapters/cursor/skills/forge-0-epic/references/pipeline-state-schema.json +1 -1
  176. package/adapters/cursor/skills/forge-0-epic/references/shared-conventions.md +62 -12
  177. package/adapters/cursor/skills/forge-0-epic/references/stage-exit-protocol.md +14 -4
  178. package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +25 -8
  179. package/adapters/cursor/skills/forge-1-prd/references/shared-conventions.md +62 -12
  180. package/adapters/cursor/skills/forge-1-prd/references/stage-exit-protocol.md +14 -4
  181. package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +25 -7
  182. package/adapters/cursor/skills/forge-2-tech/references/shared-conventions.md +62 -12
  183. package/adapters/cursor/skills/forge-2-tech/references/stage-exit-protocol.md +14 -4
  184. package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +24 -7
  185. package/adapters/cursor/skills/forge-3-specs/references/shared-conventions.md +62 -12
  186. package/adapters/cursor/skills/forge-3-specs/references/stage-exit-protocol.md +14 -4
  187. package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +23 -7
  188. package/adapters/cursor/skills/forge-4-backlog/references/shared-conventions.md +62 -12
  189. package/adapters/cursor/skills/forge-4-backlog/references/stage-exit-protocol.md +14 -4
  190. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +25 -25
  191. package/adapters/cursor/skills/forge-5-loop/references/agent-selection.md +116 -0
  192. package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +14 -107
  193. package/adapters/cursor/skills/forge-5-loop/references/shared-conventions.md +62 -12
  194. package/adapters/cursor/skills/forge-5-loop/references/stage-exit-protocol.md +14 -4
  195. package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +11 -5
  196. package/adapters/cursor/skills/forge-6-docs/references/shared-conventions.md +62 -12
  197. package/adapters/cursor/skills/forge-fix/references/shared-conventions.md +62 -12
  198. package/adapters/cursor/skills/forge-fix/references/stage-exit-protocol.md +14 -4
  199. package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +2 -2
  200. package/adapters/cursor/skills/forge-guide/references/shared-conventions.md +62 -12
  201. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +22 -12
  202. package/adapters/cursor/skills/forge-verify/references/findings-template.md +157 -0
  203. package/adapters/cursor/skills/forge-verify/references/pipeline-state-schema.json +1 -1
  204. package/adapters/cursor/skills/forge-verify/references/shared-conventions.md +62 -12
  205. package/adapters/cursor/skills/forge-verify/references/verification-checklists/backlog.md +97 -0
  206. package/adapters/cursor/skills/forge-verify/references/verification-checklists/epic.md +79 -0
  207. package/adapters/cursor/skills/forge-verify/references/verification-checklists/impl.md +48 -0
  208. package/adapters/cursor/skills/forge-verify/references/verification-checklists/prd.md +31 -0
  209. package/adapters/cursor/skills/forge-verify/references/verification-checklists/specs.md +64 -0
  210. package/adapters/cursor/skills/forge-verify/references/verification-checklists/tech.md +35 -0
  211. package/adapters/gemini/.feature-forge-bundle.json +1 -1
  212. package/adapters/gemini/agents/forge-verifier.md +1 -1
  213. package/adapters/gemini/gemini-extension.json +1 -1
  214. package/adapters/gemini/references/forge-config-schema.json +2 -2
  215. package/adapters/gemini/references/pipeline-state-schema.json +1 -1
  216. package/adapters/gemini/references/shared-conventions.md +62 -12
  217. package/adapters/gemini/references/stage-exit-protocol.md +14 -4
  218. package/adapters/gemini/references/vendor-construct-inventory.md +1 -1
  219. package/adapters/gemini/scripts/epic-manifest.py +49 -6
  220. package/adapters/gemini/scripts/forge-root.sh +47 -3
  221. package/adapters/gemini/scripts/forge-session.py +1179 -9
  222. package/adapters/gemini/scripts/validate-traceability.py +6 -1
  223. package/adapters/gemini/skills/forge/forge.md +11 -5
  224. package/adapters/gemini/skills/forge/references/pipeline-state-schema.json +1 -1
  225. package/adapters/gemini/skills/forge/references/shared-conventions.md +62 -12
  226. package/adapters/gemini/skills/forge/references/stage-exit-protocol.md +14 -4
  227. package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +1 -1
  228. package/adapters/gemini/skills/forge-0-epic/references/pipeline-state-schema.json +1 -1
  229. package/adapters/gemini/skills/forge-0-epic/references/shared-conventions.md +62 -12
  230. package/adapters/gemini/skills/forge-0-epic/references/stage-exit-protocol.md +14 -4
  231. package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +25 -8
  232. package/adapters/gemini/skills/forge-1-prd/references/shared-conventions.md +62 -12
  233. package/adapters/gemini/skills/forge-1-prd/references/stage-exit-protocol.md +14 -4
  234. package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +25 -7
  235. package/adapters/gemini/skills/forge-2-tech/references/shared-conventions.md +62 -12
  236. package/adapters/gemini/skills/forge-2-tech/references/stage-exit-protocol.md +14 -4
  237. package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +24 -7
  238. package/adapters/gemini/skills/forge-3-specs/references/shared-conventions.md +62 -12
  239. package/adapters/gemini/skills/forge-3-specs/references/stage-exit-protocol.md +14 -4
  240. package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +23 -7
  241. package/adapters/gemini/skills/forge-4-backlog/references/shared-conventions.md +62 -12
  242. package/adapters/gemini/skills/forge-4-backlog/references/stage-exit-protocol.md +14 -4
  243. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +25 -25
  244. package/adapters/gemini/skills/forge-5-loop/references/agent-selection.md +116 -0
  245. package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +14 -107
  246. package/adapters/gemini/skills/forge-5-loop/references/shared-conventions.md +62 -12
  247. package/adapters/gemini/skills/forge-5-loop/references/stage-exit-protocol.md +14 -4
  248. package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +11 -5
  249. package/adapters/gemini/skills/forge-6-docs/references/shared-conventions.md +62 -12
  250. package/adapters/gemini/skills/forge-fix/references/shared-conventions.md +62 -12
  251. package/adapters/gemini/skills/forge-fix/references/stage-exit-protocol.md +14 -4
  252. package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +2 -2
  253. package/adapters/gemini/skills/forge-guide/references/shared-conventions.md +62 -12
  254. package/adapters/gemini/skills/forge-verify/forge-verify.md +22 -12
  255. package/adapters/gemini/skills/forge-verify/references/findings-template.md +157 -0
  256. package/adapters/gemini/skills/forge-verify/references/pipeline-state-schema.json +1 -1
  257. package/adapters/gemini/skills/forge-verify/references/shared-conventions.md +62 -12
  258. package/adapters/gemini/skills/forge-verify/references/verification-checklists/backlog.md +97 -0
  259. package/adapters/gemini/skills/forge-verify/references/verification-checklists/epic.md +79 -0
  260. package/adapters/gemini/skills/forge-verify/references/verification-checklists/impl.md +48 -0
  261. package/adapters/gemini/skills/forge-verify/references/verification-checklists/prd.md +31 -0
  262. package/adapters/gemini/skills/forge-verify/references/verification-checklists/specs.md +64 -0
  263. package/adapters/gemini/skills/forge-verify/references/verification-checklists/tech.md +35 -0
  264. package/adapters/pi/.feature-forge-bundle.json +6 -0
  265. package/adapters/pi/agents/forge-researcher.md +139 -0
  266. package/adapters/pi/agents/forge-spec-writer.md +116 -0
  267. package/adapters/pi/agents/forge-verifier.md +126 -0
  268. package/adapters/pi/extensions/ask-user-question/LICENSE +21 -0
  269. package/adapters/pi/extensions/ask-user-question/README.md +91 -0
  270. package/adapters/pi/extensions/ask-user-question/ask-user-question.ts +298 -0
  271. package/adapters/pi/extensions/ask-user-question/config.ts +78 -0
  272. package/adapters/pi/extensions/ask-user-question/events.ts +57 -0
  273. package/adapters/pi/extensions/ask-user-question/index.ts +61 -0
  274. package/adapters/pi/extensions/ask-user-question/locales/de.json +27 -0
  275. package/adapters/pi/extensions/ask-user-question/locales/en.json +27 -0
  276. package/adapters/pi/extensions/ask-user-question/locales/es.json +27 -0
  277. package/adapters/pi/extensions/ask-user-question/locales/fr.json +27 -0
  278. package/adapters/pi/extensions/ask-user-question/locales/pt-BR.json +27 -0
  279. package/adapters/pi/extensions/ask-user-question/locales/pt.json +27 -0
  280. package/adapters/pi/extensions/ask-user-question/locales/ru.json +27 -0
  281. package/adapters/pi/extensions/ask-user-question/locales/uk.json +27 -0
  282. package/adapters/pi/extensions/ask-user-question/locales/zh.json +29 -0
  283. package/adapters/pi/extensions/ask-user-question/reconcile.ts +49 -0
  284. package/adapters/pi/extensions/ask-user-question/rpc-fallback.ts +168 -0
  285. package/adapters/pi/extensions/ask-user-question/state/build-questionnaire.ts +302 -0
  286. package/adapters/pi/extensions/ask-user-question/state/i18n-bridge.ts +53 -0
  287. package/adapters/pi/extensions/ask-user-question/state/key-router.ts +277 -0
  288. package/adapters/pi/extensions/ask-user-question/state/questionnaire-session.ts +234 -0
  289. package/adapters/pi/extensions/ask-user-question/state/row-intent.ts +145 -0
  290. package/adapters/pi/extensions/ask-user-question/state/selectors/contract.ts +26 -0
  291. package/adapters/pi/extensions/ask-user-question/state/selectors/derivations.ts +42 -0
  292. package/adapters/pi/extensions/ask-user-question/state/selectors/focus.ts +19 -0
  293. package/adapters/pi/extensions/ask-user-question/state/selectors/projections.ts +101 -0
  294. package/adapters/pi/extensions/ask-user-question/state/state-reducer.ts +292 -0
  295. package/adapters/pi/extensions/ask-user-question/state/state.ts +55 -0
  296. package/adapters/pi/extensions/ask-user-question/tool/format-answer.ts +31 -0
  297. package/adapters/pi/extensions/ask-user-question/tool/response-envelope.ts +49 -0
  298. package/adapters/pi/extensions/ask-user-question/tool/types.ts +147 -0
  299. package/adapters/pi/extensions/ask-user-question/tool/validate-questionnaire.ts +58 -0
  300. package/adapters/pi/extensions/ask-user-question/vendor-config-shim.ts +65 -0
  301. package/adapters/pi/extensions/ask-user-question/view/component-binding.ts +47 -0
  302. package/adapters/pi/extensions/ask-user-question/view/components/inline-input.ts +98 -0
  303. package/adapters/pi/extensions/ask-user-question/view/components/multi-select-view.ts +193 -0
  304. package/adapters/pi/extensions/ask-user-question/view/components/option-list-view.ts +70 -0
  305. package/adapters/pi/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +79 -0
  306. package/adapters/pi/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +111 -0
  307. package/adapters/pi/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +88 -0
  308. package/adapters/pi/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +202 -0
  309. package/adapters/pi/extensions/ask-user-question/view/components/preview/preview-pane.ts +228 -0
  310. package/adapters/pi/extensions/ask-user-question/view/components/submit-picker.ts +67 -0
  311. package/adapters/pi/extensions/ask-user-question/view/components/tab-bar.ts +59 -0
  312. package/adapters/pi/extensions/ask-user-question/view/components/wrapping-select.ts +293 -0
  313. package/adapters/pi/extensions/ask-user-question/view/dialog-builder.ts +224 -0
  314. package/adapters/pi/extensions/ask-user-question/view/props-adapter.ts +125 -0
  315. package/adapters/pi/extensions/ask-user-question/view/stateful-view.ts +26 -0
  316. package/adapters/pi/extensions/ask-user-question/view/tab-components.ts +18 -0
  317. package/adapters/pi/extensions/ask-user-question/view/tab-content-strategy.ts +252 -0
  318. package/adapters/pi/package.json +26 -0
  319. package/adapters/pi/references/epic-manifest-schema.json +125 -0
  320. package/adapters/{claude/skills/forge-5-loop → pi}/references/forge-config-schema.json +4 -4
  321. package/adapters/{claude/skills/forge-1-prd → pi}/references/pipeline-state-schema.json +1 -1
  322. package/adapters/pi/references/portable-root.md +71 -0
  323. package/adapters/pi/references/process-overview.md +143 -0
  324. package/adapters/pi/references/ralph-loop-contract.md +221 -0
  325. package/adapters/pi/references/shared-conventions.md +345 -0
  326. package/adapters/pi/references/skill-frontmatter.schema.json +17 -0
  327. package/adapters/pi/references/stack-resolution.md +54 -0
  328. package/adapters/pi/references/stacks/_generic.md +111 -0
  329. package/adapters/pi/references/stacks/go.md +157 -0
  330. package/adapters/pi/references/stacks/python.md +184 -0
  331. package/adapters/pi/references/stacks/rust.md +170 -0
  332. package/adapters/pi/references/stacks/typescript.md +134 -0
  333. package/adapters/pi/references/stage-exit-protocol.md +268 -0
  334. package/adapters/pi/references/templates/specs-hygiene/AGENTS.md +32 -0
  335. package/adapters/pi/references/templates/specs-hygiene/CLAUDE.md +31 -0
  336. package/adapters/pi/references/vendor-construct-inventory.md +50 -0
  337. package/adapters/pi/scripts/epic-manifest.py +1737 -0
  338. package/adapters/pi/scripts/forge-bootstrap.py +1070 -0
  339. package/adapters/pi/scripts/forge-init.sh +58 -0
  340. package/adapters/pi/scripts/forge-root.sh +179 -0
  341. package/adapters/pi/scripts/forge-session.py +3036 -0
  342. package/adapters/pi/scripts/validate-traceability.py +155 -0
  343. package/adapters/pi/skills/forge/SKILL.md +249 -0
  344. package/adapters/{claude/skills/forge-4-backlog → pi/skills/forge}/references/pipeline-state-schema.json +1 -1
  345. package/adapters/pi/skills/forge/references/process-overview.md +143 -0
  346. package/adapters/pi/skills/forge/references/shared-conventions.md +345 -0
  347. package/adapters/pi/skills/forge/references/stage-exit-protocol.md +268 -0
  348. package/adapters/pi/skills/forge-0-epic/SKILL.md +308 -0
  349. package/adapters/pi/skills/forge-0-epic/references/edit-mode.md +266 -0
  350. package/adapters/pi/skills/forge-0-epic/references/epic-manifest-subcommands.md +75 -0
  351. package/adapters/{claude/skills/forge-2-tech → pi/skills/forge-0-epic}/references/pipeline-state-schema.json +1 -1
  352. package/adapters/pi/skills/forge-0-epic/references/portable-root.md +71 -0
  353. package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +345 -0
  354. package/adapters/pi/skills/forge-0-epic/references/stage-exit-protocol.md +268 -0
  355. package/adapters/pi/skills/forge-1-prd/SKILL.md +181 -0
  356. package/adapters/pi/skills/forge-1-prd/references/prd-template.md +106 -0
  357. package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +345 -0
  358. package/adapters/pi/skills/forge-1-prd/references/stage-exit-protocol.md +268 -0
  359. package/adapters/pi/skills/forge-2-tech/SKILL.md +243 -0
  360. package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +345 -0
  361. package/adapters/pi/skills/forge-2-tech/references/stack-discovery-checklist.md +95 -0
  362. package/adapters/pi/skills/forge-2-tech/references/stack-resolution.md +54 -0
  363. package/adapters/pi/skills/forge-2-tech/references/stacks/_generic.md +111 -0
  364. package/adapters/pi/skills/forge-2-tech/references/stacks/go.md +157 -0
  365. package/adapters/pi/skills/forge-2-tech/references/stacks/python.md +184 -0
  366. package/adapters/pi/skills/forge-2-tech/references/stacks/rust.md +170 -0
  367. package/adapters/pi/skills/forge-2-tech/references/stacks/typescript.md +134 -0
  368. package/adapters/pi/skills/forge-2-tech/references/stage-exit-protocol.md +268 -0
  369. package/adapters/pi/skills/forge-3-specs/SKILL.md +195 -0
  370. package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +345 -0
  371. package/adapters/pi/skills/forge-3-specs/references/spec-archetypes.md +106 -0
  372. package/adapters/pi/skills/forge-3-specs/references/spec-examples.md +71 -0
  373. package/adapters/pi/skills/forge-3-specs/references/stacks/_generic.md +111 -0
  374. package/adapters/pi/skills/forge-3-specs/references/stacks/go.md +157 -0
  375. package/adapters/pi/skills/forge-3-specs/references/stacks/python.md +184 -0
  376. package/adapters/pi/skills/forge-3-specs/references/stacks/rust.md +170 -0
  377. package/adapters/pi/skills/forge-3-specs/references/stacks/typescript.md +134 -0
  378. package/adapters/pi/skills/forge-3-specs/references/stage-exit-protocol.md +268 -0
  379. package/adapters/pi/skills/forge-4-backlog/SKILL.md +191 -0
  380. package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +345 -0
  381. package/adapters/pi/skills/forge-4-backlog/references/stage-exit-protocol.md +268 -0
  382. package/adapters/pi/skills/forge-5-loop/SKILL.md +314 -0
  383. package/adapters/pi/skills/forge-5-loop/references/agent-selection.md +116 -0
  384. package/adapters/pi/skills/forge-5-loop/references/ralph-loop-contract.md +221 -0
  385. package/adapters/pi/skills/forge-5-loop/references/result-reporting.md +85 -0
  386. package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +248 -0
  387. package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +345 -0
  388. package/adapters/pi/skills/forge-5-loop/references/stage-exit-protocol.md +268 -0
  389. package/adapters/pi/skills/forge-6-docs/SKILL.md +208 -0
  390. package/adapters/pi/skills/forge-6-docs/references/doc-conventions.md +126 -0
  391. package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +345 -0
  392. package/adapters/pi/skills/forge-bootstrap/SKILL.md +250 -0
  393. package/adapters/pi/skills/forge-bootstrap/references/templates/ci/github-actions.yml +12 -0
  394. package/adapters/pi/skills/forge-bootstrap/references/templates/generic/run.sh +3 -0
  395. package/adapters/pi/skills/forge-bootstrap/references/templates/generic/test.sh +13 -0
  396. package/adapters/pi/skills/forge-bootstrap/references/templates/go/go.mod +3 -0
  397. package/adapters/pi/skills/forge-bootstrap/references/templates/go/main.go +12 -0
  398. package/adapters/pi/skills/forge-bootstrap/references/templates/go/main_test.go +11 -0
  399. package/adapters/pi/skills/forge-bootstrap/references/templates/hygiene/AGENTS.md +35 -0
  400. package/adapters/pi/skills/forge-bootstrap/references/templates/hygiene/CLAUDE.md +36 -0
  401. package/adapters/pi/skills/forge-bootstrap/references/templates/hygiene/README.md +11 -0
  402. package/adapters/pi/skills/forge-bootstrap/references/templates/licenses/Apache-2.0/LICENSE +198 -0
  403. package/adapters/pi/skills/forge-bootstrap/references/templates/licenses/MIT/LICENSE +21 -0
  404. package/adapters/pi/skills/forge-bootstrap/references/templates/python/pyproject.toml +24 -0
  405. package/adapters/pi/skills/forge-bootstrap/references/templates/python/src/{{PKG}}/__init__.py +5 -0
  406. package/adapters/pi/skills/forge-bootstrap/references/templates/python/src/{{PKG}}/main.py +13 -0
  407. package/adapters/pi/skills/forge-bootstrap/references/templates/python/tests/test_smoke.py +8 -0
  408. package/adapters/pi/skills/forge-bootstrap/references/templates/rust/Cargo.toml +15 -0
  409. package/adapters/pi/skills/forge-bootstrap/references/templates/rust/src/lib.rs +7 -0
  410. package/adapters/pi/skills/forge-bootstrap/references/templates/rust/src/main.rs +5 -0
  411. package/adapters/pi/skills/forge-bootstrap/references/templates/rust/tests/smoke.rs +6 -0
  412. package/adapters/pi/skills/forge-bootstrap/references/templates/typescript/package.json +15 -0
  413. package/adapters/pi/skills/forge-bootstrap/references/templates/typescript/src/index.ts +4 -0
  414. package/adapters/pi/skills/forge-bootstrap/references/templates/typescript/test/smoke.test.ts +6 -0
  415. package/adapters/pi/skills/forge-bootstrap/references/templates/typescript/tsconfig.json +14 -0
  416. package/adapters/pi/skills/forge-fix/SKILL.md +98 -0
  417. package/adapters/pi/skills/forge-fix/references/shared-conventions.md +345 -0
  418. package/adapters/pi/skills/forge-fix/references/stage-exit-protocol.md +268 -0
  419. package/adapters/pi/skills/forge-guide/SKILL.md +192 -0
  420. package/adapters/{codex/skills/forge-4-backlog → pi/skills/forge-guide}/references/forge-config-schema.json +4 -4
  421. package/adapters/pi/skills/forge-guide/references/process-overview.md +143 -0
  422. package/adapters/pi/skills/forge-guide/references/ralph-loop-contract.md +221 -0
  423. package/adapters/pi/skills/forge-guide/references/shared-conventions.md +345 -0
  424. package/adapters/pi/skills/forge-guide/references/stack-resolution.md +54 -0
  425. package/adapters/pi/skills/forge-guide/references/stacks/_generic.md +111 -0
  426. package/adapters/pi/skills/forge-guide/references/stacks/go.md +157 -0
  427. package/adapters/pi/skills/forge-guide/references/stacks/python.md +184 -0
  428. package/adapters/pi/skills/forge-guide/references/stacks/rust.md +170 -0
  429. package/adapters/pi/skills/forge-guide/references/stacks/typescript.md +134 -0
  430. package/adapters/pi/skills/forge-init/SKILL.md +72 -0
  431. package/adapters/pi/skills/forge-verify/SKILL.md +283 -0
  432. package/adapters/pi/skills/forge-verify/references/findings-template.md +157 -0
  433. package/adapters/{claude/skills/forge-3-specs → pi/skills/forge-verify}/references/pipeline-state-schema.json +1 -1
  434. package/adapters/pi/skills/forge-verify/references/shared-conventions.md +345 -0
  435. package/adapters/pi/skills/forge-verify/references/verification-checklists/backlog.md +97 -0
  436. package/adapters/pi/skills/forge-verify/references/verification-checklists/epic.md +79 -0
  437. package/adapters/pi/skills/forge-verify/references/verification-checklists/impl.md +48 -0
  438. package/adapters/pi/skills/forge-verify/references/verification-checklists/prd.md +31 -0
  439. package/adapters/pi/skills/forge-verify/references/verification-checklists/specs.md +64 -0
  440. package/adapters/pi/skills/forge-verify/references/verification-checklists/tech.md +35 -0
  441. package/dist/agent-targets.d.ts +1 -1
  442. package/dist/agent-targets.js +23 -3
  443. package/dist/detect.d.ts +1 -1
  444. package/dist/detect.js +2 -1
  445. package/dist/manifest.d.ts +1 -1
  446. package/dist/manifest.js +2 -2
  447. package/dist/placements.js +5 -1
  448. package/dist/rauf.d.ts +4 -4
  449. package/dist/rauf.js +3 -3
  450. package/dist/types.d.ts +31 -6
  451. package/dist/types.js +6 -3
  452. package/package.json +14 -3
  453. package/adapters/claude/skills/forge-4-backlog/references/forge-config-schema.json +0 -236
  454. package/adapters/claude/skills/forge-6-docs/references/pipeline-state-schema.json +0 -191
  455. package/adapters/claude/skills/forge-verify/references/verification-checklists.md +0 -477
  456. package/adapters/codex/skills/forge-1-prd/references/pipeline-state-schema.json +0 -191
  457. package/adapters/codex/skills/forge-2-tech/references/pipeline-state-schema.json +0 -191
  458. package/adapters/codex/skills/forge-3-specs/references/pipeline-state-schema.json +0 -191
  459. package/adapters/codex/skills/forge-4-backlog/references/pipeline-state-schema.json +0 -191
  460. package/adapters/codex/skills/forge-5-loop/references/forge-config-schema.json +0 -236
  461. package/adapters/codex/skills/forge-6-docs/references/pipeline-state-schema.json +0 -191
  462. package/adapters/codex/skills/forge-verify/references/verification-checklists.md +0 -477
  463. package/adapters/copilot/skills/forge-1-prd/references/pipeline-state-schema.json +0 -191
  464. package/adapters/copilot/skills/forge-2-tech/references/pipeline-state-schema.json +0 -191
  465. package/adapters/copilot/skills/forge-3-specs/references/pipeline-state-schema.json +0 -191
  466. package/adapters/copilot/skills/forge-4-backlog/references/forge-config-schema.json +0 -236
  467. package/adapters/copilot/skills/forge-4-backlog/references/pipeline-state-schema.json +0 -191
  468. package/adapters/copilot/skills/forge-5-loop/references/forge-config-schema.json +0 -236
  469. package/adapters/copilot/skills/forge-6-docs/references/pipeline-state-schema.json +0 -191
  470. package/adapters/copilot/skills/forge-verify/references/verification-checklists.md +0 -477
  471. package/adapters/cursor/skills/forge-1-prd/references/pipeline-state-schema.json +0 -191
  472. package/adapters/cursor/skills/forge-2-tech/references/pipeline-state-schema.json +0 -191
  473. package/adapters/cursor/skills/forge-3-specs/references/pipeline-state-schema.json +0 -191
  474. package/adapters/cursor/skills/forge-4-backlog/references/forge-config-schema.json +0 -236
  475. package/adapters/cursor/skills/forge-4-backlog/references/pipeline-state-schema.json +0 -191
  476. package/adapters/cursor/skills/forge-5-loop/references/forge-config-schema.json +0 -236
  477. package/adapters/cursor/skills/forge-6-docs/references/pipeline-state-schema.json +0 -191
  478. package/adapters/cursor/skills/forge-verify/references/verification-checklists.md +0 -477
  479. package/adapters/gemini/skills/forge-1-prd/references/pipeline-state-schema.json +0 -191
  480. package/adapters/gemini/skills/forge-2-tech/references/pipeline-state-schema.json +0 -191
  481. package/adapters/gemini/skills/forge-3-specs/references/pipeline-state-schema.json +0 -191
  482. package/adapters/gemini/skills/forge-4-backlog/references/forge-config-schema.json +0 -236
  483. package/adapters/gemini/skills/forge-4-backlog/references/pipeline-state-schema.json +0 -191
  484. package/adapters/gemini/skills/forge-5-loop/references/forge-config-schema.json +0 -236
  485. package/adapters/gemini/skills/forge-6-docs/references/pipeline-state-schema.json +0 -191
  486. package/adapters/gemini/skills/forge-verify/references/verification-checklists.md +0 -477
@@ -0,0 +1,3036 @@
1
+ #!/usr/bin/env python3
2
+ """Session-aware navigation helpers for the feature-forge pipeline navigator.
3
+
4
+ Read-only subcommands that drive the usability features of the `/forge`
5
+ root navigator:
6
+
7
+ python3 forge-session.py rank-features [--specs-dir DIR] [--json]
8
+ python3 forge-session.py context-usage [--config FILE] [--window N] \
9
+ [--threshold F] [--json]
10
+ python3 forge-session.py doctor [--specs-dir DIR] [--config FILE] [--json]
11
+ python3 forge-session.py discover-feature [NAME | --all] [--specs-dir DIR] [--json]
12
+ python3 forge-session.py reconcile-branch --feature F [--specs-dir DIR] \
13
+ [--config FILE] [--epic E] [--json]
14
+ python3 forge-session.py check-epic-base --feature F [--specs-dir DIR] \
15
+ [--config FILE] [--epic E] [--json]
16
+ python3 forge-session.py stage-exit --feature F --stage S [--specs-dir DIR] \
17
+ [--config FILE] [--epic E] [--next-feature N] [--host claude|generic] [--json]
18
+ python3 forge-session.py effective-config [--config FILE] [--schema PATH] [--json]
19
+
20
+ Plus the `state-*` write verbs, which author `.pipeline-state.json` so no stage
21
+ has to hand-write the JSON (and therefore no stage has to read the state schema):
22
+
23
+ python3 forge-session.py state-enter --feature F --stage S [--specs-dir DIR] \
24
+ [--epic E] [--json]
25
+ python3 forge-session.py state-artifact --feature F --stage S --path P \
26
+ [--path P ...] [--specs-dir DIR] [--epic E] [--json]
27
+ python3 forge-session.py state-complete --feature F --stage S --version N \
28
+ [--based-on STAGE=N ...] [--artifact P ...] [--commit-hash H] \
29
+ [--status complete|in-progress] [--resumable] [--preserve-commit-hash] \
30
+ [--specs-dir DIR] [--epic E] [--json]
31
+ python3 forge-session.py state-branch --feature F --branch B [--specs-dir DIR] \
32
+ [--epic E] [--json]
33
+ python3 forge-session.py state-note --feature F --note TEXT [--specs-dir DIR] \
34
+ [--epic E] [--json]
35
+ python3 forge-session.py state-decision --feature F --question Q --raised-by S \
36
+ [--rationale R] [--target-stage S] [--specs-dir DIR] [--epic E] [--json]
37
+ python3 forge-session.py state-ecr --feature F --kind K --target T --rationale R \
38
+ --raised-by S --blocks-current true|false [--specs-dir DIR] [--epic E] [--json]
39
+
40
+ `rank-features` scans the specs tree for feature-shaped directories (those that
41
+ directly contain a `.pipeline-state.json`, in both the flat
42
+ `{specsDir}/{feature}/` and nested `{specsDir}/{epic}/{feature}/` layouts) and
43
+ reports the **active** ones ordered by `updatedAt` descending, so the navigator
44
+ can offer the most-recently-touched feature as the recency default. Each row
45
+ carries the next actionable stage + its slash command, derived from the single
46
+ ordered stage map below.
47
+
48
+ `context-usage` reads the live Claude Code session transcript (the most-recently
49
+ modified `*.jsonl` under `~/.claude/projects/<cwd-slug>/`), sums the last
50
+ assistant message's token usage, and compares it to the context window so the
51
+ navigator can recommend a clean session before the next stage. It is best-effort
52
+ and degrades gracefully: when no transcript or usage is found (a non-Claude host,
53
+ or a fresh session) it reports `{"available": false}` and still exits 0, so the
54
+ caller simply omits the context advice.
55
+
56
+ `doctor` captures pipeline ground truth in one shot for debugging a confused
57
+ session or a broken install: the plugin root the sibling `forge-root.sh`
58
+ actually resolves (plus its version and commit), the current git branch vs.
59
+ each feature's recorded state branch, the recency-ranked feature summary, and
60
+ whether each feature's composed backlog path exists on disk. Every probe is
61
+ best-effort — a failure is reported as data, never as a crash — and the
62
+ command always exits 0 so it can run in any half-broken environment.
63
+
64
+ `discover-feature` looks for a feature's `.pipeline-state.json` across ALL
65
+ git branches (local heads and remote-tracking refs), so a session on the
66
+ default branch can learn that a pipeline exists on a topic branch instead of
67
+ concluding it was never started. When nothing is found locally it also asks
68
+ `git ls-remote --heads origin` about branches a single-branch clone never
69
+ fetched, and emits the exact `git fetch`/`git switch` commands a caller could
70
+ run. It is strictly read-only — it never checks anything out itself — and
71
+ like `doctor` it always exits 0 and degrades to data. Each candidate also
72
+ carries `epic`/`isEpicMember`, so a caller minting a new standalone feature can
73
+ refuse when the name is a known epic member discoverable on another branch
74
+ (the split-brain-epic guard, Issue #125).
75
+
76
+ `check-epic-base` is the defense-in-depth companion: given a feature that
77
+ resolves to a nested epic member on the current branch, it confirms the epic's
78
+ `epic-manifest.json` is actually present on HEAD. When it is absent, the member
79
+ was reached from a branch that predates or lacks the manifest commit (a detached
80
+ base) and the command emits `warn-detached-base` with the member's recorded home
81
+ branch. Read-only; always exits 0.
82
+
83
+ `stage-exit` computes everything an authoring stage's closing used to derive
84
+ in prose (the Scripted Stage Exit, `references/stage-exit-protocol.md`):
85
+ the DIRECTIVES (whether the in-stage auto-verify runs, which verify gate to
86
+ present, autoFix eligibility, the verify and next-stage commands) plus the
87
+ exact sentinel-terminated NEXT-STEPS block the skill must print verbatim as
88
+ its absolute last output. Deterministic and read-only; always exits 0.
89
+
90
+ `effective-config` resolves the `loopRunner` block deterministically so no
91
+ caller has to read `references/forge-config-schema.json` just to learn the
92
+ defaults: it extracts each field's schema `default` at runtime and merges the
93
+ project's `loopRunner` overrides on top. A missing or corrupt
94
+ `forge.config.json` resolves to pure defaults (exit 0); only an unreadable
95
+ schema is fatal (exit 2), because then there are no defaults to resolve.
96
+
97
+ The `state-*` verbs are the script's only writers. Each follows the same
98
+ resolve -> load -> mutate -> refresh `updatedAt` -> atomic write path, so every
99
+ successful write leaves a schema-conformant state file: `state-enter` stamps a
100
+ stage in-progress and moves `currentStage`, `state-artifact` appends artifact
101
+ paths to a stage (de-duplicating), `state-branch` records the branch resolved by
102
+ Branch Setup / Branch Reconciliation, and `state-note` persists the free-text
103
+ note a user volunteers at a stage exit. They never create a feature directory —
104
+ an unknown `--feature` is a usage error (exit 2) — and they never overwrite a
105
+ state file they could not parse.
106
+
107
+ `state-complete` is the largest of them: it records the completion (status,
108
+ `completedAt`, `version`, `basedOnVersions`, `artifacts`), resets `commitHash` to
109
+ null for Commit 1 of the two-commit Git Commit Protocol, and runs the
110
+ deterministic downstream staleness cascade that each stage used to describe in
111
+ prose. `--commit-hash` is the Commit-2 follow-up, setting only that field (and
112
+ refusing a stage that is not yet complete). The protocol's two recovery branches
113
+ stay executable without hand-authored JSON: `--resumable` is the failed-Commit-1
114
+ revert (status-only, no cascade), and `--preserve-commit-hash` is the "nothing to
115
+ commit" branch. A bare `--status in-progress` is something else again —
116
+ forge-5-loop's partial completion, which keeps every completion field.
117
+
118
+ `state-decision` and `state-ecr` are the two array-appending verbs. The first
119
+ appends a `deferredDecisions[]` item — a same-feature decision deliberately
120
+ postponed to a later stage; the second appends an `epicChangeRequests[]` item —
121
+ a member stage's report that the epic decomposition itself must change, whose
122
+ `blocksCurrent` boolean drives the stage exit's pause-now vs. finish-then-edit
123
+ routing (so it is required and parsed strictly: only `true`/`false`). Both always
124
+ record `status: "open"` — resolving an item is the target stage's job, never the
125
+ recorder's — and both emit exactly the schema keys, because those two array item
126
+ shapes set `additionalProperties: false`.
127
+
128
+ 3.10 baseline, Google-style docstrings, full type annotations, stdlib only —
129
+ matching the conventions of `scripts/epic-manifest.py`.
130
+
131
+ Exit codes:
132
+ 0 = ok (including an empty feature list or unavailable context usage)
133
+ 2 = usage error or unreadable I/O
134
+ """
135
+
136
+ from __future__ import annotations
137
+
138
+ import argparse
139
+ import json
140
+ import os
141
+ import subprocess
142
+ import sys
143
+ import tempfile
144
+ from datetime import datetime, timezone
145
+ from pathlib import Path
146
+ from typing import Callable, Final, TypedDict
147
+
148
+
149
+ # --------------------------------------------------------------------------- #
150
+ # Constants
151
+ # --------------------------------------------------------------------------- #
152
+
153
+ #: A directory is "feature-shaped" iff it directly contains this file.
154
+ PIPELINE_STATE_FILENAME: Final = ".pipeline-state.json"
155
+ #: Epic roots hold this (and no .pipeline-state.json) — never a feature.
156
+ MANIFEST_FILENAME: Final = "epic-manifest.json"
157
+
158
+ #: The ordered production stages. This is the ONE place stage order lives.
159
+ PRODUCTION_STAGES: Final[tuple[str, ...]] = (
160
+ "forge-1-prd",
161
+ "forge-2-tech",
162
+ "forge-3-specs",
163
+ "forge-4-backlog",
164
+ "forge-5-loop",
165
+ "forge-6-docs",
166
+ )
167
+
168
+ #: The --stage domain for the state-write verbs: the six PRODUCTION_STAGES above
169
+ #: (order-sensitive — next_stage/verify_state/stage_exit all walk that tuple, so it
170
+ #: is NEVER redefined) plus forge-0-epic, which also carries a stageEntry but is
171
+ #: excluded from the next-stage walk.
172
+ STATE_VERB_STAGES: Final[tuple[str, ...]] = ("forge-0-epic", *PRODUCTION_STAGES)
173
+
174
+ #: The `--raised-by` / `--target-stage` domains for `state-decision`, and the
175
+ #: `--kind` / `--raised-by` domains for `state-ecr`. SOURCE OF TRUTH:
176
+ #: references/pipeline-state-schema.json (the `deferredDecisions` and
177
+ #: `epicChangeRequests` array item enums). Mirrored here so an out-of-enum value is
178
+ #: rejected at parse time; a drift guard asserts they still match the schema.
179
+ DECISION_RAISED_BY: Final[tuple[str, ...]] = (
180
+ "forge-1-prd",
181
+ "forge-2-tech",
182
+ "forge-3-specs",
183
+ "forge-4-backlog",
184
+ )
185
+ DECISION_TARGET_STAGES: Final[tuple[str, ...]] = (
186
+ "forge-1-prd",
187
+ "forge-2-tech",
188
+ "forge-3-specs",
189
+ "forge-4-backlog",
190
+ "forge-5-loop",
191
+ "forge-6-docs",
192
+ )
193
+ ECR_KINDS: Final[tuple[str, ...]] = ("add-feature", "redep", "move-boundary", "split")
194
+ ECR_RAISED_BY: Final[tuple[str, ...]] = ("forge-1-prd", "forge-2-tech")
195
+
196
+ #: Production stage -> the verify token its findings file uses, and the
197
+ #: `forge-verify-<token>` key its state lives under. forge-6-docs has no verify.
198
+ VERIFY_TOKEN_BY_STAGE: Final[dict[str, str]] = {
199
+ "forge-1-prd": "prd",
200
+ "forge-2-tech": "tech",
201
+ "forge-3-specs": "specs",
202
+ "forge-4-backlog": "backlog",
203
+ "forge-5-loop": "impl",
204
+ }
205
+
206
+ #: A production stage status that counts as "done" for next-stage selection.
207
+ _DONE_STATUS: Final = "complete"
208
+ #: The authoritative forge-verify status vocabulary. SOURCE OF TRUTH:
209
+ #: references/pipeline-state-schema.json (definitions.verifyEntry.properties.status.enum).
210
+ #: A status outside this set is unrecognized and must not be silently interpreted (#148).
211
+ #: NOTE: epic-manifest.py keeps a byte-identical copy — flat, self-contained scripts have
212
+ #: no shared import module (each is copied verbatim into per-agent adapter bundles).
213
+ KNOWN_VERIFY_STATUSES: Final = frozenset(
214
+ {"pending", "passed", "findings-reported", "findings-applied", "skipped"}
215
+ )
216
+ #: Verify statuses that count as "resolved" (no outstanding verify needed). A STRICT
217
+ #: subset of KNOWN_VERIFY_STATUSES — not collapsible into it (different meaning).
218
+ _VERIFY_RESOLVED: Final = frozenset({"passed", "findings-applied", "skipped"})
219
+ #: Per-process dedupe for the unknown-verify-status diagnostic (#148) so a single
220
+ #: bogus status is flagged once, not once per verify_state() call in a command.
221
+ _UNKNOWN_VERIFY_WARNED: set[str] = set()
222
+
223
+ #: Default context window when the model can't be inferred and config is silent.
224
+ _DEFAULT_WINDOW: Final = 200_000
225
+ #: Window for 1M-context models (model id carries a `[1m]` / `-1m` marker).
226
+ _WIDE_WINDOW: Final = 1_000_000
227
+ #: Default fraction of the window past which a clean session is recommended.
228
+ _DEFAULT_THRESHOLD: Final = 0.7
229
+
230
+
231
+ # --------------------------------------------------------------------------- #
232
+ # Types
233
+ # --------------------------------------------------------------------------- #
234
+
235
+
236
+ class FeatureRow(TypedDict):
237
+ """One active feature, ranked by recency, with its next actionable step."""
238
+
239
+ name: str
240
+ epic: str | None
241
+ currentStage: str
242
+ branch: str | None
243
+ updatedAt: str | None
244
+ complete: bool
245
+ nextStage: str | None
246
+ nextCommand: str | None
247
+ verifyPending: bool
248
+ verifyCommand: str | None
249
+ verifyStage: str | None
250
+ verifyState: str
251
+ autoVerify: bool
252
+ autoFix: bool
253
+ verifyGate: str
254
+
255
+
256
+ class UsageError(Exception):
257
+ """A usage or I/O failure that must exit 2."""
258
+
259
+
260
+ # --------------------------------------------------------------------------- #
261
+ # Feature scanning & ranking
262
+ # --------------------------------------------------------------------------- #
263
+
264
+
265
+ def _read_state(state_path: Path) -> dict:
266
+ """Read a `.pipeline-state.json`, tolerating missing/corrupt files.
267
+
268
+ A missing, unreadable, or unparseable state downgrades to ``{}`` rather than
269
+ crashing the scan — the navigator simply treats that feature as not-started.
270
+ """
271
+ try:
272
+ parsed = json.loads(state_path.read_text(encoding="utf-8"))
273
+ except (OSError, json.JSONDecodeError):
274
+ return {}
275
+ return parsed if isinstance(parsed, dict) else {}
276
+
277
+
278
+ def _scan_features(specs_dir: Path) -> list[tuple[str, str | None, dict]]:
279
+ """Find every feature-shaped dir under the specs tree (flat + nested).
280
+
281
+ Descends exactly one level below each top-level dir (never deeper), matching
282
+ ``epic-manifest.py``'s feature-shaped-dir bound.
283
+
284
+ Args:
285
+ specs_dir: The configured specs directory.
286
+
287
+ Returns:
288
+ A list of ``(feature_name, epic_name_or_None, state_dict)`` tuples. The
289
+ epic name is the parent dir name for a nested member, ``None`` for a flat
290
+ feature.
291
+ """
292
+ if not specs_dir.is_dir():
293
+ return []
294
+ out: list[tuple[str, str | None, dict]] = []
295
+ for top in sorted(p for p in specs_dir.iterdir() if p.is_dir()):
296
+ flat_state = top / PIPELINE_STATE_FILENAME
297
+ if flat_state.is_file():
298
+ out.append((top.name, None, _read_state(flat_state)))
299
+ # Descend one level for nested epic members (skip the epic root itself).
300
+ for child in sorted(p for p in top.iterdir() if p.is_dir()):
301
+ nested_state = child / PIPELINE_STATE_FILENAME
302
+ if nested_state.is_file():
303
+ out.append((child.name, top.name, _read_state(nested_state)))
304
+ return out
305
+
306
+
307
+ def _stage_status(state: dict, stage: str) -> str | None:
308
+ """Return the recorded status of a stage, or None if absent."""
309
+ stages = state.get("stages")
310
+ if not isinstance(stages, dict):
311
+ return None
312
+ entry = stages.get(stage)
313
+ if not isinstance(entry, dict):
314
+ return None
315
+ status = entry.get("status")
316
+ return status if isinstance(status, str) else None
317
+
318
+
319
+ def next_stage(state: dict) -> str | None:
320
+ """Return the first production stage that is not yet complete (the next step).
321
+
322
+ Walks ``PRODUCTION_STAGES`` in order and returns the first whose recorded
323
+ status is not ``complete`` (a missing/pending/in-progress/stale stage all
324
+ count as "not done"). Returns ``None`` when every production stage is
325
+ complete (nothing left to run).
326
+
327
+ This is the derived "what runs next" value — the single source of truth for
328
+ the next stage. It is intentionally distinct from the stored
329
+ ``currentStage`` field ("where the pipeline IS"; see the schema): the next
330
+ stage is computed from ``stages[].status`` here, never read from
331
+ ``currentStage``.
332
+ """
333
+ for stage in PRODUCTION_STAGES:
334
+ if _stage_status(state, stage) != _DONE_STATUS:
335
+ return stage
336
+ return None
337
+
338
+
339
+ def _stage_version(state: dict, stage: str) -> int | None:
340
+ """Return the recorded ``version`` of a stage entry, or None if absent."""
341
+ stages = state.get("stages")
342
+ if not isinstance(stages, dict):
343
+ return None
344
+ entry = stages.get(stage)
345
+ if not isinstance(entry, dict):
346
+ return None
347
+ version = entry.get("version")
348
+ return version if isinstance(version, int) else None
349
+
350
+
351
+ def _verify_entry(state: dict, verify_key: str) -> dict:
352
+ """Return the ``forge-verify-*`` entry dict, or ``{}`` if absent."""
353
+ stages = state.get("stages")
354
+ if not isinstance(stages, dict):
355
+ return {}
356
+ entry = stages.get(verify_key)
357
+ return entry if isinstance(entry, dict) else {}
358
+
359
+
360
+ def _warn_unknown_verify_status(stage_name: str, status: object) -> None:
361
+ """Emit a one-time stderr diagnostic for an out-of-vocabulary verify status (#148).
362
+
363
+ The freshness classifier maps an unrecognized status to "never verified" — correct,
364
+ but silent, so a typo poisons the downstream gate (e.g. forge-5-loop's dependency
365
+ check) with no clue. Flagging it here makes the bad value visible where it is read.
366
+ """
367
+ key = f"{stage_name}={status!r}"
368
+ if key in _UNKNOWN_VERIFY_WARNED:
369
+ return
370
+ _UNKNOWN_VERIFY_WARNED.add(key)
371
+ known = ", ".join(sorted(KNOWN_VERIFY_STATUSES))
372
+ print(
373
+ f"feature-forge: unknown {stage_name} status {status!r} "
374
+ f"(treated as unverified; expected one of {known})",
375
+ file=sys.stderr,
376
+ )
377
+
378
+
379
+ def verify_state(state: dict) -> tuple[str | None, str]:
380
+ """Classify verify freshness for the most-recently-completed stage.
381
+
382
+ Returns ``(stage, state_label)`` where ``state_label`` is one of:
383
+
384
+ - ``fresh`` — verify is resolved AND its ``verifiedStageVersion`` matches the
385
+ stage's current ``version`` (so no re-verify is needed).
386
+ - ``stale`` — verify was resolved once, but the stage version has since moved
387
+ (artifact revised) OR the entry predates the freshness ledger (no
388
+ ``verifiedStageVersion``). A revised artifact must be re-verified.
389
+ - ``failing`` — verify ran and reported findings that are not yet applied
390
+ (``findings-reported``).
391
+ - ``never`` — the stage completed but verify has not run at all.
392
+ - ``skipped`` — the user explicitly chose to proceed without verifying. A
393
+ resolved, non-pending state: it is deliberately NOT re-offered or
394
+ auto-verified, and (unlike a genuine verification result) it does not go
395
+ stale on an artifact revision — skip writers record no version to compare
396
+ against, and re-surfacing would override an explicit human decision.
397
+ - ``none`` — no completed verify-capable stage (nothing to verify), stage
398
+ is ``None``.
399
+
400
+ Only the most-recent completed production stage is considered, matching the
401
+ navigator's "verify before continuing" gate. Absent ``verifiedStageVersion``
402
+ on a ``passed``/``findings-applied`` entry (legacy state) is deliberately
403
+ treated as ``stale`` — verify rather than skip.
404
+ """
405
+ for stage in reversed(PRODUCTION_STAGES):
406
+ if _stage_status(state, stage) != _DONE_STATUS:
407
+ continue
408
+ token = VERIFY_TOKEN_BY_STAGE.get(stage)
409
+ if token is None:
410
+ continue # forge-6-docs has no verify step
411
+ entry = _verify_entry(state, f"forge-verify-{token}")
412
+ status = entry.get("status")
413
+ if status == "skipped":
414
+ # An explicit skip is resolved and non-pending — preserve the user's
415
+ # decision. It never goes stale (no recorded version to compare), so
416
+ # the freshness check below deliberately does not apply.
417
+ return stage, "skipped"
418
+ if status not in _VERIFY_RESOLVED:
419
+ if status == "findings-reported":
420
+ return stage, "failing"
421
+ # An unrecognized status (outside KNOWN_VERIFY_STATUSES) is treated as
422
+ # "never verified" — defensible, but flag it once so a typo (e.g. the
423
+ # eye-slip 'findings-resolved') doesn't silently poison the gate that
424
+ # reads this label (#148). ``pending``/``None`` are known/absent → quiet.
425
+ if status is not None and status not in KNOWN_VERIFY_STATUSES:
426
+ _warn_unknown_verify_status(f"forge-verify-{token}", status)
427
+ return stage, "never"
428
+ verified_version = entry.get("verifiedStageVersion")
429
+ stage_version = _stage_version(state, stage)
430
+ if (
431
+ isinstance(verified_version, int)
432
+ and stage_version is not None
433
+ and verified_version == stage_version
434
+ ):
435
+ return stage, "fresh"
436
+ return stage, "stale"
437
+ return None, "none"
438
+
439
+
440
+ def pending_verify(state: dict) -> str | None:
441
+ """Return the production stage whose verify is outstanding, if any.
442
+
443
+ Outstanding means the most-recently-completed production stage's verify is not
444
+ ``fresh`` (never run, reported findings, or gone stale after an artifact
445
+ revision). An explicit ``skipped`` is treated as resolved (never outstanding).
446
+ Surfaced so the navigator can offer "verify before continuing" as an
447
+ alternative to advancing. Returns ``None`` when the latest stage is fresh,
448
+ skipped, or there is nothing to verify.
449
+ """
450
+ stage, label = verify_state(state)
451
+ return stage if label not in ("fresh", "none", "skipped") else None
452
+
453
+
454
+ def _parse_ts(value: str | None) -> datetime | None:
455
+ """Parse an ISO-8601 timestamp (tolerating a trailing 'Z'), else None."""
456
+ if not isinstance(value, str):
457
+ return None
458
+ try:
459
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
460
+ except ValueError:
461
+ return None
462
+ if dt.tzinfo is None:
463
+ dt = dt.replace(tzinfo=timezone.utc)
464
+ return dt
465
+
466
+
467
+ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
468
+ """Build the recency-ranked active-feature rows (the rank-features payload).
469
+
470
+ Active features (``pipelineStatus == "active"``, the default when absent) are
471
+ sorted by ``updatedAt`` descending — most recently touched first — so the
472
+ navigator's recency default is row 0.
473
+
474
+ ``config`` is the loaded forge.config.json (or ``{}``); it drives the effective
475
+ ``autoVerify``/``autoFix`` per stage so the navigator can branch without
476
+ re-reading config.
477
+ """
478
+ config = config or {}
479
+ # Fail closed: only a literal JSON ``true`` enables artifact-mutating autoFix.
480
+ global_auto_fix = config.get("autoFix") is True
481
+ rows: list[FeatureRow] = []
482
+ for name, epic, state in _scan_features(specs_dir):
483
+ status = state.get("pipelineStatus", "active")
484
+ if status != "active":
485
+ continue
486
+ nxt = next_stage(state)
487
+ vstage, vlabel = verify_state(state)
488
+ verify_pending = vstage is not None and vlabel not in ("fresh", "none", "skipped")
489
+ effective_auto_verify = auto_verify_for(config, vstage) if vstage else False
490
+ branch = state.get("branch")
491
+ updated = state.get("updatedAt")
492
+ rows.append({
493
+ "name": name,
494
+ "epic": epic,
495
+ # currentStage = "where the pipeline IS" (the recorded field). When a
496
+ # legacy/absent state omits it, fall back to the DERIVED next stage
497
+ # for display only — never conflate the two elsewhere (schema O1).
498
+ "currentStage": state.get("currentStage") or (nxt or "complete"),
499
+ "branch": branch if isinstance(branch, str) else None,
500
+ "updatedAt": updated if isinstance(updated, str) else None,
501
+ "complete": nxt is None,
502
+ "nextStage": nxt,
503
+ "nextCommand": f"/skill:{nxt} {name}" if nxt else None,
504
+ "verifyPending": verify_pending,
505
+ "verifyCommand": f"/skill:forge-verify {name}" if verify_pending else None,
506
+ "verifyStage": vstage,
507
+ "verifyState": vlabel,
508
+ "autoVerify": effective_auto_verify,
509
+ "autoFix": global_auto_fix and effective_auto_verify,
510
+ # Single resolved verify-gate classification (5b — one exit computation,
511
+ # mirroring stage-exit's `verifyGate`): the navigator reads this instead of
512
+ # re-deriving from verifyPending + autoVerify in prose. `auto` = the §2b
513
+ # catch-up runs it unattended; `standard` = the §3 gate (degrades to
514
+ # manual-print on a non-Claude host); `none` = nothing outstanding.
515
+ "verifyGate": (
516
+ "none" if not verify_pending
517
+ else "auto" if effective_auto_verify
518
+ else "standard"
519
+ ),
520
+ })
521
+ # Sort by updatedAt desc; rows without a parseable timestamp sort last.
522
+ rows.sort(
523
+ key=lambda r: (_parse_ts(r["updatedAt"]) or datetime.min.replace(tzinfo=timezone.utc)),
524
+ reverse=True,
525
+ )
526
+ return rows
527
+
528
+
529
+ def _counts(specs_dir: Path) -> dict[str, int]:
530
+ """Tally active/paused/abandoned pipelines across the specs tree."""
531
+ tally = {"active": 0, "paused": 0, "abandoned": 0}
532
+ for _name, _epic, state in _scan_features(specs_dir):
533
+ status = state.get("pipelineStatus", "active")
534
+ if status in tally:
535
+ tally[status] += 1
536
+ return tally
537
+
538
+
539
+ # --------------------------------------------------------------------------- #
540
+ # Context-window usage
541
+ # --------------------------------------------------------------------------- #
542
+
543
+
544
+ def _cwd_slug(cwd: Path) -> str:
545
+ """Map a working directory to its Claude Code project-dir slug.
546
+
547
+ Claude Code names the per-project transcript dir by replacing path
548
+ separators (and dots) in the absolute cwd with hyphens, e.g.
549
+ ``/home/u/proj`` -> ``-home-u-proj``.
550
+ """
551
+ return str(cwd.resolve()).replace("/", "-").replace(".", "-")
552
+
553
+
554
+ def _latest_transcript(cwd: Path) -> Path | None:
555
+ """Return the most-recently-modified transcript JSONL for this cwd, if any."""
556
+ project_dir = Path.home() / ".claude" / "projects" / _cwd_slug(cwd)
557
+ if not project_dir.is_dir():
558
+ return None
559
+ transcripts = [p for p in project_dir.glob("*.jsonl") if p.is_file()]
560
+ if not transcripts:
561
+ return None
562
+ return max(transcripts, key=lambda p: p.stat().st_mtime)
563
+
564
+
565
+ def _last_usage(transcript: Path) -> tuple[int, str | None] | None:
566
+ """Scan a transcript from the end for the last `usage` record.
567
+
568
+ Returns ``(token_total, model_id)`` where the total sums
569
+ ``input_tokens + cache_creation_input_tokens + cache_read_input_tokens +
570
+ output_tokens`` of the most recent message carrying a usage object — i.e. the
571
+ current context occupancy. Returns ``None`` if no usable record is found.
572
+ """
573
+ try:
574
+ lines = transcript.read_text(encoding="utf-8").splitlines()
575
+ except OSError:
576
+ return None
577
+ for line in reversed(lines):
578
+ line = line.strip()
579
+ if not line or '"usage"' not in line:
580
+ continue
581
+ try:
582
+ record = json.loads(line)
583
+ except json.JSONDecodeError:
584
+ continue
585
+ message = record.get("message")
586
+ usage = message.get("usage") if isinstance(message, dict) else record.get("usage")
587
+ if not isinstance(usage, dict):
588
+ continue
589
+ # A malformed transcript may carry a non-numeric usage field; skip that
590
+ # record rather than crash the whole context-usage read (ValueError/TypeError).
591
+ try:
592
+ total = (
593
+ int(usage.get("input_tokens", 0) or 0)
594
+ + int(usage.get("cache_creation_input_tokens", 0) or 0)
595
+ + int(usage.get("cache_read_input_tokens", 0) or 0)
596
+ + int(usage.get("output_tokens", 0) or 0)
597
+ )
598
+ except (TypeError, ValueError):
599
+ continue
600
+ if total <= 0:
601
+ continue
602
+ model = message.get("model") if isinstance(message, dict) else record.get("model")
603
+ return total, (model if isinstance(model, str) else None)
604
+ return None
605
+
606
+
607
+ def _infer_window(model: str | None) -> int:
608
+ """Infer the context window from a model id (1M-context markers -> wide)."""
609
+ if model and ("[1m]" in model.lower() or "-1m" in model.lower()):
610
+ return _WIDE_WINDOW
611
+ return _DEFAULT_WINDOW
612
+
613
+
614
+ def _load_config(config_path: Path) -> dict:
615
+ """Read forge.config.json into a dict, tolerating missing/corrupt files.
616
+
617
+ A missing, unreadable, or non-object config downgrades to ``{}`` so callers
618
+ read every key through absent-safe ``.get`` defaults.
619
+ """
620
+ try:
621
+ config = json.loads(config_path.read_text(encoding="utf-8"))
622
+ except (OSError, json.JSONDecodeError):
623
+ return {}
624
+ return config if isinstance(config, dict) else {}
625
+
626
+
627
+ def _config_value(config_path: Path, key: str):
628
+ """Read a single key from forge.config.json, or None if absent/unreadable."""
629
+ return _load_config(config_path).get(key)
630
+
631
+
632
+ def auto_verify_for(config: dict, stage: str) -> bool:
633
+ """Return the effective auto-verify setting for ``stage``.
634
+
635
+ Per-stage override in ``autoVerifyStages`` wins over the global ``autoVerify``;
636
+ both default to off, so a config with neither key means "no auto-verify".
637
+
638
+ Parsing is strict and **fails closed**: only a literal JSON ``true`` enables
639
+ auto-verify. A non-boolean value (e.g. the string ``"false"``, which is truthy
640
+ in Python) is treated as off, not on. The schema already rejects non-booleans
641
+ at author time; this guards a hand-edited config from silently enabling
642
+ automation.
643
+ """
644
+ stages = config.get("autoVerifyStages")
645
+ if isinstance(stages, dict) and stage in stages:
646
+ return stages[stage] is True
647
+ return config.get("autoVerify") is True
648
+
649
+
650
+ def invalid_auto_verify_keys(config: dict) -> list[str]:
651
+ """Return ``autoVerifyStages`` keys outside the verify-capable stage ids.
652
+
653
+ An unknown/typo key (e.g. ``forge-1-prod``) would silently never take effect,
654
+ turning an intended off-switch into a no-op. Surfacing it lets the navigator
655
+ warn instead of failing quietly. Mirrors the schema's ``propertyNames.enum``.
656
+ """
657
+ stages = config.get("autoVerifyStages")
658
+ if not isinstance(stages, dict):
659
+ return []
660
+ return [key for key in stages if key not in VERIFY_TOKEN_BY_STAGE]
661
+
662
+
663
+ def context_usage(
664
+ config_path: Path,
665
+ window_override: int | None,
666
+ threshold_override: float | None,
667
+ ) -> dict:
668
+ """Compute live context-window occupancy for the current session.
669
+
670
+ Window precedence: ``--window`` > config ``contextWindowTokens`` > inferred
671
+ from the transcript's model id > ``_DEFAULT_WINDOW``. When inferring (no
672
+ override, no config) and the observed token total already exceeds the default
673
+ window, the window is auto-bumped to ``_WIDE_WINDOW`` — observed tokens above
674
+ 200k prove a wider (1M-beta) window is active, so this corrects the reading
675
+ without ever under-reporting a genuine 200k session. Threshold precedence:
676
+ ``--threshold`` > config ``contextWarnThreshold`` > ``_DEFAULT_THRESHOLD``.
677
+
678
+ Returns a dict with ``available: True`` and ``{tokens, windowTokens, pct,
679
+ overThreshold, recommendation, model}`` when usage is found, or
680
+ ``{available: False, reason}`` otherwise. Never raises for a missing
681
+ transcript — that is the expected non-Claude / fresh-session path.
682
+ """
683
+ threshold = threshold_override
684
+ if threshold is None:
685
+ cfg_threshold = _config_value(config_path, "contextWarnThreshold")
686
+ threshold = (
687
+ float(cfg_threshold)
688
+ if isinstance(cfg_threshold, (int, float))
689
+ else _DEFAULT_THRESHOLD
690
+ )
691
+
692
+ transcript = _latest_transcript(Path.cwd())
693
+ if transcript is None:
694
+ return {"available": False, "reason": "no session transcript found"}
695
+ found = _last_usage(transcript)
696
+ if found is None:
697
+ return {"available": False, "reason": "no usage record in transcript"}
698
+ tokens, model = found
699
+
700
+ window = window_override
701
+ if window is None or window <= 0:
702
+ cfg_window = _config_value(config_path, "contextWindowTokens")
703
+ if isinstance(cfg_window, int) and cfg_window > 0:
704
+ window = cfg_window
705
+ else:
706
+ # Inferring (no override, no config). Start from the model marker /
707
+ # conservative default, then auto-bump: observed tokens above the
708
+ # default window PROVE a wider window is active (a 200k session can
709
+ # never exceed 200k), so widen to 1M rather than report a nonsensical
710
+ # >100%. Never under-reports a real 200k session, which can't trip it.
711
+ window = _infer_window(model)
712
+ if tokens > window:
713
+ window = _WIDE_WINDOW
714
+
715
+ pct = round(tokens / window, 4)
716
+ over = pct >= threshold
717
+ if over:
718
+ recommendation = "clean-session"
719
+ else:
720
+ recommendation = "continue"
721
+ return {
722
+ "available": True,
723
+ "tokens": tokens,
724
+ "windowTokens": window,
725
+ "pct": pct,
726
+ "threshold": threshold,
727
+ "overThreshold": over,
728
+ "recommendation": recommendation,
729
+ "model": model,
730
+ }
731
+
732
+
733
+ # --------------------------------------------------------------------------- #
734
+ # Doctor
735
+ # --------------------------------------------------------------------------- #
736
+
737
+
738
+ def _git_output(args: list[str]) -> str | None:
739
+ """Run a read-only git command and return stripped stdout, or None.
740
+
741
+ Any failure (git missing, not a repo, nonzero exit, timeout) degrades to
742
+ ``None`` — doctor reports absence rather than crashing.
743
+ """
744
+ try:
745
+ proc = subprocess.run(
746
+ ["git", *args], capture_output=True, text=True, timeout=10,
747
+ )
748
+ except (OSError, subprocess.TimeoutExpired):
749
+ return None
750
+ if proc.returncode != 0:
751
+ return None
752
+ out = proc.stdout.strip()
753
+ return out or None
754
+
755
+
756
+ def _resolve_plugin_root() -> dict:
757
+ """Resolve the plugin root by running the sibling ``forge-root.sh``.
758
+
759
+ Uses the resolver that ships next to this script, so the answer reflects
760
+ the install this helper actually belongs to — exactly what a skill's
761
+ bootstrap prelude would find (or fail to find). On success the dict also
762
+ carries the root's ``version`` (from ``.claude-plugin/plugin.json`` or the
763
+ neutral ``.feature-forge-bundle.json``) and, when the root is a git
764
+ checkout, its short ``commit`` — enough to spot version skew between the
765
+ resolved root and the skills a session loaded.
766
+ """
767
+ resolver = Path(__file__).resolve().parent / "forge-root.sh"
768
+ if not resolver.is_file():
769
+ return {"resolved": False, "error": f"resolver not found: {resolver}"}
770
+ try:
771
+ proc = subprocess.run(
772
+ ["bash", str(resolver)], capture_output=True, text=True, timeout=10,
773
+ )
774
+ except (OSError, subprocess.TimeoutExpired) as exc:
775
+ return {"resolved": False, "error": str(exc)}
776
+ if proc.returncode != 0:
777
+ return {
778
+ "resolved": False,
779
+ "error": proc.stderr.strip() or f"resolver exited {proc.returncode}",
780
+ }
781
+ root = proc.stdout.strip()
782
+ info: dict = {"resolved": True, "root": root}
783
+ for rel in (".claude-plugin/plugin.json", ".feature-forge-bundle.json"):
784
+ manifest = Path(root) / rel
785
+ if manifest.is_file():
786
+ version = _load_config(manifest).get("version")
787
+ if isinstance(version, str):
788
+ info["version"] = version
789
+ info["manifest"] = rel
790
+ break
791
+ commit = _git_output(["-C", root, "rev-parse", "--short", "HEAD"])
792
+ if commit:
793
+ info["commit"] = commit
794
+ return info
795
+
796
+
797
+ def _backlog_path(config: dict, name: str, epic: str | None, specs_dir: Path) -> Path:
798
+ """Compose a feature's backlog.json path per the forge-4-backlog rule.
799
+
800
+ ``{backlogDir}/{feature}/backlog.json`` when ``backlogDir`` is configured,
801
+ else ``{resolvedFeatureDir}/backlog.json`` (flat or nested under the epic).
802
+ """
803
+ backlog_dir = config.get("backlogDir")
804
+ if isinstance(backlog_dir, str) and backlog_dir:
805
+ return Path(backlog_dir) / name / "backlog.json"
806
+ feature_dir = specs_dir / epic / name if epic else specs_dir / name
807
+ return feature_dir / "backlog.json"
808
+
809
+
810
+ def doctor_report(specs_dir: Path, config_path: Path) -> dict:
811
+ """Assemble the ground-truth diagnostic payload (always succeeds).
812
+
813
+ One snapshot of everything a confused session needs checked: resolved
814
+ plugin root + version/commit, current git branch vs. each feature's
815
+ recorded state branch, the recency-ranked feature summary, and whether
816
+ each feature's composed backlog path exists on disk.
817
+ """
818
+ config = _load_config(config_path)
819
+ # --show-current (not rev-parse HEAD) so an unborn branch (fresh repo,
820
+ # no commits yet) still reports its name instead of failing.
821
+ current_branch = _git_output(["branch", "--show-current"])
822
+ default_branch = _default_branch()
823
+ rows = build_rows(specs_dir, config)
824
+ features = []
825
+ for row in rows:
826
+ backlog = _backlog_path(config, row["name"], row["epic"], specs_dir)
827
+ state_branch = row["branch"]
828
+ mismatch = bool(state_branch and current_branch and state_branch != current_branch)
829
+ # Classify a mismatch: on a topic branch it is adoptable (imposed/session-branch
830
+ # drift, Chunk 6); on the default branch it is real drift-back, only a warning.
831
+ branch_reconcile = None
832
+ if mismatch:
833
+ branch_reconcile = "warn-drift" if current_branch == default_branch else "adopt-current"
834
+ features.append({
835
+ "name": row["name"],
836
+ "epic": row["epic"],
837
+ "currentStage": row["currentStage"],
838
+ "nextStage": row["nextStage"],
839
+ "verifyState": row["verifyState"],
840
+ "stateBranch": state_branch,
841
+ "branchMatchesState": (
842
+ state_branch == current_branch
843
+ if state_branch and current_branch
844
+ else None
845
+ ),
846
+ "branchReconcile": branch_reconcile,
847
+ "backlogPath": str(backlog),
848
+ "backlogExists": backlog.is_file(),
849
+ })
850
+ return {
851
+ "pluginRoot": _resolve_plugin_root(),
852
+ "currentBranch": current_branch,
853
+ "specsDir": str(specs_dir),
854
+ "specsDirExists": specs_dir.is_dir(),
855
+ "configPath": str(config_path),
856
+ "configExists": config_path.is_file(),
857
+ "counts": _counts(specs_dir),
858
+ "features": features,
859
+ "invalidAutoVerifyKeys": invalid_auto_verify_keys(config),
860
+ "rootSandbox": _root_sandbox_status(),
861
+ }
862
+
863
+
864
+ def _root_sandbox_status() -> dict:
865
+ """Report the root/sandbox launch condition for forge-5-loop (issue #99).
866
+
867
+ On a hosted remote (e.g. Claude.ai) the loop runs as root, where rauf's
868
+ ``claude --dangerously-skip-permissions`` is refused unless ``IS_SANDBOX``
869
+ is set. forge-5-loop exports ``IS_SANDBOX=${IS_SANDBOX:-1}`` at launch when
870
+ root; this surfaces the same condition as a diagnosable check. ``geteuid``
871
+ is absent on Windows — treat that as non-root.
872
+ """
873
+ geteuid = getattr(os, "geteuid", None)
874
+ is_root = geteuid() == 0 if geteuid is not None else False
875
+ is_sandbox_set = os.environ.get("IS_SANDBOX") not in (None, "")
876
+ return {
877
+ "isRoot": is_root,
878
+ "isSandboxSet": is_sandbox_set,
879
+ # True only when the loop would need to supply the default at launch.
880
+ "loopWillSetSandbox": is_root and not is_sandbox_set,
881
+ }
882
+
883
+
884
+ def _print_doctor(report: dict) -> None:
885
+ """Print the human-readable doctor report."""
886
+ root = report["pluginRoot"]
887
+ if root.get("resolved"):
888
+ detail = " ".join(
889
+ f"{key}={root[key]}" for key in ("version", "commit") if key in root
890
+ )
891
+ print(f"plugin root: {root['root']}" + (f" ({detail})" if detail else ""))
892
+ else:
893
+ print(f"plugin root: UNRESOLVED — {root.get('error', 'unknown')}")
894
+ print(f"current branch: {report['currentBranch'] or '(not a git repo)'}")
895
+ print(
896
+ f"specs dir: {report['specsDir']}"
897
+ + ("" if report["specsDirExists"] else " (MISSING)")
898
+ )
899
+ print(
900
+ f"config: {report['configPath']}"
901
+ + ("" if report["configExists"] else " (MISSING)")
902
+ )
903
+ counts = report["counts"]
904
+ print(
905
+ f"features: {counts['active']} active "
906
+ f"(paused: {counts['paused']}, abandoned: {counts['abandoned']})"
907
+ )
908
+ for feat in report["features"]:
909
+ label = feat["name"] + (f" [{feat['epic']}]" if feat["epic"] else "")
910
+ branch = feat["stateBranch"] or "?"
911
+ if feat["branchMatchesState"] is False:
912
+ if feat.get("branchReconcile") == "adopt-current":
913
+ branch += " (MISMATCH — reconcile: adopt current branch)"
914
+ elif feat.get("branchReconcile") == "warn-drift":
915
+ branch += " (MISMATCH — on default branch; create a topic branch)"
916
+ else:
917
+ branch += " (MISMATCH vs current)"
918
+ backlog = "exists" if feat["backlogExists"] else "MISSING"
919
+ print(
920
+ f" - {label}: stage={feat['currentStage']} "
921
+ f"verify={feat['verifyState']} branch={branch} "
922
+ f"backlog={backlog} ({feat['backlogPath']})"
923
+ )
924
+ invalid = report.get("invalidAutoVerifyKeys") or []
925
+ if invalid:
926
+ print(" ! invalid autoVerifyStages keys (ignored): " + ", ".join(invalid))
927
+ rs = report.get("rootSandbox") or {}
928
+ if rs.get("isRoot"):
929
+ if rs.get("isSandboxSet"):
930
+ print("root/sandbox: running as root; IS_SANDBOX already set — loop launch OK")
931
+ else:
932
+ print(
933
+ "root/sandbox: running as root; IS_SANDBOX not set — forge-5-loop will "
934
+ "export IS_SANDBOX=1 at launch so rauf's "
935
+ "--dangerously-skip-permissions is not refused"
936
+ )
937
+
938
+
939
+ # --------------------------------------------------------------------------- #
940
+ # Cross-branch feature discovery
941
+ # --------------------------------------------------------------------------- #
942
+
943
+
944
+ def _specs_rel(specs_dir: str) -> str:
945
+ """Normalize a specs dir to the repo-relative POSIX form git ls-tree uses."""
946
+ rel = specs_dir.replace("\\", "/")
947
+ while rel.startswith("./"):
948
+ rel = rel[2:]
949
+ return rel.rstrip("/")
950
+
951
+
952
+ def _state_paths_in_ref(ref: str, specs_rel: str, name: str) -> list[str]:
953
+ """Feature-shaped ``.pipeline-state.json`` paths for ``name`` in one ref.
954
+
955
+ Mirrors the ``_scan_features`` flat/nested bound: exactly
956
+ ``{specsDir}/{name}/.pipeline-state.json`` or
957
+ ``{specsDir}/{epic}/{name}/.pipeline-state.json`` — never deeper.
958
+ """
959
+ listing = _git_output(["ls-tree", "-r", "--name-only", ref, "--", specs_rel])
960
+ if not listing:
961
+ return []
962
+ hits: list[str] = []
963
+ prefix = specs_rel + "/"
964
+ for path in listing.splitlines():
965
+ if not path.startswith(prefix) or not path.endswith("/" + PIPELINE_STATE_FILENAME):
966
+ continue
967
+ segments = path[len(prefix):].split("/")
968
+ # [name, state-file] (flat) or [epic, name, state-file] (nested).
969
+ if len(segments) == 2 and segments[0] == name:
970
+ hits.append(path)
971
+ elif len(segments) == 3 and segments[1] == name:
972
+ hits.append(path)
973
+ return hits
974
+
975
+
976
+ def _read_state_at_ref(ref: str, path: str) -> dict:
977
+ """Parse ``git show ref:path`` as pipeline state, downgrading failures to {}."""
978
+ raw = _git_output(["show", f"{ref}:{path}"])
979
+ if raw is None:
980
+ return {}
981
+ try:
982
+ parsed = json.loads(raw)
983
+ except json.JSONDecodeError:
984
+ return {}
985
+ return parsed if isinstance(parsed, dict) else {}
986
+
987
+
988
+ def _epic_membership(path: str, specs_rel: str, state: dict) -> tuple[str | None, bool]:
989
+ """Derive ``(epic, isEpicMember)`` for a discovered candidate.
990
+
991
+ A candidate is an epic member when its state carries an ``epic`` back-pointer
992
+ **or** its path is nested (``{specsDir}/{epic}/{name}/.pipeline-state.json``).
993
+ Nested-ness is structurally authoritative; the ``epic`` field is the recorded
994
+ back-pointer. When the state lacks the field, the nested directory name is used
995
+ so the signal is never "member of epic None".
996
+ """
997
+ prefix = specs_rel + "/"
998
+ nested_epic: str | None = None
999
+ if path.startswith(prefix):
1000
+ segments = path[len(prefix):].split("/")
1001
+ if len(segments) == 3: # [epic, name, state-file]
1002
+ nested_epic = segments[0]
1003
+ epic = state.get("epic")
1004
+ epic = epic if isinstance(epic, str) and epic else nested_epic
1005
+ return epic, bool(nested_epic) or bool(epic)
1006
+
1007
+
1008
+ def _list_refs(pattern: str) -> list[tuple[str, str]]:
1009
+ """Return ``(short_ref, committer_date)`` pairs under a ref namespace."""
1010
+ raw = _git_output([
1011
+ "for-each-ref",
1012
+ "--format=%(refname:short)\t%(committerdate:iso-strict)",
1013
+ pattern,
1014
+ ])
1015
+ if not raw:
1016
+ return []
1017
+ out: list[tuple[str, str]] = []
1018
+ for line in raw.splitlines():
1019
+ ref, _, date = line.partition("\t")
1020
+ if ref:
1021
+ out.append((ref, date))
1022
+ return out
1023
+
1024
+
1025
+ def discover_feature(name: str, specs_dir: str) -> dict:
1026
+ """Find a feature's pipeline state across all branches (strictly read-only).
1027
+
1028
+ Scans every local head and remote-tracking ref for a feature-shaped
1029
+ ``.pipeline-state.json``, parses each hit via ``git show``, and ranks
1030
+ candidates by (state's own ``branch`` field matches the ref) first, then
1031
+ local-before-remote-tracking, then newest commit. When no candidate exists
1032
+ locally, ``git ls-remote --heads origin`` surfaces plausibly-named
1033
+ branches a single-branch clone never fetched, as ``needsFetch`` entries
1034
+ with the exact fetch/switch commands.
1035
+
1036
+ Never mutates anything: checkout is the caller's decision (and requires
1037
+ the user's explicit accept plus a clean tree — see shared-conventions).
1038
+ """
1039
+ if _git_output(["rev-parse", "--git-dir"]) is None:
1040
+ return {
1041
+ "feature": name,
1042
+ "gitRepo": False,
1043
+ "currentBranch": None,
1044
+ "candidates": [],
1045
+ "remoteCandidates": [],
1046
+ }
1047
+ current_branch = _git_output(["branch", "--show-current"])
1048
+ specs_rel = _specs_rel(specs_dir)
1049
+
1050
+ refs = [(ref, date, False) for ref, date in _list_refs("refs/heads")]
1051
+ refs += [(ref, date, True) for ref, date in _list_refs("refs/remotes")]
1052
+
1053
+ candidates: list[dict] = []
1054
+ matched_branches: set[str] = set()
1055
+ known_branches: set[str] = set()
1056
+ for ref, commit_date, is_remote in refs:
1057
+ branch = ref.split("/", 1)[1] if is_remote else ref
1058
+ if is_remote and (not branch or branch == "HEAD"):
1059
+ continue
1060
+ known_branches.add(branch)
1061
+ if branch in matched_branches:
1062
+ continue # the local head already yielded this branch's state
1063
+ for path in _state_paths_in_ref(ref, specs_rel, name):
1064
+ state = _read_state_at_ref(ref, path)
1065
+ state_branch = state.get("branch")
1066
+ state_branch = state_branch if isinstance(state_branch, str) else None
1067
+ updated = state.get("updatedAt")
1068
+ epic, is_epic_member = _epic_membership(path, specs_rel, state)
1069
+ matched_branches.add(branch)
1070
+ candidates.append({
1071
+ "branch": branch,
1072
+ "ref": ref,
1073
+ "remoteTracking": is_remote,
1074
+ "path": path,
1075
+ "stateBranch": state_branch,
1076
+ "stateBranchMatches": state_branch == branch,
1077
+ "currentStage": state.get("currentStage"),
1078
+ "pipelineStatus": state.get("pipelineStatus", "active"),
1079
+ "epic": epic,
1080
+ "isEpicMember": is_epic_member,
1081
+ "updatedAt": updated if isinstance(updated, str) else None,
1082
+ "commitDate": commit_date or None,
1083
+ "isCurrentBranch": branch == current_branch,
1084
+ "switchCommand": f"git switch {branch}",
1085
+ })
1086
+
1087
+ def _rank(cand: dict) -> tuple:
1088
+ ts = _parse_ts(cand["commitDate"]) or datetime.min.replace(tzinfo=timezone.utc)
1089
+ return (
1090
+ not cand["stateBranchMatches"],
1091
+ cand["remoteTracking"],
1092
+ -ts.timestamp(),
1093
+ )
1094
+
1095
+ candidates.sort(key=_rank)
1096
+
1097
+ # Single-branch clones: the branch holding the state may never have been
1098
+ # fetched. Only when nothing was found locally, ask the remote for heads we
1099
+ # do not know and surface the plausibly-named ones (the feature name appears
1100
+ # in the branch name — e.g. forge/<feature>). These are name-based hints
1101
+ # only; their contents were NOT inspected.
1102
+ remote_candidates: list[dict] = []
1103
+ if not candidates:
1104
+ ls_remote = _git_output(["ls-remote", "--heads", "origin"])
1105
+ for line in (ls_remote or "").splitlines():
1106
+ _, _, refname = line.partition("\t")
1107
+ if not refname.startswith("refs/heads/"):
1108
+ continue
1109
+ branch = refname[len("refs/heads/"):]
1110
+ if branch in known_branches or name not in branch:
1111
+ continue
1112
+ remote_candidates.append({
1113
+ "branch": branch,
1114
+ "needsFetch": True,
1115
+ "fetchCommand": f"git fetch origin {branch}:refs/remotes/origin/{branch}",
1116
+ "switchCommand": f"git switch {branch}",
1117
+ })
1118
+
1119
+ return {
1120
+ "feature": name,
1121
+ "gitRepo": True,
1122
+ "currentBranch": current_branch,
1123
+ "specsDir": specs_rel,
1124
+ "candidates": candidates,
1125
+ "remoteCandidates": remote_candidates,
1126
+ }
1127
+
1128
+
1129
+ def _print_discover(payload: dict) -> None:
1130
+ """Print the human-readable discovery report."""
1131
+ name = payload["feature"]
1132
+ if not payload["gitRepo"]:
1133
+ print(f"discover-feature {name}: not a git repository — nothing to scan")
1134
+ return
1135
+ candidates = payload["candidates"]
1136
+ remote = payload["remoteCandidates"]
1137
+ if not candidates and not remote:
1138
+ print(
1139
+ f"discover-feature {name}: no pipeline state found on any local or "
1140
+ "remote-tracking branch"
1141
+ )
1142
+ return
1143
+ for cand in candidates:
1144
+ marks = []
1145
+ if cand["isCurrentBranch"]:
1146
+ marks.append("current branch")
1147
+ if cand["remoteTracking"]:
1148
+ marks.append("remote-tracking")
1149
+ if not cand["stateBranchMatches"] and cand["stateBranch"]:
1150
+ marks.append(f"state records branch {cand['stateBranch']}")
1151
+ if cand.get("isEpicMember"):
1152
+ marks.append(f"member of epic {cand.get('epic') or '?'}")
1153
+ suffix = f" ({'; '.join(marks)})" if marks else ""
1154
+ print(
1155
+ f" {cand['branch']}: stage={cand['currentStage'] or '?'} "
1156
+ f"status={cand['pipelineStatus']} path={cand['path']}{suffix}"
1157
+ )
1158
+ if not cand["isCurrentBranch"]:
1159
+ print(f" switch: {cand['switchCommand']}")
1160
+ for cand in remote:
1161
+ print(
1162
+ f" {cand['branch']}: on origin only (never fetched; contents not "
1163
+ "inspected — name matches)"
1164
+ )
1165
+ print(f" fetch: {cand['fetchCommand']}")
1166
+ print(f" switch: {cand['switchCommand']}")
1167
+
1168
+
1169
+ def _all_state_paths_in_ref(ref: str, specs_rel: str) -> list[tuple[str, str]]:
1170
+ """Every feature-shaped ``.pipeline-state.json`` in one ref as ``(path, feature)``.
1171
+
1172
+ The ``--all`` counterpart to ``_state_paths_in_ref``: same flat/nested bound
1173
+ (``{specsDir}/{name}/…`` or ``{specsDir}/{epic}/{name}/…``) but for every
1174
+ feature, not one named one.
1175
+ """
1176
+ listing = _git_output(["ls-tree", "-r", "--name-only", ref, "--", specs_rel])
1177
+ if not listing:
1178
+ return []
1179
+ hits: list[tuple[str, str]] = []
1180
+ prefix = specs_rel + "/"
1181
+ for path in listing.splitlines():
1182
+ if not path.startswith(prefix) or not path.endswith("/" + PIPELINE_STATE_FILENAME):
1183
+ continue
1184
+ segments = path[len(prefix):].split("/")
1185
+ if len(segments) == 2: # [name, state-file] (flat)
1186
+ hits.append((path, segments[0]))
1187
+ elif len(segments) == 3: # [epic, name, state-file] (nested)
1188
+ hits.append((path, segments[1]))
1189
+ return hits
1190
+
1191
+
1192
+ def discover_all(specs_dir: str) -> dict:
1193
+ """Discover EVERY feature's pipeline state across all branches (read-only, Chunk 5c).
1194
+
1195
+ The empty-dashboard counterpart to ``discover-feature <name>``: enumerates every
1196
+ feature-shaped state across local heads + remote-tracking refs and groups the
1197
+ candidates by feature, so a fresh clone / default-branch session can see the whole
1198
+ branch-scattered pipeline set instead of nothing. Never mutates anything.
1199
+ """
1200
+ if _git_output(["rev-parse", "--git-dir"]) is None:
1201
+ return {"gitRepo": False, "currentBranch": None, "features": []}
1202
+ current_branch = _git_output(["branch", "--show-current"])
1203
+ specs_rel = _specs_rel(specs_dir)
1204
+ refs = [(ref, date, False) for ref, date in _list_refs("refs/heads")]
1205
+ refs += [(ref, date, True) for ref, date in _list_refs("refs/remotes")]
1206
+
1207
+ by_feature: dict[str, list[dict]] = {}
1208
+ for ref, commit_date, is_remote in refs:
1209
+ branch = ref.split("/", 1)[1] if is_remote else ref
1210
+ if is_remote and (not branch or branch == "HEAD"):
1211
+ continue
1212
+ for path, feature in _all_state_paths_in_ref(ref, specs_rel):
1213
+ seen = by_feature.setdefault(feature, [])
1214
+ if any(c["branch"] == branch for c in seen):
1215
+ continue # a local head already yielded this branch's state
1216
+ state = _read_state_at_ref(ref, path)
1217
+ state_branch = state.get("branch")
1218
+ state_branch = state_branch if isinstance(state_branch, str) else None
1219
+ epic, is_epic_member = _epic_membership(path, specs_rel, state)
1220
+ seen.append({
1221
+ "branch": branch,
1222
+ "remoteTracking": is_remote,
1223
+ "path": path,
1224
+ "stateBranch": state_branch,
1225
+ "stateBranchMatches": state_branch == branch,
1226
+ "currentStage": state.get("currentStage"),
1227
+ "pipelineStatus": state.get("pipelineStatus", "active"),
1228
+ "epic": epic,
1229
+ "isEpicMember": is_epic_member,
1230
+ "commitDate": commit_date or None,
1231
+ "isCurrentBranch": branch == current_branch,
1232
+ "switchCommand": f"git switch {branch}",
1233
+ })
1234
+
1235
+ def _rank(cand: dict) -> tuple:
1236
+ ts = _parse_ts(cand["commitDate"]) or datetime.min.replace(tzinfo=timezone.utc)
1237
+ return (not cand["stateBranchMatches"], cand["remoteTracking"], -ts.timestamp())
1238
+
1239
+ features = []
1240
+ for feature in sorted(by_feature):
1241
+ cands = sorted(by_feature[feature], key=_rank)
1242
+ features.append({"feature": feature, "candidates": cands})
1243
+ return {"gitRepo": True, "currentBranch": current_branch, "features": features}
1244
+
1245
+
1246
+ def _print_discover_all(payload: dict) -> None:
1247
+ """Human-readable ``discover-feature --all`` report."""
1248
+ if not payload["gitRepo"]:
1249
+ print("discover-feature --all: not a git repository — nothing to scan")
1250
+ return
1251
+ if not payload["features"]:
1252
+ print("discover-feature --all: no pipeline state found on any local or "
1253
+ "remote-tracking branch")
1254
+ return
1255
+ for feat in payload["features"]:
1256
+ print(f"{feat['feature']}:")
1257
+ for cand in feat["candidates"]:
1258
+ marks = []
1259
+ if cand["isCurrentBranch"]:
1260
+ marks.append("current branch")
1261
+ if cand["remoteTracking"]:
1262
+ marks.append("remote-tracking")
1263
+ if not cand["stateBranchMatches"] and cand["stateBranch"]:
1264
+ marks.append(f"state records branch {cand['stateBranch']}")
1265
+ if cand.get("isEpicMember"):
1266
+ marks.append(f"member of epic {cand.get('epic') or '?'}")
1267
+ suffix = f" ({'; '.join(marks)})" if marks else ""
1268
+ print(f" {cand['branch']}: stage={cand['currentStage'] or '?'} "
1269
+ f"status={cand['pipelineStatus']}{suffix}")
1270
+ if not cand["isCurrentBranch"]:
1271
+ print(f" switch: {cand['switchCommand']}")
1272
+
1273
+
1274
+ # --------------------------------------------------------------------------- #
1275
+ # Branch reconciliation (Chunk 6) — imposed/session-branch drift
1276
+ # --------------------------------------------------------------------------- #
1277
+
1278
+
1279
+ def _default_branch() -> str | None:
1280
+ """The repo's default branch: origin/HEAD target, else `main`/`master` if present."""
1281
+ ref = _git_output(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"])
1282
+ if ref:
1283
+ return ref.rsplit("/", 1)[-1]
1284
+ for cand in ("main", "master"):
1285
+ if _git_output(["rev-parse", "--verify", "--quiet", f"refs/heads/{cand}"]) is not None:
1286
+ return cand
1287
+ return None
1288
+
1289
+
1290
+ def reconcile_branch(
1291
+ name: str, specs_dir: Path, config_path: Path, epic: str | None = None
1292
+ ) -> dict:
1293
+ """Decide whether a feature's recorded ``branch`` should adopt the current branch.
1294
+
1295
+ Read-only: it emits a decision; the caller performs any state write. A hosted
1296
+ environment (Claude.ai remote, cloud agents) imposes an arbitrary session branch
1297
+ that Branch Setup silently records; when the user moves to the intended branch the
1298
+ recorded ``branch`` goes stale and every branch-aware mechanism keys off it. This
1299
+ reconciler treats *where the state actually resolves* as the source of truth, with a
1300
+ default-branch guardrail so genuine drift-back-to-default is still surfaced, not
1301
+ silently adopted.
1302
+ """
1303
+ if _git_output(["rev-parse", "--git-dir"]) is None:
1304
+ return {"feature": name, "gitRepo": False, "reconcile": False,
1305
+ "action": "none", "reason": "not a git repository"}
1306
+ current = _git_output(["branch", "--show-current"])
1307
+ default = _default_branch()
1308
+ config = _load_config(config_path)
1309
+ row = next(
1310
+ (r for r in build_rows(specs_dir, config)
1311
+ if r["name"] == name and (epic is None or r["epic"] == epic)),
1312
+ None,
1313
+ )
1314
+ state_path = None
1315
+ if row is not None:
1316
+ parent = specs_dir / row["epic"] / name if row["epic"] else specs_dir / name
1317
+ state_path = str(parent / PIPELINE_STATE_FILENAME)
1318
+ base = {
1319
+ "feature": name,
1320
+ "gitRepo": True,
1321
+ "currentBranch": current,
1322
+ "defaultBranch": default,
1323
+ "stateBranch": row["branch"] if row else None,
1324
+ "resolvesOnCurrentBranch": row is not None,
1325
+ "statePath": state_path,
1326
+ "newBranch": None,
1327
+ }
1328
+ if current is None:
1329
+ return {**base, "reconcile": False, "action": "none",
1330
+ "reason": "no current branch (detached HEAD or unborn branch)"}
1331
+ if row is None:
1332
+ return {**base, "reconcile": False, "action": "not-resolved",
1333
+ "reason": "feature state does not resolve on the current branch — "
1334
+ "use discover-feature to locate it"}
1335
+ state_branch = base["stateBranch"]
1336
+ if state_branch == current:
1337
+ return {**base, "reconcile": False, "action": "none",
1338
+ "reason": "recorded branch already matches the current branch"}
1339
+ if current == default:
1340
+ return {**base, "reconcile": False, "action": "warn-drift",
1341
+ "reason": f"on the default branch ({default}); recording it would commit "
1342
+ "here — create/switch to a topic branch instead of reconciling"}
1343
+ detail = (f"recorded branch {state_branch!r} differs from the current topic branch"
1344
+ if state_branch else "no branch recorded")
1345
+ return {**base, "reconcile": True, "action": "adopt-current", "newBranch": current,
1346
+ "reason": f"{detail}; the feature state resolves here, so adopt the current branch"}
1347
+
1348
+
1349
+ def _print_reconcile(payload: dict) -> None:
1350
+ """Human-readable reconcile-branch report."""
1351
+ if not payload["gitRepo"]:
1352
+ print(f"reconcile-branch {payload['feature']}: not a git repository")
1353
+ return
1354
+ print(f"reconcile-branch {payload['feature']}: {payload['action']} — {payload['reason']}")
1355
+ print(f" current={payload['currentBranch']} recorded={payload['stateBranch'] or '(none)'} "
1356
+ f"default={payload['defaultBranch']}")
1357
+ if payload["reconcile"]:
1358
+ print(f" → write state branch := {payload['newBranch']} ({payload['statePath']})")
1359
+
1360
+
1361
+ # --------------------------------------------------------------------------- #
1362
+ # Epic-member base guard (Issue #125) — detached-base detection
1363
+ # --------------------------------------------------------------------------- #
1364
+
1365
+
1366
+ def check_epic_base(
1367
+ name: str, specs_dir: Path, config_path: Path, epic: str | None = None
1368
+ ) -> dict:
1369
+ """Verify the current HEAD actually contains the epic manifest for a nested member.
1370
+
1371
+ Defense-in-depth for the split-brain-epic failure (Issue #125): when a feature
1372
+ resolves to a nested epic-member directory but the epic's ``epic-manifest.json``
1373
+ is absent from the current checkout, the member stub was reached from a branch
1374
+ that predates (or otherwise lacks) the manifest commit — a detached base. This
1375
+ is read-only: it emits a decision; the caller stops or warns.
1376
+
1377
+ Actions:
1378
+ - ``none`` — not a git repo, a standalone feature (no epic to check), or the
1379
+ manifest is present on HEAD. Nothing to do.
1380
+ - ``not-resolved`` — the feature does not resolve on the current branch.
1381
+ - ``warn-detached-base`` — nested member resolves here but the manifest is
1382
+ missing on HEAD; ``homeBranch`` is the member stub's recorded ``branch``.
1383
+ """
1384
+ base = {
1385
+ "feature": name,
1386
+ "gitRepo": True,
1387
+ "epic": epic,
1388
+ "isEpicMember": False,
1389
+ "manifestOnHead": None,
1390
+ "homeBranch": None,
1391
+ }
1392
+ if _git_output(["rev-parse", "--git-dir"]) is None:
1393
+ return {**base, "gitRepo": False, "action": "none",
1394
+ "reason": "not a git repository"}
1395
+ config = _load_config(config_path)
1396
+ row = next(
1397
+ (r for r in build_rows(specs_dir, config)
1398
+ if r["name"] == name and (epic is None or r["epic"] == epic)),
1399
+ None,
1400
+ )
1401
+ if row is None:
1402
+ return {**base, "action": "not-resolved",
1403
+ "reason": "feature state does not resolve on the current branch — "
1404
+ "use discover-feature to locate it"}
1405
+ member_epic = row["epic"]
1406
+ if not member_epic:
1407
+ return {**base, "action": "none",
1408
+ "reason": "standalone feature — no epic base to check"}
1409
+ base = {**base, "epic": member_epic, "isEpicMember": True,
1410
+ "homeBranch": row["branch"]}
1411
+ manifest = specs_dir / member_epic / MANIFEST_FILENAME
1412
+ if manifest.is_file():
1413
+ return {**base, "manifestOnHead": True, "action": "none",
1414
+ "reason": f"epic manifest present on the current branch "
1415
+ f"({member_epic}/{MANIFEST_FILENAME})"}
1416
+ return {**base, "manifestOnHead": False, "action": "warn-detached-base",
1417
+ "reason": f"member of epic {member_epic!r} resolves here, but "
1418
+ f"{member_epic}/{MANIFEST_FILENAME} is absent on the current "
1419
+ f"branch — this base predates or lacks the epic manifest"}
1420
+
1421
+
1422
+ def _print_check_epic_base(payload: dict) -> None:
1423
+ """Human-readable check-epic-base report."""
1424
+ if not payload["gitRepo"]:
1425
+ print(f"check-epic-base {payload['feature']}: not a git repository")
1426
+ return
1427
+ print(f"check-epic-base {payload['feature']}: {payload['action']} — {payload['reason']}")
1428
+ if payload["action"] == "warn-detached-base":
1429
+ print(f" → switch to the epic's home branch: {payload['homeBranch'] or '(unknown)'}")
1430
+
1431
+
1432
+ # --------------------------------------------------------------------------- #
1433
+ # Scripted Stage Exit
1434
+ # --------------------------------------------------------------------------- #
1435
+
1436
+ #: Authoring stages whose closing runs stage-exit (the loop keeps bespoke exits).
1437
+ EXIT_STAGES: Final[tuple[str, ...]] = (
1438
+ "forge-0-epic",
1439
+ "forge-1-prd",
1440
+ "forge-2-tech",
1441
+ "forge-3-specs",
1442
+ "forge-4-backlog",
1443
+ )
1444
+
1445
+ #: Stage id -> the noun phrase gate wording uses (the old {stage} stamp slot).
1446
+ STAGE_NOUN: Final[dict[str, str]] = {
1447
+ "forge-0-epic": "the epic decomposition",
1448
+ "forge-1-prd": "the PRD",
1449
+ "forge-2-tech": "the tech spec",
1450
+ "forge-3-specs": "the implementation specs",
1451
+ "forge-4-backlog": "the backlog",
1452
+ }
1453
+
1454
+ #: Verify token per exit stage. Extends the production map with the epic stage,
1455
+ #: whose verify entry is recorded under ``forge-verify-epic``.
1456
+ _EXIT_VERIFY_TOKEN: Final[dict[str, str]] = {
1457
+ **VERIFY_TOKEN_BY_STAGE,
1458
+ "forge-0-epic": "epic",
1459
+ }
1460
+
1461
+ #: The stage each exit hands off to when pipeline state cannot say better.
1462
+ _EXIT_NEXT_STAGE: Final[dict[str, str]] = {
1463
+ "forge-0-epic": "forge-1-prd",
1464
+ "forge-1-prd": "forge-2-tech",
1465
+ "forge-2-tech": "forge-3-specs",
1466
+ "forge-3-specs": "forge-4-backlog",
1467
+ "forge-4-backlog": "forge-5-loop",
1468
+ }
1469
+
1470
+ #: The fixed final line of the NEXT-STEPS block. The stamp instructs the skill
1471
+ #: to print the block verbatim as its absolute last output — nothing after this.
1472
+ NEXT_STEPS_SENTINEL: Final = "─ forge: end of stage ─"
1473
+
1474
+
1475
+ def _verify_state_for(state: dict, stage: str) -> str:
1476
+ """Classify THIS stage's verify freshness (stage-scoped ``verify_state``).
1477
+
1478
+ Same labels as ``verify_state`` — fresh / stale / failing / never /
1479
+ skipped / none — but for the given stage rather than the most-recently
1480
+ completed one, because stage-exit runs inside the stage that just closed.
1481
+ """
1482
+ token = _EXIT_VERIFY_TOKEN.get(stage)
1483
+ if token is None:
1484
+ return "none"
1485
+ entry = _verify_entry(state, f"forge-verify-{token}")
1486
+ status = entry.get("status")
1487
+ if status == "skipped":
1488
+ return "skipped"
1489
+ if status == "findings-reported":
1490
+ return "failing"
1491
+ if status not in _VERIFY_RESOLVED:
1492
+ return "never"
1493
+ verified_version = entry.get("verifiedStageVersion")
1494
+ stage_version = _stage_version(state, stage)
1495
+ if (
1496
+ isinstance(verified_version, int)
1497
+ and stage_version is not None
1498
+ and verified_version == stage_version
1499
+ ):
1500
+ return "fresh"
1501
+ return "stale"
1502
+
1503
+
1504
+ def _resolve_feature_dir(specs_dir: Path, feature: str, epic: str | None) -> Path:
1505
+ """Best-effort feature dir (flat, else unique nested, else flat literal).
1506
+
1507
+ stage-exit tolerates an unresolvable dir — the state read downgrades to
1508
+ ``{}`` and every directive still computes from defaults.
1509
+ """
1510
+ if epic:
1511
+ return specs_dir / epic / feature
1512
+ flat = specs_dir / feature
1513
+ if (flat / PIPELINE_STATE_FILENAME).is_file():
1514
+ return flat
1515
+ if specs_dir.is_dir():
1516
+ nested = [
1517
+ p for p in specs_dir.glob(f"*/{feature}")
1518
+ if (p / PIPELINE_STATE_FILENAME).is_file()
1519
+ ]
1520
+ if len(nested) == 1:
1521
+ return nested[0]
1522
+ return flat
1523
+
1524
+
1525
+ def _host_command(command: str, host: str) -> str:
1526
+ """Rewrite a `/skill:` slash command to the host's surface.
1527
+
1528
+ Pi's slash-command surface is `/skill:` (matching the adapter body's
1529
+ `/skill:` -> `/skill:` translation). The scripted stage-exit output bypasses
1530
+ that body translation, so it rewrites the commands it emits here. No-op for
1531
+ claude/generic, which keep the canonical `/skill:` form.
1532
+ """
1533
+ return command.replace("/skill:", "/skill:") if host == "pi" else command
1534
+
1535
+
1536
+ def _next_steps_block(
1537
+ next_command: str, host: str, reconcile: dict | None = None
1538
+ ) -> str:
1539
+ """Render the sentinel-terminated NEXT-STEPS block for the given host.
1540
+
1541
+ The Claude wording uses the literal ``/clear`` slash-command; the generic
1542
+ wording is host-neutral (matching the adapter build's host-term table, so
1543
+ a non-Claude bundle invoking ``--host generic`` never instructs a fake
1544
+ slash-command).
1545
+
1546
+ ``reconcile`` carries the epic-backflow routing (§Epic backflow in
1547
+ ``references/stage-exit-protocol.md``). When it marks a **blocking** request
1548
+ (``required: true``), the fenced primary command becomes the epic reconcile
1549
+ command and the normal next stage is demoted to a follow-up line. When it
1550
+ marks only **non-blocking** requests (``reminder: true``), the fenced command
1551
+ stays the normal next stage and a reminder line is appended. Either way the
1552
+ added prose is host-neutral (no literal ``/clear``) so it survives verbatim
1553
+ into a generic bundle.
1554
+ """
1555
+ if host == "claude":
1556
+ clear_line = (
1557
+ "1. `/clear` — recommended unconditionally at this stage boundary; "
1558
+ "every artifact is on disk, so the work survives the clear. "
1559
+ "I can't `/clear` for you — you have to run it yourself."
1560
+ )
1561
+ next_line = (
1562
+ "2. Then start a fresh session and run the next stage below — or "
1563
+ "re-run `/skill:forge` to let the navigator resume from disk."
1564
+ )
1565
+ elif host == "pi":
1566
+ # Pi's fresh-session command is `/new` (not `/clear`); its slash-command
1567
+ # surface is `/skill:` (the fenced command below is rewritten to match).
1568
+ clear_line = (
1569
+ "1. `/new` — recommended unconditionally at this stage boundary; every "
1570
+ "artifact is on disk, so the work survives starting a fresh session. "
1571
+ "I can't run `/new` for you — you have to run it yourself."
1572
+ )
1573
+ next_line = (
1574
+ "2. Then, in the new session, run the next stage below — or re-run "
1575
+ "`/skill:forge` to let the navigator resume from disk."
1576
+ )
1577
+ else:
1578
+ clear_line = (
1579
+ "1. Clear your session / start a fresh session — recommended "
1580
+ "unconditionally at this stage boundary; every artifact is on "
1581
+ "disk, so the work survives it."
1582
+ )
1583
+ next_line = (
1584
+ "2. Then start a fresh session and run the next stage below — or "
1585
+ "re-run the forge navigator skill to resume from disk."
1586
+ )
1587
+ blocking = bool(reconcile and reconcile.get("required"))
1588
+ # The primary actionable command goes in a fenced block so mobile/remote hosts
1589
+ # get a native copy button (inline code is not tap-to-copy). For a blocking
1590
+ # epic-change request the primary is the reconcile command; otherwise it is the
1591
+ # normal next-stage command. The fence sits before the sentinel, so the
1592
+ # sentinel remains the absolute last line.
1593
+ fenced_command = _host_command(reconcile["command"] if blocking else next_command, host)
1594
+ lines = ["**Next steps**", clear_line]
1595
+ if blocking:
1596
+ count = reconcile["count"]
1597
+ plural = "s" if count != 1 else ""
1598
+ lines.append(
1599
+ f"2. Then reconcile the epic **before** the next stage — {count} "
1600
+ f"blocking epic change request{plural} flagged, and proceeding would "
1601
+ "build this feature's artifacts on a decomposition that is about to "
1602
+ "change. Run the reconcile command below first."
1603
+ )
1604
+ else:
1605
+ lines.append(next_line)
1606
+ lines.append("")
1607
+ lines.append(f"```\n{fenced_command}\n```")
1608
+ if blocking and reconcile.get("deferred"):
1609
+ deferred_cmd = _host_command(reconcile["deferred"], host)
1610
+ lines.append(f"After reconciling, continue the pipeline with: `{deferred_cmd}`")
1611
+ elif reconcile and reconcile.get("reminder"):
1612
+ count = reconcile["count"]
1613
+ plural = "s" if count != 1 else ""
1614
+ lines.append(
1615
+ f"You also flagged {count} epic change{plural} to reconcile when "
1616
+ f"convenient: `{_host_command(reconcile['command'], host)}`"
1617
+ )
1618
+ lines.append(NEXT_STEPS_SENTINEL)
1619
+ return "\n".join(lines)
1620
+
1621
+
1622
+ def stage_exit(
1623
+ feature: str,
1624
+ stage: str,
1625
+ specs_dir: Path,
1626
+ config_path: Path,
1627
+ epic: str | None,
1628
+ host: str,
1629
+ next_feature: str | None,
1630
+ ) -> dict:
1631
+ """Compute the Scripted Stage Exit payload: DIRECTIVES + NEXT-STEPS block.
1632
+
1633
+ Directive semantics (the contract in ``references/stage-exit-protocol.md``):
1634
+
1635
+ - ``runInStageVerify`` — the effective auto-verify (per-stage override,
1636
+ else global; strict-true) is on AND this stage's verify is not already
1637
+ resolved (fresh/skipped). The skill then dispatches the clean-room
1638
+ verify in-session (principle #2: verify before the clear).
1639
+ - ``autoFixEligible`` — ``autoFix`` is strict-true AND the in-stage verify
1640
+ runs AND the working tree is clean. Findings-level preconditions (zero
1641
+ unresolved decisions) remain the skill's runtime check.
1642
+ - ``verifyGate`` — ``none`` when verify is resolved or the in-stage run
1643
+ covers it; ``standard`` when auto-verify is off and verification is
1644
+ outstanding on a host with a question mechanism + clean-room path
1645
+ (``--host claude``); ``manual-print`` for the same state on a generic
1646
+ host (print ``verifyCommand`` instead of presenting the gate).
1647
+ - ``nextStage``/``nextCommand`` — from pipeline state when it already
1648
+ records this stage complete (first non-complete production stage), else
1649
+ the fixed successor. ``--next-feature`` names the first actionable
1650
+ feature for the epic handoff; without it the runtime placeholder
1651
+ ``{first-actionable-feature}`` passes through for the skill to resolve.
1652
+ - ``epicReconcile`` — present only when the exiting member carries
1653
+ ``open`` ``epicChangeRequests`` (epic-backflow). ``required: true`` (any
1654
+ ``blocksCurrent: true`` request) interposes a reconcile-first exit: the
1655
+ NEXT-STEPS primary command becomes ``/skill:forge-0-epic {epic}``
1656
+ and the normal next stage is deferred. Only non-blocking requests set
1657
+ ``reminder: true`` and append a non-blocking reminder line. Absent when
1658
+ there are no open requests (common path) or the epic name is unresolvable.
1659
+
1660
+ Read-only, deterministic, exit 0 — errors degrade to defaults, never
1661
+ crash a stage closing.
1662
+ """
1663
+ config = _load_config(config_path)
1664
+ feature_dir = _resolve_feature_dir(specs_dir, feature, epic)
1665
+ state = _read_state(feature_dir / PIPELINE_STATE_FILENAME)
1666
+
1667
+ git_repo = _git_output(["rev-parse", "--git-dir"]) is not None
1668
+ clean_tree: bool | None = None
1669
+ if git_repo:
1670
+ porcelain = _git_output(["status", "--porcelain"])
1671
+ clean_tree = porcelain is None or porcelain == ""
1672
+
1673
+ verify_label = _verify_state_for(state, stage)
1674
+ resolved = verify_label in ("fresh", "skipped")
1675
+ effective_auto_verify = auto_verify_for(config, stage)
1676
+ run_in_stage = effective_auto_verify and not resolved
1677
+ auto_fix_eligible = (
1678
+ config.get("autoFix") is True and run_in_stage and clean_tree is True
1679
+ )
1680
+ if resolved or effective_auto_verify:
1681
+ verify_gate = "none"
1682
+ elif host == "claude":
1683
+ verify_gate = "standard"
1684
+ else:
1685
+ verify_gate = "manual-print"
1686
+
1687
+ next_stage_id = _EXIT_NEXT_STAGE.get(stage)
1688
+ state_next = next_stage(state)
1689
+ if (
1690
+ stage in PRODUCTION_STAGES
1691
+ and state_next is not None
1692
+ and PRODUCTION_STAGES.index(state_next) > PRODUCTION_STAGES.index(stage)
1693
+ ):
1694
+ # State records this stage complete AND its walk lands beyond it —
1695
+ # trust it (it skips stages already completed out of order). A missing
1696
+ # or behind-the-stage walk (state not yet flushed, corrupt file) falls
1697
+ # back to the fixed successor, never to an earlier stage.
1698
+ next_stage_id = state_next
1699
+ next_arg = next_feature or (
1700
+ "{first-actionable-feature}" if stage == "forge-0-epic" else feature
1701
+ )
1702
+ next_command = f"/skill:{next_stage_id} {next_arg}" if next_stage_id else None
1703
+
1704
+ # Epic backflow routing: an exiting member may carry epic-level change requests
1705
+ # (recorded by forge-1-prd/forge-2-tech). A `blocksCurrent: true` request means
1706
+ # the current feature's next stage would build on a soon-to-change decomposition,
1707
+ # so the exit interposes a reconcile-first step; only-`false` requests append a
1708
+ # non-blocking reminder. Read-only; the common path (no open requests) is a no-op.
1709
+ # The epic name comes from the `--epic` arg or the state's `epic` back-pointer.
1710
+ epic_reconcile: dict | None = None
1711
+ epic_name = epic or state.get("epic")
1712
+ open_requests = [
1713
+ r
1714
+ for r in state.get("epicChangeRequests", [])
1715
+ if isinstance(r, dict) and r.get("status") == "open"
1716
+ ]
1717
+ if open_requests and epic_name:
1718
+ reconcile_command = f"/skill:forge-0-epic {epic_name}"
1719
+ blocking = [r for r in open_requests if r.get("blocksCurrent") is True]
1720
+ if blocking:
1721
+ epic_reconcile = {
1722
+ "required": True,
1723
+ "command": reconcile_command,
1724
+ "count": len(blocking),
1725
+ "deferred": next_command,
1726
+ }
1727
+ else:
1728
+ epic_reconcile = {
1729
+ "required": False,
1730
+ "reminder": True,
1731
+ "command": reconcile_command,
1732
+ "count": len(open_requests),
1733
+ }
1734
+
1735
+ directives = {
1736
+ "stage": stage,
1737
+ "stageNoun": STAGE_NOUN.get(stage, stage),
1738
+ "feature": feature,
1739
+ "runInStageVerify": run_in_stage,
1740
+ "verifyGate": verify_gate,
1741
+ "autoFixEligible": auto_fix_eligible,
1742
+ "verifyState": verify_label,
1743
+ "verifyCommand": _host_command(f"/skill:forge-verify {feature}", host),
1744
+ "autoVerifyEffective": effective_auto_verify,
1745
+ "nextStage": next_stage_id,
1746
+ "nextCommand": _host_command(next_command, host) if next_command else next_command,
1747
+ "invalidAutoVerifyKeys": invalid_auto_verify_keys(config),
1748
+ "gitRepo": git_repo,
1749
+ "cleanTree": clean_tree,
1750
+ "host": host,
1751
+ }
1752
+ if epic_reconcile is not None:
1753
+ directives["epicReconcile"] = epic_reconcile
1754
+ return {
1755
+ "directives": directives,
1756
+ "nextSteps": _next_steps_block(
1757
+ next_command or "/skill:forge", host, epic_reconcile
1758
+ ),
1759
+ "sentinel": NEXT_STEPS_SENTINEL,
1760
+ }
1761
+
1762
+
1763
+ def _print_stage_exit(payload: dict) -> None:
1764
+ """Print DIRECTIVES then the NEXT-STEPS block (the skill-facing form)."""
1765
+ print("DIRECTIVES:")
1766
+ print(json.dumps(payload["directives"], indent=2, ensure_ascii=False))
1767
+ print(
1768
+ "NEXT-STEPS (print this block verbatim as your absolute last output — "
1769
+ "nothing after the sentinel):"
1770
+ )
1771
+ print(payload["nextSteps"])
1772
+
1773
+
1774
+ # --------------------------------------------------------------------------- #
1775
+ # Effective loopRunner config
1776
+ # --------------------------------------------------------------------------- #
1777
+
1778
+
1779
+ def _default_schema_path() -> Path:
1780
+ """Return the bundled forge-config-schema.json path (sibling references/ dir).
1781
+
1782
+ Resolved relative to this script file so `effective-config` works from any
1783
+ cwd. Overridable via the ``--schema`` flag (chiefly for tests).
1784
+
1785
+ Returns:
1786
+ The Path to ``references/forge-config-schema.json`` next to ``scripts/``.
1787
+ """
1788
+ return Path(__file__).resolve().parent.parent / "references" / "forge-config-schema.json"
1789
+
1790
+
1791
+ def _loop_runner_defaults(schema_path: Path) -> dict[str, object]:
1792
+ """Extract every ``loopRunner`` field's schema ``default``.
1793
+
1794
+ Reads ``properties.loopRunner.properties.<field>.default`` for each field.
1795
+ Stdlib-only (``json`` + dict access), mirroring
1796
+ ``tests/test_config_defaults_parity.py``. The schema is the single source of
1797
+ truth; nothing here is hardcoded.
1798
+
1799
+ Only fields that actually declare a ``default`` keyword are included. Every
1800
+ ``loopRunner`` field does today; a field losing its default would be a schema
1801
+ regression the drift guard catches, not something silently patched here.
1802
+
1803
+ Args:
1804
+ schema_path: Path to ``forge-config-schema.json``.
1805
+
1806
+ Returns:
1807
+ A dict mapping each ``loopRunner`` field name to its declared default
1808
+ value (templates such as ``"{bin} loop run …"`` are returned literally).
1809
+
1810
+ Raises:
1811
+ UsageError: If the schema is missing, unreadable, unparseable, or lacks a
1812
+ ``loopRunner.properties`` object — a deterministic failure that must
1813
+ exit 2. Never returns partial/empty defaults silently.
1814
+ """
1815
+ try:
1816
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
1817
+ except OSError as exc:
1818
+ raise UsageError(f"config schema unreadable: {schema_path} ({exc})") from exc
1819
+ except json.JSONDecodeError as exc:
1820
+ raise UsageError(f"config schema is not valid JSON: {schema_path} ({exc})") from exc
1821
+
1822
+ props = None
1823
+ if isinstance(schema, dict):
1824
+ loop_runner = schema.get("properties", {})
1825
+ if isinstance(loop_runner, dict):
1826
+ loop_runner = loop_runner.get("loopRunner", {})
1827
+ if isinstance(loop_runner, dict):
1828
+ props = loop_runner.get("properties")
1829
+ if not isinstance(props, dict) or not props:
1830
+ raise UsageError(f"config schema has no loopRunner.properties object: {schema_path}")
1831
+
1832
+ return {
1833
+ field: spec["default"]
1834
+ for field, spec in props.items()
1835
+ if isinstance(spec, dict) and "default" in spec
1836
+ }
1837
+
1838
+
1839
+ def resolve_loop_runner(config_path: Path, schema_path: Path) -> dict[str, object]:
1840
+ """Resolve the effective ``loopRunner`` config: schema defaults + user overrides.
1841
+
1842
+ Reads the schema defaults, then merges the user's ``loopRunner`` block (from
1843
+ ``forge.config.json`` via the existing ``_load_config``) OVER them. A user
1844
+ field replaces the default; an absent field keeps the default. The result is
1845
+ the fully-resolved block the loop consumes — computed deterministically so no
1846
+ model ever merges it by hand.
1847
+
1848
+ Args:
1849
+ config_path: Path to ``forge.config.json`` (``_load_config`` tolerates a
1850
+ missing/corrupt file, yielding pure defaults).
1851
+ schema_path: Path to ``forge-config-schema.json`` (source of the defaults).
1852
+
1853
+ Returns:
1854
+ The resolved ``loopRunner`` object: every schema-defaulted field present,
1855
+ with user overrides applied.
1856
+
1857
+ Raises:
1858
+ UsageError: If the schema is unreadable/unparseable (propagated from
1859
+ ``_loop_runner_defaults``) — exit 2, a deterministic failure.
1860
+ """
1861
+ resolved: dict[str, object] = dict(_loop_runner_defaults(schema_path))
1862
+
1863
+ user_loop_runner = _load_config(config_path).get("loopRunner")
1864
+ if isinstance(user_loop_runner, dict):
1865
+ for key, value in user_loop_runner.items():
1866
+ # Flat override: a user value replaces the default for that field.
1867
+ # (A future nested loopRunner field would recurse here; today every
1868
+ # field is a scalar, so a shallow override is exact.) An unknown key
1869
+ # is carried through — the model would have carried it too, and the
1870
+ # config schema is the authority that flags it at author time.
1871
+ resolved[key] = value
1872
+
1873
+ return resolved
1874
+
1875
+
1876
+ def _print_effective_config(resolved: dict[str, object]) -> None:
1877
+ """Print the resolved loopRunner config as an aligned key: value table.
1878
+
1879
+ Args:
1880
+ resolved: The resolved loopRunner object from ``resolve_loop_runner``.
1881
+ """
1882
+ print("Effective loopRunner config:")
1883
+ width = max((len(k) for k in resolved), default=0)
1884
+ for key in sorted(resolved):
1885
+ print(f" {key.ljust(width)} : {resolved[key]!r}")
1886
+
1887
+
1888
+ # --------------------------------------------------------------------------- #
1889
+ # State writes (shared machinery for the state-* verbs)
1890
+ # --------------------------------------------------------------------------- #
1891
+
1892
+
1893
+ def _now_iso() -> str:
1894
+ """Return the current UTC time as a Z-suffixed, second-precision ISO-8601 string.
1895
+
1896
+ Matches the `.pipeline-state.json` timestamp convention already on disk (the
1897
+ schema's ``format: date-time`` values; the read path normalizes a trailing
1898
+ ``Z``). Second precision keeps `updatedAt`/`startedAt`/`completedAt` visually
1899
+ consistent with the values other pipeline writers produce.
1900
+
1901
+ Returns:
1902
+ A timestamp like ``"2026-07-29T03:30:00Z"``.
1903
+ """
1904
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1905
+
1906
+
1907
+ def _write_state(state_path: Path, state: dict) -> None:
1908
+ """Atomically write a `.pipeline-state.json` (temp file + os.replace).
1909
+
1910
+ Mirrors epic-manifest.py's ``atomic_write``: write to a sibling temp file in
1911
+ the same directory as the target, flush + fsync the bytes, then os.replace()
1912
+ the temp file onto the target. os.replace is atomic on POSIX within one
1913
+ filesystem, so an interrupted write never leaves a partial or corrupt state
1914
+ file. Concurrent multi-session mutation is out of scope (single writer
1915
+ assumed, matching epic-manifest.py).
1916
+
1917
+ Args:
1918
+ state_path: Destination path, e.g.
1919
+ ``{specsDir}/{feature}/.pipeline-state.json``.
1920
+ state: The fully-formed state dict to serialize.
1921
+
1922
+ Raises:
1923
+ UsageError: If the temp file cannot be created/written or the replace
1924
+ fails (→ exit 2). The temp file is removed first, so a failed write
1925
+ leaves no debris and the original target untouched.
1926
+ """
1927
+ try:
1928
+ fd, tmp_name = tempfile.mkstemp(
1929
+ prefix=f".{state_path.name}.", suffix=".tmp", dir=state_path.parent
1930
+ )
1931
+ except OSError as exc:
1932
+ raise UsageError(f"atomic write to {state_path} failed: {exc}") from exc
1933
+ tmp_path = Path(tmp_name)
1934
+ try:
1935
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
1936
+ json.dump(state, handle, indent=2, ensure_ascii=False)
1937
+ handle.write("\n")
1938
+ handle.flush()
1939
+ os.fsync(handle.fileno())
1940
+ os.replace(tmp_path, state_path)
1941
+ except OSError as exc:
1942
+ tmp_path.unlink(missing_ok=True)
1943
+ raise UsageError(f"atomic write to {state_path} failed: {exc}") from exc
1944
+
1945
+
1946
+ def _resolve_feature_dir_for_write(
1947
+ specs_dir: Path, feature: str, epic: str | None
1948
+ ) -> Path:
1949
+ """Fail-closed feature dir for the ``state-*`` WRITERS.
1950
+
1951
+ ``_resolve_feature_dir`` is the reader's best-effort resolver: it returns the
1952
+ flat ``{specsDir}/{feature}`` whenever that dir carries a state file, and
1953
+ falls back to the flat literal on a multi-match. That tolerance was written
1954
+ for ``stage-exit``, which is READ-ONLY — an unresolvable dir there just
1955
+ downgrades to ``{}``. For a writer the same tolerance means a bare
1956
+ ``--feature api`` mutates a standalone ``{specsDir}/api/`` while an epic
1957
+ member ``{specsDir}/{epic}/api/`` of the same name is silently left behind:
1958
+ cross-feature state corruption at exit 0.
1959
+
1960
+ So the write path mirrors ``epic-manifest.py resolve`` — the canonical
1961
+ resolver that produced ``{resolvedFeatureDir}`` in the first place, and which
1962
+ rejects an ambiguous name with a structured ``ambiguous:`` finding. A writer
1963
+ must not be more permissive than that resolver: more than one candidate
1964
+ carrying a state file, with no explicit ``--epic``, is a hard stop.
1965
+
1966
+ Args:
1967
+ specs_dir: The configured specs directory (``--specs-dir``).
1968
+ feature: The feature name (``--feature``).
1969
+ epic: The owning epic name for a nested member, else None (``--epic``).
1970
+
1971
+ Returns:
1972
+ The resolved feature directory. With ``--epic`` the nested path is taken
1973
+ verbatim; otherwise the single candidate carrying a state file, or the
1974
+ flat path when none does (the first-write case).
1975
+
1976
+ Raises:
1977
+ UsageError: The bare name matches more than one directory carrying a
1978
+ state file (→ exit 2, nothing written).
1979
+ """
1980
+ if epic:
1981
+ return specs_dir / epic / feature
1982
+ flat = specs_dir / feature
1983
+ candidates = [flat] if (flat / PIPELINE_STATE_FILENAME).is_file() else []
1984
+ if specs_dir.is_dir():
1985
+ candidates.extend(
1986
+ sorted(
1987
+ p
1988
+ for p in specs_dir.glob(f"*/{feature}")
1989
+ if (p / PIPELINE_STATE_FILENAME).is_file()
1990
+ )
1991
+ )
1992
+ if len(candidates) > 1:
1993
+ listed = ", ".join(str(p) for p in candidates)
1994
+ raise UsageError(
1995
+ f"ambiguous feature {feature!r}: {len(candidates)} directories carry a "
1996
+ f"state file ({listed}) — pass --epic <epic> to name the one to write. "
1997
+ f"Refusing to guess; nothing was written."
1998
+ )
1999
+ return candidates[0] if candidates else flat
2000
+
2001
+
2002
+ def _load_state_for_write(
2003
+ specs_dir: Path, feature: str, epic: str | None
2004
+ ) -> tuple[Path, dict]:
2005
+ """Resolve a feature's state path and load its current state for mutation.
2006
+
2007
+ Resolves through the fail-closed `_resolve_feature_dir_for_write`, NOT the
2008
+ reader's tolerant `_resolve_feature_dir`. Deliberately does NOT
2009
+ reuse `_read_state`: that reader downgrades a *corrupt* file to ``{}`` because
2010
+ the navigator's read-only sweep can safely treat it as not-started. A writer
2011
+ that inherited it would atomically replace a corrupt-but-recoverable state
2012
+ file with a near-empty one at exit 0. So: absent -> ``{}``; present but
2013
+ unparseable -> refuse, leaving the file byte-intact.
2014
+
2015
+ The verbs never create a feature directory; an unknown ``--feature`` is a
2016
+ usage error, not a silent create.
2017
+
2018
+ Args:
2019
+ specs_dir: The configured specs directory (``--specs-dir``).
2020
+ feature: The feature name (``--feature``).
2021
+ epic: The owning epic name for a nested member, else None (``--epic``).
2022
+
2023
+ Returns:
2024
+ A ``(state_path, state)`` tuple. ``state`` is a schema-shaped shell when
2025
+ no state file exists yet (see the seeding below).
2026
+
2027
+ Raises:
2028
+ UsageError: The bare ``feature`` name is ambiguous (more than one
2029
+ candidate directory carries a state file and no ``--epic`` was
2030
+ given), the feature directory does not exist, or the state file
2031
+ exists but is not a JSON object (→ exit 2).
2032
+ """
2033
+ state_dir = _resolve_feature_dir_for_write(specs_dir, feature, epic)
2034
+ if not state_dir.is_dir():
2035
+ raise UsageError(
2036
+ f"no feature directory at {state_dir} — check --feature "
2037
+ f"(and --epic for a nested epic member)"
2038
+ )
2039
+ state_path = state_dir / PIPELINE_STATE_FILENAME
2040
+ if state_path.exists():
2041
+ try:
2042
+ state = json.loads(state_path.read_text(encoding="utf-8"))
2043
+ except json.JSONDecodeError as exc:
2044
+ raise UsageError(
2045
+ f"{state_path} exists but is not valid JSON ({exc}); refusing to "
2046
+ f"overwrite it. Fix or move the file, then re-run."
2047
+ ) from exc
2048
+ if not isinstance(state, dict):
2049
+ raise UsageError(
2050
+ f"{state_path} is not a JSON object; refusing to overwrite it."
2051
+ )
2052
+ else:
2053
+ state = {}
2054
+
2055
+ # Seed the schema-required top-level fields for EVERY verb, not just
2056
+ # state-enter. Branch Setup fires state-branch before the entry stamp
2057
+ # (references/shared-conventions.md), so without this a first-write
2058
+ # state-branch would persist {"branch": ..., "updatedAt": ...} — missing
2059
+ # every required field — at exit 0. setdefault keeps existing state as-is.
2060
+ # (`updatedAt`, the sixth required field, is stamped by _commit_state.)
2061
+ state.setdefault("feature", feature)
2062
+ state.setdefault("createdAt", _now_iso())
2063
+ state.setdefault("pipelineStatus", "active")
2064
+ state.setdefault("stages", {})
2065
+ state.setdefault("currentStage", PRODUCTION_STAGES[0])
2066
+ return state_path, state
2067
+
2068
+
2069
+ def _commit_state(state_path: Path, state: dict) -> dict:
2070
+ """Refresh ``updatedAt`` and write ``state`` atomically; return it for echo.
2071
+
2072
+ Every verb calls this exactly once, after its mutation, so ``updatedAt`` is
2073
+ always refreshed on a successful write and the write is atomic.
2074
+
2075
+ Args:
2076
+ state_path: The resolved ``.pipeline-state.json`` path.
2077
+ state: The mutated state dict.
2078
+
2079
+ Returns:
2080
+ The same ``state`` dict (now carrying a fresh ``updatedAt``), so the verb
2081
+ can echo it under ``--json``.
2082
+
2083
+ Raises:
2084
+ UsageError: If the atomic write fails (→ exit 2).
2085
+ """
2086
+ state["updatedAt"] = _now_iso()
2087
+ _write_state(state_path, state)
2088
+ return state
2089
+
2090
+
2091
+ def _stage_entry(state: dict, stage: str) -> dict:
2092
+ """Return (creating if absent) the mutable ``stages.{stage}`` sub-object.
2093
+
2094
+ Bootstraps ``state["stages"]`` and ``state["stages"][stage]`` when missing, so
2095
+ a verb can write into a brand-new state (``{}``), and returns the stage dict
2096
+ for in-place mutation. The bootstrap seeds ``{"status": "pending"}`` rather
2097
+ than ``{}`` because ``stageEntry`` declares ``required: ["status"]`` — an entry
2098
+ created by state-artifact (which sets only ``artifacts``) would otherwise be
2099
+ schema-invalid at exit 0.
2100
+
2101
+ Args:
2102
+ state: The full state dict (mutated in place).
2103
+ stage: A stage id from ``STATE_VERB_STAGES`` (e.g. ``"forge-1-prd"``).
2104
+
2105
+ Returns:
2106
+ The mutable ``stages.{stage}`` dict.
2107
+ """
2108
+ stages = state.setdefault("stages", {})
2109
+ return stages.setdefault(stage, {"status": "pending"})
2110
+
2111
+
2112
+ # --------------------------------------------------------------------------- #
2113
+ # State-write verbs
2114
+ # --------------------------------------------------------------------------- #
2115
+
2116
+
2117
+ def cmd_state_enter(feature: str, stage: str, specs_dir: Path, epic: str | None) -> dict:
2118
+ """Apply the Entry Stamp: mark ``stage`` in-progress and set ``currentStage``.
2119
+
2120
+ Idempotent on re-entry within the same run: re-stamping an already
2121
+ in-progress stage simply refreshes ``startedAt``/``updatedAt``. The
2122
+ interactive resume-vs-restart decision stays the skill's — the verb never
2123
+ prompts. The write is left uncommitted; the stage's existing exit commit
2124
+ stages it later.
2125
+
2126
+ Args:
2127
+ feature: Feature name.
2128
+ stage: The stage being entered (a ``STATE_VERB_STAGES`` id).
2129
+ specs_dir: Specs directory.
2130
+ epic: Owning epic name, or None.
2131
+
2132
+ Returns:
2133
+ The mutated state dict (for the --json echo).
2134
+
2135
+ Raises:
2136
+ UsageError: Unknown feature directory, unparseable state file, or a
2137
+ failed atomic write (→ exit 2).
2138
+ """
2139
+ state_path, state = _load_state_for_write(specs_dir, feature, epic)
2140
+ entry = _stage_entry(state, stage)
2141
+ entry["status"] = "in-progress"
2142
+ entry["startedAt"] = _now_iso()
2143
+ state["currentStage"] = stage
2144
+ return _commit_state(state_path, state)
2145
+
2146
+
2147
+ def cmd_state_artifact(
2148
+ feature: str, stage: str, paths: list[str], specs_dir: Path, epic: str | None
2149
+ ) -> dict:
2150
+ """Append each path in ``paths`` to ``stages.{stage}.artifacts``, de-duplicating.
2151
+
2152
+ Idempotent: an already-tracked path is a no-op (no duplicate append), so a
2153
+ resumed run that re-records files it wrote earlier does not bloat the array.
2154
+ ``updatedAt`` is refreshed even on the all-duplicates branch, keeping "state
2155
+ was touched" honest. The verb does NOT stat the file — it records the path
2156
+ the skill asserts it wrote.
2157
+
2158
+ Args:
2159
+ feature: Feature name.
2160
+ stage: The producing stage id.
2161
+ paths: Artifact paths relative to the feature dir (repeatable ``--path``).
2162
+ specs_dir: Specs directory.
2163
+ epic: Owning epic name, or None.
2164
+
2165
+ Returns:
2166
+ The mutated state dict (for the --json echo).
2167
+
2168
+ Raises:
2169
+ UsageError: Unknown feature directory, unparseable state file, or a
2170
+ failed atomic write (→ exit 2).
2171
+ """
2172
+ state_path, state = _load_state_for_write(specs_dir, feature, epic)
2173
+ entry = _stage_entry(state, stage)
2174
+ artifacts = entry.setdefault("artifacts", [])
2175
+ for path in paths:
2176
+ if path not in artifacts:
2177
+ artifacts.append(path)
2178
+ return _commit_state(state_path, state)
2179
+
2180
+
2181
+ def _parse_based_on(pairs: list[str]) -> dict[str, int]:
2182
+ """Parse ``--based-on STAGE=N`` tokens into a ``{stageId: int}`` map.
2183
+
2184
+ Args:
2185
+ pairs: Raw ``STAGE=N`` strings from repeated ``--based-on`` flags.
2186
+
2187
+ Returns:
2188
+ A ``{stageId: version}`` dict (empty when no pairs were given — the
2189
+ forge-1-prd case, which records ``basedOnVersions == {}``).
2190
+
2191
+ Raises:
2192
+ UsageError: If a token lacks ``=`` or its value is not an integer
2193
+ (→ exit 2).
2194
+ """
2195
+ out: dict[str, int] = {}
2196
+ for token in pairs:
2197
+ if "=" not in token:
2198
+ raise UsageError(f"--based-on expects STAGE=N, got: {token!r}")
2199
+ stage_id, _, raw = token.partition("=")
2200
+ try:
2201
+ out[stage_id] = int(raw)
2202
+ except ValueError as exc:
2203
+ raise UsageError(f"--based-on version must be an integer: {token!r}") from exc
2204
+ return out
2205
+
2206
+
2207
+ #: Stages the staleness cascade may mark stale (downstream authored artifacts).
2208
+ #: The scope is tech..docs, matching the pre-R4 canon this cascade replaces —
2209
+ #: forge-1-prd L134 named `forge-2-tech` FIRST among the stages a PRD revision
2210
+ #: invalidates, and the tech spec is a PRD revision's most direct dependent.
2211
+ #: forge-1-prd is never marked stale by a later completion (nothing downstream
2212
+ #: feeds back into it). Keyed off this map, NOT off PRODUCTION_STAGES ordering —
2213
+ #: the two are not interchangeable (a positional slice from the completing stage
2214
+ #: would also break on forge-0-epic, which is a valid --stage but not a
2215
+ #: PRODUCTION_STAGES member).
2216
+ _CASCADE_TARGETS: Final[tuple[str, ...]] = (
2217
+ "forge-2-tech",
2218
+ "forge-3-specs",
2219
+ "forge-4-backlog",
2220
+ "forge-5-loop",
2221
+ "forge-6-docs",
2222
+ )
2223
+
2224
+
2225
+ def _cascade_staleness(state: dict, completed_stage: str, new_version: int) -> list[str]:
2226
+ """Mark downstream stages ``stale`` when they were built on an OLDER version.
2227
+
2228
+ Deterministic replacement for the model-prose rule in each stage's completion
2229
+ step ("if any downstream stage has basedOnVersions referencing an older
2230
+ version, set its status to stale"). For every downstream target (tech..docs),
2231
+ if its recorded ``basedOnVersions[completed_stage]`` is an integer strictly
2232
+ less than ``new_version`` AND the stage is currently ``complete``, flip it to
2233
+ ``stale``. A downstream stage that never referenced this upstream, or already
2234
+ references the new version, is untouched. A ``pending``/``in-progress``/
2235
+ already-``stale`` downstream stage is not re-flipped — only a ``complete``
2236
+ artifact can go stale.
2237
+
2238
+ Args:
2239
+ state: The full state dict (mutated in place).
2240
+ completed_stage: The stage that just completed (e.g. "forge-1-prd").
2241
+ new_version: That stage's new version.
2242
+
2243
+ Returns:
2244
+ The list of stage ids newly marked stale (for the --json echo / printer).
2245
+ """
2246
+ stages = state.get("stages", {})
2247
+ newly_stale: list[str] = []
2248
+ for target in _CASCADE_TARGETS:
2249
+ if target == completed_stage:
2250
+ continue
2251
+ entry = stages.get(target)
2252
+ if not isinstance(entry, dict) or entry.get("status") != "complete":
2253
+ continue
2254
+ based_on = entry.get("basedOnVersions")
2255
+ if not isinstance(based_on, dict):
2256
+ continue
2257
+ recorded = based_on.get(completed_stage)
2258
+ if isinstance(recorded, int) and not isinstance(recorded, bool) and recorded < new_version:
2259
+ entry["status"] = "stale"
2260
+ newly_stale.append(target)
2261
+ return newly_stale
2262
+
2263
+
2264
+ def cmd_state_complete(
2265
+ feature: str,
2266
+ stage: str,
2267
+ version: int,
2268
+ based_on: dict[str, int],
2269
+ artifacts: list[str],
2270
+ commit_hash: str | None,
2271
+ specs_dir: Path,
2272
+ epic: str | None,
2273
+ status: str | None = None,
2274
+ preserve_commit_hash: bool = False,
2275
+ resumable: bool = False,
2276
+ ) -> dict:
2277
+ """Mark ``stage`` complete, bump version, record provenance, cascade staleness.
2278
+
2279
+ Three branches, in precedence order:
2280
+
2281
+ 1. ``commit_hash`` given — Commit 2 of the two-commit Git Commit Protocol.
2282
+ Sets ONLY ``commitHash``, leaving status/version/artifacts intact. Guarded
2283
+ on the stage already being ``complete``, so a typo'd ``--stage`` cannot
2284
+ write a lone ``{"commitHash": …}`` entry (which would violate
2285
+ ``stageEntry``'s ``required: ["status"]``) at exit 0.
2286
+ 2. ``resumable`` — the failed-Commit-1 revert (`references/shared-conventions.md`
2287
+ L245). Records ONLY ``status = "in-progress"`` plus the ``updatedAt``
2288
+ refresh: no completedAt, no version bump, no basedOnVersions/artifacts
2289
+ write, no commitHash reset, no cascade. The frozen contract is "leave state
2290
+ as in-progress so the stage can be resumed"; stamping a completion, bumping
2291
+ the version, or cascading staleness off a commit that never landed are all
2292
+ behavioral changes.
2293
+ 3. Otherwise — the completion write: status, completedAt, version,
2294
+ basedOnVersions, artifacts, ``commitHash = None`` (Commit 1) unless
2295
+ ``preserve_commit_hash``, then the downstream staleness cascade.
2296
+
2297
+ Branch 2 is gated on ``resumable``, NOT on ``status == "in-progress"``:
2298
+ forge-5-loop's PARTIAL completion also passes ``--status in-progress`` but is a
2299
+ real completion-with-artifacts, so it takes branch 3 and keeps its
2300
+ completedAt/version/basedOnVersions/artifacts. Only ``status`` differs between
2301
+ ``--status complete`` and a bare ``--status in-progress``. Conflating the two
2302
+ would silently discard the ``--based-on`` item 013 passes on that call.
2303
+
2304
+ Args:
2305
+ feature: Feature name.
2306
+ stage: The completing stage id.
2307
+ version: The stage's new version.
2308
+ based_on: Parsed ``{upstreamStage: version}`` provenance map.
2309
+ artifacts: Final canonical artifact path list for this stage.
2310
+ commit_hash: If given, record it as the stage's commitHash (Commit 2);
2311
+ else set commitHash to None (Commit 1).
2312
+ specs_dir: Specs directory.
2313
+ epic: Owning epic name, or None.
2314
+ status: Terminal status to record — "complete" (the default when the flag
2315
+ is absent) or "in-progress" for a partial forge-5-loop run. ``None``
2316
+ means "not passed".
2317
+ preserve_commit_hash: Skip the ``commitHash = None`` reset, for the Git
2318
+ Commit Protocol's "Nothing to commit" branch (L248).
2319
+ resumable: Failed-Commit-1 revert (L245). Record only the status; implies
2320
+ ``--status in-progress``.
2321
+
2322
+ Returns:
2323
+ The mutated state dict, plus a synthetic ``_cascadedStale`` key that is
2324
+ surfaced in the --json echo / printer but NEVER written to disk.
2325
+
2326
+ Raises:
2327
+ UsageError: Contradictory ``--resumable --status complete``, a
2328
+ ``--commit-hash`` follow-up against a stage that is not complete, an
2329
+ unknown feature directory, an unparseable state file, or a failed
2330
+ atomic write (→ exit 2).
2331
+ """
2332
+ if resumable and status == "complete":
2333
+ raise UsageError(
2334
+ "--resumable implies --status in-progress; do not pass --status complete"
2335
+ )
2336
+ state_path, state = _load_state_for_write(specs_dir, feature, epic)
2337
+ entry = _stage_entry(state, stage)
2338
+ cascaded: list[str] = []
2339
+ if commit_hash is not None:
2340
+ # Commit-2 follow-up: record the real hash, leave everything else intact.
2341
+ actual = entry.get("status")
2342
+ if actual != _DONE_STATUS:
2343
+ raise UsageError(
2344
+ f"--commit-hash requires {stage} to be complete (status: {actual!r}); "
2345
+ "run state-complete without --commit-hash first"
2346
+ )
2347
+ entry["commitHash"] = commit_hash
2348
+ elif resumable:
2349
+ # Failed-Commit-1 revert (L245): record ONLY the status. See the note above
2350
+ # on why this is gated on --resumable rather than on the status value.
2351
+ entry["status"] = "in-progress"
2352
+ else:
2353
+ entry["status"] = status or _DONE_STATUS # "complete" | "in-progress" (partial)
2354
+ entry["completedAt"] = _now_iso()
2355
+ entry["version"] = version
2356
+ entry["basedOnVersions"] = based_on
2357
+ entry["artifacts"] = artifacts
2358
+ if not preserve_commit_hash:
2359
+ entry["commitHash"] = None # Commit 1 of the Commit Protocol
2360
+ cascaded = _cascade_staleness(state, stage, version)
2361
+ result = _commit_state(state_path, state)
2362
+ # Surface the cascade result for the caller without persisting it in state:
2363
+ # _commit_state already wrote the real dict, and `echo` is a copy.
2364
+ echo = dict(result)
2365
+ echo["_cascadedStale"] = cascaded
2366
+ return echo
2367
+
2368
+
2369
+ def cmd_state_branch(feature: str, branch: str, specs_dir: Path, epic: str | None) -> dict:
2370
+ """Set the top-level ``branch`` field.
2371
+
2372
+ Records the branch resolved by Branch Setup / Branch Reconciliation. The verb
2373
+ only writes the field; the interactive prompts and the visible one-line
2374
+ reconciliation note stay unchanged skill prose.
2375
+
2376
+ Branch Setup fires before the Entry Stamp, so this verb can legitimately be
2377
+ the FIRST thing to touch a feature's state file — `_load_state_for_write`'s
2378
+ field seeding is what keeps that first write schema-valid.
2379
+
2380
+ Args:
2381
+ feature: Feature name.
2382
+ branch: The branch name to record.
2383
+ specs_dir: Specs directory.
2384
+ epic: Owning epic name, or None.
2385
+
2386
+ Returns:
2387
+ The mutated state dict (for the --json echo).
2388
+
2389
+ Raises:
2390
+ UsageError: Unknown feature directory, unparseable state file, or a
2391
+ failed atomic write (→ exit 2).
2392
+ """
2393
+ state_path, state = _load_state_for_write(specs_dir, feature, epic)
2394
+ state["branch"] = branch
2395
+ return _commit_state(state_path, state)
2396
+
2397
+
2398
+ def cmd_state_note(feature: str, note: str, specs_dir: Path, epic: str | None) -> dict:
2399
+ """Set the top-level ``notes`` field to ``note``.
2400
+
2401
+ Overwrites any existing note (the field is a single free-text string, not an
2402
+ append log — matching the schema's ``notes: string``). The skill's "offer a
2403
+ note — don't force one" statement is unchanged; this verb runs only when the
2404
+ user volunteered text.
2405
+
2406
+ Args:
2407
+ feature: Feature name.
2408
+ note: The note text.
2409
+ specs_dir: Specs directory.
2410
+ epic: Owning epic name, or None.
2411
+
2412
+ Returns:
2413
+ The mutated state dict (for the --json echo).
2414
+
2415
+ Raises:
2416
+ UsageError: Unknown feature directory, unparseable state file, or a
2417
+ failed atomic write (→ exit 2).
2418
+ """
2419
+ state_path, state = _load_state_for_write(specs_dir, feature, epic)
2420
+ state["notes"] = note
2421
+ return _commit_state(state_path, state)
2422
+
2423
+
2424
+ def cmd_state_decision(
2425
+ feature: str,
2426
+ question: str,
2427
+ raised_by: str,
2428
+ rationale: str | None,
2429
+ target_stage: str | None,
2430
+ specs_dir: Path,
2431
+ epic: str | None,
2432
+ ) -> dict:
2433
+ """Append an open deferred-decision item to ``deferredDecisions[]``.
2434
+
2435
+ Emits exactly the schema keys — the array item sets
2436
+ ``additionalProperties: false``, so a convenience field is a hard validation
2437
+ failure: required ``question``/``raisedBy``/``raisedAt``/``status``, plus
2438
+ ``rationale``/``targetStage`` only when provided. ``status`` is always
2439
+ ``"open"``; the recorder never resolves a decision (the target stage flips it
2440
+ to ``"addressed"``).
2441
+
2442
+ Args:
2443
+ feature: Feature name.
2444
+ question: The deferred decision, phrased for the target stage.
2445
+ raised_by: The deferring stage id.
2446
+ rationale: Optional reason for deferring.
2447
+ target_stage: Optional resolving stage id.
2448
+ specs_dir: Specs directory.
2449
+ epic: Owning epic name, or None.
2450
+
2451
+ Returns:
2452
+ The mutated state dict (for the --json echo).
2453
+
2454
+ Raises:
2455
+ UsageError: Unknown feature directory, unparseable state file, or a
2456
+ failed atomic write (→ exit 2).
2457
+ """
2458
+ state_path, state = _load_state_for_write(specs_dir, feature, epic)
2459
+ item: dict = {
2460
+ "question": question,
2461
+ "raisedBy": raised_by,
2462
+ "raisedAt": _now_iso(),
2463
+ "status": "open",
2464
+ }
2465
+ if rationale is not None:
2466
+ item["rationale"] = rationale
2467
+ if target_stage is not None:
2468
+ item["targetStage"] = target_stage
2469
+ state.setdefault("deferredDecisions", []).append(item)
2470
+ return _commit_state(state_path, state)
2471
+
2472
+
2473
+ def _parse_bool(raw: str, flag: str) -> bool:
2474
+ """Parse an explicit boolean CLI value; fail closed on anything else.
2475
+
2476
+ Args:
2477
+ raw: The raw flag value (e.g. from ``--blocks-current``).
2478
+ flag: The flag name, for the error message.
2479
+
2480
+ Returns:
2481
+ ``True`` for ``"true"``, ``False`` for ``"false"`` (case-insensitive,
2482
+ surrounding whitespace ignored).
2483
+
2484
+ Raises:
2485
+ UsageError: For any other value (→ exit 2), so a typo like ``"yes"`` is
2486
+ rejected rather than silently misrouting the stage exit.
2487
+ """
2488
+ normalized = raw.strip().lower()
2489
+ if normalized == "true":
2490
+ return True
2491
+ if normalized == "false":
2492
+ return False
2493
+ raise UsageError(f"{flag} expects true|false, got: {raw!r}")
2494
+
2495
+
2496
+ def cmd_state_ecr(
2497
+ feature: str,
2498
+ kind: str,
2499
+ target: str,
2500
+ rationale: str,
2501
+ raised_by: str,
2502
+ blocks_current: bool,
2503
+ specs_dir: Path,
2504
+ epic: str | None,
2505
+ ) -> dict:
2506
+ """Append an open epic-change-request item to ``epicChangeRequests[]``.
2507
+
2508
+ Emits exactly the schema keys — the array item sets
2509
+ ``additionalProperties: false``, so a convenience field is a hard validation
2510
+ failure. All six payload fields are required, and ``status`` is always
2511
+ ``"open"`` (only forge-0-epic edit mode flips it). ``blocksCurrent`` drives
2512
+ stage-exit routing, so it is a strictly-parsed boolean.
2513
+
2514
+ Args:
2515
+ feature: Feature name.
2516
+ kind: One of add-feature|redep|move-boundary|split.
2517
+ target: The sibling feature to add, or the affected feature/boundary.
2518
+ rationale: Why the epic must change.
2519
+ raised_by: forge-1-prd or forge-2-tech.
2520
+ blocks_current: True → pause-now; False → finish-then-edit.
2521
+ specs_dir: Specs directory.
2522
+ epic: Owning epic name, or None.
2523
+
2524
+ Returns:
2525
+ The mutated state dict (for the --json echo).
2526
+
2527
+ Raises:
2528
+ UsageError: Unknown feature directory, unparseable state file, or a
2529
+ failed atomic write (→ exit 2).
2530
+ """
2531
+ state_path, state = _load_state_for_write(specs_dir, feature, epic)
2532
+ item = {
2533
+ "kind": kind,
2534
+ "target": target,
2535
+ "rationale": rationale,
2536
+ "blocksCurrent": blocks_current,
2537
+ "raisedBy": raised_by,
2538
+ "raisedAt": _now_iso(),
2539
+ "status": "open",
2540
+ }
2541
+ state.setdefault("epicChangeRequests", []).append(item)
2542
+ return _commit_state(state_path, state)
2543
+
2544
+
2545
+ def _print_state_enter(state: dict) -> None:
2546
+ """Print the one-line human summary for `state-enter`."""
2547
+ print(f"entered {state['currentStage']} (in-progress) for {state['feature']}")
2548
+
2549
+
2550
+ def _print_state_artifact(state: dict, stage: str, paths: list[str]) -> None:
2551
+ """Print the one-line human summary for `state-artifact`."""
2552
+ total = len(state.get("stages", {}).get(stage, {}).get("artifacts", []))
2553
+ print(f"tracked {stage} artifact(s): {', '.join(paths)} ({total} total)")
2554
+
2555
+
2556
+ def _print_state_complete(
2557
+ state: dict, stage: str, commit_hash: str | None, resumable: bool
2558
+ ) -> None:
2559
+ """Print the one-line human summary for `state-complete` (one per branch)."""
2560
+ if commit_hash is not None:
2561
+ print(f"recorded {stage} commitHash: {commit_hash}")
2562
+ return
2563
+ if resumable:
2564
+ print(f"left {stage} in-progress (resumable — no completion recorded)")
2565
+ return
2566
+ entry = state.get("stages", {}).get(stage, {})
2567
+ label = (
2568
+ "completed"
2569
+ if entry.get("status") == _DONE_STATUS
2570
+ else f"partially completed ({entry.get('status')})"
2571
+ )
2572
+ recorded = entry.get("commitHash")
2573
+ cascaded = state.get("_cascadedStale") or []
2574
+ suffix = f"; marked stale: {', '.join(cascaded)}" if cascaded else ""
2575
+ print(
2576
+ f"{label} {stage} v{entry.get('version')} "
2577
+ f"(commitHash: {'null' if recorded is None else recorded}){suffix}"
2578
+ )
2579
+
2580
+
2581
+ def _print_state_branch(state: dict) -> None:
2582
+ """Print the one-line human summary for `state-branch`."""
2583
+ print(f"recorded branch for {state['feature']}: {state['branch']}")
2584
+
2585
+
2586
+ def _print_state_note(state: dict) -> None:
2587
+ """Print the one-line human summary for `state-note`."""
2588
+ print(f"note set for {state['feature']} ({len(state['notes'])} chars)")
2589
+
2590
+
2591
+ def _print_state_decision(state: dict) -> None:
2592
+ """Print the one-line human summary for `state-decision` (the item appended)."""
2593
+ item = state["deferredDecisions"][-1]
2594
+ target = item.get("targetStage")
2595
+ routing = f"{item['raisedBy']} → {target}" if target else f"{item['raisedBy']}, no target stage"
2596
+ print(f"deferred decision recorded (raisedBy {routing})")
2597
+
2598
+
2599
+ def _print_state_ecr(state: dict) -> None:
2600
+ """Print the one-line human summary for `state-ecr` (the item appended)."""
2601
+ item = state["epicChangeRequests"][-1]
2602
+ blocks = "true" if item["blocksCurrent"] else "false"
2603
+ print(
2604
+ f"epic change request recorded ({item['kind']} → {item['target']}, "
2605
+ f"blocksCurrent={blocks})"
2606
+ )
2607
+
2608
+
2609
+ # --------------------------------------------------------------------------- #
2610
+ # CLI dispatch
2611
+ # --------------------------------------------------------------------------- #
2612
+
2613
+
2614
+ def _emit(payload: dict, json_output: bool, printer: Callable[[dict], None]) -> None:
2615
+ """Emit a state-verb result: the full JSON echo on --json, else the printer.
2616
+
2617
+ Args:
2618
+ payload: The verb's resulting state dict.
2619
+ json_output: The ``--json`` flag.
2620
+ printer: The verb's one-line human-readable printer.
2621
+ """
2622
+ if json_output:
2623
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
2624
+ else:
2625
+ printer(payload)
2626
+
2627
+
2628
+ def _print_rank_table(rows: list[FeatureRow], counts: dict[str, int]) -> None:
2629
+ """Print a human-readable recency-ranked feature list."""
2630
+ print(
2631
+ f"Active: {counts['active']} "
2632
+ f"(paused: {counts['paused']}, abandoned: {counts['abandoned']})"
2633
+ )
2634
+ if not rows:
2635
+ print(" (no active feature pipelines)")
2636
+ return
2637
+ for idx, row in enumerate(rows):
2638
+ marker = "→" if idx == 0 else " "
2639
+ label = row["name"] + (f" [{row['epic']}]" if row["epic"] else "")
2640
+ nxt = row["nextCommand"] or "complete"
2641
+ print(f" {marker} {label}: {row['currentStage']} — next: {nxt}")
2642
+ if row["verifyPending"]:
2643
+ print(f" (verify available: {row['verifyCommand']})")
2644
+
2645
+
2646
+ def _print_context(usage: dict) -> None:
2647
+ """Print a one-line human-readable context-usage summary."""
2648
+ if not usage.get("available"):
2649
+ print(f"context usage: unavailable ({usage.get('reason', 'unknown')})")
2650
+ return
2651
+ pct = round(usage["pct"] * 100, 1)
2652
+ flag = " — over threshold, clean session recommended" if usage["overThreshold"] else ""
2653
+ print(
2654
+ f"context: {usage['tokens']:,} / {usage['windowTokens']:,} tokens "
2655
+ f"(~{pct}%){flag}"
2656
+ )
2657
+
2658
+
2659
+ def main() -> int:
2660
+ parser = argparse.ArgumentParser(prog="forge-session.py", description=__doc__)
2661
+ sub = parser.add_subparsers(dest="cmd", required=True)
2662
+
2663
+ p_rank = sub.add_parser("rank-features", help="Rank active features by recency")
2664
+ p_rank.add_argument("--specs-dir", default="./specs", help="Specs directory")
2665
+ p_rank.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
2666
+ p_rank.add_argument("--json", action="store_true", dest="json_output")
2667
+
2668
+ p_ctx = sub.add_parser("context-usage", help="Report live context-window usage")
2669
+ p_ctx.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
2670
+ p_ctx.add_argument("--window", type=int, default=None, help="Override context window size")
2671
+ p_ctx.add_argument("--threshold", type=float, default=None, help="Override warn fraction (0-1)")
2672
+ p_ctx.add_argument("--json", action="store_true", dest="json_output")
2673
+
2674
+ p_doc = sub.add_parser("doctor", help="Capture pipeline ground truth for debugging")
2675
+ p_doc.add_argument("--specs-dir", default="./specs", help="Specs directory")
2676
+ p_doc.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
2677
+ p_doc.add_argument("--json", action="store_true", dest="json_output")
2678
+
2679
+ p_disc = sub.add_parser(
2680
+ "discover-feature", help="Find a feature's pipeline state across all branches"
2681
+ )
2682
+ p_disc.add_argument("name", nargs="?", default=None,
2683
+ help="Feature name to discover (omit with --all)")
2684
+ p_disc.add_argument("--all", action="store_true", dest="discover_all",
2685
+ help="Discover every feature across all branches (empty-dashboard)")
2686
+ p_disc.add_argument("--specs-dir", default="./specs", help="Specs directory")
2687
+ p_disc.add_argument("--json", action="store_true", dest="json_output")
2688
+
2689
+ p_recon = sub.add_parser(
2690
+ "reconcile-branch",
2691
+ help="Decide whether a feature's recorded branch should adopt the current branch",
2692
+ )
2693
+ p_recon.add_argument("--feature", required=True, help="Feature name")
2694
+ p_recon.add_argument("--specs-dir", default="./specs", help="Specs directory")
2695
+ p_recon.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
2696
+ p_recon.add_argument("--epic", default=None, help="Epic name for a nested member")
2697
+ p_recon.add_argument("--json", action="store_true", dest="json_output")
2698
+
2699
+ p_base = sub.add_parser(
2700
+ "check-epic-base",
2701
+ help="Verify HEAD contains the epic manifest for a resolved nested member",
2702
+ )
2703
+ p_base.add_argument("--feature", required=True, help="Feature name")
2704
+ p_base.add_argument("--specs-dir", default="./specs", help="Specs directory")
2705
+ p_base.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
2706
+ p_base.add_argument("--epic", default=None, help="Epic name for a nested member")
2707
+ p_base.add_argument("--json", action="store_true", dest="json_output")
2708
+
2709
+ p_exit = sub.add_parser(
2710
+ "stage-exit", help="Emit the Scripted Stage Exit directives + NEXT-STEPS block"
2711
+ )
2712
+ p_exit.add_argument("--feature", required=True,
2713
+ help="Feature name (the epic name for forge-0-epic)")
2714
+ p_exit.add_argument("--stage", required=True, choices=EXIT_STAGES,
2715
+ help="The just-completed authoring stage")
2716
+ p_exit.add_argument("--specs-dir", default="./specs", help="Specs directory")
2717
+ p_exit.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
2718
+ p_exit.add_argument("--epic", default=None, help="Epic name for a nested member")
2719
+ p_exit.add_argument("--next-feature", default=None, dest="next_feature",
2720
+ help="First actionable feature (epic handoff next-command arg)")
2721
+ p_exit.add_argument("--host", default="claude", choices=("claude", "generic", "pi"),
2722
+ help="Host wording for the NEXT-STEPS block")
2723
+ p_exit.add_argument("--json", action="store_true", dest="json_output")
2724
+
2725
+ p_eff = sub.add_parser(
2726
+ "effective-config",
2727
+ help="Resolve the loopRunner config from schema defaults + user overrides",
2728
+ )
2729
+ p_eff.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
2730
+ p_eff.add_argument(
2731
+ "--schema", default=None,
2732
+ help="forge-config-schema.json path (default: bundled references/ copy)",
2733
+ )
2734
+ p_eff.add_argument("--json", action="store_true", dest="json_output")
2735
+
2736
+ p_enter = sub.add_parser(
2737
+ "state-enter", help="Stamp a stage as in-progress (Entry Stamp)"
2738
+ )
2739
+ p_enter.add_argument("--feature", required=True, help="Feature name")
2740
+ p_enter.add_argument("--stage", required=True, choices=STATE_VERB_STAGES,
2741
+ help="The stage being entered")
2742
+ p_enter.add_argument("--specs-dir", default="./specs", help="Specs directory")
2743
+ p_enter.add_argument("--epic", default=None, help="Epic name for a nested member")
2744
+ p_enter.add_argument("--json", action="store_true", dest="json_output")
2745
+
2746
+ p_art = sub.add_parser(
2747
+ "state-artifact", help="Append artifact paths to a stage (de-duplicating)"
2748
+ )
2749
+ p_art.add_argument("--feature", required=True, help="Feature name")
2750
+ p_art.add_argument("--stage", required=True, choices=STATE_VERB_STAGES,
2751
+ help="The stage producing the artifact")
2752
+ p_art.add_argument("--path", required=True, action="append", dest="paths",
2753
+ metavar="PATH",
2754
+ help="Artifact path relative to the feature dir (repeatable)")
2755
+ p_art.add_argument("--specs-dir", default="./specs", help="Specs directory")
2756
+ p_art.add_argument("--epic", default=None, help="Epic name for a nested member")
2757
+ p_art.add_argument("--json", action="store_true", dest="json_output")
2758
+
2759
+ p_comp = sub.add_parser(
2760
+ "state-complete", help="Mark a stage complete; bump version; cascade staleness"
2761
+ )
2762
+ p_comp.add_argument("--feature", required=True, help="Feature name")
2763
+ p_comp.add_argument("--stage", required=True, choices=STATE_VERB_STAGES,
2764
+ help="The stage being completed")
2765
+ p_comp.add_argument("--version", type=int, required=True,
2766
+ help="This stage's new version (integer)")
2767
+ p_comp.add_argument("--based-on", action="append", default=[], dest="based_on",
2768
+ metavar="STAGE=N",
2769
+ help="Upstream version this artifact was built on (repeatable)")
2770
+ p_comp.add_argument("--artifact", action="append", default=[], dest="artifacts",
2771
+ metavar="PATH",
2772
+ help="Artifact path produced by this stage (repeatable)")
2773
+ p_comp.add_argument("--commit-hash", default=None, dest="commit_hash",
2774
+ help="Commit 2 follow-up: record the artifact commit's hash")
2775
+ p_comp.add_argument("--status", default=None,
2776
+ choices=("complete", "in-progress"),
2777
+ help="Terminal status to record (default: complete). "
2778
+ "Use in-progress for a partial forge-5-loop run -- the "
2779
+ "stage still records completedAt/version/basedOnVersions/"
2780
+ "artifacts; only the status differs.")
2781
+ p_comp.add_argument("--resumable", action="store_true",
2782
+ help="Failed-Commit-1 revert (L245): record ONLY status="
2783
+ "in-progress, leaving completedAt/version/basedOnVersions/"
2784
+ "artifacts/commitHash untouched and firing no cascade. "
2785
+ "Implies --status in-progress.")
2786
+ p_comp.add_argument("--preserve-commit-hash", action="store_true",
2787
+ dest="preserve_commit_hash",
2788
+ help="Do not reset commitHash to null on completion "
2789
+ "(the Git Commit Protocol's 'Nothing to commit' branch)")
2790
+ p_comp.add_argument("--specs-dir", default="./specs", help="Specs directory")
2791
+ p_comp.add_argument("--epic", default=None, help="Epic name for a nested member")
2792
+ p_comp.add_argument("--json", action="store_true", dest="json_output")
2793
+
2794
+ p_br = sub.add_parser("state-branch", help="Set the top-level branch field")
2795
+ p_br.add_argument("--feature", required=True, help="Feature name")
2796
+ p_br.add_argument("--branch", required=True, help="Branch name to record")
2797
+ p_br.add_argument("--specs-dir", default="./specs", help="Specs directory")
2798
+ p_br.add_argument("--epic", default=None, help="Epic name for a nested member")
2799
+ p_br.add_argument("--json", action="store_true", dest="json_output")
2800
+
2801
+ p_note = sub.add_parser("state-note", help="Set the top-level notes field")
2802
+ p_note.add_argument("--feature", required=True, help="Feature name")
2803
+ p_note.add_argument("--note", required=True, help="Note text to persist")
2804
+ p_note.add_argument("--specs-dir", default="./specs", help="Specs directory")
2805
+ p_note.add_argument("--epic", default=None, help="Epic name for a nested member")
2806
+ p_note.add_argument("--json", action="store_true", dest="json_output")
2807
+
2808
+ p_dec = sub.add_parser(
2809
+ "state-decision", help="Append a deferred decision (status: open)"
2810
+ )
2811
+ p_dec.add_argument("--feature", required=True, help="Feature name")
2812
+ p_dec.add_argument("--question", required=True,
2813
+ help="The deferred decision, phrased for the target stage")
2814
+ p_dec.add_argument("--raised-by", required=True, dest="raised_by",
2815
+ choices=DECISION_RAISED_BY,
2816
+ help="The stage deferring the decision")
2817
+ p_dec.add_argument("--rationale", default=None, help="Why it is deferred (optional)")
2818
+ p_dec.add_argument("--target-stage", default=None, dest="target_stage",
2819
+ choices=DECISION_TARGET_STAGES,
2820
+ help="The stage that should resolve it (optional)")
2821
+ p_dec.add_argument("--specs-dir", default="./specs", help="Specs directory")
2822
+ p_dec.add_argument("--epic", default=None, help="Epic name for a nested member")
2823
+ p_dec.add_argument("--json", action="store_true", dest="json_output")
2824
+
2825
+ p_ecr = sub.add_parser(
2826
+ "state-ecr", help="Append an epic change request (status: open)"
2827
+ )
2828
+ p_ecr.add_argument("--feature", required=True, help="Feature name")
2829
+ p_ecr.add_argument("--kind", required=True, choices=ECR_KINDS,
2830
+ help="The decomposition change kind")
2831
+ p_ecr.add_argument("--target", required=True,
2832
+ help="The sibling feature to add, or the feature/boundary affected")
2833
+ p_ecr.add_argument("--rationale", required=True, help="Why the epic must change")
2834
+ p_ecr.add_argument("--raised-by", required=True, dest="raised_by",
2835
+ choices=ECR_RAISED_BY,
2836
+ help="The stage that detected the epic-level concern")
2837
+ p_ecr.add_argument("--blocks-current", required=True, dest="blocks_current",
2838
+ metavar="true|false",
2839
+ help="true → pause-now (reconcile before proceeding); "
2840
+ "false → finish-then-edit")
2841
+ p_ecr.add_argument("--specs-dir", default="./specs", help="Specs directory")
2842
+ p_ecr.add_argument("--epic", default=None, help="Epic name for a nested member")
2843
+ p_ecr.add_argument("--json", action="store_true", dest="json_output")
2844
+
2845
+ args = parser.parse_args()
2846
+
2847
+ try:
2848
+ if args.cmd == "rank-features":
2849
+ specs_dir = Path(args.specs_dir)
2850
+ config = _load_config(Path(args.config))
2851
+ rows = build_rows(specs_dir, config)
2852
+ counts = _counts(specs_dir)
2853
+ invalid_keys = invalid_auto_verify_keys(config)
2854
+ if args.json_output:
2855
+ payload = {"active": rows, "counts": counts}
2856
+ if invalid_keys:
2857
+ payload["invalidAutoVerifyKeys"] = invalid_keys
2858
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
2859
+ else:
2860
+ _print_rank_table(rows, counts)
2861
+ if invalid_keys:
2862
+ print(
2863
+ " ! invalid autoVerifyStages keys (ignored): "
2864
+ + ", ".join(invalid_keys)
2865
+ )
2866
+ return 0
2867
+
2868
+ if args.cmd == "context-usage":
2869
+ usage = context_usage(Path(args.config), args.window, args.threshold)
2870
+ if args.json_output:
2871
+ print(json.dumps(usage, indent=2, ensure_ascii=False))
2872
+ else:
2873
+ _print_context(usage)
2874
+ return 0
2875
+
2876
+ if args.cmd == "doctor":
2877
+ report = doctor_report(Path(args.specs_dir), Path(args.config))
2878
+ if args.json_output:
2879
+ print(json.dumps(report, indent=2, ensure_ascii=False))
2880
+ else:
2881
+ _print_doctor(report)
2882
+ return 0
2883
+
2884
+ if args.cmd == "discover-feature":
2885
+ if args.discover_all:
2886
+ payload = discover_all(args.specs_dir)
2887
+ printer = _print_discover_all
2888
+ elif args.name:
2889
+ payload = discover_feature(args.name, args.specs_dir)
2890
+ printer = _print_discover
2891
+ else:
2892
+ parser.error("discover-feature requires a NAME or --all")
2893
+ if args.json_output:
2894
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
2895
+ else:
2896
+ printer(payload)
2897
+ return 0
2898
+
2899
+ if args.cmd == "reconcile-branch":
2900
+ payload = reconcile_branch(
2901
+ args.feature, Path(args.specs_dir), Path(args.config), args.epic
2902
+ )
2903
+ if args.json_output:
2904
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
2905
+ else:
2906
+ _print_reconcile(payload)
2907
+ return 0
2908
+
2909
+ if args.cmd == "check-epic-base":
2910
+ payload = check_epic_base(
2911
+ args.feature, Path(args.specs_dir), Path(args.config), args.epic
2912
+ )
2913
+ if args.json_output:
2914
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
2915
+ else:
2916
+ _print_check_epic_base(payload)
2917
+ return 0
2918
+
2919
+ if args.cmd == "stage-exit":
2920
+ payload = stage_exit(
2921
+ args.feature,
2922
+ args.stage,
2923
+ Path(args.specs_dir),
2924
+ Path(args.config),
2925
+ args.epic,
2926
+ args.host,
2927
+ args.next_feature,
2928
+ )
2929
+ if args.json_output:
2930
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
2931
+ else:
2932
+ _print_stage_exit(payload)
2933
+ return 0
2934
+
2935
+ if args.cmd == "effective-config":
2936
+ schema_path = Path(args.schema) if args.schema else _default_schema_path()
2937
+ resolved = resolve_loop_runner(Path(args.config), schema_path)
2938
+ if args.json_output:
2939
+ print(json.dumps(resolved, indent=2, ensure_ascii=False))
2940
+ else:
2941
+ _print_effective_config(resolved)
2942
+ return 0
2943
+
2944
+ if args.cmd == "state-enter":
2945
+ payload = cmd_state_enter(
2946
+ args.feature, args.stage, Path(args.specs_dir), args.epic
2947
+ )
2948
+ _emit(payload, args.json_output, _print_state_enter)
2949
+ return 0
2950
+
2951
+ if args.cmd == "state-artifact":
2952
+ payload = cmd_state_artifact(
2953
+ args.feature, args.stage, args.paths, Path(args.specs_dir), args.epic
2954
+ )
2955
+ _emit(
2956
+ payload,
2957
+ args.json_output,
2958
+ lambda state: _print_state_artifact(state, args.stage, args.paths),
2959
+ )
2960
+ return 0
2961
+
2962
+ if args.cmd == "state-complete":
2963
+ payload = cmd_state_complete(
2964
+ args.feature,
2965
+ args.stage,
2966
+ args.version,
2967
+ _parse_based_on(args.based_on),
2968
+ args.artifacts,
2969
+ args.commit_hash,
2970
+ Path(args.specs_dir),
2971
+ args.epic,
2972
+ status=args.status,
2973
+ preserve_commit_hash=args.preserve_commit_hash,
2974
+ resumable=args.resumable,
2975
+ )
2976
+ _emit(
2977
+ payload,
2978
+ args.json_output,
2979
+ lambda state: _print_state_complete(
2980
+ state, args.stage, args.commit_hash, args.resumable
2981
+ ),
2982
+ )
2983
+ return 0
2984
+
2985
+ if args.cmd == "state-branch":
2986
+ payload = cmd_state_branch(
2987
+ args.feature, args.branch, Path(args.specs_dir), args.epic
2988
+ )
2989
+ _emit(payload, args.json_output, _print_state_branch)
2990
+ return 0
2991
+
2992
+ if args.cmd == "state-note":
2993
+ payload = cmd_state_note(
2994
+ args.feature, args.note, Path(args.specs_dir), args.epic
2995
+ )
2996
+ _emit(payload, args.json_output, _print_state_note)
2997
+ return 0
2998
+
2999
+ if args.cmd == "state-decision":
3000
+ payload = cmd_state_decision(
3001
+ args.feature,
3002
+ args.question,
3003
+ args.raised_by,
3004
+ args.rationale,
3005
+ args.target_stage,
3006
+ Path(args.specs_dir),
3007
+ args.epic,
3008
+ )
3009
+ _emit(payload, args.json_output, _print_state_decision)
3010
+ return 0
3011
+
3012
+ if args.cmd == "state-ecr":
3013
+ payload = cmd_state_ecr(
3014
+ args.feature,
3015
+ args.kind,
3016
+ args.target,
3017
+ args.rationale,
3018
+ args.raised_by,
3019
+ _parse_bool(args.blocks_current, "--blocks-current"),
3020
+ Path(args.specs_dir),
3021
+ args.epic,
3022
+ )
3023
+ _emit(payload, args.json_output, _print_state_ecr)
3024
+ return 0
3025
+
3026
+ raise UsageError(f"unknown command: {args.cmd}")
3027
+ except UsageError as exc:
3028
+ print(f"Error: {exc}", file=sys.stderr)
3029
+ return 2
3030
+ except OSError as exc:
3031
+ print(f"Error: {exc}", file=sys.stderr)
3032
+ return 2
3033
+
3034
+
3035
+ if __name__ == "__main__":
3036
+ sys.exit(main())