@garygentry/feature-forge 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (384) hide show
  1. package/adapters/claude/.feature-forge-bundle.json +1 -1
  2. package/adapters/claude/agents/forge-verifier.md +3 -1
  3. package/adapters/claude/references/decisions/single-writer-threat-model.md +53 -0
  4. package/adapters/claude/references/epic-state-schema.json +50 -0
  5. package/adapters/claude/references/forge-config-schema.json +18 -0
  6. package/adapters/claude/references/forge-decisions-schema.json +33 -0
  7. package/adapters/claude/references/pipeline-state-schema.json +34 -2
  8. package/adapters/claude/references/ralph-loop-contract.md +6 -3
  9. package/adapters/claude/references/shared-conventions.md +15 -4
  10. package/adapters/claude/references/stage-exit-protocol.md +55 -11
  11. package/adapters/claude/scripts/epic-manifest.py +82 -4
  12. package/adapters/claude/scripts/fix-sweep.py +1180 -0
  13. package/adapters/claude/scripts/forge-session.py +1124 -26
  14. package/adapters/claude/skills/forge/SKILL.md +5 -5
  15. package/adapters/claude/skills/forge/references/pipeline-state-schema.json +34 -2
  16. package/adapters/claude/skills/forge/references/shared-conventions.md +15 -4
  17. package/adapters/claude/skills/forge/references/stage-exit-protocol.md +55 -11
  18. package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +5 -1
  19. package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +5 -0
  20. package/adapters/claude/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  21. package/adapters/claude/skills/forge-0-epic/references/shared-conventions.md +15 -4
  22. package/adapters/claude/skills/forge-0-epic/references/stage-exit-protocol.md +55 -11
  23. package/adapters/claude/skills/forge-1-prd/SKILL.md +3 -1
  24. package/adapters/claude/skills/forge-1-prd/references/shared-conventions.md +15 -4
  25. package/adapters/claude/skills/forge-1-prd/references/stage-exit-protocol.md +55 -11
  26. package/adapters/claude/skills/forge-2-tech/SKILL.md +5 -1
  27. package/adapters/claude/skills/forge-2-tech/references/shared-conventions.md +15 -4
  28. package/adapters/claude/skills/forge-2-tech/references/stage-exit-protocol.md +55 -11
  29. package/adapters/claude/skills/forge-3-specs/SKILL.md +5 -1
  30. package/adapters/claude/skills/forge-3-specs/references/shared-conventions.md +15 -4
  31. package/adapters/claude/skills/forge-3-specs/references/stage-exit-protocol.md +55 -11
  32. package/adapters/claude/skills/forge-4-backlog/SKILL.md +41 -3
  33. package/adapters/claude/skills/forge-4-backlog/references/shared-conventions.md +15 -4
  34. package/adapters/claude/skills/forge-4-backlog/references/stage-exit-protocol.md +55 -11
  35. package/adapters/claude/skills/forge-5-loop/SKILL.md +36 -36
  36. package/adapters/claude/skills/forge-5-loop/references/agent-selection.md +16 -0
  37. package/adapters/claude/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  38. package/adapters/claude/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  39. package/adapters/claude/skills/forge-5-loop/references/result-reporting.md +40 -11
  40. package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +22 -4
  41. package/adapters/claude/skills/forge-5-loop/references/shared-conventions.md +15 -4
  42. package/adapters/claude/skills/forge-5-loop/references/stage-exit-protocol.md +55 -11
  43. package/adapters/claude/skills/forge-6-docs/SKILL.md +29 -6
  44. package/adapters/claude/skills/forge-6-docs/references/shared-conventions.md +15 -4
  45. package/adapters/claude/skills/forge-6-docs/references/stage-exit-protocol.md +55 -11
  46. package/adapters/claude/skills/forge-fix/SKILL.md +34 -0
  47. package/adapters/claude/skills/forge-fix/references/shared-conventions.md +15 -4
  48. package/adapters/claude/skills/forge-fix/references/stage-exit-protocol.md +55 -11
  49. package/adapters/claude/skills/forge-guide/references/forge-config-schema.json +18 -0
  50. package/adapters/claude/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  51. package/adapters/claude/skills/forge-guide/references/shared-conventions.md +15 -4
  52. package/adapters/claude/skills/forge-verify/SKILL.md +10 -11
  53. package/adapters/claude/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  54. package/adapters/claude/skills/forge-verify/references/findings-template.md +30 -0
  55. package/adapters/claude/skills/forge-verify/references/shared-conventions.md +15 -4
  56. package/adapters/claude/skills/forge-verify/references/stage-exit-protocol.md +55 -11
  57. package/adapters/claude/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  58. package/adapters/claude/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  59. package/adapters/claude/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  60. package/adapters/codex/.feature-forge-bundle.json +1 -1
  61. package/adapters/codex/agents/forge-verifier.toml +3 -1
  62. package/adapters/codex/references/decisions/single-writer-threat-model.md +53 -0
  63. package/adapters/codex/references/epic-state-schema.json +50 -0
  64. package/adapters/codex/references/forge-config-schema.json +18 -0
  65. package/adapters/codex/references/forge-decisions-schema.json +33 -0
  66. package/adapters/codex/references/pipeline-state-schema.json +34 -2
  67. package/adapters/codex/references/process-overview.md +2 -2
  68. package/adapters/codex/references/ralph-loop-contract.md +6 -3
  69. package/adapters/codex/references/shared-conventions.md +44 -33
  70. package/adapters/codex/references/stage-exit-protocol.md +64 -20
  71. package/adapters/codex/scripts/epic-manifest.py +82 -4
  72. package/adapters/codex/scripts/fix-sweep.py +1180 -0
  73. package/adapters/codex/scripts/forge-session.py +1124 -26
  74. package/adapters/codex/skills/forge/SKILL.md +8 -8
  75. package/adapters/codex/skills/forge/references/pipeline-state-schema.json +34 -2
  76. package/adapters/codex/skills/forge/references/process-overview.md +2 -2
  77. package/adapters/codex/skills/forge/references/shared-conventions.md +44 -33
  78. package/adapters/codex/skills/forge/references/stage-exit-protocol.md +64 -20
  79. package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +14 -10
  80. package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  81. package/adapters/codex/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  82. package/adapters/codex/skills/forge-0-epic/references/shared-conventions.md +44 -33
  83. package/adapters/codex/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
  84. package/adapters/codex/skills/forge-1-prd/SKILL.md +3 -1
  85. package/adapters/codex/skills/forge-1-prd/references/shared-conventions.md +44 -33
  86. package/adapters/codex/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
  87. package/adapters/codex/skills/forge-2-tech/SKILL.md +5 -1
  88. package/adapters/codex/skills/forge-2-tech/references/shared-conventions.md +44 -33
  89. package/adapters/codex/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
  90. package/adapters/codex/skills/forge-3-specs/SKILL.md +5 -1
  91. package/adapters/codex/skills/forge-3-specs/references/shared-conventions.md +44 -33
  92. package/adapters/codex/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
  93. package/adapters/codex/skills/forge-4-backlog/SKILL.md +41 -3
  94. package/adapters/codex/skills/forge-4-backlog/references/shared-conventions.md +44 -33
  95. package/adapters/codex/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
  96. package/adapters/codex/skills/forge-5-loop/SKILL.md +36 -36
  97. package/adapters/codex/skills/forge-5-loop/references/agent-selection.md +17 -1
  98. package/adapters/codex/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  99. package/adapters/codex/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  100. package/adapters/codex/skills/forge-5-loop/references/result-reporting.md +40 -11
  101. package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +26 -8
  102. package/adapters/codex/skills/forge-5-loop/references/shared-conventions.md +44 -33
  103. package/adapters/codex/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
  104. package/adapters/codex/skills/forge-6-docs/SKILL.md +29 -6
  105. package/adapters/codex/skills/forge-6-docs/references/shared-conventions.md +44 -33
  106. package/adapters/codex/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
  107. package/adapters/codex/skills/forge-fix/SKILL.md +34 -0
  108. package/adapters/codex/skills/forge-fix/references/shared-conventions.md +44 -33
  109. package/adapters/codex/skills/forge-fix/references/stage-exit-protocol.md +64 -20
  110. package/adapters/codex/skills/forge-guide/SKILL.md +1 -1
  111. package/adapters/codex/skills/forge-guide/references/forge-config-schema.json +18 -0
  112. package/adapters/codex/skills/forge-guide/references/process-overview.md +2 -2
  113. package/adapters/codex/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  114. package/adapters/codex/skills/forge-guide/references/shared-conventions.md +44 -33
  115. package/adapters/codex/skills/forge-init/SKILL.md +1 -1
  116. package/adapters/codex/skills/forge-verify/SKILL.md +11 -12
  117. package/adapters/codex/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  118. package/adapters/codex/skills/forge-verify/references/findings-template.md +32 -2
  119. package/adapters/codex/skills/forge-verify/references/shared-conventions.md +44 -33
  120. package/adapters/codex/skills/forge-verify/references/stage-exit-protocol.md +64 -20
  121. package/adapters/codex/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  122. package/adapters/codex/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  123. package/adapters/codex/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  124. package/adapters/codex/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  125. package/adapters/copilot/.feature-forge-bundle.json +1 -1
  126. package/adapters/copilot/agents/forge-verifier.md +3 -1
  127. package/adapters/copilot/references/decisions/single-writer-threat-model.md +53 -0
  128. package/adapters/copilot/references/epic-state-schema.json +50 -0
  129. package/adapters/copilot/references/forge-config-schema.json +18 -0
  130. package/adapters/copilot/references/forge-decisions-schema.json +33 -0
  131. package/adapters/copilot/references/pipeline-state-schema.json +34 -2
  132. package/adapters/copilot/references/process-overview.md +2 -2
  133. package/adapters/copilot/references/ralph-loop-contract.md +6 -3
  134. package/adapters/copilot/references/shared-conventions.md +44 -33
  135. package/adapters/copilot/references/stage-exit-protocol.md +64 -20
  136. package/adapters/copilot/scripts/epic-manifest.py +82 -4
  137. package/adapters/copilot/scripts/fix-sweep.py +1180 -0
  138. package/adapters/copilot/scripts/forge-session.py +1124 -26
  139. package/adapters/copilot/skills/forge/forge.md +8 -8
  140. package/adapters/copilot/skills/forge/references/pipeline-state-schema.json +34 -2
  141. package/adapters/copilot/skills/forge/references/process-overview.md +2 -2
  142. package/adapters/copilot/skills/forge/references/shared-conventions.md +44 -33
  143. package/adapters/copilot/skills/forge/references/stage-exit-protocol.md +64 -20
  144. package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +14 -10
  145. package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  146. package/adapters/copilot/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  147. package/adapters/copilot/skills/forge-0-epic/references/shared-conventions.md +44 -33
  148. package/adapters/copilot/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
  149. package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +3 -1
  150. package/adapters/copilot/skills/forge-1-prd/references/shared-conventions.md +44 -33
  151. package/adapters/copilot/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
  152. package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +5 -1
  153. package/adapters/copilot/skills/forge-2-tech/references/shared-conventions.md +44 -33
  154. package/adapters/copilot/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
  155. package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +5 -1
  156. package/adapters/copilot/skills/forge-3-specs/references/shared-conventions.md +44 -33
  157. package/adapters/copilot/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
  158. package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +41 -3
  159. package/adapters/copilot/skills/forge-4-backlog/references/shared-conventions.md +44 -33
  160. package/adapters/copilot/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
  161. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +36 -36
  162. package/adapters/copilot/skills/forge-5-loop/references/agent-selection.md +17 -1
  163. package/adapters/copilot/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  164. package/adapters/copilot/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  165. package/adapters/copilot/skills/forge-5-loop/references/result-reporting.md +40 -11
  166. package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +26 -8
  167. package/adapters/copilot/skills/forge-5-loop/references/shared-conventions.md +44 -33
  168. package/adapters/copilot/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
  169. package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +29 -6
  170. package/adapters/copilot/skills/forge-6-docs/references/shared-conventions.md +44 -33
  171. package/adapters/copilot/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
  172. package/adapters/copilot/skills/forge-fix/forge-fix.md +34 -0
  173. package/adapters/copilot/skills/forge-fix/references/shared-conventions.md +44 -33
  174. package/adapters/copilot/skills/forge-fix/references/stage-exit-protocol.md +64 -20
  175. package/adapters/copilot/skills/forge-guide/forge-guide.md +1 -1
  176. package/adapters/copilot/skills/forge-guide/references/forge-config-schema.json +18 -0
  177. package/adapters/copilot/skills/forge-guide/references/process-overview.md +2 -2
  178. package/adapters/copilot/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  179. package/adapters/copilot/skills/forge-guide/references/shared-conventions.md +44 -33
  180. package/adapters/copilot/skills/forge-init/forge-init.md +1 -1
  181. package/adapters/copilot/skills/forge-verify/forge-verify.md +11 -12
  182. package/adapters/copilot/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  183. package/adapters/copilot/skills/forge-verify/references/findings-template.md +32 -2
  184. package/adapters/copilot/skills/forge-verify/references/shared-conventions.md +44 -33
  185. package/adapters/copilot/skills/forge-verify/references/stage-exit-protocol.md +64 -20
  186. package/adapters/copilot/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  187. package/adapters/copilot/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  188. package/adapters/copilot/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  189. package/adapters/copilot/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  190. package/adapters/cursor/.feature-forge-bundle.json +1 -1
  191. package/adapters/cursor/agents/forge-verifier.mdc +3 -1
  192. package/adapters/cursor/references/decisions/single-writer-threat-model.md +53 -0
  193. package/adapters/cursor/references/epic-state-schema.json +50 -0
  194. package/adapters/cursor/references/forge-config-schema.json +18 -0
  195. package/adapters/cursor/references/forge-decisions-schema.json +33 -0
  196. package/adapters/cursor/references/pipeline-state-schema.json +34 -2
  197. package/adapters/cursor/references/process-overview.md +2 -2
  198. package/adapters/cursor/references/ralph-loop-contract.md +6 -3
  199. package/adapters/cursor/references/shared-conventions.md +44 -33
  200. package/adapters/cursor/references/stage-exit-protocol.md +64 -20
  201. package/adapters/cursor/scripts/epic-manifest.py +82 -4
  202. package/adapters/cursor/scripts/fix-sweep.py +1180 -0
  203. package/adapters/cursor/scripts/forge-session.py +1124 -26
  204. package/adapters/cursor/skills/forge/forge.mdc +8 -8
  205. package/adapters/cursor/skills/forge/references/pipeline-state-schema.json +34 -2
  206. package/adapters/cursor/skills/forge/references/process-overview.md +2 -2
  207. package/adapters/cursor/skills/forge/references/shared-conventions.md +44 -33
  208. package/adapters/cursor/skills/forge/references/stage-exit-protocol.md +64 -20
  209. package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +14 -10
  210. package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  211. package/adapters/cursor/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  212. package/adapters/cursor/skills/forge-0-epic/references/shared-conventions.md +44 -33
  213. package/adapters/cursor/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
  214. package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +3 -1
  215. package/adapters/cursor/skills/forge-1-prd/references/shared-conventions.md +44 -33
  216. package/adapters/cursor/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
  217. package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +5 -1
  218. package/adapters/cursor/skills/forge-2-tech/references/shared-conventions.md +44 -33
  219. package/adapters/cursor/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
  220. package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +5 -1
  221. package/adapters/cursor/skills/forge-3-specs/references/shared-conventions.md +44 -33
  222. package/adapters/cursor/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
  223. package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +41 -3
  224. package/adapters/cursor/skills/forge-4-backlog/references/shared-conventions.md +44 -33
  225. package/adapters/cursor/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
  226. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +36 -36
  227. package/adapters/cursor/skills/forge-5-loop/references/agent-selection.md +17 -1
  228. package/adapters/cursor/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  229. package/adapters/cursor/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  230. package/adapters/cursor/skills/forge-5-loop/references/result-reporting.md +40 -11
  231. package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +26 -8
  232. package/adapters/cursor/skills/forge-5-loop/references/shared-conventions.md +44 -33
  233. package/adapters/cursor/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
  234. package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +29 -6
  235. package/adapters/cursor/skills/forge-6-docs/references/shared-conventions.md +44 -33
  236. package/adapters/cursor/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
  237. package/adapters/cursor/skills/forge-fix/forge-fix.mdc +34 -0
  238. package/adapters/cursor/skills/forge-fix/references/shared-conventions.md +44 -33
  239. package/adapters/cursor/skills/forge-fix/references/stage-exit-protocol.md +64 -20
  240. package/adapters/cursor/skills/forge-guide/forge-guide.mdc +1 -1
  241. package/adapters/cursor/skills/forge-guide/references/forge-config-schema.json +18 -0
  242. package/adapters/cursor/skills/forge-guide/references/process-overview.md +2 -2
  243. package/adapters/cursor/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  244. package/adapters/cursor/skills/forge-guide/references/shared-conventions.md +44 -33
  245. package/adapters/cursor/skills/forge-init/forge-init.mdc +1 -1
  246. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +11 -12
  247. package/adapters/cursor/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  248. package/adapters/cursor/skills/forge-verify/references/findings-template.md +32 -2
  249. package/adapters/cursor/skills/forge-verify/references/shared-conventions.md +44 -33
  250. package/adapters/cursor/skills/forge-verify/references/stage-exit-protocol.md +64 -20
  251. package/adapters/cursor/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  252. package/adapters/cursor/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  253. package/adapters/cursor/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  254. package/adapters/cursor/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  255. package/adapters/gemini/.feature-forge-bundle.json +1 -1
  256. package/adapters/gemini/agents/forge-verifier.md +3 -1
  257. package/adapters/gemini/gemini-extension.json +1 -1
  258. package/adapters/gemini/references/decisions/single-writer-threat-model.md +53 -0
  259. package/adapters/gemini/references/epic-state-schema.json +50 -0
  260. package/adapters/gemini/references/forge-config-schema.json +18 -0
  261. package/adapters/gemini/references/forge-decisions-schema.json +33 -0
  262. package/adapters/gemini/references/pipeline-state-schema.json +34 -2
  263. package/adapters/gemini/references/process-overview.md +2 -2
  264. package/adapters/gemini/references/ralph-loop-contract.md +6 -3
  265. package/adapters/gemini/references/shared-conventions.md +44 -33
  266. package/adapters/gemini/references/stage-exit-protocol.md +64 -20
  267. package/adapters/gemini/scripts/epic-manifest.py +82 -4
  268. package/adapters/gemini/scripts/fix-sweep.py +1180 -0
  269. package/adapters/gemini/scripts/forge-session.py +1124 -26
  270. package/adapters/gemini/skills/forge/forge.md +8 -8
  271. package/adapters/gemini/skills/forge/references/pipeline-state-schema.json +34 -2
  272. package/adapters/gemini/skills/forge/references/process-overview.md +2 -2
  273. package/adapters/gemini/skills/forge/references/shared-conventions.md +44 -33
  274. package/adapters/gemini/skills/forge/references/stage-exit-protocol.md +64 -20
  275. package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +14 -10
  276. package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +8 -3
  277. package/adapters/gemini/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  278. package/adapters/gemini/skills/forge-0-epic/references/shared-conventions.md +44 -33
  279. package/adapters/gemini/skills/forge-0-epic/references/stage-exit-protocol.md +64 -20
  280. package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +3 -1
  281. package/adapters/gemini/skills/forge-1-prd/references/shared-conventions.md +44 -33
  282. package/adapters/gemini/skills/forge-1-prd/references/stage-exit-protocol.md +64 -20
  283. package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +5 -1
  284. package/adapters/gemini/skills/forge-2-tech/references/shared-conventions.md +44 -33
  285. package/adapters/gemini/skills/forge-2-tech/references/stage-exit-protocol.md +64 -20
  286. package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +5 -1
  287. package/adapters/gemini/skills/forge-3-specs/references/shared-conventions.md +44 -33
  288. package/adapters/gemini/skills/forge-3-specs/references/stage-exit-protocol.md +64 -20
  289. package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +41 -3
  290. package/adapters/gemini/skills/forge-4-backlog/references/shared-conventions.md +44 -33
  291. package/adapters/gemini/skills/forge-4-backlog/references/stage-exit-protocol.md +64 -20
  292. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +36 -36
  293. package/adapters/gemini/skills/forge-5-loop/references/agent-selection.md +17 -1
  294. package/adapters/gemini/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  295. package/adapters/gemini/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  296. package/adapters/gemini/skills/forge-5-loop/references/result-reporting.md +40 -11
  297. package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +26 -8
  298. package/adapters/gemini/skills/forge-5-loop/references/shared-conventions.md +44 -33
  299. package/adapters/gemini/skills/forge-5-loop/references/stage-exit-protocol.md +64 -20
  300. package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +29 -6
  301. package/adapters/gemini/skills/forge-6-docs/references/shared-conventions.md +44 -33
  302. package/adapters/gemini/skills/forge-6-docs/references/stage-exit-protocol.md +64 -20
  303. package/adapters/gemini/skills/forge-fix/forge-fix.md +34 -0
  304. package/adapters/gemini/skills/forge-fix/references/shared-conventions.md +44 -33
  305. package/adapters/gemini/skills/forge-fix/references/stage-exit-protocol.md +64 -20
  306. package/adapters/gemini/skills/forge-guide/forge-guide.md +1 -1
  307. package/adapters/gemini/skills/forge-guide/references/forge-config-schema.json +18 -0
  308. package/adapters/gemini/skills/forge-guide/references/process-overview.md +2 -2
  309. package/adapters/gemini/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  310. package/adapters/gemini/skills/forge-guide/references/shared-conventions.md +44 -33
  311. package/adapters/gemini/skills/forge-init/forge-init.md +1 -1
  312. package/adapters/gemini/skills/forge-verify/forge-verify.md +11 -12
  313. package/adapters/gemini/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  314. package/adapters/gemini/skills/forge-verify/references/findings-template.md +32 -2
  315. package/adapters/gemini/skills/forge-verify/references/shared-conventions.md +44 -33
  316. package/adapters/gemini/skills/forge-verify/references/stage-exit-protocol.md +64 -20
  317. package/adapters/gemini/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  318. package/adapters/gemini/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  319. package/adapters/gemini/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  320. package/adapters/gemini/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  321. package/adapters/pi/.feature-forge-bundle.json +1 -1
  322. package/adapters/pi/agents/forge-verifier.md +3 -1
  323. package/adapters/pi/references/decisions/single-writer-threat-model.md +53 -0
  324. package/adapters/pi/references/epic-state-schema.json +50 -0
  325. package/adapters/pi/references/forge-config-schema.json +18 -0
  326. package/adapters/pi/references/forge-decisions-schema.json +33 -0
  327. package/adapters/pi/references/pipeline-state-schema.json +34 -2
  328. package/adapters/pi/references/process-overview.md +2 -2
  329. package/adapters/pi/references/ralph-loop-contract.md +6 -3
  330. package/adapters/pi/references/shared-conventions.md +33 -22
  331. package/adapters/pi/references/stage-exit-protocol.md +63 -19
  332. package/adapters/pi/scripts/epic-manifest.py +82 -4
  333. package/adapters/pi/scripts/fix-sweep.py +1180 -0
  334. package/adapters/pi/scripts/forge-session.py +1124 -26
  335. package/adapters/pi/skills/forge/SKILL.md +5 -5
  336. package/adapters/pi/skills/forge/references/pipeline-state-schema.json +34 -2
  337. package/adapters/pi/skills/forge/references/process-overview.md +2 -2
  338. package/adapters/pi/skills/forge/references/shared-conventions.md +33 -22
  339. package/adapters/pi/skills/forge/references/stage-exit-protocol.md +63 -19
  340. package/adapters/pi/skills/forge-0-epic/references/edit-mode.md +8 -4
  341. package/adapters/pi/skills/forge-0-epic/references/epic-manifest-subcommands.md +6 -1
  342. package/adapters/pi/skills/forge-0-epic/references/pipeline-state-schema.json +34 -2
  343. package/adapters/pi/skills/forge-0-epic/references/shared-conventions.md +33 -22
  344. package/adapters/pi/skills/forge-0-epic/references/stage-exit-protocol.md +63 -19
  345. package/adapters/pi/skills/forge-1-prd/SKILL.md +3 -1
  346. package/adapters/pi/skills/forge-1-prd/references/shared-conventions.md +33 -22
  347. package/adapters/pi/skills/forge-1-prd/references/stage-exit-protocol.md +63 -19
  348. package/adapters/pi/skills/forge-2-tech/SKILL.md +5 -1
  349. package/adapters/pi/skills/forge-2-tech/references/shared-conventions.md +33 -22
  350. package/adapters/pi/skills/forge-2-tech/references/stage-exit-protocol.md +63 -19
  351. package/adapters/pi/skills/forge-3-specs/SKILL.md +5 -1
  352. package/adapters/pi/skills/forge-3-specs/references/shared-conventions.md +33 -22
  353. package/adapters/pi/skills/forge-3-specs/references/stage-exit-protocol.md +63 -19
  354. package/adapters/pi/skills/forge-4-backlog/SKILL.md +41 -3
  355. package/adapters/pi/skills/forge-4-backlog/references/shared-conventions.md +33 -22
  356. package/adapters/pi/skills/forge-4-backlog/references/stage-exit-protocol.md +63 -19
  357. package/adapters/pi/skills/forge-5-loop/SKILL.md +36 -36
  358. package/adapters/pi/skills/forge-5-loop/references/agent-selection.md +16 -0
  359. package/adapters/pi/skills/forge-5-loop/references/ralph-loop-contract.md +6 -3
  360. package/adapters/pi/skills/forge-5-loop/references/recovery-procedure.md +349 -0
  361. package/adapters/pi/skills/forge-5-loop/references/result-reporting.md +40 -11
  362. package/adapters/pi/skills/forge-5-loop/references/runner-contract.md +25 -7
  363. package/adapters/pi/skills/forge-5-loop/references/shared-conventions.md +33 -22
  364. package/adapters/pi/skills/forge-5-loop/references/stage-exit-protocol.md +63 -19
  365. package/adapters/pi/skills/forge-6-docs/SKILL.md +29 -6
  366. package/adapters/pi/skills/forge-6-docs/references/shared-conventions.md +33 -22
  367. package/adapters/pi/skills/forge-6-docs/references/stage-exit-protocol.md +63 -19
  368. package/adapters/pi/skills/forge-fix/SKILL.md +34 -0
  369. package/adapters/pi/skills/forge-fix/references/shared-conventions.md +33 -22
  370. package/adapters/pi/skills/forge-fix/references/stage-exit-protocol.md +63 -19
  371. package/adapters/pi/skills/forge-guide/references/forge-config-schema.json +18 -0
  372. package/adapters/pi/skills/forge-guide/references/process-overview.md +2 -2
  373. package/adapters/pi/skills/forge-guide/references/ralph-loop-contract.md +6 -3
  374. package/adapters/pi/skills/forge-guide/references/shared-conventions.md +33 -22
  375. package/adapters/pi/skills/forge-verify/SKILL.md +10 -11
  376. package/adapters/pi/skills/forge-verify/references/decisions/single-writer-threat-model.md +53 -0
  377. package/adapters/pi/skills/forge-verify/references/findings-template.md +31 -1
  378. package/adapters/pi/skills/forge-verify/references/shared-conventions.md +33 -22
  379. package/adapters/pi/skills/forge-verify/references/stage-exit-protocol.md +63 -19
  380. package/adapters/pi/skills/forge-verify/references/verification-checklists/backlog.md +73 -0
  381. package/adapters/pi/skills/forge-verify/references/verification-checklists/epic.md +1 -1
  382. package/adapters/pi/skills/forge-verify/references/verification-checklists/impl.md +85 -0
  383. package/adapters/pi/skills/forge-verify/references/verification-checklists/specs.md +43 -1
  384. package/package.json +1 -1
@@ -0,0 +1,1180 @@
1
+ #!/usr/bin/env python3
2
+ """Sweep a fix delta for surviving occurrences of corrected text (REQ-SWEEP-01..03).
3
+
4
+ Two deterministic, model-free subcommands used by the forge-fix pass:
5
+
6
+ ``sweep`` extracts the removed lines of ``git diff HEAD`` as needles and
7
+ reports every surviving normalized occurrence across the
8
+ repository's tracked and untracked files.
9
+ ``plan-coverage`` asserts that a verification findings document's Fix Execution
10
+ Plan covers every finding it reports, naming omissions.
11
+
12
+ Usage:
13
+ python3 fix-sweep.py sweep [--repo-root DIR] [--exclude PREFIX]...
14
+ [--min-chars N] [--json]
15
+ python3 fix-sweep.py plan-coverage FINDINGS_DOC [--json]
16
+
17
+ Exit codes:
18
+ 0 = sweep found no survivors (or was skipped: no git delta);
19
+ plan-coverage fully covered, or not applicable
20
+ 1 = sweep reported one or more survivors;
21
+ plan-coverage found uncovered findings and/or a claimed-total mismatch
22
+ 2 = usage or environment error (bad flag, unreadable document, git failure
23
+ inside a valid repository)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import bisect
30
+ import json
31
+ import re
32
+ import subprocess
33
+ import sys
34
+ from pathlib import Path, PurePosixPath
35
+ from typing import Final, TypedDict
36
+
37
+
38
+ # ─────────────────────────────────────────────────────────────────────────────
39
+ # Module-level constants.
40
+ #
41
+ # The first four are the sweep's shared vocabulary, carried here rather than
42
+ # imported: a standalone, import-free single-file script has no shared module to
43
+ # import from (the same reason epic-manifest.py and forge-session.py keep
44
+ # byte-identical copies of KNOWN_VERIFY_STATUSES).
45
+ # ─────────────────────────────────────────────────────────────────────────────
46
+
47
+ #: Minimum normalized length for a removed line to become a needle (REQ-SWEEP-02).
48
+ #: Also the --min-chars default.
49
+ MIN_NEEDLE_CHARS: Final[int] = 24
50
+
51
+ #: Path segment excluding findings documents from the corpus — unconditional.
52
+ #: Findings documents quote corrected claims by design: they are audit records,
53
+ #: not survivors.
54
+ VERIFICATION_SEGMENT: Final[str] = ".verification"
55
+
56
+ #: Drift-gated regenerated tree, excluded ONLY when the gate is detectably
57
+ #: present at DRIFT_GATE_SENTINEL.
58
+ DRIFT_GATED_PREFIX: Final[str] = "adapters/"
59
+
60
+ #: Repo-relative sentinel whose presence proves the drift gate exists, and so
61
+ #: gates the DRIFT_GATED_PREFIX exclusion.
62
+ DRIFT_GATE_SENTINEL: Final[str] = "scripts/build-adapters.py"
63
+
64
+ #: Label reported in SweepReport["excludes"] for the VERIFICATION_SEGMENT rule.
65
+ #: The rule matches a path SEGMENT; the label is the human-facing prefix form.
66
+ VERIFICATION_EXCLUDE_LABEL: Final[str] = ".verification/"
67
+
68
+ #: Wall-clock bound on every git subprocess. The sweep runs inside a fix pass; a
69
+ #: hung git must fail the pass loudly (exit 2), never hang it.
70
+ GIT_TIMEOUT_SECONDS: Final[int] = 30
71
+
72
+ #: run_git() return code when git could not be executed at all (binary missing,
73
+ #: OSError) or exceeded GIT_TIMEOUT_SECONDS. Callers classify it — a probe treats
74
+ #: it as the skip path, a corpus call raises UsageError.
75
+ GIT_UNAVAILABLE: Final[int] = -1
76
+
77
+ #: Non-alphanumeric run -> single space. Sole regex of normalize(); the `+`
78
+ #: quantifier is what collapses runs, so no second pass is needed.
79
+ _NON_ALNUM: Final = re.compile(r"[^a-z0-9]+")
80
+
81
+ #: Unified-diff hunk header. Group 1 is the a-side start line — the only field the
82
+ #: needle line numbering needs. `,count` is omitted by git when it is 1.
83
+ HUNK_RE: Final = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
84
+
85
+ #: Findings-document `## ` heading; the negative lookahead keeps `###`/`####`
86
+ #: out. This block is the authoritative form of the findings-document anchors.
87
+ H2_RE: Final = re.compile(r"^##(?!#)\s*(.*?)\s*$")
88
+
89
+ #: Findings-document `### ` heading — the sub-section scope for step counting.
90
+ H3_RE: Final = re.compile(r"^###(?!#)\s*(.*?)\s*$")
91
+
92
+ #: Finding heading `### V-NNN: {title}`; group 1 is the finding id.
93
+ FINDING_RE: Final = re.compile(r"^### (V-\d{3}):")
94
+
95
+ #: Execution-step heading `#### Step {N}: {title}`.
96
+ STEP_RE: Final = re.compile(r"^#### Step \d+:")
97
+
98
+ #: The `- **Addresses:** …` coverage field of one execution step.
99
+ ADDRESSES_RE: Final = re.compile(r"^\s*-\s*\*\*Addresses:\*\*")
100
+
101
+ #: Any finding id, used as a findall over an Addresses field.
102
+ FINDING_ID_RE: Final = re.compile(r"V-\d{3}")
103
+
104
+ #: The `## Summary` claimed total; group 1 is the claimed count.
105
+ TOTAL_FINDINGS_RE: Final = re.compile(r"Total findings:\s*(\d+)")
106
+
107
+ #: Fenced-code delimiter. Lines inside a fence are skipped by the findings parser
108
+ #: so the template's fenced `### V-001:` examples cannot fabricate findings.
109
+ FENCE_RE: Final = re.compile(r"^\s*(?:```|~~~)")
110
+
111
+
112
+ # ─────────────────────────────────────────────────────────────────────────────
113
+ # Types and errors — the JSON-boundary shapes and the one exception type.
114
+ # ─────────────────────────────────────────────────────────────────────────────
115
+
116
+
117
+ class Needle(TypedDict):
118
+ """One removed line surviving extraction filters, in normalized form.
119
+
120
+ Keys:
121
+ file: Repo-relative path the line was removed from (diff's a-side path).
122
+ line: 1-based line number in the PRE-fix file (from the @@ hunk header's
123
+ a-side start, plus offset within the hunk's removed run).
124
+ normalized: normalize(original) — the matching key.
125
+ original: The removed line's raw text, verbatim (REQ-OBS-01/REQ-SEC-01:
126
+ echoed without elision; it is already in git history).
127
+ """
128
+
129
+ file: str
130
+ line: int
131
+ normalized: str
132
+ original: str
133
+
134
+
135
+ class NormalizedFile(TypedDict):
136
+ """A corpus file prepared for substring matching.
137
+
138
+ Keys:
139
+ path: Repo-relative POSIX path.
140
+ blob: normalize() applied to the full file content — one string, so a
141
+ match spanning the file's original line breaks still lands (the F-5
142
+ whitespace-reflow success criterion).
143
+ line_starts: For each character offset in `blob`, enough structure to map
144
+ a match offset back to the 1-based line number in the ORIGINAL file.
145
+ Concretely: a sorted list of (blob_offset, original_line) pairs; the
146
+ match's line is the last pair whose blob_offset <= match offset
147
+ (bisect).
148
+ """
149
+
150
+ path: str
151
+ blob: str
152
+ line_starts: list[tuple[int, int]]
153
+
154
+
155
+ class SweepHit(TypedDict):
156
+ """One surviving occurrence of corrected text (REQ-OBS-01).
157
+
158
+ Keys:
159
+ file: Repo-relative path of the surviving occurrence.
160
+ line: 1-based line in the CURRENT working-tree file where the match
161
+ begins (mapped through NormalizedFile.line_starts).
162
+ needle: The matched needle's ORIGINAL removed text, verbatim
163
+ (REQ-SEC-01: no elision — it is already in git history).
164
+ excerpt: The original text of the matched region in the corpus file
165
+ (the line(s) overlapping the match span), verbatim.
166
+ sourceFile: Needle provenance — file the text was removed from.
167
+ sourceLine: Needle provenance — pre-fix line number of the removal.
168
+ """
169
+
170
+ file: str
171
+ line: int
172
+ needle: str
173
+ excerpt: str
174
+ sourceFile: str
175
+ sourceLine: int
176
+
177
+
178
+ class DroppedNeedles(TypedDict):
179
+ """Filter counters for the evidence archive.
180
+
181
+ Keys:
182
+ belowFloor: Count of raw needles dropped because normalize(original) was
183
+ shorter than MIN_NEEDLE_CHARS (filter 1). A needle counted
184
+ here is never tested for reflow.
185
+ reflowSuppressed: Count of raw needles dropped because their normalized
186
+ text appears in the delta's normalized added text (filter 2).
187
+
188
+ Invariant: belowFloor + reflowSuppressed + len(needles) equals the raw
189
+ removed-line count extracted from the delta.
190
+ """
191
+
192
+ belowFloor: int
193
+ reflowSuppressed: int
194
+
195
+
196
+ class SweepReport(TypedDict):
197
+ """Top-level `sweep --json` payload.
198
+
199
+ Keys:
200
+ skipped: True iff no delta was available (REQ-SWEEP-07).
201
+ reason: None when not skipped; "not-a-git-repo" | "no-head" when skipped.
202
+ baseline: Always "HEAD" when the sweep ran; None when skipped.
203
+ needles: Surviving needles after both extraction filters.
204
+ droppedNeedles: Filter counters.
205
+ excludes: The exclusion prefixes/segments actually applied this run —
206
+ [".verification/"] always; plus "adapters/" when gated;
207
+ plus any --exclude values, in the order applied.
208
+ filesScanned: Count of corpus files read and matched (decode-skipped
209
+ files are not counted).
210
+ hits: All survivors, ordered by (file, line) for determinism.
211
+ """
212
+
213
+ skipped: bool
214
+ reason: str | None
215
+ baseline: str | None
216
+ needles: list[Needle]
217
+ droppedNeedles: DroppedNeedles
218
+ excludes: list[str]
219
+ filesScanned: int
220
+ hits: list[SweepHit]
221
+
222
+
223
+ class PlanCoverageReport(TypedDict):
224
+ """Top-level `plan-coverage --json` payload (REQ-CARD-01, REQ-CARD-04).
225
+
226
+ Keys:
227
+ applicable: False when the document has no `## Findings` section or no
228
+ `## Fix Execution Plan` section — exit 0, nothing asserted
229
+ (REQ-CARD-04 analog at the fix-pass level).
230
+ findings: Every V-NNN id found as a `### V-NNN:` heading under
231
+ `## Findings`, in document order.
232
+ steps: Count of `#### Step {N}:` entries under `### Execution Steps`.
233
+ covered: Findings ids appearing in >=1 step's `**Addresses:**` field.
234
+ uncovered: Findings ids appearing in NO step's Addresses field —
235
+ omissions BY NAME, never a count delta.
236
+ claimedTotal: The N parsed from `## Summary`'s `Total findings: {N}`
237
+ line; None when no such line exists.
238
+ actualTotal: len(findings) — re-derived, never trusted from prose.
239
+ totalMismatch: True iff claimedTotal is not None and differs from
240
+ actualTotal. False whenever claimedTotal is None.
241
+ """
242
+
243
+ applicable: bool
244
+ findings: list[str]
245
+ steps: int
246
+ covered: list[str]
247
+ uncovered: list[str]
248
+ claimedTotal: int | None
249
+ actualTotal: int
250
+ totalMismatch: bool
251
+
252
+
253
+ class UsageError(Exception):
254
+ """A caller/environment error that maps to exit 2.
255
+
256
+ Raised for: an unreadable findings document, a git invocation that fails
257
+ inside a valid repository (timeout, non-zero exit on diff/ls-files), a
258
+ repository with no working tree (a bare repo: `rev-parse --git-dir`
259
+ succeeds while `rev-parse --show-toplevel` fails), or invalid flag
260
+ combinations. The message is printed as `Error: {msg}` on stderr; stdout
261
+ stays empty (the exit-2 convention).
262
+ """
263
+
264
+
265
+ # ─────────────────────────────────────────────────────────────────────────────
266
+ # Normalization — one contract, applied to needles and corpus alike.
267
+ # ─────────────────────────────────────────────────────────────────────────────
268
+
269
+
270
+ def normalize(text: str) -> str:
271
+ """Normalize text for sweep matching.
272
+
273
+ Lowercases, maps every non-alphanumeric character to a space, collapses
274
+ whitespace runs to a single space, and strips. Two texts that differ only
275
+ in case, punctuation, or line-wrapping normalize identically — the
276
+ reflowed-prose recall target of REQ-SWEEP-02.
277
+
278
+ Args:
279
+ text: Raw text (a diff line or file content).
280
+
281
+ Returns:
282
+ The normalized form; possibly the empty string.
283
+ """
284
+ return _NON_ALNUM.sub(" ", text.lower()).strip()
285
+
286
+
287
+ # ─────────────────────────────────────────────────────────────────────────────
288
+ # Bounded git helper — deliberately not shared with forge-session.py.
289
+ # ─────────────────────────────────────────────────────────────────────────────
290
+
291
+
292
+ def run_git(args: list[str], repo_root: Path) -> tuple[int, str, str]:
293
+ """Run one bounded, read-only git command with cwd set to `repo_root`.
294
+
295
+ The helper NEVER classifies: it reports what happened and lets the caller
296
+ decide whether the outcome is the skip path or a UsageError. A git
297
+ binary that cannot be executed at all, or one that exceeds
298
+ GIT_TIMEOUT_SECONDS, yields GIT_UNAVAILABLE rather than raising — the probe
299
+ calls in resolve_repo_root() must treat a missing git as "not a repo"
300
+ (REQ-SWEEP-07), while list_corpus_paths() must treat it as exit 2.
301
+
302
+ Args:
303
+ args: git arguments after the program name, e.g. ["rev-parse", "HEAD"].
304
+ repo_root: Directory used as the subprocess cwd. Every git invocation in
305
+ this script runs from the repository top level so that paths in
306
+ output are repo-relative.
307
+
308
+ Returns:
309
+ (returncode, stdout, stderr). returncode is GIT_UNAVAILABLE (-1) when
310
+ git could not be run or timed out; stdout is "" in that case and stderr
311
+ carries a short diagnostic.
312
+ """
313
+ try:
314
+ proc = subprocess.run(
315
+ ["git", *args],
316
+ cwd=repo_root,
317
+ capture_output=True,
318
+ text=True,
319
+ timeout=GIT_TIMEOUT_SECONDS,
320
+ )
321
+ except (OSError, subprocess.TimeoutExpired) as exc:
322
+ return GIT_UNAVAILABLE, "", str(exc)
323
+ return proc.returncode, proc.stdout, proc.stderr
324
+
325
+
326
+ def _first_line(text: str) -> str:
327
+ """Return the first non-empty-stripped line of `text`, or "".
328
+
329
+ Args:
330
+ text: Captured stderr from a git invocation.
331
+
332
+ Returns:
333
+ The first line, stripped; the empty string when there is none.
334
+ """
335
+ stripped = text.strip()
336
+ return stripped.splitlines()[0] if stripped else ""
337
+
338
+
339
+ # ─────────────────────────────────────────────────────────────────────────────
340
+ # sweep pipeline.
341
+ # ─────────────────────────────────────────────────────────────────────────────
342
+
343
+
344
+ def resolve_repo_root(start_dir: Path) -> tuple[Path | None, str | None]:
345
+ """Resolve the repository top level and detect the two skip conditions.
346
+
347
+ Probes in order: `rev-parse --git-dir` (is this a repository at all?),
348
+ `rev-parse --show-toplevel` (where is the working tree?), and
349
+ `rev-parse HEAD` (is there a baseline to diff against?). The top level —
350
+ not `start_dir` — becomes the cwd of every later git call, so a sweep
351
+ launched from a subdirectory still enumerates the whole corpus rather than
352
+ the subtree `git ls-files` would default to.
353
+
354
+ Args:
355
+ start_dir: The --repo-root value (default: the process cwd).
356
+
357
+ Returns:
358
+ (repo_root, None) when the sweep can run, or (None, reason) where reason
359
+ is "not-a-git-repo" or "no-head" — the two values SweepReport["reason"]
360
+ admits.
361
+
362
+ Raises:
363
+ UsageError: The repository exists but has no working tree (a bare repo:
364
+ --git-dir succeeds while --show-toplevel fails), or --show-toplevel
365
+ fails for any other reason. Not a skip — an operational failure
366
+ (exit 2), and forge-fix never runs in a bare repo.
367
+ """
368
+ rc, _out, _err = run_git(["rev-parse", "--git-dir"], start_dir)
369
+ if rc != 0:
370
+ return None, "not-a-git-repo"
371
+
372
+ rc, out, _err = run_git(["rev-parse", "--show-toplevel"], start_dir)
373
+ if rc != 0 or not out.strip():
374
+ raise UsageError(
375
+ f"repository has no working tree (bare repo): {start_dir}"
376
+ )
377
+ repo_root = Path(out.strip())
378
+
379
+ rc, _out, _err = run_git(["rev-parse", "HEAD"], repo_root)
380
+ if rc != 0:
381
+ return None, "no-head"
382
+ return repo_root, None
383
+
384
+
385
+ def _diff_path(raw: str) -> str | None:
386
+ """Decode one `---`/`+++` diff header path.
387
+
388
+ A path git C-quoted (embedded quote, backslash, or control character) is
389
+ unquoted best-effort by stripping the surrounding double quotes; a
390
+ mis-decoded path affects only provenance reporting, never matching.
391
+
392
+ Args:
393
+ raw: The header text after the `--- ` / `+++ ` prefix.
394
+
395
+ Returns:
396
+ The repo-relative path with its `a/` or `b/` prefix stripped, or None
397
+ for `/dev/null`.
398
+ """
399
+ value = raw.strip()
400
+ if value.startswith('"') and value.endswith('"') and len(value) >= 2:
401
+ value = value[1:-1]
402
+ if value == "/dev/null":
403
+ return None
404
+ if value.startswith("a/") or value.startswith("b/"):
405
+ value = value[2:]
406
+ return value
407
+
408
+
409
+ def extract_needles(diff_text: str) -> tuple[list[Needle], dict[str, list[str]]]:
410
+ """Parse a unified diff into raw needles and per-file added lines.
411
+
412
+ `--unified=0` means every diff body line is a change: hunk headers delimit
413
+ the changed runs exactly, so a-side line numbers are computable without
414
+ context-line bookkeeping.
415
+
416
+ Parse contract:
417
+ * `diff --git …` resets file state and clears the in-hunk flag.
418
+ * `--- a/{path}` sets the a-side path; `+++ b/{path}` sets the b-side path.
419
+ A leading `a/`/`b/` prefix is stripped; `/dev/null` maps to None.
420
+ * `@@ -a[,b] +c[,d] @@` sets the a-side counter to `a` and raises the
421
+ in-hunk flag.
422
+ * ONLY while in-hunk: a line starting with `-` is a removed line whose
423
+ content is the text after the prefix, at the current a-side counter,
424
+ which then advances by one; a line starting with `+` is an added line,
425
+ appended to added_by_file[b_path] (it does not move the a-side counter);
426
+ anything else (` `, `\\`) is ignored.
427
+ Gating on the in-hunk flag is what keeps a removed line whose content is
428
+ literally `--` (rendered `---`) from being mistaken for a file header —
429
+ headers only ever appear before the first `@@` of a file.
430
+
431
+ Args:
432
+ diff_text: stdout of `git diff HEAD --unified=0 --no-color`.
433
+
434
+ Returns:
435
+ (raw_needles, added_by_file) where raw_needles are Needle dicts in
436
+ document order with `normalized` already computed, and added_by_file maps
437
+ each b-side path to its normalized non-empty added lines in order — the
438
+ input to reflow suppression.
439
+ """
440
+ raw_needles: list[Needle] = []
441
+ added_by_file: dict[str, list[str]] = {}
442
+ a_path: str | None = None
443
+ b_path: str | None = None
444
+ a_line = 0
445
+ in_hunk = False
446
+
447
+ for line in diff_text.splitlines():
448
+ if line.startswith("diff --git "):
449
+ a_path = None
450
+ b_path = None
451
+ in_hunk = False
452
+ continue
453
+ if not in_hunk:
454
+ if line.startswith("--- "):
455
+ a_path = _diff_path(line[4:])
456
+ continue
457
+ if line.startswith("+++ "):
458
+ b_path = _diff_path(line[4:])
459
+ continue
460
+ hunk = HUNK_RE.match(line)
461
+ if hunk:
462
+ a_line = int(hunk.group(1))
463
+ in_hunk = True
464
+ continue
465
+ if not in_hunk:
466
+ continue
467
+ if line.startswith("-"):
468
+ content = line[1:]
469
+ raw_needles.append(
470
+ Needle(
471
+ file=a_path or "",
472
+ line=a_line,
473
+ normalized=normalize(content),
474
+ original=content,
475
+ )
476
+ )
477
+ a_line += 1
478
+ elif line.startswith("+"):
479
+ piece = normalize(line[1:])
480
+ if piece:
481
+ added_by_file.setdefault(b_path or "", []).append(piece)
482
+ return raw_needles, added_by_file
483
+
484
+
485
+ def filter_needles(
486
+ raw: list[Needle],
487
+ added_by_file: dict[str, list[str]],
488
+ min_chars: int,
489
+ ) -> tuple[list[Needle], DroppedNeedles]:
490
+ """Apply the two extraction filters, in order, with counters.
491
+
492
+ 1. Length floor: `len(needle["normalized"]) < min_chars` -> dropped,
493
+ counted in `belowFloor`. A line that normalizes to "" is dropped here.
494
+ 2. Reflow/move suppression: the needle's normalized text appearing as a
495
+ substring of the delta's added text -> dropped, counted in
496
+ `reflowSuppressed`. Text merely moved or re-wrapped was not corrected;
497
+ sweeping it would flag every reflow as a survivor.
498
+
499
+ The order matters for the counters: a below-floor needle is counted ONCE,
500
+ in `belowFloor`, and never tested for reflow.
501
+
502
+ The added text is built once per run: per file, its normalized added lines
503
+ joined with a single space (in diff order); then those per-file strings
504
+ joined with a single space, in the order files appeared in the diff —
505
+ concatenated per file, then joined delta-wide. Joining rather than
506
+ testing lines individually is deliberate — a corrected sentence re-wrapped
507
+ across new line breaks must still suppress.
508
+
509
+ Args:
510
+ raw: Needles in extraction order.
511
+ added_by_file: Normalized added lines per b-side path.
512
+ min_chars: The --min-chars value; MIN_NEEDLE_CHARS by default.
513
+
514
+ Returns:
515
+ (surviving needles in extraction order, the DroppedNeedles counters).
516
+ """
517
+ added_text = " ".join(" ".join(lines) for lines in added_by_file.values())
518
+ survivors: list[Needle] = []
519
+ below_floor = 0
520
+ reflow_suppressed = 0
521
+
522
+ for needle in raw:
523
+ if len(needle["normalized"]) < min_chars:
524
+ below_floor += 1
525
+ continue
526
+ if needle["normalized"] in added_text:
527
+ reflow_suppressed += 1
528
+ continue
529
+ survivors.append(needle)
530
+
531
+ counters = DroppedNeedles(
532
+ belowFloor=below_floor, reflowSuppressed=reflow_suppressed
533
+ )
534
+ return survivors, counters
535
+
536
+
537
+ def list_corpus_paths(repo_root: Path) -> list[str]:
538
+ """Enumerate candidate corpus paths: tracked plus untracked-not-ignored.
539
+
540
+ Runs `git ls-files -z --cached --others --exclude-standard` — NUL-terminated
541
+ so paths containing spaces, quotes, or newlines survive intact and no quoting
542
+ mode can mangle them. During an unresolved merge, `--cached` lists
543
+ a conflicted path once per stage; entries are de-duplicated preserving first
544
+ appearance, then sorted lexicographically so the scan order — and therefore
545
+ every tie-break in the output — is deterministic.
546
+
547
+ Args:
548
+ repo_root: Repository top level; also the subprocess cwd.
549
+
550
+ Returns:
551
+ Sorted, unique, repo-relative POSIX paths.
552
+
553
+ Raises:
554
+ UsageError: ls-files exited non-zero or could not be run.
555
+ """
556
+ rc, out, err = run_git(
557
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], repo_root
558
+ )
559
+ if rc != 0:
560
+ raise UsageError(f"git ls-files failed ({rc}): {_first_line(err)}")
561
+ seen: dict[str, None] = {}
562
+ for entry in out.split("\0"):
563
+ if entry:
564
+ seen.setdefault(entry, None)
565
+ return sorted(seen)
566
+
567
+
568
+ def applicable_excludes(repo_root: Path, user_excludes: list[str]) -> list[str]:
569
+ """Compute the exclusion labels actually in force this run.
570
+
571
+ Order, matching SweepReport["excludes"]:
572
+ 1. VERIFICATION_EXCLUDE_LABEL — always.
573
+ 2. DRIFT_GATED_PREFIX — ONLY when (repo_root / DRIFT_GATE_SENTINEL) is an
574
+ existing file. REQ-SWEEP-03 defines a CLASS (trees a mechanical drift
575
+ gate already keeps fresh); `adapters/` is only this repository's
576
+ instance, and a consumer repo's ungated `adapters/` must be swept, not
577
+ silently dropped.
578
+ 3. Each --exclude value, in the order given.
579
+
580
+ Args:
581
+ repo_root: Repository top level.
582
+ user_excludes: Raw --exclude values.
583
+
584
+ Returns:
585
+ The labels, in application order, for the payload and for is_excluded().
586
+ """
587
+ excludes = [VERIFICATION_EXCLUDE_LABEL]
588
+ if (repo_root / DRIFT_GATE_SENTINEL).is_file():
589
+ excludes.append(DRIFT_GATED_PREFIX)
590
+ excludes.extend(user_excludes)
591
+ return excludes
592
+
593
+
594
+ def is_excluded(path: str, excludes: list[str], user_excludes: list[str]) -> bool:
595
+ """Decide whether one repo-relative path is out of corpus.
596
+
597
+ Rules, in evaluation order:
598
+ 1. VERIFICATION_SEGMENT appears as a path SEGMENT — at any depth,
599
+ unconditionally. Segment matching, not prefix matching: findings
600
+ documents live at `{featureDir}/.verification/…`, never at the root.
601
+ 2. The path starts with DRIFT_GATED_PREFIX and that label is present in
602
+ `excludes` (i.e. the gate sentinel was found).
603
+ 3. The path starts with any of `user_excludes`, compared as a plain string
604
+ prefix against the repo-relative POSIX path.
605
+
606
+ Args:
607
+ path: Repo-relative POSIX path from list_corpus_paths().
608
+ excludes: Labels from applicable_excludes() (used for rule 2's gate).
609
+ user_excludes: The --exclude values (rule 3).
610
+
611
+ Returns:
612
+ True when the path must not be read or matched.
613
+ """
614
+ if VERIFICATION_SEGMENT in PurePosixPath(path).parts:
615
+ return True
616
+ if DRIFT_GATED_PREFIX in excludes and path.startswith(DRIFT_GATED_PREFIX):
617
+ return True
618
+ return any(path.startswith(prefix) for prefix in user_excludes)
619
+
620
+
621
+ def build_normalized_file(path: str, content: str) -> NormalizedFile:
622
+ """Normalize a file into one blob plus a blob-offset -> line-number map.
623
+
624
+ The blob is behaviorally `normalize(content)`: each original line is
625
+ normalized independently and the non-empty results are joined with a single
626
+ space. That is identical to normalizing the whole content, because the line
627
+ break between two lines is itself a non-alphanumeric run that collapses to
628
+ one space — and building it line-wise is what makes `line_starts` free.
629
+
630
+ Algorithm, per original line (1-based) of `content.splitlines()`:
631
+ * piece = normalize(raw_line).
632
+ * If piece is empty (blank line, or punctuation only), record
633
+ (current_offset, line_number) as a zero-width entry and continue. The
634
+ entry keeps the map total over lines while contributing no blob text;
635
+ because it is recorded BEFORE the following line's entry and carries a
636
+ strictly smaller-or-equal offset, the bisect in line_for_offset() can
637
+ never attribute a match to it (no match can begin at a separator).
638
+ * Otherwise: if the blob is non-empty, append a single space separator and
639
+ advance the offset by 1; then record (current_offset, line_number) —
640
+ pointing at the piece's FIRST character — and append the piece,
641
+ advancing the offset by len(piece).
642
+
643
+ `line_starts` is therefore sorted by offset by construction, which is what
644
+ line_for_offset()'s bisect requires.
645
+
646
+ Args:
647
+ path: Repo-relative POSIX path (stored on the result).
648
+ content: The file's working-tree text (read post-fix,
649
+ so just-corrected sites read as corrected, not as survivors).
650
+
651
+ Returns:
652
+ The NormalizedFile. The ORIGINAL lines are deliberately not stored on
653
+ it — the TypedDict's three keys are fixed — so the caller keeps
654
+ `content.splitlines()` alongside it for excerpt rendering.
655
+ """
656
+ pieces: list[str] = []
657
+ line_starts: list[tuple[int, int]] = []
658
+ offset = 0
659
+
660
+ for line_number, raw_line in enumerate(content.splitlines(), start=1):
661
+ piece = normalize(raw_line)
662
+ if not piece:
663
+ line_starts.append((offset, line_number))
664
+ continue
665
+ if pieces:
666
+ pieces.append(" ")
667
+ offset += 1
668
+ line_starts.append((offset, line_number))
669
+ pieces.append(piece)
670
+ offset += len(piece)
671
+
672
+ return NormalizedFile(path=path, blob="".join(pieces), line_starts=line_starts)
673
+
674
+
675
+ def line_for_offset(nf: NormalizedFile, offset: int) -> int:
676
+ """Map a blob offset back to a 1-based line number in the original file.
677
+
678
+ Uses `bisect.bisect_right` over the offsets of `nf["line_starts"]`, taking
679
+ the entry at index-1: the LAST pair whose blob_offset <= offset. When a
680
+ zero-width (blank-line) entry ties with the following real entry,
681
+ bisect_right selects the later — the line whose text actually begins there.
682
+
683
+ Args:
684
+ nf: A NormalizedFile from build_normalized_file().
685
+ offset: A blob offset, expected to be a match start or end.
686
+
687
+ Returns:
688
+ The 1-based original line number; 1 for an empty map (empty file, which
689
+ cannot produce a match anyway).
690
+ """
691
+ starts = nf["line_starts"]
692
+ if not starts:
693
+ return 1
694
+ offsets = [entry[0] for entry in starts]
695
+ index = bisect.bisect_right(offsets, offset)
696
+ if index == 0:
697
+ return starts[0][1]
698
+ return starts[index - 1][1]
699
+
700
+
701
+ def dedupe_needles(needles: list[Needle]) -> list[Needle]:
702
+ """Pick one representative per distinct normalized text, first extracted wins.
703
+
704
+ Duplicate needles stay distinct in the payload, but a corpus hit
705
+ reports the FIRST extracted needle matching it. Deduplicating here makes
706
+ that deterministic and removes redundant str.find() calls.
707
+
708
+ Args:
709
+ needles: Surviving needles in extraction order.
710
+
711
+ Returns:
712
+ Representatives in extraction order.
713
+ """
714
+ seen: set[str] = set()
715
+ representatives: list[Needle] = []
716
+ for needle in needles:
717
+ if needle["normalized"] in seen:
718
+ continue
719
+ seen.add(needle["normalized"])
720
+ representatives.append(needle)
721
+ return representatives
722
+
723
+
724
+ def scan_file(
725
+ nf: NormalizedFile,
726
+ original_lines: list[str],
727
+ representatives: list[Needle],
728
+ ) -> list[SweepHit]:
729
+ """Search one prepared corpus file for every representative needle.
730
+
731
+ One hit per (file, needle), at the FIRST match offset: disposition
732
+ is recorded per file + needle, so reporting every occurrence in a file would
733
+ generate hits the disposition vocabulary cannot address separately.
734
+
735
+ The file a needle was removed from is scanned like any other — self-file
736
+ hits count: a surviving duplicate two sections below the
737
+ corrected site is the F-5 self-contradiction and must be reported. The
738
+ corrected site itself does not match because content is read post-fix.
739
+
740
+ Args:
741
+ nf: The prepared file from build_normalized_file().
742
+ original_lines: `content.splitlines()` for the same file.
743
+ representatives: Output of dedupe_needles().
744
+
745
+ Returns:
746
+ Hits for this file, in representative order; the caller sorts globally.
747
+ """
748
+ hits: list[SweepHit] = []
749
+ blob = nf["blob"]
750
+ for needle in representatives:
751
+ target = needle["normalized"]
752
+ if not target:
753
+ continue
754
+ offset = blob.find(target)
755
+ if offset < 0:
756
+ continue
757
+ line = line_for_offset(nf, offset)
758
+ end = line_for_offset(nf, offset + len(target) - 1)
759
+ excerpt = "\n".join(original_lines[line - 1 : end])
760
+ hits.append(
761
+ SweepHit(
762
+ file=nf["path"],
763
+ line=line,
764
+ needle=needle["original"],
765
+ excerpt=excerpt,
766
+ sourceFile=needle["file"],
767
+ sourceLine=needle["line"],
768
+ )
769
+ )
770
+ return hits
771
+
772
+
773
+ def run_sweep(
774
+ start_dir: Path,
775
+ user_excludes: list[str],
776
+ min_chars: int,
777
+ ) -> SweepReport:
778
+ """Execute the whole sweep and return the payload.
779
+
780
+ Steps: resolve_repo_root() -> skip payload or continue; run the diff;
781
+ extract_needles(); filter_needles(); applicable_excludes();
782
+ list_corpus_paths(); for each non-excluded readable path
783
+ build_normalized_file() + scan_file(); sort hits; assemble.
784
+
785
+ Args:
786
+ start_dir: The --repo-root value.
787
+ user_excludes: --exclude values, in order.
788
+ min_chars: --min-chars value.
789
+
790
+ Returns:
791
+ A fully populated SweepReport. Never partially populated: the skip
792
+ shape fills every key.
793
+
794
+ Raises:
795
+ UsageError: Any git failure inside a valid repository, or a bare repo
796
+ — exit 2, and the fix pass closes on its failure outcome.
797
+ """
798
+ repo_root, reason = resolve_repo_root(start_dir)
799
+ if repo_root is None:
800
+ return SweepReport(
801
+ skipped=True,
802
+ reason=reason,
803
+ baseline=None,
804
+ needles=[],
805
+ droppedNeedles=DroppedNeedles(belowFloor=0, reflowSuppressed=0),
806
+ excludes=[],
807
+ filesScanned=0,
808
+ hits=[],
809
+ )
810
+
811
+ rc, diff_text, err = run_git(
812
+ ["-c", "core.quotePath=false", "diff", "HEAD", "--unified=0", "--no-color"],
813
+ repo_root,
814
+ )
815
+ if rc != 0:
816
+ raise UsageError(f"git diff failed ({rc}): {_first_line(err)}")
817
+
818
+ raw_needles, added_by_file = extract_needles(diff_text)
819
+ needles, dropped = filter_needles(raw_needles, added_by_file, min_chars)
820
+ excludes = applicable_excludes(repo_root, user_excludes)
821
+ representatives = dedupe_needles(needles)
822
+ rep_order = {
823
+ (needle["file"], needle["line"]): index
824
+ for index, needle in enumerate(representatives)
825
+ }
826
+
827
+ files_scanned = 0
828
+ hits: list[SweepHit] = []
829
+ for path in list_corpus_paths(repo_root):
830
+ if is_excluded(path, excludes, user_excludes):
831
+ continue
832
+ try:
833
+ content = (repo_root / path).read_text(encoding="utf-8")
834
+ except (OSError, UnicodeDecodeError):
835
+ continue
836
+ files_scanned += 1
837
+ nf = build_normalized_file(path, content)
838
+ hits.extend(scan_file(nf, content.splitlines(), representatives))
839
+
840
+ hits.sort(
841
+ key=lambda hit: (
842
+ hit["file"],
843
+ hit["line"],
844
+ rep_order.get((hit["sourceFile"], hit["sourceLine"]), 0),
845
+ )
846
+ )
847
+
848
+ return SweepReport(
849
+ skipped=False,
850
+ reason=None,
851
+ baseline="HEAD",
852
+ needles=needles,
853
+ droppedNeedles=dropped,
854
+ excludes=excludes,
855
+ filesScanned=files_scanned,
856
+ hits=hits,
857
+ )
858
+
859
+
860
+ # ─────────────────────────────────────────────────────────────────────────────
861
+ # plan-coverage.
862
+ # ─────────────────────────────────────────────────────────────────────────────
863
+
864
+
865
+ def parse_findings_doc(text: str) -> PlanCoverageReport:
866
+ """Parse a findings document into the coverage payload.
867
+
868
+ State carried per line: `h2` (the current `## ` heading text, reset by every
869
+ `## ` line), `h3` (the current `### ` heading text, reset by every `## ` and
870
+ every `### ` line), and `in_fence` (toggled by a line matching FENCE_RE).
871
+ Lines inside a fence are skipped entirely — the findings template ships
872
+ fenced markdown examples containing `### V-001:` and `**Addresses:**`
873
+ literals, and counting those would fabricate findings or coverage.
874
+
875
+ Scoped recognitions (each ONLY under its stated scope):
876
+ * `### V-NNN:` while h2 == "Findings" -> append the id to `findings` in
877
+ document order (duplicates ignored: first wins).
878
+ * `#### Step N:` while h2 == "Fix Execution Plan" and
879
+ h3 == "Execution Steps" -> increment `steps`.
880
+ * `- **Addresses:** …` in the same scope -> every FINDING_ID_RE match on
881
+ that line joins the covered set. Scope is the SECTION, not an enclosing
882
+ step heading: a mis-numbered `#### Step` heading must not silently drop
883
+ the coverage it declares.
884
+ * `Total findings: N` while h2 == "Summary" -> the first such match sets
885
+ `claimedTotal`; later ones are ignored.
886
+
887
+ Args:
888
+ text: The full document text.
889
+
890
+ Returns:
891
+ A fully populated PlanCoverageReport.
892
+ """
893
+ h2: str | None = None
894
+ h3: str | None = None
895
+ in_fence = False
896
+ has_findings = False
897
+ has_plan = False
898
+ findings: list[str] = []
899
+ covered_ids: set[str] = set()
900
+ steps = 0
901
+ claimed_total: int | None = None
902
+
903
+ for line in text.splitlines():
904
+ if FENCE_RE.match(line):
905
+ in_fence = not in_fence
906
+ continue
907
+ if in_fence:
908
+ continue
909
+
910
+ heading2 = H2_RE.match(line)
911
+ if heading2:
912
+ h2 = heading2.group(1)
913
+ h3 = None
914
+ if h2 == "Findings":
915
+ has_findings = True
916
+ elif h2 == "Fix Execution Plan":
917
+ has_plan = True
918
+ continue
919
+
920
+ heading3 = H3_RE.match(line)
921
+ if heading3:
922
+ h3 = heading3.group(1)
923
+ finding = FINDING_RE.match(line)
924
+ if finding and h2 == "Findings":
925
+ finding_id = finding.group(1)
926
+ if finding_id not in findings:
927
+ findings.append(finding_id)
928
+ continue
929
+
930
+ in_steps = h2 == "Fix Execution Plan" and h3 == "Execution Steps"
931
+ if in_steps and STEP_RE.match(line):
932
+ steps += 1
933
+ continue
934
+ if in_steps and ADDRESSES_RE.match(line):
935
+ covered_ids.update(FINDING_ID_RE.findall(line))
936
+ continue
937
+ if h2 == "Summary" and claimed_total is None:
938
+ total = TOTAL_FINDINGS_RE.search(line)
939
+ if total:
940
+ claimed_total = int(total.group(1))
941
+
942
+ if not (has_findings and has_plan):
943
+ return PlanCoverageReport(
944
+ applicable=False,
945
+ findings=[],
946
+ steps=0,
947
+ covered=[],
948
+ uncovered=[],
949
+ claimedTotal=None,
950
+ actualTotal=0,
951
+ totalMismatch=False,
952
+ )
953
+
954
+ covered = [fid for fid in findings if fid in covered_ids]
955
+ uncovered = [fid for fid in findings if fid not in covered_ids]
956
+ actual_total = len(findings)
957
+ return PlanCoverageReport(
958
+ applicable=True,
959
+ findings=findings,
960
+ steps=steps,
961
+ covered=covered,
962
+ uncovered=uncovered,
963
+ claimedTotal=claimed_total,
964
+ actualTotal=actual_total,
965
+ totalMismatch=claimed_total is not None and claimed_total != actual_total,
966
+ )
967
+
968
+
969
+ def run_plan_coverage(doc_path: Path) -> PlanCoverageReport:
970
+ """Read and parse a findings document.
971
+
972
+ Args:
973
+ doc_path: The FINDINGS_DOC argument.
974
+
975
+ Returns:
976
+ The PlanCoverageReport from parse_findings_doc().
977
+
978
+ Raises:
979
+ UsageError: The path does not exist, is a directory, is unreadable, or
980
+ is not valid UTF-8 — exit 2. An unreadable path is an error,
981
+ while a readable-but-unrecognizable document is `applicable: false`.
982
+ """
983
+ try:
984
+ text = doc_path.read_text(encoding="utf-8")
985
+ except OSError as exc:
986
+ detail = exc.strerror or str(exc)
987
+ raise UsageError(
988
+ f"cannot read findings document: {doc_path} ({detail})"
989
+ ) from exc
990
+ except UnicodeDecodeError as exc:
991
+ raise UsageError(
992
+ f"cannot read findings document: {doc_path} (not valid UTF-8)"
993
+ ) from exc
994
+ return parse_findings_doc(text)
995
+
996
+
997
+ # ─────────────────────────────────────────────────────────────────────────────
998
+ # Rendering.
999
+ # ─────────────────────────────────────────────────────────────────────────────
1000
+
1001
+
1002
+ def render_sweep(report: SweepReport, json_output: bool) -> None:
1003
+ """Render a SweepReport to stdout.
1004
+
1005
+ With `--json`, stdout carries exactly one JSON object and no human lines.
1006
+ Otherwise the report is rendered in the check-spec-purity.py reporting
1007
+ style: a verdict line, then one indented row per hit. A skip always
1008
+ produces a visible line, so it is never silent on any surface
1009
+ (REQ-SWEEP-07).
1010
+
1011
+ Args:
1012
+ report: The payload from run_sweep().
1013
+ json_output: True when --json was passed.
1014
+ """
1015
+ if json_output:
1016
+ print(json.dumps(report, indent=2))
1017
+ return
1018
+
1019
+ if report["skipped"]:
1020
+ print(f"sweep: SKIPPED — no git delta ({report['reason']})")
1021
+ return
1022
+
1023
+ needle_count = len(report["needles"])
1024
+ files = report["filesScanned"]
1025
+ hits = report["hits"]
1026
+ if not hits:
1027
+ dropped = report["droppedNeedles"]
1028
+ print(
1029
+ f"sweep: PASS — 0 survivor(s) in {files} file(s) "
1030
+ f"({needle_count} needle(s), {dropped['belowFloor']} below floor, "
1031
+ f"{dropped['reflowSuppressed']} reflowed)."
1032
+ )
1033
+ return
1034
+
1035
+ print(
1036
+ f"sweep: FAIL — {len(hits)} survivor(s) in {files} file(s) "
1037
+ f"({needle_count} needle(s)):"
1038
+ )
1039
+ for hit in hits:
1040
+ text = hit["needle"].strip()
1041
+ print(
1042
+ f" {hit['file']}:{hit['line']}: survivor of \"{text}\" "
1043
+ f"(removed at {hit['sourceFile']}:{hit['sourceLine']})"
1044
+ )
1045
+
1046
+
1047
+ def render_plan_coverage(report: PlanCoverageReport, json_output: bool) -> None:
1048
+ """Render a PlanCoverageReport to stdout.
1049
+
1050
+ Both FAIL lines are printed when a document is both uncovered and
1051
+ mismatched.
1052
+
1053
+ Args:
1054
+ report: The payload from run_plan_coverage().
1055
+ json_output: True when --json was passed.
1056
+ """
1057
+ if json_output:
1058
+ print(json.dumps(report, indent=2))
1059
+ return
1060
+
1061
+ if not report["applicable"]:
1062
+ print(
1063
+ "plan-coverage: NOT APPLICABLE — no `## Findings` and/or "
1064
+ "`## Fix Execution Plan` section."
1065
+ )
1066
+ return
1067
+
1068
+ uncovered = report["uncovered"]
1069
+ if not uncovered and not report["totalMismatch"]:
1070
+ print(
1071
+ f"plan-coverage: PASS — {len(report['findings'])} finding(s), "
1072
+ f"{report['steps']} step(s), all covered."
1073
+ )
1074
+ return
1075
+
1076
+ if uncovered:
1077
+ print(f"plan-coverage: FAIL — {len(uncovered)} uncovered finding(s):")
1078
+ for finding_id in uncovered:
1079
+ print(
1080
+ f" {finding_id}: named in no execution step's "
1081
+ f"**Addresses:** field"
1082
+ )
1083
+ if report["totalMismatch"]:
1084
+ print(
1085
+ f"plan-coverage: FAIL — claimed {report['claimedTotal']}, actual "
1086
+ f"{report['actualTotal']} (`## Summary` total disagrees with "
1087
+ f"`### V-NNN:` count)"
1088
+ )
1089
+
1090
+
1091
+ # ─────────────────────────────────────────────────────────────────────────────
1092
+ # CLI.
1093
+ # ─────────────────────────────────────────────────────────────────────────────
1094
+
1095
+
1096
+ def _build_parser() -> argparse.ArgumentParser:
1097
+ """Build the parser with one subparser per subcommand (epic-manifest.py idiom).
1098
+
1099
+ Returns:
1100
+ The configured parser. `--json` is stored as `json_output` on both
1101
+ subcommands so `main()` can read it uniformly.
1102
+ """
1103
+ parser = argparse.ArgumentParser(prog="fix-sweep.py", description=__doc__)
1104
+ sub = parser.add_subparsers(dest="cmd", required=True)
1105
+
1106
+ p_sweep = sub.add_parser("sweep", help="Report survivors of corrected text")
1107
+ p_sweep.add_argument(
1108
+ "--repo-root",
1109
+ default=".",
1110
+ help="Directory inside the repository to sweep (default: cwd). The "
1111
+ "repository top level is resolved from it.",
1112
+ )
1113
+ p_sweep.add_argument(
1114
+ "--exclude",
1115
+ action="append",
1116
+ default=[],
1117
+ metavar="PREFIX",
1118
+ help="Additional repo-relative path prefix to exclude (repeatable).",
1119
+ )
1120
+ p_sweep.add_argument(
1121
+ "--min-chars",
1122
+ type=int,
1123
+ default=MIN_NEEDLE_CHARS,
1124
+ metavar="N",
1125
+ help=f"Minimum normalized needle length (default: {MIN_NEEDLE_CHARS}).",
1126
+ )
1127
+ p_sweep.add_argument(
1128
+ "--json", action="store_true", dest="json_output", help="Output as JSON"
1129
+ )
1130
+
1131
+ p_plan = sub.add_parser(
1132
+ "plan-coverage", help="Assert Fix Execution Plan coverage of the findings"
1133
+ )
1134
+ p_plan.add_argument(
1135
+ "findings_doc",
1136
+ metavar="FINDINGS_DOC",
1137
+ help="Path to a verification findings document",
1138
+ )
1139
+ p_plan.add_argument(
1140
+ "--json", action="store_true", dest="json_output", help="Output as JSON"
1141
+ )
1142
+ return parser
1143
+
1144
+
1145
+ def main() -> int:
1146
+ """Parse arguments, dispatch, and map exceptions to exit codes.
1147
+
1148
+ Returns:
1149
+ 0, 1, or 2 per the exit-code table in the module docstring.
1150
+ """
1151
+ args = _build_parser().parse_args()
1152
+ try:
1153
+ if args.cmd == "sweep":
1154
+ if args.min_chars < 1:
1155
+ raise UsageError("--min-chars must be >= 1")
1156
+ for prefix in args.exclude:
1157
+ if not prefix.strip():
1158
+ raise UsageError("--exclude requires a non-empty path prefix")
1159
+ report = run_sweep(
1160
+ start_dir=Path(args.repo_root),
1161
+ user_excludes=list(args.exclude),
1162
+ min_chars=args.min_chars,
1163
+ )
1164
+ render_sweep(report, args.json_output)
1165
+ return 1 if report["hits"] else 0
1166
+ if args.cmd == "plan-coverage":
1167
+ plan = run_plan_coverage(Path(args.findings_doc))
1168
+ render_plan_coverage(plan, args.json_output)
1169
+ return 1 if (plan["uncovered"] or plan["totalMismatch"]) else 0
1170
+ raise UsageError(f"unknown command: {args.cmd}")
1171
+ except UsageError as exc:
1172
+ print(f"Error: {exc}", file=sys.stderr)
1173
+ return 2
1174
+ except OSError as exc:
1175
+ print(f"Error: {exc}", file=sys.stderr)
1176
+ return 2
1177
+
1178
+
1179
+ if __name__ == "__main__":
1180
+ sys.exit(main())