@opengsd/gsd-core 1.18.0 → 1.20.0

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 (308) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.opencode/plugins/gsd-core.js +2 -3
  4. package/agents/gsd-code-fixer.compact.md +7 -7
  5. package/agents/gsd-code-fixer.md +13 -7
  6. package/agents/gsd-code-reviewer.compact.md +49 -8
  7. package/agents/gsd-code-reviewer.md +53 -14
  8. package/agents/gsd-executor.md +8 -4
  9. package/agents/gsd-verifier.md +1 -1
  10. package/bin/install.js +422 -103
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +179 -30
  13. package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
  14. package/gsd-core/bin/lib/args-terminator.cjs +114 -0
  15. package/gsd-core/bin/lib/broken-windows.cjs +59 -1
  16. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  17. package/gsd-core/bin/lib/capability-lock.cjs +13 -2
  18. package/gsd-core/bin/lib/capability-registry.cjs +376 -103
  19. package/gsd-core/bin/lib/capability-state.cjs +14 -8
  20. package/gsd-core/bin/lib/capability-validator.cjs +32 -0
  21. package/gsd-core/bin/lib/commands.cjs +27 -17
  22. package/gsd-core/bin/lib/config-loader.cjs +13 -3
  23. package/gsd-core/bin/lib/config.cjs +256 -58
  24. package/gsd-core/bin/lib/core-utils.cjs +45 -0
  25. package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
  26. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  27. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  28. package/gsd-core/bin/lib/gate-context-drift.cjs +9 -4
  29. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
  30. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  33. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  34. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
  35. package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
  36. package/gsd-core/bin/lib/git-info-exclude.cjs +52 -0
  37. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +31 -12
  38. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  39. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  40. package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
  41. package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
  42. package/gsd-core/bin/lib/host-integration.cjs +13 -6
  43. package/gsd-core/bin/lib/init.cjs +245 -68
  44. package/gsd-core/bin/lib/install-engine.cjs +246 -32
  45. package/gsd-core/bin/lib/install-profiles.cjs +9 -4
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
  47. package/gsd-core/bin/lib/io.cjs +6 -0
  48. package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
  49. package/gsd-core/bin/lib/markdown-sectionizer.cjs +147 -1
  50. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  51. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  52. package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
  54. package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
  55. package/gsd-core/bin/lib/milestone.cjs +61 -51
  56. package/gsd-core/bin/lib/model-catalog.cjs +8 -0
  57. package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
  58. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  59. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  60. package/gsd-core/bin/lib/phase-id.cjs +629 -38
  61. package/gsd-core/bin/lib/phase-lifecycle.cjs +12 -44
  62. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  63. package/gsd-core/bin/lib/phase.cjs +3066 -653
  64. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  65. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  66. package/gsd-core/bin/lib/planning-snapshot.cjs +15 -8
  67. package/gsd-core/bin/lib/planning-workspace.cjs +21 -3
  68. package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
  69. package/gsd-core/bin/lib/read-only-git-env.cjs +79 -0
  70. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  71. package/gsd-core/bin/lib/review-lane-descriptor.cjs +57 -2
  72. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  73. package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
  74. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  75. package/gsd-core/bin/lib/roadmap-parser.cjs +735 -137
  76. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  77. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -7
  78. package/gsd-core/bin/lib/roadmap.cjs +383 -123
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
  82. package/gsd-core/bin/lib/shell-command-projection.cjs +151 -19
  83. package/gsd-core/bin/lib/smart-entry.cjs +35 -11
  84. package/gsd-core/bin/lib/state-contract.cjs +39 -15
  85. package/gsd-core/bin/lib/state-document.cjs +344 -27
  86. package/gsd-core/bin/lib/state-transition.cjs +156 -32
  87. package/gsd-core/bin/lib/state.cjs +527 -186
  88. package/gsd-core/bin/lib/surface.cjs +9 -0
  89. package/gsd-core/bin/lib/task-command-router.cjs +5 -25
  90. package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
  91. package/gsd-core/bin/lib/template.cjs +2 -2
  92. package/gsd-core/bin/lib/validate.cjs +3 -2
  93. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  94. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  95. package/gsd-core/bin/lib/verification.cjs +96 -9
  96. package/gsd-core/bin/lib/verify.cjs +4 -2
  97. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  98. package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
  99. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  100. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  101. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  102. package/gsd-core/references/cicd-review-patterns.md +45 -0
  103. package/gsd-core/references/git-integration.md +11 -0
  104. package/gsd-core/references/loop-hook-dispatch.md +13 -6
  105. package/gsd-core/references/necessity-review.md +48 -0
  106. package/gsd-core/references/planning-config.md +2 -0
  107. package/gsd-core/references/tdd.md +2 -2
  108. package/gsd-core/templates/DEBUG.md +16 -0
  109. package/gsd-core/templates/config.json +2 -1
  110. package/gsd-core/templates/verification-report.md +1 -1
  111. package/gsd-core/workflows/audit-fix.md +2 -1
  112. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
  113. package/gsd-core/workflows/code-review-fix.md +46 -5
  114. package/gsd-core/workflows/code-review.md +64 -7
  115. package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
  116. package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
  117. package/gsd-core/workflows/complete-milestone.md +37 -150
  118. package/gsd-core/workflows/diagnose-issues.md +29 -5
  119. package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
  120. package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
  121. package/gsd-core/workflows/discuss-phase-power.md +19 -2
  122. package/gsd-core/workflows/discuss-phase.md +6 -10
  123. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
  124. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
  126. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
  127. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
  128. package/gsd-core/workflows/execute-phase.md +10 -6
  129. package/gsd-core/workflows/execute-plan.md +1 -1
  130. package/gsd-core/workflows/health.md +2 -1
  131. package/gsd-core/workflows/import.md +1 -1
  132. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
  133. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  134. package/gsd-core/workflows/plan-phase.md +14 -14
  135. package/gsd-core/workflows/pr-branch.md +2 -0
  136. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
  137. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
  138. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
  139. package/gsd-core/workflows/quick.md +1 -1
  140. package/gsd-core/workflows/reapply-patches.md +104 -0
  141. package/gsd-core/workflows/review.md +70 -154
  142. package/gsd-core/workflows/section-manifest.json +5 -0
  143. package/gsd-core/workflows/secure-phase.md +1 -1
  144. package/gsd-core/workflows/settings-advanced.md +57 -9
  145. package/gsd-core/workflows/settings.md +1 -0
  146. package/gsd-core/workflows/ship.md +15 -4
  147. package/gsd-core/workflows/spec-phase.md +3 -6
  148. package/gsd-core/workflows/verify-work.md +4 -3
  149. package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
  150. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  151. package/hooks/dist/gsd-graphify-update.sh +6 -0
  152. package/hooks/dist/gsd-path-export.js +95 -0
  153. package/hooks/dist/gsd-secret-read-guard.js +172 -39
  154. package/hooks/dist/gsd-statusline.js +37 -3
  155. package/hooks/dist/gsd-windsurf-pre-write.js +29 -2
  156. package/hooks/dist/gsd-workflow-guard.js +3 -2
  157. package/hooks/dist/gsd-worktree-path-guard.js +29 -2
  158. package/hooks/dist/lib/git-probe.js +16 -0
  159. package/hooks/dist/lib/isolation-sentinel.js +44 -0
  160. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  161. package/hooks/gsd-agent-isolation-guard.js +11 -1
  162. package/hooks/gsd-cursor-subagent-start.js +6 -2
  163. package/hooks/gsd-graphify-update.sh +6 -0
  164. package/hooks/gsd-path-export.js +95 -0
  165. package/hooks/gsd-secret-read-guard.js +172 -39
  166. package/hooks/gsd-statusline.js +37 -3
  167. package/hooks/gsd-windsurf-pre-write.js +29 -2
  168. package/hooks/gsd-workflow-guard.js +3 -2
  169. package/hooks/gsd-worktree-path-guard.js +29 -2
  170. package/hooks/hooks.json +1 -0
  171. package/hooks/lib/git-probe.js +16 -0
  172. package/hooks/lib/isolation-sentinel.js +44 -0
  173. package/hooks/managed-hooks-registry.cjs +1 -0
  174. package/package.json +15 -8
  175. package/pi/gsd.cjs +1047 -10
  176. package/pi/package-agents/gsd-advisor-researcher.md +113 -0
  177. package/pi/package-agents/gsd-ai-researcher.md +111 -0
  178. package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
  179. package/pi/package-agents/gsd-code-fixer.md +774 -0
  180. package/pi/package-agents/gsd-code-reviewer.md +441 -0
  181. package/pi/package-agents/gsd-codebase-mapper.md +850 -0
  182. package/pi/package-agents/gsd-debug-session-manager.md +416 -0
  183. package/pi/package-agents/gsd-debugger.md +1274 -0
  184. package/pi/package-agents/gsd-doc-classifier.md +269 -0
  185. package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
  186. package/pi/package-agents/gsd-doc-verifier.md +212 -0
  187. package/pi/package-agents/gsd-doc-writer.md +626 -0
  188. package/pi/package-agents/gsd-dom-verifier.md +164 -0
  189. package/pi/package-agents/gsd-domain-researcher.md +144 -0
  190. package/pi/package-agents/gsd-eval-auditor.md +185 -0
  191. package/pi/package-agents/gsd-eval-planner.md +149 -0
  192. package/pi/package-agents/gsd-executor.md +898 -0
  193. package/pi/package-agents/gsd-framework-selector.md +159 -0
  194. package/pi/package-agents/gsd-integration-checker.md +476 -0
  195. package/pi/package-agents/gsd-intel-updater.md +338 -0
  196. package/pi/package-agents/gsd-mempalace-curator.md +49 -0
  197. package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
  198. package/pi/package-agents/gsd-pattern-mapper.md +340 -0
  199. package/pi/package-agents/gsd-phase-researcher.md +897 -0
  200. package/pi/package-agents/gsd-plan-checker.md +1077 -0
  201. package/pi/package-agents/gsd-planner.md +1003 -0
  202. package/pi/package-agents/gsd-project-researcher.md +611 -0
  203. package/pi/package-agents/gsd-research-synthesizer.md +259 -0
  204. package/pi/package-agents/gsd-roadmapper.md +779 -0
  205. package/pi/package-agents/gsd-security-auditor.md +171 -0
  206. package/pi/package-agents/gsd-ui-auditor.md +581 -0
  207. package/pi/package-agents/gsd-ui-checker.md +417 -0
  208. package/pi/package-agents/gsd-ui-researcher.md +469 -0
  209. package/pi/package-agents/gsd-user-profiler.md +174 -0
  210. package/pi/package-agents/gsd-verifier.md +963 -0
  211. package/pi/package-agents/manifest.json +39 -0
  212. package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
  213. package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
  214. package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
  215. package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
  216. package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
  217. package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
  218. package/pi/package-skills/gsd-capture/SKILL.md +68 -0
  219. package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
  220. package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
  221. package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
  222. package/pi/package-skills/gsd-config/SKILL.md +60 -0
  223. package/pi/package-skills/gsd-debug/SKILL.md +64 -0
  224. package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
  225. package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
  226. package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
  227. package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
  228. package/pi/package-skills/gsd-explore/SKILL.md +26 -0
  229. package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
  230. package/pi/package-skills/gsd-fast/SKILL.md +33 -0
  231. package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
  232. package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
  233. package/pi/package-skills/gsd-health/SKILL.md +36 -0
  234. package/pi/package-skills/gsd-help/SKILL.md +37 -0
  235. package/pi/package-skills/gsd-import/SKILL.md +46 -0
  236. package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
  237. package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
  238. package/pi/package-skills/gsd-manager/SKILL.md +46 -0
  239. package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
  240. package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
  241. package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
  242. package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
  243. package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
  244. package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
  245. package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
  246. package/pi/package-skills/gsd-next/SKILL.md +34 -0
  247. package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
  248. package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
  249. package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
  250. package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
  251. package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
  252. package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
  253. package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
  254. package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
  255. package/pi/package-skills/gsd-phase/SKILL.md +61 -0
  256. package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
  257. package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
  258. package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
  259. package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
  260. package/pi/package-skills/gsd-progress/SKILL.md +51 -0
  261. package/pi/package-skills/gsd-quick/SKILL.md +179 -0
  262. package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
  263. package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
  264. package/pi/package-skills/gsd-review/SKILL.md +50 -0
  265. package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
  266. package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
  267. package/pi/package-skills/gsd-settings/SKILL.md +30 -0
  268. package/pi/package-skills/gsd-ship/SKILL.md +27 -0
  269. package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
  270. package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
  271. package/pi/package-skills/gsd-spike/SKILL.md +53 -0
  272. package/pi/package-skills/gsd-stats/SKILL.md +22 -0
  273. package/pi/package-skills/gsd-surface/SKILL.md +177 -0
  274. package/pi/package-skills/gsd-thread/SKILL.md +29 -0
  275. package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
  276. package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
  277. package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
  278. package/pi/package-skills/gsd-undo/SKILL.md +38 -0
  279. package/pi/package-skills/gsd-update/SKILL.md +54 -0
  280. package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
  281. package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
  282. package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
  283. package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
  284. package/scripts/build-hooks.js +4 -0
  285. package/scripts/ci-next-health.cjs +246 -23
  286. package/scripts/command-contract-helpers.cjs +1 -1
  287. package/scripts/docs-guard-registry.cjs +22 -7
  288. package/scripts/gen-features.cjs +14 -2
  289. package/scripts/gen-health-docs.cjs +13 -1
  290. package/scripts/gen-pi-package-skills.cjs +217 -0
  291. package/scripts/lib/drift-scan.cjs +27 -0
  292. package/scripts/lib/macos-conformance-tier.generated.cjs +17 -1
  293. package/scripts/lib/platform-conformance-tier.generated.cjs +37 -1
  294. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
  295. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  296. package/scripts/lint-retired-runtime-name.cjs +5 -0
  297. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  298. package/scripts/mutation-matrix.cjs +23 -1
  299. package/scripts/npm-audit-baseline.cjs +41 -7
  300. package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
  301. package/scripts/select-docs-guards.cjs +1 -1
  302. package/scripts/self-test-report-policy.cjs +350 -0
  303. package/scripts/sync-next-version.cjs +38 -6
  304. package/skills/gsd-update/SKILL.md +5 -2
  305. package/vscode/package.json +1 -1
  306. package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
  307. package/scripts/diagnose-validate-commit-phases.cjs +0 -348
  308. package/scripts/xtrace-phase-timing.cjs +0 -205
@@ -29,6 +29,14 @@
29
29
  * # documented #934 advisory posture, but
30
30
  * # its coverage is ALWAYS headline-reported.
31
31
  *
32
+ * [--hunks] # pre-merge mode (#4435): per-hunk stock vs
33
+ * # user text, content hash, recommendation
34
+ * # and exception reasons; always exits 0
35
+ * [--decisions <path>] # gate mode only (#4435): JSON file of human
36
+ * # keep/retire/rewrite decisions bound to
37
+ * # hunk content hashes; retire/rewrite drop
38
+ * # that hunk's required lines from the gate
39
+ *
32
40
  * Exit codes (default gate mode):
33
41
  * 0 — every user-added line is present in the merged file (gate passes)
34
42
  * 1 — at least one missing line in at least one file (gate fails); outranks
@@ -60,12 +68,15 @@ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
60
68
  // release's writer dropped). Same module the installer's preserve-check uses,
61
69
  // so the two readers cannot drift apart again.
62
70
  const { findPristineByHash, findPristineInGit } = require('./lib/pristine-baseline.cjs');
71
+ // #4435: line matcher (Myers, edit-distance capped) used to split a backup into
72
+ // per-hunk user/stock regions and to locate upstream changes over the baseline.
73
+ const { matchLines } = require('./lib/markdown-line-diff.cjs');
63
74
 
64
75
  const SIGNIFICANT_MIN_CHARS = 12;
65
76
  const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i;
66
77
 
67
78
  function parseArgs(argv) {
68
- const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false, classify: false, minBaselineCoverage: null };
79
+ const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false, classify: false, minBaselineCoverage: null, hunks: false, decisionsPath: null };
69
80
  for (let i = 0; i < argv.length; i++) {
70
81
  const arg = argv[i];
71
82
  if (arg === '--patches-dir') opts.patchesDir = argv[++i];
@@ -73,7 +84,12 @@ function parseArgs(argv) {
73
84
  else if (arg === '--pristine-dir') opts.pristineDir = argv[++i];
74
85
  else if (arg === '--json') opts.json = true;
75
86
  else if (arg === '--classify') opts.classify = true;
76
- else if (arg === '--min-baseline-coverage') {
87
+ else if (arg === '--hunks') opts.hunks = true;
88
+ else if (arg === '--decisions') {
89
+ const raw = argv[++i];
90
+ if (raw === undefined) throw new ExitError(2, '--decisions requires a path');
91
+ opts.decisionsPath = raw;
92
+ } else if (arg === '--min-baseline-coverage') {
77
93
  const raw = argv[++i];
78
94
  if (raw === undefined) {
79
95
  throw new ExitError(2, '--min-baseline-coverage requires a value between 0 and 1');
@@ -85,7 +101,7 @@ function parseArgs(argv) {
85
101
  opts.minBaselineCoverage = value;
86
102
  } else if (arg === '--help' || arg === '-h') {
87
103
  process.stdout.write(
88
- 'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json] [--classify] [--min-baseline-coverage <0..1>]\n',
104
+ 'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json] [--classify] [--hunks] [--decisions <path>] [--min-baseline-coverage <0..1>]\n',
89
105
  );
90
106
  throw new ExitError(0);
91
107
  } else {
@@ -469,7 +485,279 @@ function coverageHeadline(covered, total) {
469
485
  return unverified > 0 ? `${base} (${unverified} unverified)` : base;
470
486
  }
471
487
 
472
- function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashes, skillsRedirect }) {
488
+ // ── #4435: per-hunk model ────────────────────────────────────────────────
489
+ const HUNK_REASON = Object.freeze({
490
+ BASELINE_UNCONFIRMED: 'baseline_unconfirmed',
491
+ DIFF_TOO_LARGE: 'diff_too_large',
492
+ ALREADY_UPSTREAM: 'already_upstream',
493
+ PARTIALLY_UPSTREAM: 'partially_upstream',
494
+ ANCHOR_ORPHANED: 'anchor_orphaned',
495
+ REGION_CHANGED_UPSTREAM: 'region_changed_upstream',
496
+ });
497
+ const HUNK_RECOMMENDATION = Object.freeze({ KEEP: 'keep', RETIRE: 'retire', REVIEW: 'review' });
498
+ const DISPOSITION = Object.freeze({ KEEP: 'keep', RETIRE: 'retire', REWRITE: 'rewrite' });
499
+
500
+ /**
501
+ * #4435: split a backup into hunks against the pristine baseline. Pure. The
502
+ * union of every hunk's required_lines equals computeUserAddedLines(backup,
503
+ * pristine) — same significance + normalization predicate.
504
+ */
505
+ function computeHunks(fileKey, backupContent, pristineContent) {
506
+ const B = backupContent.split(/\r?\n/);
507
+ if (!pristineContent) {
508
+ const required = computeUserAddedLines(backupContent, null);
509
+ if (required.length === 0) return [];
510
+ return numberHunks(fileKey, [{
511
+ kind: 'file',
512
+ user_lines: B.filter(isSignificantLine),
513
+ stock_lines: [],
514
+ required_lines: required,
515
+ anchor_before: null,
516
+ anchor_after: null,
517
+ stock_range: null,
518
+ diff_too_large: false,
519
+ }]);
520
+ }
521
+ const P = pristineContent.split(/\r?\n/);
522
+ const nP = P.map(normalizeUpstreamOwnedLine);
523
+ const nB = B.map(normalizeUpstreamOwnedLine);
524
+ const pristineSet = new Set(nP);
525
+ const isRequired = (line) =>
526
+ isSignificantLine(line) && !pristineSet.has(normalizeUpstreamOwnedLine(line));
527
+ const m = matchLines(nP, nB);
528
+ if (m === null) {
529
+ const required = B.filter(isRequired);
530
+ if (required.length === 0) return [];
531
+ return numberHunks(fileKey, [{
532
+ kind: 'replacement',
533
+ user_lines: required,
534
+ stock_lines: [],
535
+ required_lines: required.slice(),
536
+ anchor_before: null,
537
+ anchor_after: null,
538
+ stock_range: null,
539
+ diff_too_large: true,
540
+ }]);
541
+ }
542
+ const hunks = [];
543
+ let prevP = -1;
544
+ let run = [];
545
+ const flush = (s, e) => {
546
+ const userLines = run;
547
+ run = [];
548
+ const stockLines = P.slice(s, e);
549
+ if (!userLines.some(isSignificantLine) && !stockLines.some(isSignificantLine)) return;
550
+ const kind = userLines.length && stockLines.length ? 'replacement' : userLines.length ? 'addition' : 'deletion';
551
+ let before = null;
552
+ for (let i = s - 1; i >= 0; i--) {
553
+ if (isSignificantLine(P[i])) { before = P[i]; break; }
554
+ }
555
+ let after = null;
556
+ for (let i = e; i < P.length; i++) {
557
+ if (isSignificantLine(P[i])) { after = P[i]; break; }
558
+ }
559
+ hunks.push({
560
+ kind,
561
+ user_lines: userLines,
562
+ stock_lines: stockLines,
563
+ required_lines: userLines.filter(isRequired),
564
+ anchor_before: before,
565
+ anchor_after: after,
566
+ stock_range: [s, e],
567
+ diff_too_large: false,
568
+ });
569
+ };
570
+ for (let j = 0; j < B.length; j++) {
571
+ if (m[j] === -1) {
572
+ run.push(B[j]);
573
+ } else {
574
+ flush(prevP + 1, m[j]);
575
+ prevP = m[j];
576
+ }
577
+ }
578
+ flush(prevP + 1, P.length);
579
+ return numberHunks(fileKey, hunks);
580
+ }
581
+
582
+ function numberHunks(fileKey, hunks) {
583
+ return hunks.map((h, i) => ({
584
+ hunk_id: i + 1,
585
+ hunk_hash: sha256(JSON.stringify([fileKey, h.kind, h.user_lines, h.stock_lines, h.anchor_before, h.anchor_after, h.stock_range])),
586
+ kind: h.kind,
587
+ user_lines: h.user_lines,
588
+ stock_lines: h.stock_lines,
589
+ required_lines: h.required_lines,
590
+ anchor_before: h.anchor_before,
591
+ anchor_after: h.anchor_after,
592
+ stock_range: h.stock_range,
593
+ diff_too_large: h.diff_too_large,
594
+ }));
595
+ }
596
+
597
+ /**
598
+ * #4435: annotate hunks with presence in the fresh install, closed-vocabulary
599
+ * reasons and a recommendation. Pure; strips stock_range / diff_too_large.
600
+ */
601
+ function annotateHunks(hunks, { pristineContent, installedContent, baselineConfirmed }) {
602
+ const installed = installedContent == null ? '' : installedContent;
603
+ const iLines = new Set(installed.split(/\r?\n/).map((l) => normalizeUpstreamOwnedLine(l).trim()));
604
+ let upstreamUnknown = false;
605
+ const intervals = [];
606
+ if (pristineContent && hunks.some((h) => h.stock_range)) {
607
+ const P = pristineContent.split(/\r?\n/);
608
+ const nP = P.map(normalizeUpstreamOwnedLine);
609
+ const nI = installed.split(/\r?\n/).map(normalizeUpstreamOwnedLine);
610
+ const u = matchLines(nP, nI);
611
+ if (u === null) {
612
+ upstreamUnknown = true;
613
+ } else {
614
+ let prevP = -1;
615
+ let pendingInsert = false;
616
+ for (let k = 0; k < nI.length; k++) {
617
+ if (u[k] === -1) {
618
+ pendingInsert = true;
619
+ } else {
620
+ if (pendingInsert || u[k] > prevP + 1) intervals.push([prevP + 1, u[k]]);
621
+ pendingInsert = false;
622
+ prevP = u[k];
623
+ }
624
+ }
625
+ if (pendingInsert || P.length > prevP + 1) intervals.push([prevP + 1, P.length]);
626
+ }
627
+ }
628
+ const touches = ([s, e]) => intervals.some(([a, b]) => a <= e && s <= b);
629
+ const inInstall = (l) => iLines.has(normalizeUpstreamOwnedLine(l).trim());
630
+
631
+ return hunks.map((h) => {
632
+ const required = h.required_lines;
633
+ const n = required.filter((l) => installed.includes(l)).length;
634
+ const present = required.length === 0 ? 'n/a' : n === required.length ? 'all' : n === 0 ? 'none' : 'some';
635
+ const reasons = [];
636
+ if (!baselineConfirmed) reasons.push(HUNK_REASON.BASELINE_UNCONFIRMED);
637
+ if (h.diff_too_large || (upstreamUnknown && h.stock_range)) reasons.push(HUNK_REASON.DIFF_TOO_LARGE);
638
+ const stockSig = h.stock_lines.filter(isSignificantLine);
639
+ if (
640
+ (h.kind !== 'deletion' && present === 'all') ||
641
+ (h.kind === 'deletion' && stockSig.length > 0 && stockSig.every((l) => !inInstall(l)))
642
+ ) reasons.push(HUNK_REASON.ALREADY_UPSTREAM);
643
+ if (present === 'some') reasons.push(HUNK_REASON.PARTIALLY_UPSTREAM);
644
+ const anchors = [h.anchor_before, h.anchor_after].filter(Boolean);
645
+ if (anchors.length > 0 && anchors.every((a) => !inInstall(a))) reasons.push(HUNK_REASON.ANCHOR_ORPHANED);
646
+ if (h.stock_range && !upstreamUnknown && touches(h.stock_range)) reasons.push(HUNK_REASON.REGION_CHANGED_UPSTREAM);
647
+ let recommendation;
648
+ if (reasons.includes(HUNK_REASON.BASELINE_UNCONFIRMED) || reasons.includes(HUNK_REASON.DIFF_TOO_LARGE)) {
649
+ recommendation = HUNK_RECOMMENDATION.REVIEW;
650
+ } else if (reasons.includes(HUNK_REASON.ALREADY_UPSTREAM)) {
651
+ recommendation = HUNK_RECOMMENDATION.RETIRE;
652
+ } else if (reasons.length > 0) {
653
+ recommendation = HUNK_RECOMMENDATION.REVIEW;
654
+ } else {
655
+ recommendation = HUNK_RECOMMENDATION.KEEP;
656
+ }
657
+ const { stock_range: _sr, diff_too_large: _dtl, ...rest } = h;
658
+ return { ...rest, present_in_install: present, reasons, recommendation, exception: recommendation !== HUNK_RECOMMENDATION.KEEP };
659
+ });
660
+ }
661
+
662
+ /**
663
+ * #4435: per-file hunk record shared by --hunks and --decisions validation.
664
+ */
665
+ function buildFileHunks({ relPath, patchesDir, configDir, pristineDir, pristineHashes, skillsRedirect }) {
666
+ const out = { file: relPath, baseline: 'none', reason: null, hunks: [], installedContent: null, pristineContent: null };
667
+ const backupPath = path.join(patchesDir, relPath);
668
+ if (!fs.existsSync(backupPath) || !fs.statSync(backupPath).isFile()) return out;
669
+ let backupContent;
670
+ try {
671
+ backupContent = fs.readFileSync(backupPath, 'utf8');
672
+ } catch {
673
+ out.reason = REASON.FAIL_READ_ERROR;
674
+ return out;
675
+ }
676
+ const installedPath = resolveInstalledPath(configDir, relPath, skillsRedirect);
677
+ try {
678
+ const st = fs.statSync(installedPath);
679
+ if (!st.isFile()) out.reason = REASON.FAIL_INSTALLED_NOT_REGULAR_FILE;
680
+ else out.installedContent = fs.readFileSync(installedPath, 'utf8');
681
+ } catch (err) {
682
+ out.reason = err && err.code === 'ENOENT' ? REASON.FAIL_INSTALLED_MISSING : REASON.FAIL_READ_ERROR;
683
+ }
684
+ const { resolution, content } = resolvePristineBaseline({ relPath, configDir, pristineDir, pristineHashes });
685
+ let pristine = null;
686
+ if (resolution === PRISTINE_RESOLUTION.VALIDATED) {
687
+ out.baseline = 'validated';
688
+ pristine = content;
689
+ } else if (resolution === PRISTINE_RESOLUTION.UNVALIDATED) {
690
+ out.baseline = 'unvalidated';
691
+ pristine = content;
692
+ } else if (resolution === PRISTINE_RESOLUTION.DRIFTED) {
693
+ out.reason = REASON.OK_PRISTINE_DRIFT_DETECTED;
694
+ } else if (resolution === PRISTINE_RESOLUTION.ABSENT_RECORDED) {
695
+ out.reason = REASON.OK_NO_BASELINE;
696
+ }
697
+ out.pristineContent = pristine;
698
+ out.hunks = computeHunks(relPath.replace(/\\/g, '/'), backupContent, pristine);
699
+ return out;
700
+ }
701
+
702
+ /**
703
+ * #4435: strict loader for the human decisions file. Every malformed shape is
704
+ * a usage error (exit 2) — a decision file is a trust boundary.
705
+ */
706
+ function loadDecisions(decisionsPath) {
707
+ let raw;
708
+ try {
709
+ raw = fs.readFileSync(decisionsPath, 'utf8');
710
+ } catch (err) {
711
+ throw new ExitError(2, `cannot read decisions file: ${decisionsPath}${err && err.code ? ` (${err.code})` : ''}`);
712
+ }
713
+ let parsed;
714
+ try {
715
+ parsed = JSON.parse(raw);
716
+ } catch {
717
+ throw new ExitError(2, `decisions file is not valid JSON: ${decisionsPath}`);
718
+ }
719
+ const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
720
+ if (!isPlain(parsed) || !Array.isArray(parsed.decisions)) {
721
+ throw new ExitError(2, 'decisions file must be an object with a "decisions" array');
722
+ }
723
+ const dispositions = new Set(Object.values(DISPOSITION));
724
+ const byKey = new Map();
725
+ parsed.decisions.forEach((d, i) => {
726
+ if (!isPlain(d)) throw new ExitError(2, `invalid decision at index ${i}: not an object`);
727
+ if (typeof d.file !== 'string' || d.file === '') throw new ExitError(2, `invalid decision at index ${i}: file must be a non-empty string`);
728
+ if (typeof d.hunk_hash !== 'string' || !/^[0-9a-f]{64}$/.test(d.hunk_hash)) {
729
+ throw new ExitError(2, `invalid decision at index ${i}: hunk_hash must be 64 lowercase hex characters`);
730
+ }
731
+ if (typeof d.disposition !== 'string' || !dispositions.has(d.disposition)) {
732
+ throw new ExitError(2, `invalid decision at index ${i}: disposition must be one of ${[...dispositions].join(', ')}`);
733
+ }
734
+ // #4435: a rewrite must carry the user's replacement text so the gate can
735
+ // verify it landed; keep/retire must not carry one.
736
+ let replacement = null;
737
+ if (d.disposition === DISPOSITION.REWRITE) {
738
+ if (!Array.isArray(d.replacement_lines) || d.replacement_lines.length === 0 || !d.replacement_lines.every((x) => typeof x === 'string')) {
739
+ throw new ExitError(2, `invalid decision at index ${i}: rewrite requires a non-empty replacement_lines array of strings`);
740
+ }
741
+ replacement = d.replacement_lines.slice();
742
+ } else if (Object.prototype.hasOwnProperty.call(d, 'replacement_lines')) {
743
+ throw new ExitError(2, `invalid decision at index ${i}: replacement_lines is allowed only for rewrite`);
744
+ }
745
+ const file = d.file.replace(/\\/g, '/');
746
+ const key = `${file}\0${d.hunk_hash}`;
747
+ const prior = byKey.get(key);
748
+ if (prior) {
749
+ if (prior.disposition !== d.disposition
750
+ || JSON.stringify(prior.replacement_lines) !== JSON.stringify(replacement)) {
751
+ throw new ExitError(2, `conflicting decisions for ${file} ${d.hunk_hash}`);
752
+ }
753
+ return;
754
+ }
755
+ byKey.set(key, { file, hunk_hash: d.hunk_hash, disposition: d.disposition, replacement_lines: replacement });
756
+ });
757
+ return [...byKey.values()];
758
+ }
759
+
760
+ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashes, skillsRedirect, retiredHashes, extraRequired }) {
473
761
  const backupPath = path.join(patchesDir, relPath);
474
762
  const installedPath = resolveInstalledPath(configDir, relPath, skillsRedirect);
475
763
  const result = { file: relPath, status: 'ok', missing: [], reason: null };
@@ -535,7 +823,16 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe
535
823
  // content (null for OVERBROAD) feeds computeUserAddedLines, whose no-pristine
536
824
  // branch is the over-broad fallback.
537
825
 
538
- const userAdded = computeUserAddedLines(backupContent, pristineContent);
826
+ let userAdded = computeUserAddedLines(backupContent, pristineContent);
827
+ // #4435: a hunk retired/rewritten by a recorded human decision (bound to its
828
+ // content hash) stops being required; every other hunk's lines still are.
829
+ if (retiredHashes && retiredHashes.size > 0) {
830
+ userAdded = computeHunks(relPath.replace(/\\/g, '/'), backupContent, pristineContent)
831
+ .filter((h) => !retiredHashes.has(h.hunk_hash))
832
+ .flatMap((h) => h.required_lines);
833
+ }
834
+ // #4435: a rewrite's user-supplied replacement must be present in the merge.
835
+ if (extraRequired && extraRequired.length > 0) userAdded = userAdded.concat(extraRequired);
539
836
  if (userAdded.length === 0) {
540
837
  // Backup and pristine match exactly (or no significant content) — nothing
541
838
  // to verify but also nothing to lose. Report as ok with diagnostic code.
@@ -654,6 +951,12 @@ function classifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHas
654
951
 
655
952
  function main() {
656
953
  const opts = parseArgs(process.argv.slice(2));
954
+ if (opts.hunks && opts.classify) {
955
+ throw new ExitError(2, '--hunks and --classify are mutually exclusive');
956
+ }
957
+ if (opts.decisionsPath !== null && (opts.hunks || opts.classify)) {
958
+ throw new ExitError(2, '--decisions applies only to the post-merge gate');
959
+ }
657
960
  if (!opts.patchesDir || !opts.configDir) {
658
961
  throw new ExitError(2, '--patches-dir and --config-dir are required');
659
962
  }
@@ -672,6 +975,50 @@ function main() {
672
975
  // `home` override outside configDir (codex global → $HOME/.agents/skills).
673
976
  const skillsRedirect = resolveSkillsRedirect(opts.configDir);
674
977
 
978
+ // #4435: --hunks is the pre-merge per-hunk presentation mode. It recommends;
979
+ // it never decides and never gates (always exit 0).
980
+ if (opts.hunks) {
981
+ const fileRecords = files.map((relPath) => {
982
+ const rec = buildFileHunks({
983
+ relPath,
984
+ patchesDir: opts.patchesDir,
985
+ configDir: opts.configDir,
986
+ pristineDir: opts.pristineDir,
987
+ pristineHashes,
988
+ skillsRedirect,
989
+ });
990
+ return {
991
+ file: rec.file,
992
+ baseline: rec.baseline,
993
+ reason: rec.reason,
994
+ hunks: annotateHunks(rec.hunks, {
995
+ pristineContent: rec.pristineContent,
996
+ installedContent: rec.installedContent,
997
+ baselineConfirmed: rec.baseline === 'validated',
998
+ }),
999
+ };
1000
+ });
1001
+ const hunksTotal = fileRecords.reduce((n, f) => n + f.hunks.length, 0);
1002
+ const exceptionsTotal = fileRecords.reduce((n, f) => n + f.hunks.filter((h) => h.exception).length, 0);
1003
+ if (opts.json) {
1004
+ process.stdout.write(
1005
+ JSON.stringify({ checked: fileRecords.length, hunks_total: hunksTotal, exceptions_total: exceptionsTotal, files: fileRecords }, null, 2) + '\n',
1006
+ );
1007
+ } else {
1008
+ process.stdout.write(`# Reapply Patch Hunks (#4435)\n\n`);
1009
+ process.stdout.write(`Checked: ${fileRecords.length} file(s)\n`);
1010
+ process.stdout.write(`Hunks: ${hunksTotal} (${exceptionsTotal} exception(s))\n`);
1011
+ for (const f of fileRecords) {
1012
+ for (const h of f.hunks) {
1013
+ if (h.exception) {
1014
+ process.stdout.write(`- ${f.file} hunk ${h.hunk_id} [${h.recommendation}] ${h.reasons.join(', ')}\n`);
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+ return 0;
1020
+ }
1021
+
675
1022
  // Bug #4136: --classify is the pre-merge mode the workflow runs in Step 4
676
1023
  // to decide which files NOT to merge. It is informational — always exit 0 —
677
1024
  // because the binding enforcement stays with the post-merge gate below;
@@ -720,6 +1067,47 @@ function main() {
720
1067
  return 0;
721
1068
  }
722
1069
 
1070
+ // #4435: validate every decision against the current hunks BEFORE verifying.
1071
+ // A decision whose hash no longer matches a hunk is stale and aborts (exit 2).
1072
+ let decisions = null;
1073
+ const retiredByFile = new Map();
1074
+ const retiredList = [];
1075
+ const rewriteLinesByFile = new Map();
1076
+ if (opts.decisionsPath !== null) {
1077
+ decisions = loadDecisions(opts.decisionsPath);
1078
+ const fileSet = new Set(files.map((f) => f.replace(/\\/g, '/')));
1079
+ const relByKey = new Map(files.map((f) => [f.replace(/\\/g, '/'), f]));
1080
+ const hunkCache = new Map();
1081
+ for (const d of decisions) {
1082
+ if (!fileSet.has(d.file)) {
1083
+ throw new ExitError(2, `decision names a file not in the patches dir: ${d.file}`);
1084
+ }
1085
+ if (!hunkCache.has(d.file)) {
1086
+ hunkCache.set(d.file, buildFileHunks({
1087
+ relPath: relByKey.get(d.file),
1088
+ patchesDir: opts.patchesDir,
1089
+ configDir: opts.configDir,
1090
+ pristineDir: opts.pristineDir,
1091
+ pristineHashes,
1092
+ skillsRedirect,
1093
+ }).hunks);
1094
+ }
1095
+ const hunk = hunkCache.get(d.file).find((h) => h.hunk_hash === d.hunk_hash);
1096
+ if (!hunk) {
1097
+ throw new ExitError(2, `stale decision: hunk ${d.hunk_hash} no longer matches ${d.file} (the backup or baseline changed since it was shown)`);
1098
+ }
1099
+ if (d.disposition === DISPOSITION.RETIRE || d.disposition === DISPOSITION.REWRITE) {
1100
+ if (!retiredByFile.has(d.file)) retiredByFile.set(d.file, new Set());
1101
+ retiredByFile.get(d.file).add(d.hunk_hash);
1102
+ retiredList.push({ file: d.file, hunk_id: hunk.hunk_id, hunk_hash: d.hunk_hash, disposition: d.disposition });
1103
+ if (d.disposition === DISPOSITION.REWRITE) {
1104
+ if (!rewriteLinesByFile.has(d.file)) rewriteLinesByFile.set(d.file, []);
1105
+ rewriteLinesByFile.get(d.file).push(...d.replacement_lines.filter(isSignificantLine));
1106
+ }
1107
+ }
1108
+ }
1109
+ }
1110
+
723
1111
  const results = files.map((relPath) =>
724
1112
  verifyFile({
725
1113
  relPath,
@@ -728,6 +1116,8 @@ function main() {
728
1116
  pristineDir: opts.pristineDir,
729
1117
  pristineHashes,
730
1118
  skillsRedirect,
1119
+ retiredHashes: retiredByFile.get(relPath.replace(/\\/g, '/')),
1120
+ extraRequired: rewriteLinesByFile.get(relPath.replace(/\\/g, '/')),
731
1121
  }),
732
1122
  );
733
1123
 
@@ -770,9 +1160,9 @@ function main() {
770
1160
  }
771
1161
 
772
1162
  if (opts.json) {
773
- process.stdout.write(
774
- JSON.stringify({ checked: results.length, failures: failures.length, drifted, drifted_files, no_baseline, no_baseline_files, baseline_covered, results }, null, 2) + '\n',
775
- );
1163
+ const report = { checked: results.length, failures: failures.length, drifted, drifted_files, no_baseline, no_baseline_files, baseline_covered, results };
1164
+ if (decisions) report.decisions = { applied: decisions.length, retired: retiredList };
1165
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
776
1166
  } else {
777
1167
  process.stdout.write(`# Hunk Verification Gate (#2969)\n\n`);
778
1168
  process.stdout.write(`Checked: ${results.length} file(s)\n`);
@@ -780,7 +1170,18 @@ function main() {
780
1170
  // #4135: the coverage headline is printed on EVERY run, before any
781
1171
  // per-file detail, so a near-zero-coverage green run can never render
782
1172
  // identically to a fully-verified one.
783
- process.stdout.write(`${coverageHeadline(baseline_covered, results.length)}\n\n`);
1173
+ process.stdout.write(`${coverageHeadline(baseline_covered, results.length)}\n`);
1174
+ if (decisions) {
1175
+ process.stdout.write(`Decisions: ${decisions.length} applied, ${retiredList.length} hunk(s) retired by user decision\n`);
1176
+ }
1177
+ process.stdout.write('\n');
1178
+ if (decisions && retiredList.length > 0) {
1179
+ process.stdout.write(`## Hunks retired or rewritten by user decision\n\n`);
1180
+ for (const r of retiredList) {
1181
+ process.stdout.write(`- ${r.file} hunk ${r.hunk_id} (${r.disposition})\n`);
1182
+ }
1183
+ process.stdout.write('\n');
1184
+ }
784
1185
  if (baseline_covered < results.length) {
785
1186
  if (!opts.pristineDir) {
786
1187
  process.stdout.write(`No --pristine-dir: over-broad fallback — nothing was verified against a pristine baseline (#2998).\n\n`);
@@ -821,5 +1222,5 @@ if (require.main === module) {
821
1222
  runMain(main);
822
1223
  }
823
1224
 
824
- module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, classifyFile, walk, REASON, CLASSIFICATION, PRISTINE_RESOLUTION, resolvePristineBaseline, readPristineHashes, sha256, resolveInstalledPath, resolveSkillsRedirect, coverageHeadline, classifyBaselineCoverage };
1225
+ module.exports = { computeHunks, annotateHunks, buildFileHunks, HUNK_REASON, HUNK_RECOMMENDATION, DISPOSITION, computeUserAddedLines, isSignificantLine, verifyFile, classifyFile, walk, REASON, CLASSIFICATION, PRISTINE_RESOLUTION, resolvePristineBaseline, readPristineHashes, sha256, resolveInstalledPath, resolveSkillsRedirect, coverageHeadline, classifyBaselineCoverage };
825
1226
 
@@ -0,0 +1,45 @@
1
+ # CI/CD Pipeline Review Patterns
2
+
3
+ Rubric for the **CI/CD pipelines** entry of `gsd-code-reviewer`'s language-aware checks. Read at `standard` and `deep` depth only, when a file in scope is a pipeline file. These defects rarely fail a test. They show up at release or deploy time, as a broken release or a bad production deploy.
4
+
5
+ Pipeline files are recognized by path, not extension: `.github/workflows/*.yml` / `*.yaml`, `Dockerfile*` / `Containerfile`, `docker-compose*.yml` / `compose*.yaml`, and deploy scripts (`deploy*.sh` and equivalents). A deploy script is also a Shell file: apply both sets of checks.
6
+
7
+ Adapted from the `ci-cd-reliability-architecture` skill in [l-gevity/l-gevity-skills](https://github.com/l-gevity/l-gevity-skills) (MIT), reduced to what is checkable from file contents at review time (#4101).
8
+
9
+ ## Checks
10
+
11
+ | Check | Signal | Why it matters | Severity |
12
+ |---|---|---|---|
13
+ | **Non-idempotent step** | `npm install` where a `package-lock.json` exists (should be `npm ci`); `pip install` with no lock or pinned requirements; `mkdir` without `-p`; an unguarded `rm`; delete-then-create of a live resource | A re-run produces a different result, or fails part-way and leaves the target broken | Warning |
14
+ | **Missing timeout** | A job with `runs-on:` and no job-level `timeout-minutes:` | A stuck job holds its runner until the 6-hour default | Warning |
15
+ | **Missing concurrency guard** | A deploy workflow with no `concurrency` group and `cancel-in-progress: true` | Two pushes run in parallel; the older run can finish last and deploy over the newer one | Warning |
16
+ | **Per-environment rebuild** | The same commit is built separately for each environment instead of build-once/promote | The artifact that reaches production is not the artifact that passed CI | Warning |
17
+ | **Deploy without health check or rollback** | A deploy step with no post-deploy health check and no rollback path | A bad deploy stays live until a human notices | Warning |
18
+ | **Load-bearing `continue-on-error`** | `continue-on-error: true` on a step whose success later steps assume | The failure is masked; later steps run on a broken state and the job reports green | Warning |
19
+ | **Static cloud credentials** | Long-lived cloud keys stored as secrets for a provider that supports OIDC (`permissions: id-token: write`) | A leaked key stays valid until someone rotates it; OIDC tokens expire per run | Warning |
20
+ | **Unpinned third-party action** | `uses: owner/action@main`, `@master` or `@latest` | An unpinned ref lets the action's code change between two runs of the same commit | Warning |
21
+
22
+ A hardcoded secret in a pipeline file stays Critical under the existing secrets rule. Style-level pipeline findings (step naming, step ordering, redundant checkout options) are Info.
23
+
24
+ ## Evidence rule
25
+
26
+ Cite the line. For the concurrency, rebuild and rollback checks, also name what makes the workflow a deploy workflow (the trigger, the environment, the deploy command): those checks are about deploys, not every workflow.
27
+
28
+ ## Negative space: NOT findings
29
+
30
+ - **Global tool installs.** `npm install -g <tool>` installs a tool, not the project's dependency tree. A repository with no `package-lock.json` has nothing for `npm ci` to read.
31
+ - **Reusable workflow calls.** A job that calls a reusable workflow (`uses:` at the job level) cannot set `timeout-minutes`; the called workflow owns its timeouts.
32
+ - **Pinned refs.** A version tag (`@v4`) or a full commit SHA is a pin. Local actions (`./path`) and `docker://` references are not third-party action refs.
33
+ - **CI-only workflows.** `cancel-in-progress` is required on deploy workflows. On a workflow that only runs tests it is an optimization, not a Warning.
34
+ - **Informational steps.** `continue-on-error` on a step nothing depends on (a coverage upload, a notification) masks nothing.
35
+ - **Providers without OIDC.** A secret for a provider that does not support OIDC is not a static-credential finding.
36
+
37
+ ## Example
38
+
39
+ ```markdown
40
+ ### WR-02: Deploy workflow lacks concurrency guard — stale commit can deploy last
41
+
42
+ **File:** `.github/workflows/deploy.yml:8`
43
+ **Issue:** Two pushes in quick succession run in parallel; the older run can finish after the newer one and overwrite production.
44
+ **Fix:** Add a `concurrency` group keyed on the ref, with `cancel-in-progress: true`.
45
+ ```
@@ -295,4 +295,15 @@ This stages `src/api/users.ts` and `src/types/user.ts` in the `backend/` repo, a
295
295
 
296
296
  Files that don't match any configured sub-repo are reported as unmatched.
297
297
 
298
+ ### Milestone Tagging Across Sub-Repos
299
+
300
+ A GSD milestone version names planned work in `.planning/`; it is not any repository's release version. With `git.repo_tagging: "per_repo"` and a non-empty `sub_repos`, `/gsd:complete-milestone` records one release map entry per repository (status, release, reference, environment, tag choice) instead of creating one `v[X.Y]` tag in the current directory:
301
+
302
+ 1. `gsd_run query milestone release-tags --map <tags.json> --dry-run` checks every requested tag (repository listed in `sub_repos`, ref resolves, tag name valid, no tag at a different commit) before anything is written.
303
+ 2. After one consolidated confirmation, `gsd_run query milestone release-tags --map <tags.json>` creates the annotated tags, each with the target repository as its working directory. Re-running the same map is idempotent (`already_tagged`).
304
+ 3. `milestone complete <version> --release-map <file>` archives the map by recorded value.
305
+ 4. Nothing is pushed by default. Each repository's tag has its own confirmation naming the repository and its remote.
306
+
307
+ With `git.create_tag: false` the map is still recorded and no tag is created. See [How to tag a multi-repository milestone](../../docs/how-to/tag-a-multi-repo-milestone.md).
308
+
298
309
  </sub_repos_support>
@@ -91,12 +91,19 @@ not one shape of one.
91
91
  capability manifest, which may be third-party — and `gates[].check` is **not** one of the
92
92
  executable surfaces the install consent prompt discloses (`hooks`, command modules,
93
93
  `mcpServers`, reviewer lanes), so a capability can be consented to as declarative-only and
94
- still reach a shell through a gate. Check the query value you read from `activeHooks` against
95
- `^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*)*$` yourself, IN-CONTEXT — **never** by pasting it into a
96
- shell command to be tested there, because a value carrying a quote, `;`, a `` ` ``, `$(`, or a
97
- newline would terminate the assignment and run as its own statement before any shell-side
98
- check could execute. A value that fails is a malformed manifest: record a warning, route it
99
- per the gate's `onError`, and do not run it. Pass `check.predicate` as a **single argv
94
+ still reach a shell through a gate. Normalize first, then validate: the registry declares query
95
+ ids in dotted form (`ui.plan-gate`, `verify.codebase-drift`) and `gsd_run check` accepts them by
96
+ turning each `.` into `-`, so replace every `.` with `-` in the query value you read from
97
+ `activeHooks`, then check that normalized value against
98
+ `^[a-z][a-z0-9]*(-[a-z0-9]+)*( [a-z][a-z0-9]*(-[a-z0-9]+)*)*$` yourself, IN-CONTEXT — **never** by
99
+ pasting it into a shell command to be tested there, because a value carrying a quote, `;`, a
100
+ `` ` ``, `$(`, or a newline would terminate the assignment and run as its own statement before
101
+ any shell-side check could execute. The normalized value is the one value you validate and the
102
+ one value you pass: wherever a workflow shows `${hook.check.query}`, write the normalized value.
103
+ A leading dot, a trailing dot or consecutive dots become a leading, trailing or doubled hyphen,
104
+ which the pattern rejects. A value that fails — or that passes but names no known check, which
105
+ `gsd_run check` rejects as an unknown check subcommand — is a malformed manifest: record a
106
+ warning, route it per the gate's `onError`, and do not run it. Pass `check.predicate` as a **single argv
100
107
  element** for the same reason — never re-quote it into a shell string, where an apostrophe
101
108
  would close the literal. This is the identical requirement `step` → `ref.command` carries
102
109
  above; it was stated there and omitted here, which is the gap #3559 closed.
@@ -0,0 +1,48 @@
1
+ # Necessity Review
2
+
3
+ Rubric for category 4 (Necessity) of `gsd-code-reviewer`. Read at `standard` and `deep` depth only, before classifying findings. It asks a question the other three categories do not: **does the problem this code addresses actually occur in this stack?** Correct, clean, well-tested code can still guard against nothing.
4
+
5
+ Taxonomy adapted from the `functionality-complexity-tradeoff` skill in [l-gevity/l-gevity-skills](https://github.com/l-gevity/l-gevity-skills) (MIT), #4100.
6
+
7
+ ## Taxonomy
8
+
9
+ | Subtype | Signal | Example |
10
+ |---|---|---|
11
+ | **Impossible-state guard** | Defends against a state ruled out by the type system, the deployment topology, or a runtime invariant | Null-check on a value whose type is non-nullable end to end; client/server version-skew check in a single-artifact deploy |
12
+ | **Already-defended-elsewhere** | A concern fully owned by another layer, duplicated here | Manual HTML escaping on top of an auto-escaping template engine; manual rollback inside an outer transaction that already rolls back |
13
+ | **Cargo-culted pattern** | A pattern whose prerequisites do not hold in this context | Connection pool in a CLI that runs for 200 ms; singleton cache in a stateless lambda |
14
+ | **Phantom requirement** | Solves a requirement that was never real or has provably lapsed | Feature flag for a launch that completed and is on for every environment; a migration shim for data the repo proves was migrated everywhere |
15
+ | **Generality without instantiation** | An abstraction with exactly one instantiation for its whole lifetime | Strategy pattern with one strategy; plugin registry with one plugin; config key holding the same value in every environment |
16
+
17
+ ## Evidence rule
18
+
19
+ Verify the premise before reviewing the implementation. A necessity finding must state **why the guarded state cannot occur** (or why the requirement lapsed) and cite where that is proven: the type signature, the build/deploy configuration, the call sites, the flag's values. The pattern name alone is not a finding — drop it.
20
+
21
+ ## Severity
22
+
23
+ - **Warning** (`WR-`) — the impossibility or lapse is provable from the repository.
24
+ - **Info** (`IN-`) — it rests on judgment (likely, but not shown).
25
+ - **Never Critical.** Removal is a scope decision for a human, not a defect fix.
26
+
27
+ ## Negative space — NOT necessity findings
28
+
29
+ - **Trust boundary.** A guard on data crossing a trust boundary (user input, network, files, environment, deserialized data, another process) is never an impossible-state guard, even when internal types claim the value is safe — types do not hold across a trust boundary.
30
+ - **Defense in depth.** A deliberate second security layer is not "already-defended-elsewhere".
31
+ - **Test seams.** A single-implementation interface that exists as a dependency-injection or test seam is not "generality without instantiation".
32
+ - **Unreachable code** is category 3 (Code Quality, dead code). Necessity is code that runs, but defends against nothing.
33
+ - **Unproven lapse.** A flag or migration whose lapse cannot be shown from the repository is Info at most.
34
+
35
+ ## REVIEW.md format
36
+
37
+ A necessity finding keeps its normal ID and section (`WR-` under `## Warnings`, `IN-` under `## Info`), names its subtype in the title, and carries the category marker line. The marker is what keeps `gsd-code-fixer` from removing the code under the default fix scope:
38
+
39
+ ```markdown
40
+ ### WR-04: Impossible-state guard — client version check in single-artifact SPA
41
+
42
+ **File:** `src/api/client.ts:42`
43
+ **Category:** necessity
44
+ **Issue:** The guarded state (client/server version skew) cannot occur: both are built and deployed as one artifact (`vite.config.ts:12`, `deploy/Dockerfile:8`).
45
+ **Fix:** Remove the check; record the invariant in a comment or ADR rather than a runtime branch.
46
+ ```
47
+
48
+ Count every necessity finding in the frontmatter `necessity:` key. It is a subset of `warning` + `info`, never added to `total`.