@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,1737 @@
1
+ #!/usr/bin/env python3
2
+ """Read, validate, and atomically mutate an epic manifest.
3
+
4
+ The deterministic core for Epic Orchestration: name->directory resolution,
5
+ acyclicity and schema validation, global name-uniqueness, path containment,
6
+ live per-feature status derivation, and atomic manifest mutation.
7
+
8
+ Usage:
9
+ python3 epic-manifest.py resolve <name> [--specs-dir DIR]
10
+ python3 epic-manifest.py validate <epic> [--specs-dir DIR] [--json]
11
+ python3 epic-manifest.py check-name <name> [--specs-dir DIR]
12
+ python3 epic-manifest.py render-status <epic> [--specs-dir DIR] [--json]
13
+ python3 epic-manifest.py add-feature <epic> <name> --charter TEXT \
14
+ [--depends-on A,B] [--specs-dir DIR] [--json]
15
+ python3 epic-manifest.py remove-feature <epic> <name> [--specs-dir DIR] [--json]
16
+ python3 epic-manifest.py reorder <epic> --order A,B,C [--specs-dir DIR] [--json]
17
+ python3 epic-manifest.py set-dep <epic> <name> --depends-on A,B [--specs-dir DIR] [--json]
18
+ python3 epic-manifest.py set-status <epic> --status STATE [--specs-dir DIR] [--json]
19
+
20
+ Exit codes:
21
+ 0 = ok / valid / unique / resolved
22
+ 1 = findings / validation failure / duplicate / ambiguous / not-found
23
+ 2 = usage error or I/O error (missing file, unreadable, unsafe path)
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import shutil
31
+ import sys
32
+ import tempfile
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+ from typing import Final, Literal, TypedDict
36
+
37
+
38
+ # --------------------------------------------------------------------------- #
39
+ # Constants (00-core-definitions.md §6)
40
+ # --------------------------------------------------------------------------- #
41
+
42
+ #: A safe feature/epic name: one kebab-case token (00 §6).
43
+ SAFE_NAME_RE: Final = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
44
+ #: A directory is "feature-shaped" iff it directly contains this file.
45
+ PIPELINE_STATE_FILENAME: Final = ".pipeline-state.json"
46
+ #: Canonical filenames sited at the epic subtree root.
47
+ MANIFEST_FILENAME: Final = "epic-manifest.json"
48
+ NARRATIVE_FILENAME: Final = "EPIC.md"
49
+
50
+ #: The authoritative forge-verify status vocabulary. SOURCE OF TRUTH:
51
+ #: references/pipeline-state-schema.json (definitions.verifyEntry.properties.status.enum).
52
+ #: A member state carrying a forge-verify-*.status outside this set is treated as
53
+ #: incomplete-for-orchestration but SURFACED (never guessed silently) by render_status
54
+ #: — a single typo'd status otherwise poisons the epic rollup + dependency gates (#148).
55
+ #: NOTE: forge-session.py keeps a byte-identical copy of this constant — flat, self-
56
+ #: contained scripts have no shared import module (each is copied verbatim into adapters).
57
+ KNOWN_VERIFY_STATUSES: Final = frozenset(
58
+ {"pending", "passed", "findings-reported", "findings-applied", "skipped"}
59
+ )
60
+ #: The subset of KNOWN_VERIFY_STATUSES that makes a member's forge-verify-impl count as
61
+ #: complete-for-orchestration (00 §7). A STRICT subset — 'findings-reported' (unfixed),
62
+ #: 'skipped', and 'pending' do NOT unblock dependents. Not collapsible into the set above.
63
+ _VERIFY_ORCH_COMPLETE: Final = frozenset({"passed", "findings-applied"})
64
+
65
+ #: The six production stages in pipeline order — mirrors ``PRODUCTION_STAGES`` in
66
+ #: forge-session.py. Used ONLY to DERIVE "what runs next" from ``stages[].status``
67
+ #: (see ``_next_production_stage``), never to read it out of ``currentStage``:
68
+ #: the schema defines ``currentStage`` as "where the pipeline IS" (the most
69
+ #: recently *started* stage), which is one stage behind the next un-run stage for
70
+ #: the whole window between a stage completing and its successor being entered.
71
+ _PRODUCTION_STAGES: Final = (
72
+ "forge-1-prd",
73
+ "forge-2-tech",
74
+ "forge-3-specs",
75
+ "forge-4-backlog",
76
+ "forge-5-loop",
77
+ "forge-6-docs",
78
+ )
79
+
80
+
81
+ # --------------------------------------------------------------------------- #
82
+ # Type Definitions (00-core-definitions.md §4, §5; 02 §8.4)
83
+ # --------------------------------------------------------------------------- #
84
+
85
+ FindingCode = Literal[
86
+ "corrupt-json", # manifest is not parseable JSON (REQ-ROBUST-02)
87
+ "schema", # manifest violates epic-manifest-schema.json
88
+ "duplicate-name", # a feature/epic name occurs more than once in the tree (REQ-DIR-04)
89
+ "dangling-ref", # dependsOn / consumes.from references an unknown feature (REQ-ROBUST-02)
90
+ "cycle", # the dependsOn graph contains a cycle (REQ-EPIC-05)
91
+ "unsafe-name", # a name contains a path separator, "..", or is absolute (REQ-SEC-02)
92
+ "not-found", # a name resolves to zero feature-shaped directories
93
+ "ambiguous", # a name resolves to more than one feature-shaped directory (REQ-DIR-04)
94
+ "cached-status", # a Feature object illegally carries a status field (REQ-STATE-02)
95
+ ]
96
+
97
+
98
+ class Finding(TypedDict):
99
+ """A single, actionable validation or resolution failure.
100
+
101
+ Attributes:
102
+ code: Machine-readable category (see FindingCode).
103
+ message: Human-readable, actionable description. Includes offending
104
+ identifiers and, where relevant, the conflicting paths.
105
+ feature: The feature name the finding pertains to, or None for
106
+ manifest- or epic-level findings.
107
+ """
108
+
109
+ code: FindingCode
110
+ message: str
111
+ feature: str | None
112
+
113
+
114
+ DerivedStatus = Literal[
115
+ "not-started", # no .pipeline-state.json, or all stages pending
116
+ "in-progress", # at least one stage started, loop not complete-for-orchestration
117
+ "complete", # complete-for-orchestration per 00 §7
118
+ ]
119
+
120
+
121
+ class FeatureStatus(TypedDict):
122
+ """Live per-feature status derived from its own pipeline state (00 §5).
123
+
124
+ Attributes:
125
+ name: Feature name.
126
+ stage: The feature's current pipeline stage (its currentStage), or
127
+ "forge-0-epic" if the member directory exists but no stage has run.
128
+ status: Coarse derived status (see DerivedStatus). Reuses existing
129
+ navigator status semantics for display.
130
+ blocked: True if any entry in unmetDeps is non-empty.
131
+ unmetDeps: Names of this feature's direct dependencies that are not yet
132
+ complete-for-orchestration (00 §7). Empty when actionable or complete.
133
+ openEpicChangeRequests: Count of this member's ``epicChangeRequests``
134
+ entries with ``status == "open"`` — epic-level change requests raised
135
+ by a member stage that forge-0-epic edit mode has not yet reconciled.
136
+ 0 for standalone features or members with no pending requests.
137
+ blockingEpicChangeRequests: The subset of ``openEpicChangeRequests`` with
138
+ ``blocksCurrent == true`` (pause-now, reconcile-before-specs). Always
139
+ ``<= openEpicChangeRequests``.
140
+ """
141
+
142
+ name: str
143
+ stage: str
144
+ status: DerivedStatus
145
+ blocked: bool
146
+ unmetDeps: list[str]
147
+ openEpicChangeRequests: int
148
+ blockingEpicChangeRequests: int
149
+
150
+
151
+ class Rollup(TypedDict):
152
+ """Aggregate completion counts for the epic dashboard (00 §8)."""
153
+
154
+ complete: int #: Number of member features complete-for-orchestration (00 §7).
155
+ total: int #: Total member features in the manifest (0 for an empty epic).
156
+
157
+
158
+ class RenderStatus(TypedDict):
159
+ """The full live dashboard payload returned by render_status (00 §5, §8).
160
+
161
+ Attributes:
162
+ epic: The epic name (manifest `epic`).
163
+ status: The epic lifecycle status (00 §2.1).
164
+ features: Per-member status rows, one per manifest feature (may be empty).
165
+ actionable: Names of features whose dependsOn are all complete and that
166
+ are not themselves complete (00 §8).
167
+ parallelEligible: Subset of `actionable` with no mutual (transitive)
168
+ dependency — surfaced for future parallel execution (00 §8).
169
+ rollup: Aggregate {complete, total} counts.
170
+ nextCommand: Recommended next command for the first actionable feature, or
171
+ None when nothing is actionable (all complete, empty epic, or paused).
172
+ warnings: Human-readable diagnostics that do NOT invalidate the graph but
173
+ need surfacing — currently, members carrying a ``forge-verify-*.status``
174
+ outside ``KNOWN_VERIFY_STATUSES`` (treated as incomplete, but silently so
175
+ would poison the rollup + dependency gates, #148). Empty in the common case.
176
+ """
177
+
178
+ epic: str
179
+ status: Literal["active", "paused", "abandoned", "complete"]
180
+ features: list[FeatureStatus]
181
+ actionable: list[str]
182
+ parallelEligible: list[str]
183
+ rollup: Rollup
184
+ nextCommand: str | None
185
+ warnings: list[str]
186
+
187
+
188
+ # --------------------------------------------------------------------------- #
189
+ # Internal Exceptions (02 §2)
190
+ # --------------------------------------------------------------------------- #
191
+
192
+
193
+ class UsageError(Exception):
194
+ """A usage or I/O failure that must exit 2.
195
+
196
+ Raised for missing files, unreadable paths, malformed CLI arguments, and
197
+ unsafe-name / path-escape conditions detected before filesystem access
198
+ (REQ-SEC-02). Maps to exit code 2.
199
+
200
+ Attributes:
201
+ message: Human-readable description printed to stderr.
202
+ """
203
+
204
+ def __init__(self, message: str) -> None:
205
+ self.message = message
206
+ super().__init__(message)
207
+
208
+
209
+ class FindingsError(Exception):
210
+ """A non-fatal validation outcome that must exit 1.
211
+
212
+ Raised when an operation produces one or more Findings (00 §4) that block a
213
+ gating operation: a cycle, a dangling ref, an ambiguous/not-found name, etc.
214
+ Maps to exit code 1. Carries the structured findings so the dispatch layer
215
+ can emit them as JSON or human lines.
216
+
217
+ Attributes:
218
+ findings: The list of Findings to surface.
219
+ """
220
+
221
+ def __init__(self, findings: list["Finding"]) -> None:
222
+ self.findings = findings
223
+ super().__init__(f"{len(findings)} finding(s)")
224
+
225
+
226
+ # --------------------------------------------------------------------------- #
227
+ # Safety & I/O Layer (02 §3)
228
+ # --------------------------------------------------------------------------- #
229
+
230
+
231
+ def assert_safe_name(name: str) -> None:
232
+ """Validate a bare feature/epic name before any filesystem access.
233
+
234
+ A name is safe iff it is a single kebab-case token with no path separator,
235
+ no ``..`` segment, and is not absolute (REQ-SEC-02). This runs first in
236
+ every subcommand so that an unsafe name never reaches a glob or open().
237
+
238
+ Args:
239
+ name: The bare name supplied on the command line.
240
+
241
+ Raises:
242
+ UsageError: If the name is empty, absolute, contains '/' or '\\',
243
+ equals '..', or fails SAFE_NAME_RE. The message embeds the
244
+ offending name (e.g. ``unsafe name '../escape'``) so the caller can
245
+ surface it verbatim. Corresponds to the 'unsafe-name' Finding code
246
+ (00 §4) but is raised as a usage error because it is detected before
247
+ any manifest is read.
248
+ """
249
+ if (
250
+ not name
251
+ or name == ".."
252
+ or "/" in name
253
+ or "\\" in name
254
+ or os.path.isabs(name)
255
+ or not SAFE_NAME_RE.match(name)
256
+ ):
257
+ raise UsageError(f"unsafe name {name!r}")
258
+
259
+
260
+ def contained_path(base: Path, *parts: str) -> Path:
261
+ """Join parts onto base and assert the result stays within base.
262
+
263
+ Canonicalizes (symlink-resolves) both base and the joined path and verifies
264
+ the result is contained within the real base (REQ-SEC-02). Used before
265
+ reading or writing any manifest, narrative, or pipeline-state file so no
266
+ epic operation can read or write outside the specs subtree.
267
+
268
+ Args:
269
+ base: The containing directory (typically {specsDir} or an epic dir),
270
+ already known to exist.
271
+ *parts: Path segments to append (each already passed through
272
+ assert_safe_name when it originates from user input).
273
+
274
+ Returns:
275
+ The resolved, contained absolute path.
276
+
277
+ Raises:
278
+ UsageError: If the resolved path escapes ``base`` (message:
279
+ ``resolved path escapes specs dir: …``). Containment violations
280
+ surface only as exit-2 usage errors per the error model in
281
+ tech-spec §6 (there is no dedicated Finding code for them).
282
+ """
283
+ base_real = base.resolve()
284
+ target = (base_real / Path(*parts)).resolve()
285
+ try:
286
+ target.relative_to(base_real)
287
+ except ValueError:
288
+ raise UsageError(f"resolved path escapes specs dir: {base_real / Path(*parts)}")
289
+ return target
290
+
291
+
292
+ def load_manifest(epic_dir: Path) -> dict:
293
+ """Load and JSON-parse an epic's manifest.
294
+
295
+ Args:
296
+ epic_dir: The epic subtree directory (must already be contained within
297
+ {specsDir} via contained_path).
298
+
299
+ Returns:
300
+ The parsed manifest as a plain dict. Structural validation (schema,
301
+ cycles, dangling refs) is performed separately by ``validate`` — this
302
+ function only guarantees the file exists and parses.
303
+
304
+ Raises:
305
+ UsageError: If the manifest file is missing or unreadable (exit 2).
306
+ FindingsError: If the file exists but is not parseable JSON — emits a
307
+ single 'corrupt-json' Finding (00 §4) with the JSON error position,
308
+ so a hand-corrupted manifest fails with an actionable message rather
309
+ than a traceback (REQ-ROBUST-02). Exit 1.
310
+ """
311
+ path = epic_dir / MANIFEST_FILENAME
312
+ if not path.is_file():
313
+ raise UsageError(f"manifest not found: {path}")
314
+ try:
315
+ text = path.read_text(encoding="utf-8")
316
+ except OSError as exc:
317
+ raise UsageError(f"cannot read manifest {path}: {exc}")
318
+ try:
319
+ return json.loads(text)
320
+ except json.JSONDecodeError as exc:
321
+ raise FindingsError([
322
+ {
323
+ "code": "corrupt-json",
324
+ "message": f"manifest {path} is not valid JSON: {exc}",
325
+ "feature": None,
326
+ }
327
+ ])
328
+
329
+
330
+ def atomic_write(path: Path, data: dict) -> None:
331
+ """Write a manifest dict to disk atomically.
332
+
333
+ Writes to a temporary file **in the same directory** as the target, flushes
334
+ and fsyncs it, then ``os.replace`` swaps it into place. ``os.replace`` is
335
+ atomic on POSIX within a single filesystem, so an interrupted write never
336
+ leaves a partial or corrupt manifest (REQ-ROBUST-03). Concurrent multi-
337
+ session mutation is out of scope (single-writer assumed, PRD REQ-ROBUST-03).
338
+
339
+ Args:
340
+ path: The destination manifest path (e.g. {epic}/epic-manifest.json).
341
+ data: The fully-formed, already-validated manifest dict to serialize.
342
+
343
+ Raises:
344
+ UsageError: If the temp file cannot be created/written or the replace
345
+ fails (exit 2). On failure the temp file is removed so no debris is
346
+ left behind.
347
+ """
348
+ parent = path.parent
349
+ fd, tmp_name = tempfile.mkstemp(
350
+ prefix=f".{path.name}.", suffix=".tmp", dir=parent
351
+ )
352
+ tmp_path = Path(tmp_name)
353
+ try:
354
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
355
+ json.dump(data, handle, indent=2, ensure_ascii=False)
356
+ handle.write("\n")
357
+ handle.flush()
358
+ os.fsync(handle.fileno())
359
+ os.replace(tmp_path, path)
360
+ # fsync the parent dir so the rename itself is durable on crash, not just
361
+ # the file bytes (best-effort — some filesystems reject O_RDONLY dir fsync).
362
+ try:
363
+ dir_fd = os.open(parent, os.O_RDONLY)
364
+ try:
365
+ os.fsync(dir_fd)
366
+ finally:
367
+ os.close(dir_fd)
368
+ except OSError:
369
+ pass
370
+ except OSError as exc:
371
+ tmp_path.unlink(missing_ok=True)
372
+ raise UsageError(f"atomic write to {path} failed: {exc}")
373
+
374
+
375
+ # --------------------------------------------------------------------------- #
376
+ # Graph Algorithms (02 §4) — implemented in item 004
377
+ # --------------------------------------------------------------------------- #
378
+
379
+
380
+ def find_cycle(features: list[dict]) -> list[str] | None:
381
+ """Return a cycle in the dependsOn graph, or None if acyclic (02 §4).
382
+
383
+ Iterative DFS over the directed graph whose edges are ``feature -> dep``.
384
+ On the first back-edge into a GRAY node, reconstructs and returns the cycle
385
+ path including the repeated start node (e.g. ``["a", "b", "a"]``). A
386
+ self-dependency is a degenerate self-loop returning ``["x", "x"]``. Only
387
+ edges to names present in ``features`` are traversed (dangling refs are
388
+ reported separately by validate). O(V+E).
389
+ """
390
+ adjacency: dict[str, list[str]] = {f["name"]: list(f.get("dependsOn", [])) for f in features}
391
+ WHITE, GRAY, BLACK = 0, 1, 2
392
+ color: dict[str, int] = {name: WHITE for name in adjacency}
393
+ parent: dict[str, str | None] = {name: None for name in adjacency}
394
+
395
+ for root in adjacency:
396
+ if color[root] != WHITE:
397
+ continue
398
+ # Iterative DFS; stack holds (node, index-of-next-neighbor-to-visit).
399
+ stack: list[tuple[str, int]] = [(root, 0)]
400
+ color[root] = GRAY
401
+ while stack:
402
+ node, idx = stack[-1]
403
+ neighbors = [n for n in adjacency[node] if n in adjacency]
404
+ if idx < len(neighbors):
405
+ stack[-1] = (node, idx + 1)
406
+ nxt = neighbors[idx]
407
+ if color[nxt] == WHITE:
408
+ color[nxt] = GRAY
409
+ parent[nxt] = node
410
+ stack.append((nxt, 0))
411
+ elif color[nxt] == GRAY:
412
+ # Back-edge: reconstruct nxt -> … -> node -> nxt.
413
+ # Degenerate self-loop (node == nxt): the while loop body never
414
+ # runs, yielding ["x", "x"] — the documented self-dependency case.
415
+ path = [nxt]
416
+ cursor: str | None = node
417
+ while cursor is not None and cursor != nxt:
418
+ path.append(cursor)
419
+ cursor = parent[cursor]
420
+ path.append(nxt)
421
+ path.reverse()
422
+ return path
423
+ else:
424
+ color[node] = BLACK
425
+ stack.pop()
426
+ return None
427
+
428
+
429
+ def unmet_deps(
430
+ name: str, features: list[dict], complete: dict[str, bool]
431
+ ) -> list[str]:
432
+ """Return a feature's direct dependencies that are not complete (02 §4).
433
+
434
+ Names of this feature's direct ``dependsOn`` entries whose value in
435
+ ``complete`` is False, preserving manifest order. Empty when the feature is
436
+ actionable or itself complete.
437
+ """
438
+ by_name = {f["name"]: f for f in features}
439
+ feature = by_name.get(name, {})
440
+ return [dep for dep in feature.get("dependsOn", []) if not complete.get(dep, False)]
441
+
442
+
443
+ # --------------------------------------------------------------------------- #
444
+ # Resolution & Uniqueness (02 §5) — implemented in item 005
445
+ # --------------------------------------------------------------------------- #
446
+
447
+
448
+ def feature_dirs(specs_dir: Path) -> dict[str, list[Path]]:
449
+ """Map every feature name in the specs tree to the dirs that bear it (02 §5).
450
+
451
+ Scans both layouts to a fixed depth, treating a directory as a feature iff
452
+ it directly contains a ``.pipeline-state.json`` (00 §6, REQ-DIR-03):
453
+ * flat: {specsDir}/{name}/.pipeline-state.json
454
+ * nested: {specsDir}/{epic}/{name}/.pipeline-state.json
455
+
456
+ The returned map is keyed by bare feature name; a name with more than one
457
+ entry is a uniqueness violation (REQ-DIR-04) surfaced as 'ambiguous' or
458
+ 'duplicate-name' by the caller. Epic directories themselves (which hold
459
+ ``epic-manifest.json`` but no ``.pipeline-state.json``) are skipped, so an
460
+ epic name never collides with a feature name (01 §4.3).
461
+
462
+ Args:
463
+ specs_dir: The configured specs directory (already verified to exist).
464
+
465
+ Returns:
466
+ Dict of feature name -> sorted list of absolute feature-dir paths. A
467
+ single-entry list means the name is unique. Descends exactly one level
468
+ below each top dir — never deeper.
469
+ """
470
+ result: dict[str, list[Path]] = {}
471
+ specs_real = specs_dir.resolve()
472
+ for top in sorted(p for p in specs_real.iterdir() if p.is_dir()):
473
+ if (top / PIPELINE_STATE_FILENAME).is_file():
474
+ result.setdefault(top.name, []).append(top) # flat feature
475
+ # Descend one level for nested features (skip epic root, which has no state file).
476
+ for child in sorted(p for p in top.iterdir() if p.is_dir()):
477
+ if (child / PIPELINE_STATE_FILENAME).is_file():
478
+ result.setdefault(child.name, []).append(child)
479
+ return result
480
+
481
+
482
+ def resolve(name: str, specs_dir: Path) -> Path:
483
+ """Resolve a bare feature/epic name to its absolute directory (02 §5).
484
+
485
+ Implements the 5-step algorithm (tech-spec §3.4):
486
+ 1. reject unsafe names (assert_safe_name) — exit 2 before any FS access;
487
+ 2. flat match: {specsDir}/{name}/.pipeline-state.json wins outright;
488
+ 3. exactly one nested match resolves cleanly;
489
+ 4. more than one match anywhere -> 'ambiguous' (REQ-DIR-04);
490
+ 5. zero matches -> 'not-found'.
491
+
492
+ Standalone features resolve to their flat path exactly as today, with no
493
+ epic logic engaged (REQ-COMPAT-01/02).
494
+
495
+ Args:
496
+ name: Bare feature/epic name from the command line.
497
+ specs_dir: The configured specs directory.
498
+
499
+ Returns:
500
+ The resolved, path-contained absolute feature directory.
501
+
502
+ Raises:
503
+ UsageError: Unsafe name or missing specs dir (exit 2).
504
+ FindingsError: 'ambiguous' (lists every matching path) or 'not-found'
505
+ (exit 1). 00 §4.2 gives the canonical message shapes.
506
+ """
507
+ assert_safe_name(name)
508
+ if not specs_dir.is_dir():
509
+ raise UsageError(f"specs dir not found: {specs_dir}")
510
+
511
+ flat = specs_dir / name
512
+ if (flat / PIPELINE_STATE_FILENAME).is_file():
513
+ return contained_path(specs_dir, name) # step 2: flat match wins
514
+
515
+ matches = feature_dirs(specs_dir).get(name, [])
516
+ if len(matches) == 1: # step 3
517
+ return contained_path(matches[0].parent, matches[0].name)
518
+ if len(matches) > 1: # step 4
519
+ joined = " and ".join(str(p) for p in matches)
520
+ raise FindingsError([
521
+ {"code": "ambiguous",
522
+ "message": f"ambiguous name {name!r}: matches {joined}",
523
+ "feature": name}
524
+ ])
525
+ raise FindingsError([ # step 5
526
+ {"code": "not-found",
527
+ "message": f"no feature named {name!r} found under {specs_dir}",
528
+ "feature": name}
529
+ ])
530
+
531
+
532
+ def check_name(name: str, specs_dir: Path) -> list[Finding]:
533
+ """Return a duplicate-name finding if the name is already taken (02 §6.3).
534
+
535
+ Used by forge-0-epic before creating a new member feature so no NEW global
536
+ name collision can be introduced (REQ-DIR-04, tech-spec §3.4). Any single
537
+ existing occurrence is enough to reject — unlike ``resolve``, which tolerates
538
+ a uniquely-matching name and only errors on genuine multi-match.
539
+
540
+ Args:
541
+ name: The candidate new feature/epic name.
542
+ specs_dir: The configured specs directory.
543
+
544
+ Returns:
545
+ A single-element list with a 'duplicate-name' Finding when the name
546
+ already maps to one or more feature-shaped dirs (or to an existing epic
547
+ dir); an empty list when the name is free.
548
+
549
+ Raises:
550
+ UsageError: Unsafe name or missing specs dir (exit 2).
551
+ """
552
+ assert_safe_name(name)
553
+ if not specs_dir.is_dir():
554
+ raise UsageError(f"specs dir not found: {specs_dir}")
555
+ existing = feature_dirs(specs_dir).get(name, [])
556
+ # An epic dir (manifest, no state file) with this name also collides.
557
+ epic_dir = specs_dir / name
558
+ if (epic_dir / MANIFEST_FILENAME).is_file():
559
+ existing = [*existing, epic_dir]
560
+ if existing:
561
+ joined = ", ".join(str(p) for p in existing)
562
+ return [{"code": "duplicate-name",
563
+ "message": f"duplicate feature name {name!r} (also at {joined})",
564
+ "feature": name}]
565
+ return []
566
+
567
+
568
+ # --------------------------------------------------------------------------- #
569
+ # Validation (02 §6.2, §10) — implemented in item 006
570
+ # --------------------------------------------------------------------------- #
571
+
572
+
573
+ #: Top-level required keys (00 §2.1, mirrors epic-manifest-schema.json).
574
+ _TOP_REQUIRED: Final = (
575
+ "schemaVersion", "epic", "description", "status",
576
+ "narrativeDoc", "createdAt", "updatedAt", "features",
577
+ )
578
+ #: Required keys on each Feature object (00 §2.2).
579
+ _FEATURE_REQUIRED: Final = ("name", "charter", "dependsOn", "exposes", "consumes")
580
+ #: Optional keys on each Feature object. `mutatesShared` is the #144 precision
581
+ #: hint for cross-member coupling (array of project-root-relative path strings);
582
+ #: schema-legal when present, ignored when absent (mirrors
583
+ #: epic-manifest-schema.json definitions.feature.properties.mutatesShared).
584
+ _FEATURE_OPTIONAL: Final = ("mutatesShared",)
585
+ #: Required keys on each Contract (exposes[]) object (00 §2.3).
586
+ _CONTRACT_REQUIRED: Final = ("name", "kind", "summary")
587
+ #: Required keys on each ConsumedContract (consumes[]) object (00 §2.4).
588
+ _CONSUMED_REQUIRED: Final = ("from", "name", "summary")
589
+ #: Allowed epic lifecycle states (00 §2.1).
590
+ _EPIC_STATUSES: Final = ("active", "paused", "abandoned", "complete")
591
+ #: Allowed Contract kinds (00 §2.3).
592
+ _CONTRACT_KINDS: Final = ("function", "type", "endpoint", "module", "event")
593
+
594
+
595
+ def _schema(message: str, feature: str | None = None) -> Finding:
596
+ """Construct a 'schema' Finding (00 §4)."""
597
+ return {"code": "schema", "message": message, "feature": feature}
598
+
599
+
600
+ def _schema_findings(manifest: dict) -> list[Finding]:
601
+ """Hand-rolled stdlib schema checker over the manifest (02 §6.2, 00 §2.6).
602
+
603
+ Asserts required keys/types/enums/consts from 00 §2 and explicitly rejects
604
+ any ``features[].status`` key (REQ-STATE-02 -> 'cached-status'). No
605
+ third-party ``jsonschema`` (01 §2.1). Returns 'schema' findings plus, for a
606
+ per-feature status key, a 'cached-status' finding.
607
+ """
608
+ findings: list[Finding] = []
609
+ if not isinstance(manifest, dict):
610
+ return [_schema(f"manifest must be a JSON object, got {type(manifest).__name__}")]
611
+
612
+ for key in _TOP_REQUIRED:
613
+ if key not in manifest:
614
+ findings.append(_schema(f"missing required key {key!r}"))
615
+
616
+ if "schemaVersion" in manifest and manifest["schemaVersion"] != 1:
617
+ findings.append(_schema(f"schemaVersion must be 1, got {manifest['schemaVersion']!r}"))
618
+ if "narrativeDoc" in manifest and manifest["narrativeDoc"] != NARRATIVE_FILENAME:
619
+ findings.append(_schema(f"narrativeDoc must be {NARRATIVE_FILENAME!r}, got {manifest['narrativeDoc']!r}")) # noqa: E501
620
+ for key in ("epic", "description", "createdAt", "updatedAt"):
621
+ if key in manifest and not isinstance(manifest[key], str):
622
+ findings.append(_schema(f"{key} must be a string"))
623
+ for key in ("createdAt", "updatedAt"):
624
+ if isinstance(manifest.get(key), str):
625
+ try:
626
+ # Py3.10's fromisoformat rejects a trailing 'Z'; normalize it first.
627
+ datetime.fromisoformat(manifest[key].replace("Z", "+00:00"))
628
+ except ValueError:
629
+ findings.append(_schema(f"{key} must be an ISO-8601 date-time, got {manifest[key]!r}")) # noqa: E501
630
+ if "status" in manifest and manifest["status"] not in _EPIC_STATUSES:
631
+ findings.append(_schema(f"status must be one of {list(_EPIC_STATUSES)}, got {manifest['status']!r}")) # noqa: E501
632
+ for key in manifest:
633
+ if key not in _TOP_REQUIRED:
634
+ findings.append(_schema(f"unknown key {key!r}"))
635
+
636
+ features = manifest.get("features")
637
+ if "features" in manifest and not isinstance(features, list):
638
+ findings.append(_schema("features must be an array"))
639
+ return findings
640
+ if not isinstance(features, list):
641
+ return findings
642
+
643
+ for idx, feat in enumerate(features):
644
+ if not isinstance(feat, dict):
645
+ findings.append(_schema(f"features[{idx}] must be an object"))
646
+ continue
647
+ fname = feat.get("name") if isinstance(feat.get("name"), str) else None
648
+ label = fname or f"features[{idx}]"
649
+ if "status" in feat:
650
+ findings.append({
651
+ "code": "cached-status",
652
+ "message": f"feature {label!r} carries a forbidden 'status' key (REQ-STATE-02)",
653
+ "feature": fname,
654
+ })
655
+ for key in _FEATURE_REQUIRED:
656
+ if key not in feat:
657
+ findings.append(_schema(f"feature {label!r} missing required key {key!r}", fname))
658
+ for key in feat:
659
+ # 'status' is rejected separately above via the dedicated 'cached-status' code.
660
+ if key not in _FEATURE_REQUIRED and key not in _FEATURE_OPTIONAL and key != "status":
661
+ findings.append(_schema(f"feature {label!r} has unknown key {key!r}", fname))
662
+ for key in ("name", "charter"):
663
+ if key in feat and not isinstance(feat[key], str):
664
+ findings.append(_schema(f"feature {label!r} {key} must be a string", fname))
665
+ if "mutatesShared" in feat and (
666
+ not isinstance(feat["mutatesShared"], list)
667
+ or not all(isinstance(p, str) for p in feat["mutatesShared"])
668
+ ):
669
+ findings.append(_schema(f"feature {label!r} mutatesShared must be an array of strings", fname)) # noqa: E501
670
+ if "dependsOn" in feat:
671
+ if not isinstance(feat["dependsOn"], list) or not all(isinstance(d, str) for d in feat["dependsOn"]): # noqa: E501
672
+ findings.append(_schema(f"feature {label!r} dependsOn must be an array of strings", fname)) # noqa: E501
673
+ for key, required, kind_check in (
674
+ ("exposes", _CONTRACT_REQUIRED, True),
675
+ ("consumes", _CONSUMED_REQUIRED, False),
676
+ ):
677
+ if key not in feat:
678
+ continue
679
+ entries = feat[key]
680
+ if not isinstance(entries, list):
681
+ findings.append(_schema(f"feature {label!r} {key} must be an array", fname))
682
+ continue
683
+ for j, entry in enumerate(entries):
684
+ if not isinstance(entry, dict):
685
+ findings.append(_schema(f"feature {label!r} {key}[{j}] must be an object", fname)) # noqa: E501
686
+ continue
687
+ for rk in required:
688
+ if rk not in entry:
689
+ findings.append(_schema(f"feature {label!r} {key}[{j}] missing required key {rk!r}", fname)) # noqa: E501
690
+ for ek in entry:
691
+ if ek not in required:
692
+ findings.append(_schema(f"feature {label!r} {key}[{j}] has unknown key {ek!r}", fname)) # noqa: E501
693
+ if kind_check and "kind" in entry and entry["kind"] not in _CONTRACT_KINDS:
694
+ findings.append(_schema(f"feature {label!r} {key}[{j}] kind must be one of {list(_CONTRACT_KINDS)}", fname)) # noqa: E501
695
+ return findings
696
+
697
+
698
+ def _validate_dict(
699
+ manifest: dict, epic_dir: Path, specs_dir: Path
700
+ ) -> list[Finding]:
701
+ """Validate an already-parsed manifest dict, returning findings (02 §6.2).
702
+
703
+ Runs the invariant checks of 00 §2.6 in order, short-circuiting only where a
704
+ later check cannot run. Reused by the item-008 mutators on the EDITED dict
705
+ before writing. Does not parse JSON (that is ``validate``'s job) — operates
706
+ purely in memory.
707
+ """
708
+ findings: list[Finding] = []
709
+
710
+ # (2) schema conformance (incl. cached-status guard).
711
+ findings.extend(_schema_findings(manifest))
712
+
713
+ features = manifest.get("features")
714
+ if not isinstance(features, list) or not all(
715
+ isinstance(f, dict) and isinstance(f.get("name"), str) for f in features
716
+ ):
717
+ # Cannot run name/graph checks without well-formed feature names.
718
+ return findings
719
+
720
+ names = [f["name"] for f in features]
721
+
722
+ # (3) epic + every feature name safe.
723
+ epic_name = manifest.get("epic")
724
+ candidates = ([epic_name] if isinstance(epic_name, str) else []) + names
725
+ for candidate in candidates:
726
+ if not SAFE_NAME_RE.match(candidate):
727
+ findings.append({
728
+ "code": "unsafe-name",
729
+ "message": f"unsafe name {candidate!r}",
730
+ "feature": candidate if candidate in names else None,
731
+ })
732
+
733
+ # (3b) names unique within the manifest.
734
+ seen: set[str] = set()
735
+ for n in names:
736
+ if n in seen:
737
+ findings.append({
738
+ "code": "duplicate-name",
739
+ "message": f"duplicate feature name {n!r} within the manifest",
740
+ "feature": n,
741
+ })
742
+ seen.add(n)
743
+
744
+ # (4) global name uniqueness across the specs tree.
745
+ if specs_dir.is_dir():
746
+ tree = feature_dirs(specs_dir)
747
+ for n in names:
748
+ dirs = tree.get(n, [])
749
+ if len(dirs) > 1:
750
+ joined = ", ".join(str(p) for p in dirs)
751
+ findings.append({
752
+ "code": "duplicate-name",
753
+ "message": f"duplicate feature name {n!r} (maps to {joined})",
754
+ "feature": n,
755
+ })
756
+
757
+ # (5) every dependsOn / consumes.from references a known feature.
758
+ known = set(names)
759
+ for feat in features:
760
+ fname = feat["name"]
761
+ for dep in feat.get("dependsOn", []) or []:
762
+ if isinstance(dep, str) and dep not in known:
763
+ findings.append({
764
+ "code": "dangling-ref",
765
+ "message": f"feature {fname!r} dependsOn unknown feature {dep!r}",
766
+ "feature": fname,
767
+ })
768
+ for entry in feat.get("consumes", []) or []:
769
+ if isinstance(entry, dict):
770
+ src = entry.get("from")
771
+ if isinstance(src, str) and src not in known:
772
+ findings.append({
773
+ "code": "dangling-ref",
774
+ "message": f"feature {fname!r} consumes from unknown feature {src!r}",
775
+ "feature": fname,
776
+ })
777
+
778
+ # (6) dependsOn graph acyclic (self-dependency surfaces as a cycle).
779
+ cycle = find_cycle(features)
780
+ if cycle is not None:
781
+ findings.append({
782
+ "code": "cycle",
783
+ "message": " → ".join(cycle),
784
+ "feature": cycle[0],
785
+ })
786
+
787
+ return findings
788
+
789
+
790
+ def validate(epic_dir: Path, specs_dir: Path) -> list[Finding]:
791
+ """Validate a single epic manifest, returning all findings (02 §6.2).
792
+
793
+ Parses the manifest (folding any corrupt-json finding from load_manifest
794
+ into the returned list) then delegates to ``_validate_dict``. Raises
795
+ UsageError (exit 2) for a missing/unreadable manifest.
796
+ """
797
+ try:
798
+ manifest = load_manifest(epic_dir)
799
+ except FindingsError as exc:
800
+ return list(exc.findings)
801
+ return _validate_dict(manifest, epic_dir, specs_dir)
802
+
803
+
804
+ # --------------------------------------------------------------------------- #
805
+ # Live Status Derivation (02 §8) — implemented in item 007
806
+ # --------------------------------------------------------------------------- #
807
+
808
+
809
+ def is_complete_for_orchestration(state: dict) -> bool:
810
+ """Apply the completion-for-orchestration predicate (00 §7, 02 §8.1).
811
+
812
+ A feature is complete-for-orchestration iff::
813
+
814
+ stages['forge-5-loop'].status == 'complete'
815
+ AND ('forge-verify-impl' absent
816
+ OR stages['forge-verify-impl'].status in {'passed', 'findings-applied'})
817
+
818
+ A feature whose forge-verify-impl is 'findings-reported' (unfixed) is NOT
819
+ complete and does NOT unblock dependents (REQ-ORCH-01). This is the single
820
+ implementation of the predicate, reused by the dependency gate and handoff
821
+ (04-pipeline-integration.md).
822
+
823
+ Args:
824
+ state: A parsed .pipeline-state.json dict (or {} if the member has none).
825
+
826
+ Returns:
827
+ True iff the feature is complete for orchestration purposes.
828
+ """
829
+ stages = state.get("stages", {})
830
+ if not isinstance(stages, dict):
831
+ return False
832
+ loop = stages.get("forge-5-loop", {})
833
+ if not isinstance(loop, dict) or loop.get("status") != "complete":
834
+ return False
835
+ impl = stages.get("forge-verify-impl")
836
+ if impl is None:
837
+ return True
838
+ if not isinstance(impl, dict):
839
+ return False
840
+ return impl.get("status") in _VERIFY_ORCH_COMPLETE
841
+
842
+
843
+ def _verify_status_warnings(name: str, state: dict) -> list[str]:
844
+ """Flag any ``forge-verify-*.status`` outside the known vocabulary (#148).
845
+
846
+ An unrecognized status (e.g. the eye-slip ``findings-resolved``, conflated with the
847
+ adjacent ``findingsResolved`` count) is correctly treated as incomplete by
848
+ ``is_complete_for_orchestration`` — but *silently*, so one typo on one member
849
+ under-reports the whole epic rollup and fabricates phantom ``unmetDeps`` on
850
+ dependents. Surfacing it turns that non-local corruption into a visible diagnostic.
851
+
852
+ Non-string / malformed status values (a list, an int) are also flagged, and the
853
+ membership test is guarded so an unhashable value never raises.
854
+ """
855
+ stages = state.get("stages", {})
856
+ if not isinstance(stages, dict):
857
+ return []
858
+ warnings: list[str] = []
859
+ known = ", ".join(sorted(KNOWN_VERIFY_STATUSES))
860
+ for stage_name, entry in stages.items():
861
+ if not (isinstance(stage_name, str) and stage_name.startswith("forge-verify-")):
862
+ continue
863
+ if not isinstance(entry, dict):
864
+ continue
865
+ status = entry.get("status")
866
+ if status is None:
867
+ continue
868
+ if isinstance(status, str) and status in KNOWN_VERIFY_STATUSES:
869
+ continue
870
+ warnings.append(
871
+ f"{name}: unknown {stage_name} status {status!r} "
872
+ f"(treated as incomplete; expected one of {known})"
873
+ )
874
+ return warnings
875
+
876
+
877
+ def _read_state_safely(state_path: Path) -> dict:
878
+ """Read and parse a member's .pipeline-state.json, tolerating corruption.
879
+
880
+ A missing, unreadable, unparseable, or torn (partially-written) member state
881
+ downgrades to ``{}`` rather than crashing the dashboard (02 §8.2). Member
882
+ state writes are made by forge-1..5 skills outside the helper's atomicity
883
+ scope, so a torn read is expected and simply renders that one feature as
884
+ ``not-started``.
885
+ """
886
+ if not state_path.is_file():
887
+ return {}
888
+ try:
889
+ parsed = json.loads(state_path.read_text(encoding="utf-8"))
890
+ except (OSError, json.JSONDecodeError):
891
+ return {}
892
+ return parsed if isinstance(parsed, dict) else {}
893
+
894
+
895
+ def derive_status(feature_dir: Path) -> FeatureStatus:
896
+ """Derive a feature's live status from its own pipeline state (00 §5, 02 §8).
897
+
898
+ Reads ``{feature_dir}/.pipeline-state.json`` and maps it to a FeatureStatus:
899
+ missing/unparseable/all-pending -> ``not-started``; complete-for-
900
+ orchestration -> ``complete``; otherwise ``in-progress``. The ``stage`` field
901
+ is the state's ``currentStage``, defaulting to ``forge-0-epic`` when the
902
+ member dir exists but no stage ran. ``blocked``/``unmetDeps`` are placeholders
903
+ (``False``/``[]``); ``render_status`` overwrites them once it knows the graph.
904
+
905
+ Args:
906
+ feature_dir: The member feature's directory.
907
+
908
+ Returns:
909
+ A FeatureStatus (00 §5) with name, stage, coarse status, and placeholder
910
+ blocked/unmetDeps.
911
+ """
912
+ name = feature_dir.name
913
+ state = _read_state_safely(feature_dir / PIPELINE_STATE_FILENAME)
914
+ stage = state.get("currentStage") or "forge-0-epic"
915
+
916
+ if not state:
917
+ derived: DerivedStatus = "not-started"
918
+ elif is_complete_for_orchestration(state):
919
+ derived = "complete"
920
+ else:
921
+ stages = state.get("stages", {})
922
+ started = isinstance(stages, dict) and any(
923
+ isinstance(entry, dict) and entry.get("status") not in (None, "pending")
924
+ for entry in stages.values()
925
+ )
926
+ derived = "in-progress" if started else "not-started"
927
+
928
+ # Epic-backflow surfacing (Phase 2): count open epicChangeRequests from the
929
+ # same state dict. A missing/torn state, a non-list value, or non-dict items
930
+ # count as 0 — a malformed request must never crash the dashboard, mirroring
931
+ # the torn-state -> not-started tolerance above.
932
+ requests = state.get("epicChangeRequests", [])
933
+ open_reqs = [
934
+ r for r in requests
935
+ if isinstance(r, dict) and r.get("status") == "open"
936
+ ] if isinstance(requests, list) else []
937
+ open_count = len(open_reqs)
938
+ blocking_count = sum(1 for r in open_reqs if r.get("blocksCurrent") is True)
939
+
940
+ return {
941
+ "name": name,
942
+ "stage": stage,
943
+ "status": derived,
944
+ "blocked": False,
945
+ "unmetDeps": [],
946
+ "openEpicChangeRequests": open_count,
947
+ "blockingEpicChangeRequests": blocking_count,
948
+ }
949
+
950
+
951
+ def _transitive_deps(name: str, adjacency: dict[str, list[str]]) -> set[str]:
952
+ """Return all features reachable from ``name`` via dependsOn edges (00 §8)."""
953
+ seen: set[str] = set()
954
+ stack = list(adjacency.get(name, []))
955
+ while stack:
956
+ cur = stack.pop()
957
+ if cur in seen:
958
+ continue
959
+ seen.add(cur)
960
+ stack.extend(adjacency.get(cur, []))
961
+ return seen
962
+
963
+
964
+ def _next_production_stage(state: dict) -> str | None:
965
+ """Return the first production stage that is not yet complete, else None.
966
+
967
+ The epic-side mirror of ``next_stage()`` in forge-session.py, and for the same
968
+ reason its docstring gives: "what runs next" is DERIVED from ``stages[].status``,
969
+ never read from the stored ``currentStage``. A missing, pending, in-progress or
970
+ stale stage all count as "not done".
971
+ """
972
+ stages = state.get("stages")
973
+ if not isinstance(stages, dict):
974
+ return _PRODUCTION_STAGES[0]
975
+ for stage in _PRODUCTION_STAGES:
976
+ entry = stages.get(stage)
977
+ if not isinstance(entry, dict) or entry.get("status") != "complete":
978
+ return stage
979
+ return None
980
+
981
+
982
+ def _next_command(feature_dir: Path, status_row: FeatureStatus) -> str:
983
+ """Recommend the next forge command for an actionable feature (02 §8.3).
984
+
985
+ ``/skill:forge-1-prd <name>`` when the feature's PRD is absent (or the
986
+ member has not progressed past epic creation), else the command for its next
987
+ un-run production stage — DERIVED from ``stages[].status``, not read from
988
+ ``currentStage`` (item 020: ``currentStage`` is "where the pipeline IS", so
989
+ reading it here recommended re-running the stage the member had just finished
990
+ for the whole window before the next stage was entered).
991
+
992
+ When every production stage is complete but the member is still actionable, the
993
+ only thing holding it back is unapplied verify findings (``forge-verify-impl``
994
+ is ``findings-reported``), so ``forge-fix`` is the accurate recommendation.
995
+ """
996
+ name = status_row["name"]
997
+ state = _read_state_safely(feature_dir / PIPELINE_STATE_FILENAME)
998
+ nxt = _next_production_stage(state)
999
+ prd_present = (feature_dir / "PRD.md").is_file()
1000
+ if not prd_present or nxt == "forge-1-prd":
1001
+ return f"/skill:forge-1-prd {name}"
1002
+ if nxt is None:
1003
+ return f"/skill:forge-fix {name}"
1004
+ return f"/skill:{nxt} {name}"
1005
+
1006
+
1007
+ def render_status(epic_dir: Path, specs_dir: Path) -> RenderStatus:
1008
+ """Build the full live dashboard payload for an epic (00 §5, §8; 02 §8.3).
1009
+
1010
+ Validates first (refusing to render over an invalid graph), then derives each
1011
+ member's live status from its own state file, computes blocked/unmetDeps,
1012
+ actionable, parallelEligible, the rollup, and the recommended next command.
1013
+
1014
+ Args:
1015
+ epic_dir: The epic subtree directory.
1016
+ specs_dir: The configured specs directory.
1017
+
1018
+ Returns:
1019
+ The RenderStatus dict (02 §8.4).
1020
+
1021
+ Raises:
1022
+ UsageError: Missing/unreadable manifest (exit 2).
1023
+ FindingsError: The manifest fails validation (exit 1).
1024
+ """
1025
+ # (1) validate first — no dashboard over an invalid graph.
1026
+ findings = validate(epic_dir, specs_dir)
1027
+ if findings:
1028
+ raise FindingsError(findings)
1029
+
1030
+ manifest = load_manifest(epic_dir)
1031
+ features = manifest.get("features", [])
1032
+
1033
+ # (2) derive each feature's status and (3) build the completion map.
1034
+ rows: list[FeatureStatus] = []
1035
+ feature_dir_by_name: dict[str, Path] = {}
1036
+ complete: dict[str, bool] = {}
1037
+ warnings: list[str] = []
1038
+ for feat in features:
1039
+ name = feat["name"]
1040
+ member_dir = contained_path(epic_dir, name)
1041
+ feature_dir_by_name[name] = member_dir
1042
+ rows.append(derive_status(member_dir))
1043
+ member_state = _read_state_safely(member_dir / PIPELINE_STATE_FILENAME)
1044
+ complete[name] = is_complete_for_orchestration(member_state)
1045
+ warnings.extend(_verify_status_warnings(name, member_state))
1046
+
1047
+ # (4) per-feature unmetDeps + blocked. A feature that is itself complete is
1048
+ # never "blocked" — unmet deps only matter for work not yet finished.
1049
+ for row in rows:
1050
+ if complete[row["name"]]:
1051
+ row["unmetDeps"] = []
1052
+ row["blocked"] = False
1053
+ continue
1054
+ deps = unmet_deps(row["name"], features, complete)
1055
+ row["unmetDeps"] = deps
1056
+ row["blocked"] = bool(deps)
1057
+
1058
+ # (5) actionable = unmetDeps empty AND not complete.
1059
+ actionable = [
1060
+ row["name"]
1061
+ for row in rows
1062
+ if not row["unmetDeps"] and not complete[row["name"]]
1063
+ ]
1064
+
1065
+ # (6) parallelEligible = actionable features with no transitive dependsOn
1066
+ # relationship to any other actionable feature.
1067
+ adjacency = {f["name"]: list(f.get("dependsOn", [])) for f in features}
1068
+ actionable_set = set(actionable)
1069
+ parallel_eligible: list[str] = []
1070
+ for name in actionable:
1071
+ related = _transitive_deps(name, adjacency)
1072
+ others = actionable_set - {name}
1073
+ # Eligible iff it neither depends on nor is depended on by another actionable.
1074
+ depends_on_other = bool(related & others)
1075
+ depended_on = any(name in _transitive_deps(o, adjacency) for o in others)
1076
+ if not depends_on_other and not depended_on:
1077
+ parallel_eligible.append(name)
1078
+
1079
+ # (7) rollup.
1080
+ rollup: Rollup = {
1081
+ "complete": sum(1 for v in complete.values() if v),
1082
+ "total": len(features),
1083
+ }
1084
+
1085
+ # (8) nextCommand for the first actionable feature, else None.
1086
+ next_command: str | None = None
1087
+ if actionable:
1088
+ first = actionable[0]
1089
+ first_row = next(r for r in rows if r["name"] == first)
1090
+ next_command = _next_command(feature_dir_by_name[first], first_row)
1091
+
1092
+ return {
1093
+ "epic": manifest.get("epic", epic_dir.name),
1094
+ "status": manifest.get("status", "active"),
1095
+ "features": rows,
1096
+ "actionable": actionable,
1097
+ "parallelEligible": parallel_eligible,
1098
+ "rollup": rollup,
1099
+ "nextCommand": next_command,
1100
+ "warnings": warnings,
1101
+ }
1102
+
1103
+
1104
+ # --------------------------------------------------------------------------- #
1105
+ # Mutators (02 §7) — implemented in item 008
1106
+ # --------------------------------------------------------------------------- #
1107
+
1108
+
1109
+ def _bump_and_write(
1110
+ epic_dir: Path, specs_dir: Path, manifest: dict
1111
+ ) -> list[Finding]:
1112
+ """Re-validate, bump updatedAt, and atomically persist a manifest (02 §7).
1113
+
1114
+ The shared tail of every mutator (REQ-ROBUST-03, REQ-OBS-01, REQ-EPIC-05).
1115
+ Re-runs ``_validate_dict`` on the EDITED manifest; if any blocking finding is
1116
+ present (cycle, dangling-ref, duplicate-name, schema, ...), the on-disk file
1117
+ is left byte-identical and the findings are returned so the caller exits 1.
1118
+ Otherwise ``updatedAt`` is set to now (UTC, ISO-8601) and the manifest is
1119
+ written via ``atomic_write``.
1120
+
1121
+ Args:
1122
+ epic_dir: The epic subtree directory.
1123
+ specs_dir: The configured specs directory (for the uniqueness re-check).
1124
+ manifest: The already-edited in-memory manifest dict.
1125
+
1126
+ Returns:
1127
+ An empty list on success (write performed); the blocking findings on
1128
+ refusal (no write performed).
1129
+
1130
+ Raises:
1131
+ UsageError: If the atomic write itself fails (exit 2).
1132
+ """
1133
+ findings = _validate_dict(manifest, epic_dir, specs_dir)
1134
+ if findings:
1135
+ return findings
1136
+ manifest["updatedAt"] = datetime.now(timezone.utc).isoformat()
1137
+ atomic_write(epic_dir / MANIFEST_FILENAME, manifest)
1138
+ return []
1139
+
1140
+
1141
+ def add_feature(
1142
+ epic_dir: Path,
1143
+ specs_dir: Path,
1144
+ name: str,
1145
+ charter: str,
1146
+ deps: list[str],
1147
+ ) -> list[Finding]:
1148
+ """Append a new member feature to the manifest (02 §7.1).
1149
+
1150
+ Appends a ``Feature`` with the given name/charter/dependsOn and EMPTY
1151
+ exposes/consumes. Re-validation surfaces a duplicate name (within the
1152
+ manifest or across the tree), an unknown dependency (``dangling-ref``), or a
1153
+ cycle, refusing the write in every such case.
1154
+
1155
+ Args:
1156
+ epic_dir: The epic subtree directory.
1157
+ specs_dir: The configured specs directory.
1158
+ name: The new member feature name (already safe-checked by the dispatch).
1159
+ charter: The feature charter text.
1160
+ deps: The new feature's dependsOn list (already comma-split).
1161
+
1162
+ Returns:
1163
+ Empty list on success; blocking findings (duplicate-name / dangling-ref /
1164
+ cycle / schema) on refusal — manifest left unchanged.
1165
+
1166
+ Raises:
1167
+ UsageError: Unsafe name, corrupt/missing manifest, or write failure.
1168
+ """
1169
+ for dep in deps:
1170
+ assert_safe_name(dep)
1171
+ manifest = load_manifest(epic_dir)
1172
+ features = manifest.setdefault("features", [])
1173
+ features.append({
1174
+ "name": name,
1175
+ "charter": charter,
1176
+ "dependsOn": deps,
1177
+ "exposes": [],
1178
+ "consumes": [],
1179
+ })
1180
+ return _bump_and_write(epic_dir, specs_dir, manifest)
1181
+
1182
+
1183
+ def remove_feature(epic_dir: Path, specs_dir: Path, name: str) -> list[Finding]:
1184
+ """Remove a member feature from the manifest (02 §7.2).
1185
+
1186
+ Drops the named feature from ``features[]``. After removal, re-validation
1187
+ surfaces any now-dangling ``dependsOn`` / ``consumes.from`` that pointed at
1188
+ the removed feature; the write is refused in that case so the references can
1189
+ be fixed first. A name that is not a member yields a ``not-found`` finding.
1190
+
1191
+ Args:
1192
+ epic_dir: The epic subtree directory.
1193
+ specs_dir: The configured specs directory.
1194
+ name: The member feature to remove.
1195
+
1196
+ Returns:
1197
+ Empty list on success; ``not-found`` if the name is not a member, or the
1198
+ now-dangling ``dangling-ref`` findings on refusal.
1199
+
1200
+ Raises:
1201
+ UsageError: Unsafe name, corrupt/missing manifest, or write failure.
1202
+ """
1203
+ manifest = load_manifest(epic_dir)
1204
+ features = manifest.get("features", [])
1205
+ if not any(isinstance(f, dict) and f.get("name") == name for f in features):
1206
+ return [{"code": "not-found",
1207
+ "message": f"feature {name!r} is not a member of epic {epic_dir.name!r}",
1208
+ "feature": name}]
1209
+ manifest["features"] = [
1210
+ f for f in features if not (isinstance(f, dict) and f.get("name") == name)
1211
+ ]
1212
+ return _bump_and_write(epic_dir, specs_dir, manifest)
1213
+
1214
+
1215
+ def reorder(epic_dir: Path, specs_dir: Path, order: list[str]) -> list[Finding]:
1216
+ """Reorder the manifest features[] to a given permutation (02 §7.3).
1217
+
1218
+ ``order`` must be an exact permutation of the current member names (purely a
1219
+ display sequence, not a dependency ordering — 00 §2.1). If it is not, a
1220
+ ``schema`` finding is returned and the manifest is left unchanged.
1221
+
1222
+ Args:
1223
+ epic_dir: The epic subtree directory.
1224
+ specs_dir: The configured specs directory.
1225
+ order: The desired member-name ordering (already comma-split).
1226
+
1227
+ Returns:
1228
+ Empty list on success; a ``schema`` finding when ``order`` is not an exact
1229
+ permutation of the current members.
1230
+
1231
+ Raises:
1232
+ UsageError: Corrupt/missing manifest or write failure.
1233
+ """
1234
+ manifest = load_manifest(epic_dir)
1235
+ features = manifest.get("features", [])
1236
+ by_name = {f["name"]: f for f in features if isinstance(f, dict) and isinstance(f.get("name"), str)} # noqa: E501
1237
+ current = sorted(by_name)
1238
+ if sorted(order) != current:
1239
+ return [{"code": "schema",
1240
+ "message": f"--order {order} is not an exact permutation of members {sorted(by_name)}", # noqa: E501
1241
+ "feature": None}]
1242
+ manifest["features"] = [by_name[n] for n in order]
1243
+ return _bump_and_write(epic_dir, specs_dir, manifest)
1244
+
1245
+
1246
+ def set_dep(
1247
+ epic_dir: Path, specs_dir: Path, name: str, deps: list[str]
1248
+ ) -> list[Finding]:
1249
+ """Replace a member feature's dependsOn list (02 §7.4, §7.6).
1250
+
1251
+ Re-validation enforces every new dependency exists (``dangling-ref``) and the
1252
+ resulting graph is acyclic (``cycle``). An empty ``deps`` clears the
1253
+ dependencies.
1254
+
1255
+ Args:
1256
+ epic_dir: The epic subtree directory.
1257
+ specs_dir: The configured specs directory.
1258
+ name: The member feature to edit.
1259
+ deps: The new dependsOn list (already comma-split; empty clears deps).
1260
+
1261
+ Returns:
1262
+ Empty list on success; blocking findings (dangling-ref / cycle /
1263
+ not-found) on refusal — manifest left unchanged.
1264
+
1265
+ Raises:
1266
+ UsageError: Unsafe name, corrupt/missing manifest, or write failure.
1267
+ """
1268
+ for dep in deps:
1269
+ assert_safe_name(dep)
1270
+ manifest = load_manifest(epic_dir)
1271
+ by_name = {
1272
+ f["name"]: f
1273
+ for f in manifest.get("features", [])
1274
+ if isinstance(f, dict) and isinstance(f.get("name"), str)
1275
+ }
1276
+ if name not in by_name:
1277
+ return [{"code": "not-found",
1278
+ "message": f"feature {name!r} is not a member of epic {epic_dir.name!r}",
1279
+ "feature": name}]
1280
+ by_name[name]["dependsOn"] = deps
1281
+ return _bump_and_write(epic_dir, specs_dir, manifest)
1282
+
1283
+
1284
+ def set_status(epic_dir: Path, specs_dir: Path, status: str) -> list[Finding]:
1285
+ """Set the epic-level lifecycle status (02 §7.5).
1286
+
1287
+ Sets the epic-level ``status`` (the value is constrained to the allowed
1288
+ lifecycle states by ``argparse`` ``choices`` before reaching here). Never
1289
+ touches per-feature status (there is none — REQ-STATE-02).
1290
+
1291
+ Args:
1292
+ epic_dir: The epic subtree directory.
1293
+ specs_dir: The configured specs directory.
1294
+ status: The new epic lifecycle status (already choice-validated).
1295
+
1296
+ Returns:
1297
+ Empty list on success; blocking findings if the manifest somehow fails
1298
+ re-validation.
1299
+
1300
+ Raises:
1301
+ UsageError: Corrupt/missing manifest or write failure.
1302
+ """
1303
+ manifest = load_manifest(epic_dir)
1304
+ manifest["status"] = status
1305
+ return _bump_and_write(epic_dir, specs_dir, manifest)
1306
+
1307
+
1308
+ def _load_state_strict(state_path: Path) -> dict:
1309
+ """Read a .pipeline-state.json, raising UsageError on a missing/corrupt file.
1310
+
1311
+ Unlike ``_read_state_safely`` (which downgrades a torn read to ``{}`` for the
1312
+ read-only dashboard), an adopt mutation must NOT silently discard the
1313
+ standalone's stage history — a corrupt source is a hard stop (exit 2) so the
1314
+ human fixes it before the irreversible move.
1315
+ """
1316
+ try:
1317
+ parsed = json.loads(state_path.read_text(encoding="utf-8"))
1318
+ except OSError as exc:
1319
+ raise UsageError(f"cannot read {state_path}: {exc}")
1320
+ except json.JSONDecodeError as exc:
1321
+ raise UsageError(f"{state_path} is not valid JSON: {exc}")
1322
+ if not isinstance(parsed, dict):
1323
+ raise UsageError(f"{state_path} is not a JSON object")
1324
+ return parsed
1325
+
1326
+
1327
+ def _merge_member_state(stub: dict, standalone: dict, epic_name: str) -> dict:
1328
+ """Merge a detached standalone's state onto an epic member stub (Issue #126).
1329
+
1330
+ The **stub** is the base — it carries the correct ``epic`` and ``branch``
1331
+ back-pointers that the epic minted. The **standalone** holds the real work, so
1332
+ its stage history / artifacts / currentStage overlay the stub. The ``epic``
1333
+ back-pointer is forced to the target epic; the stub's ``branch`` is preserved
1334
+ (only falling back to the standalone's when the stub has none).
1335
+ """
1336
+ merged = dict(stub)
1337
+ for key in ("currentStage", "artifacts", "notes",
1338
+ "deferredDecisions", "epicChangeRequests"):
1339
+ if key in standalone:
1340
+ merged[key] = standalone[key]
1341
+ stages = dict(stub.get("stages") or {})
1342
+ stages.update(standalone.get("stages") or {})
1343
+ merged["stages"] = stages
1344
+ merged["epic"] = epic_name
1345
+ if not (isinstance(stub.get("branch"), str) and stub["branch"]) and \
1346
+ isinstance(standalone.get("branch"), str) and standalone["branch"]:
1347
+ merged["branch"] = standalone["branch"]
1348
+ return merged
1349
+
1350
+
1351
+ def adopt_feature(
1352
+ epic_dir: Path,
1353
+ specs_dir: Path,
1354
+ feature: str,
1355
+ charter: str | None,
1356
+ deps: list[str],
1357
+ ) -> dict:
1358
+ """Reconcile a detached standalone feature into an epic member (Issue #126).
1359
+
1360
+ The scripted recovery for a **split-brain epic** (#125): a feature that should
1361
+ be an epic member was forged as a flat standalone at ``{specsDir}/{feature}/``.
1362
+ This relocates it into the member slot ``{epicDir}/{feature}/``, merging state
1363
+ so the stub's ``epic``/``branch`` back-pointers survive, removes the flat dir
1364
+ (no residual), and adds the feature to the manifest if absent.
1365
+
1366
+ Operates on the CURRENT tree/branch — both the flat standalone and the epic
1367
+ manifest must already be present (the human brings a cross-branch standalone
1368
+ onto the epic's home branch first, per ``docs/recovery-detached-epic-member.md``;
1369
+ EPIC.md prose is regenerated separately via forge-0-epic). Deliberately ordered
1370
+ **relocate-then-manifest**: after the flat dir is gone the feature name maps to
1371
+ exactly one dir, so ``add_feature``'s global-uniqueness re-validation stays
1372
+ clean. Re-entrant — a half-finished run (files moved, manifest not yet updated)
1373
+ completes on re-run.
1374
+
1375
+ Args:
1376
+ epic_dir: The target epic subtree directory (must hold epic-manifest.json).
1377
+ specs_dir: The configured specs directory.
1378
+ feature: The detached standalone feature name to adopt.
1379
+ charter: Charter for the manifest entry when the feature is not yet a
1380
+ member; a default is used when omitted.
1381
+ deps: dependsOn for the new manifest entry (ignored if already a member).
1382
+
1383
+ Returns:
1384
+ A summary dict (``adopted``/``relocated``/``manifestUpdated``/…) on success.
1385
+
1386
+ Raises:
1387
+ UsageError: Unsafe name, missing manifest, nothing to adopt, or an I/O
1388
+ failure (exit 2).
1389
+ FindingsError: A blocking manifest finding from ``add_feature`` (e.g. an
1390
+ unknown dependency) after relocation — exit 1; re-run after fixing.
1391
+ """
1392
+ assert_safe_name(feature)
1393
+ for dep in deps:
1394
+ assert_safe_name(dep)
1395
+
1396
+ # The epic manifest must exist on this tree (exit 2 if not — nothing to adopt into).
1397
+ load_manifest(epic_dir)
1398
+
1399
+ flat_dir = contained_path(specs_dir, feature)
1400
+ member_dir = contained_path(epic_dir, feature)
1401
+ if flat_dir == member_dir:
1402
+ raise UsageError(f"feature {feature!r} resolves to the epic dir itself")
1403
+
1404
+ flat_state = flat_dir / PIPELINE_STATE_FILENAME
1405
+ member_state = member_dir / PIPELINE_STATE_FILENAME
1406
+ flat_exists = flat_state.is_file()
1407
+ member_exists = member_state.is_file()
1408
+
1409
+ if not flat_exists and not member_exists:
1410
+ raise UsageError(
1411
+ f"nothing to adopt: neither a standalone {flat_dir} nor a member "
1412
+ f"{member_dir} has a {PIPELINE_STATE_FILENAME}"
1413
+ )
1414
+
1415
+ relocated = False
1416
+ if flat_exists:
1417
+ standalone = _load_state_strict(flat_state)
1418
+ if member_exists:
1419
+ merged = _merge_member_state(
1420
+ _read_state_safely(member_state), standalone, epic_dir.name
1421
+ )
1422
+ else:
1423
+ merged = dict(standalone)
1424
+ merged["epic"] = epic_dir.name
1425
+ # Move every artifact except the state file (merged separately) into the
1426
+ # member slot; the standalone (real work) wins on any name collision.
1427
+ member_dir.mkdir(parents=True, exist_ok=True)
1428
+ for child in sorted(flat_dir.iterdir()):
1429
+ if child.name == PIPELINE_STATE_FILENAME:
1430
+ continue
1431
+ dest = member_dir / child.name
1432
+ if dest.is_dir():
1433
+ shutil.rmtree(dest)
1434
+ elif dest.exists():
1435
+ dest.unlink()
1436
+ shutil.move(str(child), str(dest))
1437
+ atomic_write(member_state, merged)
1438
+ shutil.rmtree(flat_dir)
1439
+ relocated = True
1440
+ else:
1441
+ # Already nested (a prior run moved the files) — only ensure the back-pointer.
1442
+ stub = _read_state_safely(member_state)
1443
+ if stub.get("epic") != epic_dir.name:
1444
+ stub["epic"] = epic_dir.name
1445
+ atomic_write(member_state, stub)
1446
+
1447
+ # Ensure the feature is a manifest member (idempotent — the flat dir is gone now,
1448
+ # so add_feature's tree-uniqueness re-check sees exactly one dir for the name).
1449
+ manifest = load_manifest(epic_dir)
1450
+ already_member = any(
1451
+ isinstance(f, dict) and f.get("name") == feature
1452
+ for f in manifest.get("features", [])
1453
+ )
1454
+ manifest_updated = False
1455
+ if not already_member:
1456
+ findings = add_feature(
1457
+ epic_dir, specs_dir, feature,
1458
+ charter or f"Adopted into the {epic_dir.name} epic from a detached standalone.",
1459
+ deps,
1460
+ )
1461
+ if findings:
1462
+ raise FindingsError(findings)
1463
+ manifest_updated = True
1464
+
1465
+ return {
1466
+ "adopted": True,
1467
+ "epic": epic_dir.name,
1468
+ "feature": feature,
1469
+ "memberDir": str(member_dir),
1470
+ "relocated": relocated,
1471
+ "manifestUpdated": manifest_updated,
1472
+ "wasAlreadyMember": already_member,
1473
+ "nextSteps": [
1474
+ f"Regenerate EPIC.md prose via /skill:forge-0-epic {epic_dir.name}",
1475
+ f"Confirm the dashboard via /skill:forge {epic_dir.name}",
1476
+ f"Verify the member via /skill:forge-verify {feature}",
1477
+ "Commit the surgery (stage the epic subtree and the removed flat dir).",
1478
+ ],
1479
+ }
1480
+
1481
+
1482
+ # --------------------------------------------------------------------------- #
1483
+ # CLI Dispatch (02 §9)
1484
+ # --------------------------------------------------------------------------- #
1485
+
1486
+
1487
+ def _split_list(value: str | None) -> list[str]:
1488
+ """Split a comma-separated CLI argument into a stripped list.
1489
+
1490
+ An empty or absent value yields an empty list (e.g. ``--depends-on ""``
1491
+ clears dependencies). Each token is stripped; empty tokens are dropped.
1492
+ """
1493
+ if not value:
1494
+ return []
1495
+ return [item.strip() for item in value.split(",") if item.strip()]
1496
+
1497
+
1498
+ def _emit_findings(findings: list[Finding], as_json: bool) -> None:
1499
+ """Print findings as JSON or as one actionable line per finding.
1500
+
1501
+ JSON ({"valid": false, "findings": [...]}) goes to stdout; human-readable
1502
+ lines go to stderr (mirroring validate-traceability.py's two output modes).
1503
+ """
1504
+ if as_json:
1505
+ print(json.dumps({"valid": not findings, "findings": findings}, indent=2, ensure_ascii=False)) # noqa: E501
1506
+ else:
1507
+ for finding in findings:
1508
+ print(f"{finding['code']}: {finding['message']}", file=sys.stderr)
1509
+
1510
+
1511
+ def _print_status_table(status: RenderStatus) -> None:
1512
+ """Print a readable epic dashboard plus the recommended next command (02 §8)."""
1513
+ rollup = status["rollup"]
1514
+ print(f"Epic: {status['epic']} [{status['status']}]")
1515
+ print(f"Progress: {rollup['complete']}/{rollup['total']} complete")
1516
+ if not status["features"]:
1517
+ print(" (no features — add features to begin)")
1518
+ for row in status["features"]:
1519
+ line = f" - {row['name']}: {row['status']} (stage {row['stage']})"
1520
+ if row["blocked"]:
1521
+ line += f" — blocked on {', '.join(row['unmetDeps'])}"
1522
+ if row["openEpicChangeRequests"]:
1523
+ marker = "⚠️ BLOCKING" if row["blockingEpicChangeRequests"] else "⚠️"
1524
+ line += f" — {marker} {row['openEpicChangeRequests']} pending epic change(s)"
1525
+ print(line)
1526
+ if status["warnings"]:
1527
+ print("Warnings:")
1528
+ for warning in status["warnings"]:
1529
+ print(f" ⚠️ {warning}")
1530
+ if status["actionable"]:
1531
+ print(f"Actionable: {', '.join(status['actionable'])}")
1532
+ if status["parallelEligible"]:
1533
+ print(f"Parallel-eligible: {', '.join(status['parallelEligible'])}")
1534
+ if status["nextCommand"]:
1535
+ print(f"Next: {status['nextCommand']}")
1536
+
1537
+
1538
+ def _dispatch(args: argparse.Namespace, specs_dir: Path) -> int:
1539
+ """Route a parsed command to its handler, translating return/raise into exit codes.
1540
+
1541
+ Read-only commands (resolve / check-name / validate / render-status) print to
1542
+ stdout and return 0; mutators return findings the caller raises as a
1543
+ ``FindingsError`` (exit 1). Unknown commands raise ``UsageError`` (exit 2).
1544
+ """
1545
+ cmd: str = args.cmd
1546
+
1547
+ if cmd == "resolve":
1548
+ path = resolve(args.name, specs_dir)
1549
+ print(str(path))
1550
+ return 0
1551
+
1552
+ if cmd == "check-name":
1553
+ findings = check_name(args.name, specs_dir)
1554
+ if findings:
1555
+ raise FindingsError(findings)
1556
+ return 0
1557
+
1558
+ if cmd == "validate":
1559
+ epic_dir = contained_path(specs_dir, args.epic)
1560
+ findings = validate(epic_dir, specs_dir)
1561
+ if findings:
1562
+ raise FindingsError(findings)
1563
+ if args.json_output:
1564
+ print(json.dumps({"valid": True, "findings": []}, indent=2))
1565
+ return 0
1566
+
1567
+ if cmd == "render-status":
1568
+ epic_dir = contained_path(specs_dir, args.epic)
1569
+ status = render_status(epic_dir, specs_dir)
1570
+ if args.json_output:
1571
+ print(json.dumps(status, indent=2))
1572
+ else:
1573
+ _print_status_table(status)
1574
+ return 0
1575
+
1576
+ if cmd == "adopt-feature":
1577
+ epic_dir = contained_path(specs_dir, args.epic)
1578
+ summary = adopt_feature(
1579
+ epic_dir, specs_dir, args.name, args.charter,
1580
+ _split_list(args.depends_on),
1581
+ )
1582
+ if args.json_output:
1583
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
1584
+ else:
1585
+ print(f"Adopted {summary['feature']!r} into epic {summary['epic']!r}.")
1586
+ print(f" member dir: {summary['memberDir']}")
1587
+ print(f" relocated files: {summary['relocated']}")
1588
+ print(f" manifest added: {summary['manifestUpdated']}"
1589
+ f"{' (already a member)' if summary['wasAlreadyMember'] else ''}")
1590
+ print("Next steps:")
1591
+ for step in summary["nextSteps"]:
1592
+ print(f" - {step}")
1593
+ return 0
1594
+
1595
+ # Mutators ---------------------------------------------------------------
1596
+ if cmd in {"add-feature", "remove-feature", "reorder", "set-dep", "set-status"}:
1597
+ epic_dir = contained_path(specs_dir, args.epic)
1598
+ if cmd == "add-feature":
1599
+ findings = add_feature(
1600
+ epic_dir, specs_dir, args.name, args.charter,
1601
+ _split_list(args.depends_on),
1602
+ )
1603
+ elif cmd == "remove-feature":
1604
+ findings = remove_feature(epic_dir, specs_dir, args.name)
1605
+ elif cmd == "reorder":
1606
+ findings = reorder(epic_dir, specs_dir, _split_list(args.order))
1607
+ elif cmd == "set-dep":
1608
+ findings = set_dep(
1609
+ epic_dir, specs_dir, args.name, _split_list(args.depends_on)
1610
+ )
1611
+ else: # set-status
1612
+ findings = set_status(epic_dir, specs_dir, args.status)
1613
+ if findings:
1614
+ raise FindingsError(findings)
1615
+ return 0
1616
+
1617
+ raise UsageError(f"unknown command: {cmd}")
1618
+
1619
+
1620
+ def _build_parser() -> argparse.ArgumentParser:
1621
+ """Build the argparse parser with one subparser per subcommand (02 §9)."""
1622
+ parser = argparse.ArgumentParser(prog="epic-manifest.py", description=__doc__)
1623
+ sub = parser.add_subparsers(dest="cmd", required=True)
1624
+
1625
+ def add_specs_dir(p: argparse.ArgumentParser) -> None:
1626
+ p.add_argument("--specs-dir", default="./specs", help="Specs directory")
1627
+
1628
+ def add_json(p: argparse.ArgumentParser) -> None:
1629
+ p.add_argument(
1630
+ "--json", action="store_true", dest="json_output", help="Output as JSON"
1631
+ )
1632
+
1633
+ # resolve --------------------------------------------------------------- #
1634
+ p_resolve = sub.add_parser("resolve", help="Resolve a name to its directory")
1635
+ p_resolve.add_argument("name")
1636
+ add_specs_dir(p_resolve)
1637
+
1638
+ # validate -------------------------------------------------------------- #
1639
+ p_validate = sub.add_parser("validate", help="Validate an epic manifest")
1640
+ p_validate.add_argument("epic")
1641
+ add_specs_dir(p_validate)
1642
+ p_validate.add_argument(
1643
+ "--json", action="store_true", dest="json_output", help="Output as JSON"
1644
+ )
1645
+
1646
+ # check-name ------------------------------------------------------------ #
1647
+ p_check = sub.add_parser("check-name", help="Check global name uniqueness")
1648
+ p_check.add_argument("name")
1649
+ add_specs_dir(p_check)
1650
+
1651
+ # render-status --------------------------------------------------------- #
1652
+ p_render = sub.add_parser("render-status", help="Render the live epic dashboard")
1653
+ p_render.add_argument("epic")
1654
+ add_specs_dir(p_render)
1655
+ p_render.add_argument(
1656
+ "--json", action="store_true", dest="json_output", help="Output as JSON"
1657
+ )
1658
+
1659
+ # add-feature ----------------------------------------------------------- #
1660
+ p_add = sub.add_parser("add-feature", help="Add a member feature")
1661
+ p_add.add_argument("epic")
1662
+ p_add.add_argument("name")
1663
+ p_add.add_argument("--charter", required=True, help="One-paragraph charter")
1664
+ p_add.add_argument("--depends-on", dest="depends_on", default="", help="Comma list")
1665
+ add_specs_dir(p_add)
1666
+ add_json(p_add)
1667
+
1668
+ # adopt-feature --------------------------------------------------------- #
1669
+ p_adopt = sub.add_parser(
1670
+ "adopt-feature",
1671
+ help="Reconcile a detached standalone feature into an epic member (#126)",
1672
+ )
1673
+ p_adopt.add_argument("epic")
1674
+ p_adopt.add_argument("name")
1675
+ p_adopt.add_argument(
1676
+ "--charter", default=None,
1677
+ help="Charter for the manifest entry when the feature is not yet a member",
1678
+ )
1679
+ p_adopt.add_argument("--depends-on", dest="depends_on", default="", help="Comma list")
1680
+ add_specs_dir(p_adopt)
1681
+ add_json(p_adopt)
1682
+
1683
+ # remove-feature -------------------------------------------------------- #
1684
+ p_remove = sub.add_parser("remove-feature", help="Remove a member feature")
1685
+ p_remove.add_argument("epic")
1686
+ p_remove.add_argument("name")
1687
+ add_specs_dir(p_remove)
1688
+ add_json(p_remove)
1689
+
1690
+ # reorder --------------------------------------------------------------- #
1691
+ p_reorder = sub.add_parser("reorder", help="Reorder member features")
1692
+ p_reorder.add_argument("epic")
1693
+ p_reorder.add_argument("--order", required=True, help="Comma-separated permutation")
1694
+ add_specs_dir(p_reorder)
1695
+ add_json(p_reorder)
1696
+
1697
+ # set-dep --------------------------------------------------------------- #
1698
+ p_setdep = sub.add_parser("set-dep", help="Replace a feature's dependsOn")
1699
+ p_setdep.add_argument("epic")
1700
+ p_setdep.add_argument("name")
1701
+ p_setdep.add_argument("--depends-on", dest="depends_on", default="", help="Comma list")
1702
+ add_specs_dir(p_setdep)
1703
+ add_json(p_setdep)
1704
+
1705
+ # set-status ------------------------------------------------------------ #
1706
+ p_setstatus = sub.add_parser("set-status", help="Set the epic lifecycle status")
1707
+ p_setstatus.add_argument("epic")
1708
+ p_setstatus.add_argument(
1709
+ "--status",
1710
+ required=True,
1711
+ choices=["active", "paused", "abandoned", "complete"],
1712
+ )
1713
+ add_specs_dir(p_setstatus)
1714
+ add_json(p_setstatus)
1715
+
1716
+ return parser
1717
+
1718
+
1719
+ def main() -> int:
1720
+ parser = _build_parser()
1721
+ args = parser.parse_args()
1722
+ specs_dir = Path(args.specs_dir)
1723
+ try:
1724
+ return _dispatch(args, specs_dir)
1725
+ except UsageError as exc:
1726
+ print(f"Error: {exc.message}", file=sys.stderr)
1727
+ return 2
1728
+ except FindingsError as exc:
1729
+ _emit_findings(exc.findings, getattr(args, "json_output", False))
1730
+ return 1
1731
+ except OSError as exc:
1732
+ print(f"Error: {exc}", file=sys.stderr)
1733
+ return 2
1734
+
1735
+
1736
+ if __name__ == "__main__":
1737
+ sys.exit(main())