@opengsd/gsd-core 1.9.1 → 1.11.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 (426) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +27 -3
  5. package/agents/gsd-debug-session-manager.md +11 -0
  6. package/agents/gsd-debugger.md +12 -246
  7. package/agents/gsd-doc-synthesizer.md +2 -4
  8. package/agents/gsd-executor.md +12 -10
  9. package/agents/gsd-integration-checker.md +3 -0
  10. package/agents/gsd-mempalace-curator.md +5 -2
  11. package/agents/gsd-phase-researcher.md +20 -1
  12. package/agents/gsd-plan-checker.md +46 -0
  13. package/agents/gsd-planner.md +49 -54
  14. package/agents/gsd-roadmapper.md +21 -3
  15. package/agents/gsd-user-profiler.md +3 -0
  16. package/agents/gsd-verifier.md +26 -73
  17. package/bin/install.js +1272 -1238
  18. package/bin/lib/ui-safety-gate.cjs +2 -0
  19. package/commands/gsd/code-review.md +1 -1
  20. package/commands/gsd/execute-phase.md +1 -1
  21. package/commands/gsd/map-codebase.md +1 -1
  22. package/commands/gsd/mempalace-capture.md +2 -2
  23. package/commands/gsd/mempalace-recall.md +1 -1
  24. package/commands/gsd/new-milestone.md +2 -2
  25. package/commands/gsd/plan-phase.md +1 -1
  26. package/commands/gsd/quick.md +1 -1
  27. package/commands/gsd/review-backlog.md +2 -1
  28. package/commands/gsd/verify-work.md +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +1009 -115
  30. package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
  31. package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
  32. package/gsd-core/bin/lib/api-coverage.cjs +123 -5
  33. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  35. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  36. package/gsd-core/bin/lib/audit.cjs +926 -202
  37. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  38. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  39. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  40. package/gsd-core/bin/lib/capability-registry.cjs +608 -148
  41. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  42. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  43. package/gsd-core/bin/lib/capability-validator.cjs +507 -24
  44. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  45. package/gsd-core/bin/lib/check-command-router.cjs +114 -38
  46. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  47. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  48. package/gsd-core/bin/lib/command-aliases.cjs +94 -0
  49. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  50. package/gsd-core/bin/lib/commands.cjs +665 -99
  51. package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
  52. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  53. package/gsd-core/bin/lib/config-loader.cjs +76 -0
  54. package/gsd-core/bin/lib/config.cjs +22 -2
  55. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  56. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  57. package/gsd-core/bin/lib/core-utils.cjs +217 -40
  58. package/gsd-core/bin/lib/decisions.cjs +23 -0
  59. package/gsd-core/bin/lib/docs.cjs +3 -2
  60. package/gsd-core/bin/lib/external-job.cjs +19 -4
  61. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  62. package/gsd-core/bin/lib/frontmatter.cjs +239 -32
  63. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  64. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  65. package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
  66. package/gsd-core/bin/lib/graphify.cjs +142 -27
  67. package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
  68. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  69. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  71. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  72. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  73. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  74. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  75. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  76. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  77. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  78. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  79. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  80. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  81. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  82. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  83. package/gsd-core/bin/lib/init.cjs +1325 -169
  84. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  85. package/gsd-core/bin/lib/install-engine.cjs +805 -264
  86. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  87. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  88. package/gsd-core/bin/lib/install-profiles.cjs +160 -57
  89. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  90. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  91. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  92. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  93. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  94. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  95. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  96. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  97. package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
  98. package/gsd-core/bin/lib/io.cjs +38 -3
  99. package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
  100. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  101. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  102. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  103. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  104. package/gsd-core/bin/lib/milestone.cjs +821 -109
  105. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  106. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  107. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  108. package/gsd-core/bin/lib/pattern.cjs +122 -0
  109. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  110. package/gsd-core/bin/lib/phase-id.cjs +507 -36
  111. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  112. package/gsd-core/bin/lib/phase-locator.cjs +258 -58
  113. package/gsd-core/bin/lib/phase.cjs +891 -156
  114. package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
  115. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  116. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  117. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  118. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  119. package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
  120. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  121. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  122. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  123. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  124. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
  125. package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
  126. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  127. package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
  128. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  129. package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
  130. package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
  131. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  132. package/gsd-core/bin/lib/roadmap.cjs +405 -84
  133. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
  134. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  135. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
  136. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  137. package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
  138. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
  139. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  140. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  141. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  142. package/gsd-core/bin/lib/security.cjs +104 -5
  143. package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
  144. package/gsd-core/bin/lib/smart-entry.cjs +154 -22
  145. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  146. package/gsd-core/bin/lib/state-document.cjs +152 -8
  147. package/gsd-core/bin/lib/state-transition.cjs +424 -105
  148. package/gsd-core/bin/lib/state.cjs +1927 -401
  149. package/gsd-core/bin/lib/surface.cjs +35 -10
  150. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  151. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  152. package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
  153. package/gsd-core/bin/lib/uat.cjs +706 -64
  154. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  155. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  156. package/gsd-core/bin/lib/unusable-input.cjs +33 -0
  157. package/gsd-core/bin/lib/update-context.cjs +8 -2
  158. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  159. package/gsd-core/bin/lib/validate.cjs +20 -6
  160. package/gsd-core/bin/lib/vendor/README.md +37 -0
  161. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  162. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  163. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  164. package/gsd-core/bin/lib/verification.cjs +287 -20
  165. package/gsd-core/bin/lib/verify.cjs +368 -880
  166. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  167. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
  168. package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
  169. package/gsd-core/bin/lib/workstream.cjs +8 -2
  170. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  171. package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
  172. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  173. package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
  174. package/gsd-core/references/agent-contracts.md +43 -26
  175. package/gsd-core/references/artifact-types.md +10 -3
  176. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  177. package/gsd-core/references/checkpoints.md +2 -2
  178. package/gsd-core/references/context-budget.md +1 -1
  179. package/gsd-core/references/debugger-techniques.md +255 -0
  180. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  181. package/gsd-core/references/doc-conflict-engine.md +1 -1
  182. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  183. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  184. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  185. package/gsd-core/references/execute-phase-response-language.md +1 -1
  186. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  187. package/gsd-core/references/gate-prompts.md +1 -1
  188. package/gsd-core/references/git-planning-commit.md +2 -1
  189. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  190. package/gsd-core/references/model-profiles.md +12 -4
  191. package/gsd-core/references/mvp-concepts.md +9 -9
  192. package/gsd-core/references/planner-guidance.md +3 -9
  193. package/gsd-core/references/planner-preconditions.md +1 -1
  194. package/gsd-core/references/planner-reviews.md +1 -1
  195. package/gsd-core/references/planning-config.md +8 -6
  196. package/gsd-core/references/research-documentation-lookup.md +5 -3
  197. package/gsd-core/references/revision-loop.md +1 -1
  198. package/gsd-core/references/specless-probe-fallback.md +8 -7
  199. package/gsd-core/references/universal-anti-patterns.md +3 -3
  200. package/gsd-core/references/verifier-phase-gates.md +192 -0
  201. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  202. package/gsd-core/references/verify-mvp-mode.md +1 -1
  203. package/gsd-core/references/workstream-flag.md +22 -6
  204. package/gsd-core/references/worktree-branch-check.md +2 -2
  205. package/gsd-core/templates/discussion-log.md +1 -1
  206. package/gsd-core/templates/phase-prompt.md +2 -4
  207. package/gsd-core/templates/state.md +4 -4
  208. package/gsd-core/templates/summary-complex.md +2 -0
  209. package/gsd-core/templates/summary-minimal.md +2 -0
  210. package/gsd-core/templates/summary-standard.md +2 -0
  211. package/gsd-core/templates/summary.md +2 -0
  212. package/gsd-core/templates/verification-report.md +9 -1
  213. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  214. package/gsd-core/workflows/audit-milestone.md +3 -0
  215. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  216. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  217. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  218. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  219. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  220. package/gsd-core/workflows/autonomous.md +33 -70
  221. package/gsd-core/workflows/cleanup.md +62 -3
  222. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  223. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
  224. package/gsd-core/workflows/code-review-fix.md +37 -10
  225. package/gsd-core/workflows/code-review.md +74 -166
  226. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  227. package/gsd-core/workflows/complete-milestone.md +160 -95
  228. package/gsd-core/workflows/debug.md +16 -17
  229. package/gsd-core/workflows/diagnose-issues.md +56 -8
  230. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  231. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  232. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  233. package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
  234. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  235. package/gsd-core/workflows/docs-update.md +8 -51
  236. package/gsd-core/workflows/edit-phase.md +26 -1
  237. package/gsd-core/workflows/eval-review.md +3 -5
  238. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
  239. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  240. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  241. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  242. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
  243. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  245. package/gsd-core/workflows/execute-phase.md +103 -187
  246. package/gsd-core/workflows/execute-plan.md +36 -4
  247. package/gsd-core/workflows/explore.md +131 -4
  248. package/gsd-core/workflows/fast.md +10 -2
  249. package/gsd-core/workflows/health.md +73 -4
  250. package/gsd-core/workflows/help/modes/full.md +6 -1
  251. package/gsd-core/workflows/import.md +4 -4
  252. package/gsd-core/workflows/ingest-docs.md +7 -6
  253. package/gsd-core/workflows/mvp-phase.md +6 -3
  254. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  255. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  256. package/gsd-core/workflows/new-milestone.md +35 -47
  257. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  258. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  259. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  260. package/gsd-core/workflows/new-project.md +27 -240
  261. package/gsd-core/workflows/next.md +12 -0
  262. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  263. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  264. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  265. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  266. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  267. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  268. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  269. package/gsd-core/workflows/plan-phase.md +89 -209
  270. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  271. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  272. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  273. package/gsd-core/workflows/progress.md +45 -159
  274. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  275. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  276. package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
  277. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  278. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  279. package/gsd-core/workflows/quick.md +55 -405
  280. package/gsd-core/workflows/resume-project.md +3 -0
  281. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  282. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  283. package/gsd-core/workflows/review.md +41 -13
  284. package/gsd-core/workflows/section-manifest.json +219 -0
  285. package/gsd-core/workflows/secure-phase.md +1 -1
  286. package/gsd-core/workflows/session-report.md +2 -1
  287. package/gsd-core/workflows/settings.md +66 -2
  288. package/gsd-core/workflows/ship.md +104 -44
  289. package/gsd-core/workflows/sketch.md +1 -1
  290. package/gsd-core/workflows/spec-phase.md +41 -20
  291. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  292. package/gsd-core/workflows/spike.md +50 -16
  293. package/gsd-core/workflows/sync-skills.md +106 -13
  294. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  295. package/gsd-core/workflows/transition.md +53 -31
  296. package/gsd-core/workflows/ui-phase.md +13 -12
  297. package/gsd-core/workflows/ui-review.md +2 -2
  298. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  299. package/gsd-core/workflows/update.md +19 -8
  300. package/gsd-core/workflows/validate-phase.md +1 -1
  301. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  302. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  303. package/gsd-core/workflows/verify-work.md +17 -65
  304. package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
  305. package/hooks/dist/gsd-check-update-worker.js +64 -12
  306. package/hooks/dist/gsd-check-update.js +19 -1
  307. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  308. package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
  309. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  310. package/hooks/dist/gsd-prompt-guard.js +21 -20
  311. package/hooks/dist/gsd-read-injection-scanner.js +45 -24
  312. package/hooks/dist/gsd-statusline.js +90 -6
  313. package/hooks/dist/gsd-update-banner.js +22 -1
  314. package/hooks/dist/gsd-workflow-guard.js +134 -36
  315. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  316. package/hooks/dist/gsd-write-guard.js +359 -0
  317. package/hooks/dist/lib/git-cmd.js +92 -59
  318. package/hooks/dist/lib/injection-patterns.js +45 -0
  319. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  320. package/hooks/dist/lib/isolation-sentinel.js +277 -0
  321. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  322. package/hooks/gsd-agent-isolation-guard.js +517 -0
  323. package/hooks/gsd-check-update-worker.js +64 -12
  324. package/hooks/gsd-check-update.js +19 -1
  325. package/hooks/gsd-cursor-pre-tool.js +0 -3
  326. package/hooks/gsd-cursor-subagent-start.js +607 -26
  327. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  328. package/hooks/gsd-prompt-guard.js +21 -20
  329. package/hooks/gsd-read-injection-scanner.js +45 -24
  330. package/hooks/gsd-statusline.js +90 -6
  331. package/hooks/gsd-update-banner.js +22 -1
  332. package/hooks/gsd-workflow-guard.js +134 -36
  333. package/hooks/gsd-worktree-path-guard.js +2 -1
  334. package/hooks/gsd-write-guard.js +359 -0
  335. package/hooks/hooks.json +12 -0
  336. package/hooks/lib/git-cmd.js +92 -59
  337. package/hooks/lib/injection-patterns.js +45 -0
  338. package/hooks/lib/isolation-deny-reason.js +39 -0
  339. package/hooks/lib/isolation-sentinel.js +277 -0
  340. package/hooks/managed-hooks-registry.cjs +2 -0
  341. package/package.json +31 -10
  342. package/pi/gsd.cjs +71 -12
  343. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  344. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  345. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  346. package/scripts/build-hooks.js +9 -0
  347. package/scripts/changeset/lint.cjs +68 -6
  348. package/scripts/changeset/serialize.cjs +5 -1
  349. package/scripts/check-alias-drift.cjs +7 -43
  350. package/scripts/check-contract-drift.cjs +297 -0
  351. package/scripts/ci-test-scope.cjs +19 -2
  352. package/scripts/command-contract-helpers.cjs +903 -1
  353. package/scripts/gen-adr-index.cjs +728 -38
  354. package/scripts/gen-capability-matrix.cjs +1 -1
  355. package/scripts/gen-capability-registry.cjs +3 -15
  356. package/scripts/gen-context-index.cjs +439 -0
  357. package/scripts/gen-health-docs.cjs +390 -0
  358. package/scripts/gen-inventory-manifest.cjs +150 -4
  359. package/scripts/gen-loop-host-contract.cjs +4 -24
  360. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  361. package/scripts/gen-registry.cjs +3 -14
  362. package/scripts/gen-section-manifest.cjs +638 -0
  363. package/scripts/generate-package-identity.cjs +4 -2
  364. package/scripts/lib/alias-drift-families.cjs +46 -0
  365. package/scripts/lib/drift-scan.cjs +278 -0
  366. package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
  367. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  368. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  369. package/scripts/lint-canary-version-leak.cjs +73 -0
  370. package/scripts/lint-command-contract.cjs +96 -13
  371. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  372. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  373. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  374. package/scripts/lint-default-flip-documentation.cjs +193 -0
  375. package/scripts/lint-docs-command-form.cjs +195 -0
  376. package/scripts/lint-docs-required.cjs +9 -1
  377. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  378. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  379. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  380. package/scripts/lint-example-parser-parity.cjs +395 -0
  381. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  382. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  383. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  384. package/scripts/lint-milestone-window-drift.cjs +468 -0
  385. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  386. package/scripts/lint-plan-count-drift.cjs +318 -0
  387. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  388. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  389. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  390. package/scripts/lint-regression-test-names.cjs +15 -13
  391. package/scripts/lint-removed-but-needed.cjs +320 -0
  392. package/scripts/lint-state-field-drift.cjs +805 -0
  393. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  394. package/scripts/lint-test-file-count.allowlist.json +40 -3
  395. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  396. package/scripts/lint-vendored-deps.cjs +124 -0
  397. package/scripts/mutation-matrix.cjs +13 -0
  398. package/scripts/pr-changed-files.cjs +63 -0
  399. package/scripts/pr-template-policy.cjs +14 -4
  400. package/scripts/prompt-injection-scan.sh +52 -6
  401. package/scripts/require-issue-link-policy.cjs +192 -0
  402. package/scripts/state-write-path-drift-baseline.json +19 -0
  403. package/scripts/sync-runtime-launcher.cjs +2 -4
  404. package/skills/gsd-autonomous/SKILL.md +0 -1
  405. package/skills/gsd-code-review/SKILL.md +1 -1
  406. package/skills/gsd-execute-phase/SKILL.md +1 -2
  407. package/skills/gsd-map-codebase/SKILL.md +1 -1
  408. package/skills/gsd-mempalace-capture/SKILL.md +2 -2
  409. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  410. package/skills/gsd-new-milestone/SKILL.md +2 -2
  411. package/skills/gsd-next/SKILL.md +0 -1
  412. package/skills/gsd-plan-phase/SKILL.md +1 -2
  413. package/skills/gsd-progress/SKILL.md +0 -1
  414. package/skills/gsd-quick/SKILL.md +1 -1
  415. package/skills/gsd-review-backlog/SKILL.md +2 -1
  416. package/skills/gsd-stats/SKILL.md +0 -1
  417. package/skills/gsd-verify-work/SKILL.md +1 -1
  418. package/vscode/package.json +1 -1
  419. package/gsd-core/workflows/discovery-phase.md +0 -298
  420. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  421. package/gsd-core/workflows/verify-phase.md +0 -577
  422. package/scripts/affected-tests-lib.cjs +0 -554
  423. package/scripts/gen-emitted-baseline.cjs +0 -145
  424. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  425. package/scripts/run-affected-tests.cjs +0 -7
  426. package/scripts/run-tests.cjs +0 -1050
@@ -11,11 +11,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  };
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
+ const text_lines_cjs_1 = require("./text-lines.cjs");
14
15
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
15
16
  const security_cjs_1 = require("./security.cjs");
16
17
  // eslint-disable-next-line @typescript-eslint/no-require-imports
17
18
  const ioMod = require("./io.cjs");
18
- const { output, error } = ioMod;
19
+ const { output, error, ERROR_REASON } = ioMod;
19
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
20
21
  const configLoaderMod = require("./config-loader.cjs");
21
22
  const { loadConfig, isGitIgnored } = configLoaderMod;
@@ -24,20 +25,28 @@ const coreUtilsMod = require("./core-utils.cjs");
24
25
  const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
25
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
26
27
  const phaseIdMod = require("./phase-id.cjs");
27
- const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
28
+ const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
28
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
30
  const phaseLocatorMod = require("./phase-locator.cjs");
30
- const { getArchivedPhaseDirs, findPhaseInternal } = phaseLocatorMod;
31
+ const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
31
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
33
  const roadmapParserMod = require("./roadmap-parser.cjs");
33
- const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo, getMilestonePhaseFilter, getRoadmapPhaseInternal } = roadmapParserMod;
34
+ const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo, getRoadmapPhaseInternal } = roadmapParserMod;
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const planningScopeMod = require("./planning-scope.cjs");
37
+ const { SCOPE } = planningScopeMod;
34
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
39
  const modelResolverMod = require("./model-resolver.cjs");
36
- const { resolveModelInternal, resolveModelForTier, resolveProviderEscalation, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
40
+ const { resolveModelInternal, resolveTierInternal, resolveModelForTier, resolveProviderEscalation, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
37
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
38
42
  const agentCommandRouterMod = require("./agent-command-router.cjs");
39
43
  const { AGENT_FAILURE_CLASSES } = agentCommandRouterMod;
40
44
  const model_catalog_cjs_1 = require("./model-catalog.cjs");
45
+ // #3243 (ADR-2313 D7) — the Codex `.toml` sync's typed IR: parse/render/strip
46
+ // primitives moved from agent-install-check.cts's Phase-2 parsing into this
47
+ // leaf so both consumers share one block-range detector. See
48
+ // codex-agent-toml.cts's module header for the reader/writer reconciliation.
49
+ const codex_agent_toml_cjs_1 = require("./codex-agent-toml.cjs");
41
50
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
51
  const hostIntegrationMod = require("./host-integration.cjs");
43
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -51,6 +60,13 @@ const modelProfiles = require("./model-profiles.cjs");
51
60
  const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles;
52
61
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
53
62
  const clock_cjs_1 = require("./clock.cjs");
63
+ const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
64
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
65
+ const planScanMod = require("./plan-scan.cjs");
66
+ const { scanPhasePlans } = planScanMod;
67
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
68
+ const verificationMod = require("./verification.cjs");
69
+ const { resolveVerificationFile } = verificationMod;
54
70
  // ─── Phase Status ─────────────────────────────────────────────────────────────
55
71
  /**
56
72
  * Phase-status precedence ladder — furthest-along wins (#2408).
@@ -104,7 +120,16 @@ function determinePhaseStatus(plans, summaries, phaseDir, defaultPending) {
104
120
  // summaries >= plans — check verification
105
121
  try {
106
122
  const files = node_fs_1.default.readdirSync(phaseDir);
107
- const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
123
+ // #3473 F2: routed through the shared resolver (readdir order is
124
+ // filesystem-dependent, so the prior hand-rolled `.find()` could pick
125
+ // either file when a phase held both a canonical report and an ad-hoc
126
+ // `-CORRECTION-VERIFICATION.md` worksheet — see #3357).
127
+ // #3492: pin selection to THIS phase's own token so a stray cross-phase
128
+ // or sentinel-numbered canonically-shaped file cannot outrank this
129
+ // phase's own (possibly non-canonical) report.
130
+ const phaseDirName = node_path_1.default.basename(phaseDir);
131
+ const phaseToken = extractPhaseToken(phaseDirName);
132
+ const verificationFile = resolveVerificationFile(files, { allowBare: true, phaseToken, phaseDirName });
108
133
  if (verificationFile) {
109
134
  const verificationFilePath = node_path_1.default.join(phaseDir, verificationFile);
110
135
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(verificationFilePath) || '';
@@ -143,7 +168,7 @@ function cmdGenerateSlug(text, raw) {
143
168
  output(result, raw, slug);
144
169
  }
145
170
  function cmdCurrentTimestamp(format, raw) {
146
- const now = new Date();
171
+ const now = new Date(clock_cjs_1.realClock.now());
147
172
  let result;
148
173
  switch (format) {
149
174
  case 'date':
@@ -348,7 +373,14 @@ function cmdHistoryDigest(cwd, raw) {
348
373
  }
349
374
  try {
350
375
  for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
351
- const summaries = node_fs_1.default.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
376
+ // #3183: canonical summary set (root+nested) from the single owner.
377
+ // This call also opens every plan file's frontmatter to check
378
+ // superseded status even though cmdHistoryDigest never uses planFiles
379
+ // or the superseded distinction — that per-phase-dir cost is accepted
380
+ // deliberately (correctness/single-ownership over micro-optimization;
381
+ // summaryFiles itself is not superseded-filtered either way). Do not
382
+ // "optimize" this back into a second hand-rolled summary derivation.
383
+ const summaries = scanPhasePlans(dirPath).summaryFiles;
352
384
  for (const summary of summaries) {
353
385
  const summaryFilePath = node_path_1.default.join(dirPath, summary);
354
386
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(summaryFilePath);
@@ -419,10 +451,20 @@ function cmdResolveModel(cwd, agentType, raw) {
419
451
  const profile = config['model_profile'] || 'balanced';
420
452
  const model = resolveModelInternal(cwd, agentType);
421
453
  const effort = resolveEffortInternal(cwd, agentType);
422
- const agentModels = MODEL_PROFILES[agentType];
454
+ // Own-property guard: agentType is an unvalidated CLI positional, so a
455
+ // prototype-chain value ("toString", "constructor") would otherwise return
456
+ // an inherited truthy member from this plain object and misreport a
457
+ // genuinely unknown agent as known (unknown_agent dropped from the result).
458
+ const agentModelsMap = MODEL_PROFILES;
459
+ const agentModels = Object.hasOwn(agentModelsMap, agentType) ? agentModelsMap[agentType] : undefined;
460
+ // #2229: `tier` is additive — existing keys and their values are untouched, so
461
+ // every `--pick model` / `--pick profile` / `--raw` consumer is unaffected. It
462
+ // exists because the model id is deliberately blank under resolve_model_ids:"omit",
463
+ // which leaves a tier-sensitive guard with nothing to read.
464
+ const tier = resolveTierInternal(cwd, agentType);
423
465
  const result = agentModels
424
- ? { model, profile, effort }
425
- : { model, profile, effort, unknown_agent: true };
466
+ ? { model, profile, effort, tier }
467
+ : { model, profile, effort, tier, unknown_agent: true };
426
468
  output(result, raw, model);
427
469
  }
428
470
  function cmdResolveGranularity(cwd, phaseType, raw, override) {
@@ -489,7 +531,52 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
489
531
  const runtime = config['runtime'] || 'claude';
490
532
  const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, effort);
491
533
  const fastModeSupported = model_catalog_cjs_1.RUNTIMES_WITH_FAST_MODE.has(runtime);
492
- const agentModels = MODEL_PROFILES[agentType];
534
+ // #3534 (10a): the effective effort — what the installed agent will actually
535
+ // run at. `effort` above is the config cascade; for the claude runtime the
536
+ // per-agent frontmatter key is the source of truth (Claude Code's Agent tool
537
+ // has no per-spawn effort parameter), so the query reads the installed file.
538
+ // An ABSENT key is a real state — the agent follows the session effort
539
+ // ('inherit'), not drift. No file / no frontmatter / any read failure means
540
+ // no evidence: the resolved value is reported, flagged 'resolved' so a
541
+ // consumer can tell evidence from echo. Additive only — every existing key
542
+ // is unchanged.
543
+ let effortEffectiveSource = 'resolved';
544
+ let effortEffective = effort;
545
+ if (runtime === 'claude') {
546
+ try {
547
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
548
+ const { getGlobalConfigDir } = require('./runtime-homes.cjs');
549
+ const agentsDirEff = node_path_1.default.join(getGlobalConfigDir(runtime), 'agents');
550
+ const agentPath = node_path_1.default.join(agentsDirEff, `${agentType}.md`);
551
+ // agentType is an unvalidated CLI positional: keep the read inside the
552
+ // agents dir so `../../x` cannot point it elsewhere (defense in depth —
553
+ // the reflected surface is only a frontmatter effort line).
554
+ if (!node_path_1.default.resolve(agentPath).startsWith(node_path_1.default.resolve(agentsDirEff) + node_path_1.default.sep)) {
555
+ throw new Error('agent path escapes the agents directory');
556
+ }
557
+ const agentContent = node_fs_1.default.readFileSync(agentPath, 'utf8');
558
+ // eslint-disable-next-line local/no-unbounded-quantifier -- same lazy `*?` bounded by the `^---$/m` closing anchor as the sibling frontmatter regexes in this file
559
+ const fmMatchEff = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(agentContent);
560
+ if (fmMatchEff) {
561
+ const effortLine = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatchEff[1]);
562
+ if (effortLine) {
563
+ effortEffective = effortLine[1];
564
+ effortEffectiveSource = 'frontmatter';
565
+ }
566
+ else {
567
+ effortEffective = 'inherit';
568
+ effortEffectiveSource = 'frontmatter-absent';
569
+ }
570
+ }
571
+ }
572
+ catch { /* no frontmatter evidence — stay on the resolved value */ }
573
+ }
574
+ // Own-property guard: agentType is an unvalidated CLI positional, so a
575
+ // prototype-chain value ("toString", "constructor") would otherwise return
576
+ // an inherited truthy member from this plain object and misreport a
577
+ // genuinely unknown agent as known (unknown_agent dropped from the result).
578
+ const agentModelsMap = MODEL_PROFILES;
579
+ const agentModels = Object.hasOwn(agentModelsMap, agentType) ? agentModelsMap[agentType] : undefined;
493
580
  const result = {
494
581
  model,
495
582
  profile,
@@ -497,6 +584,8 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
497
584
  effort_rendered: rendered.value,
498
585
  effort_param: rendered.param,
499
586
  effort_propagation: rendered.channel,
587
+ effort_effective: effortEffective,
588
+ effort_effective_source: effortEffectiveSource,
500
589
  fast_mode: fastMode,
501
590
  fast_mode_supported: fastModeSupported,
502
591
  };
@@ -566,6 +655,29 @@ function setEffortFrontmatter(content, effortValue) {
566
655
  const closingStart = match.index + openLen + fmBody.length;
567
656
  return content.slice(0, closingStart) + `effort: ${effortValue}${eol}` + content.slice(closingStart);
568
657
  }
658
+ /**
659
+ * #3533 (10d) — remove exactly the frontmatter `effort:` line (and its line
660
+ * ending) so an agent configured for `inherit` carries NO key. Mirrors the
661
+ * codex-agent-toml strip discipline: targeted line removal, EOL-aware, every
662
+ * other byte (comments, sibling keys, the body) untouched.
663
+ */
664
+ function removeEffortFrontmatter(content) {
665
+ // Scoped to the FIRST frontmatter block (not a whole-file /m match): a
666
+ // preamble or body line starting with `effort:` (a fenced config example,
667
+ // a thematic-break flanked fragment) must never be the line removed.
668
+ const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
669
+ const match = fmRe.exec(content);
670
+ if (!match)
671
+ return content;
672
+ const fmBody = match[1];
673
+ const lineRe = /^effort:[ \t]*.*\r?\n?/m;
674
+ if (!lineRe.test(fmBody))
675
+ return content;
676
+ const strippedFm = fmBody.replace(lineRe, '');
677
+ const openLen = 3 + (/^---\r\n/.test(content) ? 2 : 1);
678
+ const closingStart = match.index + openLen + fmBody.length;
679
+ return content.slice(0, match.index + openLen) + strippedFm + content.slice(closingStart);
680
+ }
569
681
  /**
570
682
  * #488 — Re-sync effort: frontmatter in all installed gsd-*.md agent files to
571
683
  * match the current effort config, without requiring a full reinstall.
@@ -581,6 +693,14 @@ function cmdEffortSync(cwd, raw, opts) {
581
693
  const dryRun = opts.dryRun !== false;
582
694
  const config = loadConfig(cwd);
583
695
  const runtime = opts.runtime || config['runtime'] || 'claude';
696
+ // ADR-2313 D7 (#3243) — Codex gets its own `.toml` sync path (strip a stale
697
+ // Anthropic/tier `model` and an orphaned `model_reasoning_effort`, leaving a
698
+ // legal pin untouched). Every other non-claude runtime keeps the prior
699
+ // early-return; the claude branch below is untouched byte-for-byte.
700
+ if (runtime === 'codex') {
701
+ cmdEffortSyncCodex(raw, dryRun, opts.configDir);
702
+ return;
703
+ }
584
704
  if (runtime !== 'claude') {
585
705
  output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, reason: `runtime '${runtime}' does not use effort: frontmatter` }, raw, '');
586
706
  return;
@@ -619,8 +739,32 @@ function cmdEffortSync(cwd, raw, opts) {
619
739
  const content = node_fs_1.default.readFileSync(filePath, 'utf8');
620
740
  // Resolve using install-time logic: home defaults merged with project config.
621
741
  const universalEffort = resolveInstallTimeEffort(effortCfg, agentName);
742
+ // #3533 (10d): 'inherit' means the key must NOT exist. An absent key is
743
+ // the CORRECT state (in sync, skipped) — before #3533 absence read as null
744
+ // drift and the sync re-added a hand-stripped key on every apply. A
745
+ // present key under inherit is stripped, reported as {from, to: null}.
746
+ if (universalEffort === 'inherit') {
747
+ // eslint-disable-next-line local/no-unbounded-quantifier -- same lazy `*?` bounded by the `^---$/m` closing anchor as the concrete-path fmMatch below; duplicated here so the inherit branch validates against the same frontmatter span the strip targets
748
+ const fmMatchInherit = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
749
+ if (!fmMatchInherit) {
750
+ skipped++;
751
+ continue;
752
+ }
753
+ const effortMatchInherit = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatchInherit[1]);
754
+ if (!effortMatchInherit) {
755
+ skipped++;
756
+ continue;
757
+ }
758
+ changes.push({ agent: agentName, from: effortMatchInherit[1], to: null });
759
+ synced++;
760
+ if (!dryRun) {
761
+ node_fs_1.default.writeFileSync(filePath, removeEffortFrontmatter(content));
762
+ }
763
+ continue;
764
+ }
622
765
  const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, universalEffort);
623
766
  const newEffortValue = rendered.value;
767
+ // eslint-disable-next-line local/no-unbounded-quantifier -- lazy `*?` bounded by the `^---$/m` closing anchor, no nested quantifier, measured linear to 5MB (no-closing-marker adversarial input)
624
768
  const fmMatch = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
625
769
  if (!fmMatch) {
626
770
  skipped++;
@@ -640,6 +784,112 @@ function cmdEffortSync(cwd, raw, opts) {
640
784
  }
641
785
  output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, synced > 0 ? 'changed' : 'ok');
642
786
  }
787
+ /**
788
+ * ADR-2313 D7 (#3243) — the Codex branch of `cmdEffortSync`. Strips a stale
789
+ * Anthropic-flavored/tier `model` pin and an orphaned `model_reasoning_effort`
790
+ * from every installed `~/.codex/agents/<agent>.toml`, leaving a legal
791
+ * real-Codex pin (and its coupled effort) untouched. Dry-run by default; every
792
+ * strip reported as a structured `{agent, field, from}` change; an unparseable
793
+ * document is refused and reported, never partially rewritten (40-design.md
794
+ * "Reconciliation" — parseCodexAgentToml is the STRICT half of the reader/
795
+ * writer split). Result shape is additive over the claude branch's
796
+ * `{synced, skipped, changes, dry_run, agents_dir}` — `refused` and
797
+ * `write_failures` are new fields, never a reshape of the existing ones.
798
+ */
799
+ function cmdEffortSyncCodex(raw, dryRun, configDir) {
800
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
801
+ const { getGlobalConfigDir } = require('./runtime-homes.cjs');
802
+ const agentsDir = node_path_1.default.join(configDir || getGlobalConfigDir('codex'), 'agents');
803
+ if (!node_fs_1.default.existsSync(agentsDir)) {
804
+ output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, agents_dir: agentsDir, reason: 'agents directory not found' }, raw, '');
805
+ return;
806
+ }
807
+ // Skip symlinks — matches the claude branch's existing guard above (only
808
+ // write regular files, never follow a symlink into clobbering its target).
809
+ const files = node_fs_1.default
810
+ .readdirSync(agentsDir)
811
+ .filter(f => {
812
+ if (!f.endsWith('.toml'))
813
+ return false;
814
+ try {
815
+ return node_fs_1.default.lstatSync(node_path_1.default.join(agentsDir, f)).isFile();
816
+ }
817
+ catch {
818
+ return false;
819
+ }
820
+ })
821
+ .sort();
822
+ const changes = [];
823
+ const refused = [];
824
+ const writeFailures = [];
825
+ let synced = 0;
826
+ let skipped = 0;
827
+ for (const file of files) {
828
+ const agentName = file.replace(/\.toml$/, '');
829
+ const filePath = node_path_1.default.join(agentsDir, file);
830
+ const content = node_fs_1.default.readFileSync(filePath, 'utf8');
831
+ const parsed = (0, codex_agent_toml_cjs_1.parseCodexAgentToml)(content);
832
+ if (!parsed.ok) {
833
+ // Never partially rewritten (40-design.md, ADR-2313 reader/writer
834
+ // boundary): an unparseable document is skipped and reported, not
835
+ // guessed at.
836
+ skipped++;
837
+ refused.push({ agent: agentName, file: filePath, reason: parsed.reason });
838
+ continue;
839
+ }
840
+ let doc = parsed.doc;
841
+ const stripModelNeeded = doc.model !== null && (0, model_catalog_cjs_1.isAnthropicFlavoredModel)(doc.model);
842
+ // #838 coupling: an orphaned effort (no model) is always stale; a stale
843
+ // model's effort is coupled to it and strips with it. A legal pin's effort
844
+ // (model present, not Anthropic-flavored) is left untouched (rows 4-5).
845
+ const stripEffortNeeded = doc.reasoningEffort !== null && (stripModelNeeded || doc.model === null);
846
+ if (!stripModelNeeded && !stripEffortNeeded) {
847
+ // Posture-clean, OR a legal pin (and its coupled effort) — reported
848
+ // skipped, never synced (ADR-2313 reader/writer boundary).
849
+ skipped++;
850
+ continue;
851
+ }
852
+ const pendingChanges = [];
853
+ if (stripModelNeeded) {
854
+ pendingChanges.push({ agent: agentName, field: 'model', from: doc.model, to: null });
855
+ doc = (0, codex_agent_toml_cjs_1.stripModel)(doc);
856
+ }
857
+ if (stripEffortNeeded) {
858
+ pendingChanges.push({ agent: agentName, field: 'model_reasoning_effort', from: doc.reasoningEffort, to: null });
859
+ doc = (0, codex_agent_toml_cjs_1.stripReasoningEffort)(doc);
860
+ }
861
+ if (!dryRun) {
862
+ // Atomic publish (ADR-2313 "never partially rewritten"): write the
863
+ // rendered TOML to a sibling tmp file, then rename it over the target.
864
+ // Same-filesystem rename is atomic, so filePath is either the old bytes
865
+ // or the new ones, never truncated/half-written mid-crash. Deliberately
866
+ // NOT platformWriteSync — its normalizeContent step rewrites CRLF/
867
+ // trailing-newline bytes, which would break the byte-identical
868
+ // round-trip (A14) this writer must preserve. retryRenameSync (not a
869
+ // bare fs.renameSync) carries the transient-Windows-lock retry per
870
+ // DEFECT.WINDOWS-FS-OPS.
871
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
872
+ try {
873
+ node_fs_1.default.writeFileSync(tmpPath, (0, codex_agent_toml_cjs_1.renderCodexAgentToml)(doc));
874
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
875
+ }
876
+ catch (err) {
877
+ // Reported, not thrown — the remaining agents still get processed.
878
+ // Clean up the orphaned tmp file; filePath itself was never touched.
879
+ try {
880
+ node_fs_1.default.unlinkSync(tmpPath);
881
+ }
882
+ catch { /* already gone or never created */ }
883
+ skipped++;
884
+ writeFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
885
+ continue;
886
+ }
887
+ }
888
+ changes.push(...pendingChanges);
889
+ synced++;
890
+ }
891
+ output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, refused, write_failures: writeFailures }, raw, synced > 0 ? 'changed' : 'ok');
892
+ }
643
893
  /**
644
894
  * Detect the phase number for a commit from its `--files` path list.
645
895
  *
@@ -693,6 +943,71 @@ function detectPhaseNumberFromFiles(files) {
693
943
  }
694
944
  return null;
695
945
  }
946
+ /**
947
+ * #3587: resolve the `phase_commit_docs.<phase-id>` override for `phaseNum`
948
+ * against `config['phase_commit_docs']` (a `{ "<phase-id>": boolean }` map, the
949
+ * same shape `agent_skills`/`features` use for their dynamic key families).
950
+ * Returns `undefined` — "no override applies" — when: no phase is known (B7),
951
+ * the map carries no entry for THIS phase (B5: no cross-phase leak), or the
952
+ * entry exists but is not a boolean (B6: never silently coerced). Both sides of
953
+ * the comparison route through `normalizePhaseName` so `3`, `03`, and `PROJ-03`
954
+ * all resolve to the same entry (B4/B9), reusing the single-owner phase-id
955
+ * normalizer rather than a second, looser string-equality rule.
956
+ */
957
+ function resolvePhaseCommitDocsOverride(config, phaseNum) {
958
+ if (!phaseNum)
959
+ return undefined;
960
+ const overrides = config['phase_commit_docs'];
961
+ if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides))
962
+ return undefined;
963
+ const target = normalizePhaseName(phaseNum);
964
+ for (const [key, value] of Object.entries(overrides)) {
965
+ if (normalizePhaseName(key) === target) {
966
+ return typeof value === 'boolean' ? value : undefined;
967
+ }
968
+ }
969
+ return undefined;
970
+ }
971
+ /**
972
+ * #3587: the four-tier `commit_docs` precedence chain for a single commit —
973
+ * `phase_commit_docs.<phase-id>` (tier 1, resolved HERE because this call site
974
+ * is the one place that knows the phase — see 40-design.md "Rejected" §1: NOT
975
+ * inside `loadConfig`, which has no phase context and is called by nearly every
976
+ * command), then the pre-existing explicit `commit_docs` (tier 2), `.gitignore`
977
+ * auto-detect (tier 3), and manifest default (tier 4). Tiers 2-4 are byte-for-
978
+ * behaviour identical to the pre-#3587 inline checks (epic #2292 AC4): when no
979
+ * phase override applies, `resolved` matches exactly what those checks computed
980
+ * and `source` merely labels which of the three decided it.
981
+ *
982
+ * `isPlanningGitIgnored` is a thunk, not a plain boolean, so the pre-existing
983
+ * short-circuit is preserved byte-for-behaviour: the original inline checks
984
+ * only ever ran `isGitIgnored` (a real `git check-ignore` subprocess) when
985
+ * `commit_docs` was truthy, and a phase override or an explicit `commit_docs:
986
+ * false` must keep skipping that call entirely, not just its result. Passing
987
+ * a thunk also keeps this function pure and directly property-testable
988
+ * (test matrix F1) without spawning git.
989
+ */
990
+ function resolveCommitDocsPolicy(config, phaseNum, isPlanningGitIgnored) {
991
+ const phaseOverride = resolvePhaseCommitDocsOverride(config, phaseNum);
992
+ if (phaseOverride !== undefined)
993
+ return { resolved: phaseOverride, source: 'phase' };
994
+ if (!config['commit_docs'])
995
+ return { resolved: false, source: 'config' };
996
+ if (isPlanningGitIgnored())
997
+ return { resolved: false, source: 'gitignore' };
998
+ return { resolved: true, source: 'default' };
999
+ }
1000
+ // Reason string per commit_docs-resolution source, for the tier-1/tier-2 skip
1001
+ // envelope below. `phase` gets its OWN reason (`skipped_commit_docs_phase_false`)
1002
+ // rather than reusing `skipped_commit_docs_false` — telling a user "commit_docs
1003
+ // is false" when their project setting is actually `true` would be actively
1004
+ // misleading (design "Rejected" §3). `config` keeps the pre-existing string
1005
+ // unchanged: `agents/gsd-executor.md` pattern-matches on it (D2).
1006
+ const COMMIT_DOCS_SKIP_REASON = {
1007
+ phase: 'skipped_commit_docs_phase_false',
1008
+ config: 'skipped_commit_docs_false',
1009
+ gitignore: 'skipped_gitignored',
1010
+ };
696
1011
  function cmdCommit(cwd, message, files, raw, amend, noVerify) {
697
1012
  if (!message && !amend) {
698
1013
  error('commit message required');
@@ -706,18 +1021,20 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
706
1021
  sanitizedMessage = sanitizeForPrompt(sanitizedMessage);
707
1022
  }
708
1023
  const config = loadConfig(cwd);
709
- // Check commit_docs config
1024
+ // Check commit_docs config — #3587: resolved through the tier 1
1025
+ // (phase_commit_docs.<phase-id>) → tier 2 (commit_docs) → tier 3 (.gitignore)
1026
+ // → tier 4 (default) precedence chain; see resolveCommitDocsPolicy above.
710
1027
  // `skipped: true` is explicit so agent prompts can match on a first-class
711
1028
  // success signal rather than inferring "skip" from "committed is missing"
712
1029
  // and improvising raw git fallbacks (#3678).
713
- if (!config['commit_docs']) {
714
- const result = { committed: false, skipped: true, hash: null, reason: 'skipped_commit_docs_false' };
715
- output(result, raw, 'skipped');
716
- return;
717
- }
718
- // Check if .planning is gitignored
719
- if (isGitIgnored(cwd, '.planning')) {
720
- const result = { committed: false, skipped: true, hash: null, reason: 'skipped_gitignored' };
1030
+ const commitDocsPolicy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(files), () => isGitIgnored(cwd, '.planning'));
1031
+ if (!commitDocsPolicy.resolved) {
1032
+ const result = {
1033
+ committed: false,
1034
+ skipped: true,
1035
+ hash: null,
1036
+ reason: COMMIT_DOCS_SKIP_REASON[commitDocsPolicy.source],
1037
+ };
721
1038
  output(result, raw, 'skipped');
722
1039
  return;
723
1040
  }
@@ -752,7 +1069,19 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
752
1069
  }
753
1070
  }
754
1071
  else if (branchingStrategy === 'milestone') {
755
- const milestone = getMilestoneInfo(cwd);
1072
+ const milestoneInfo = getMilestoneInfo(cwd);
1073
+ // #3216 review Finding 3: explicit scope gate instead of plain truthiness.
1074
+ // COMPLETE and TRUNCATED both carry a real `version` (ADR-3180 §7.2 rule
1075
+ // 6 — TRUNCATED means the version resolved but the milestone's NAME did
1076
+ // not), so a TRUNCATED identity is acceptable here: `milestone.version`
1077
+ // only feeds a BRANCH NAME, and `generateSlugInternal(null) || 'milestone'`
1078
+ // already degrades the missing name to the literal "milestone" slug on
1079
+ // purpose. This differs from `archivePhaseDirectories` (milestone.cts),
1080
+ // which uses the same value as a DIRECTORY NAME and therefore demands
1081
+ // COMPLETE only — a real-but-unnamed version is not safe enough there.
1082
+ const milestone = milestoneInfo.scope === SCOPE.COMPLETE || milestoneInfo.scope === SCOPE.TRUNCATED
1083
+ ? milestoneInfo.value
1084
+ : null;
756
1085
  if (milestone && milestone.version) {
757
1086
  branchName = config['milestone_branch_template']
758
1087
  .replace('{milestone}', milestone.version)
@@ -762,21 +1091,34 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
762
1091
  if (branchName) {
763
1092
  const currentBranch = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
764
1093
  if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
765
- // #2539: the #1278 intent is to CREATE the phase/milestone branch
766
- // before the FIRST commit on it — not to force-switch an already-
767
- // checked-out working branch onto a DIFFERENT existing branch. The
768
- // prior fallback to a bare `git checkout <branch>` silently switched
769
- // the whole working tree onto an existing unrelated branch in the same
770
- // call that then committed (the only trace was a reflog entry). So:
771
- // create-if-absent only. If the resolved branch already exists and the
772
- // tree is on some other branch, do NOT switch — but never silently: log
773
- // the resolution so the operator sees that the phase branch was
774
- // resolved and deliberately not switched to (#2539 AC2: an auto-
775
- // checkout mid-commit must never happen silently).
776
- const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
777
- if (create.exitCode !== 0) {
778
- // `git checkout -b` fails (non-zero) when the branch already exists.
779
- // The operator is on the branch they intend to be on; commit there.
1094
+ // #2539/#3079/#3207: two cases the prior (#3079) code collapsed into one.
1095
+ // #1278 intent: CREATE the phase/milestone branch before the FIRST commit
1096
+ // on it so the phase's work accumulates there. #3079/#2539 hazard: never
1097
+ // silently switch an already-checked-out working branch onto a DIFFERENT
1098
+ // EXISTING branch — that resurrects merged-and-deleted phase branches and
1099
+ // silently moves HEAD onto a stale ref (#2539 AC2: an auto-checkout
1100
+ // mid-commit must never happen silently).
1101
+ // Reconciliation (#3207): a brand-new branch has no resurrection target,
1102
+ // so create-and-switch is safe here and is exactly the #1278 intent; an
1103
+ // EXISTING branch is never switched to (the else arm logs + commits in
1104
+ // place). The fresh create is logged so the first phase-scoped commit is
1105
+ // not silent about where the work is landing (#3207 AC3).
1106
+ const verify = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
1107
+ if (verify.exitCode !== 0) {
1108
+ // Branch does not exist — CREATE AND SWITCH (the #1278 first-commit
1109
+ // case). checkout -b cannot resurrect anything: the branch was just
1110
+ // verified absent, so it is created fresh at HEAD.
1111
+ const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
1112
+ if (create.exitCode === 0) {
1113
+ process.stderr.write(`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`);
1114
+ }
1115
+ else {
1116
+ process.stderr.write(`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
1117
+ `(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`);
1118
+ }
1119
+ }
1120
+ else {
1121
+ // Branch already exists — do NOT switch, commit on current branch.
780
1122
  process.stderr.write(`Warning: resolved ${branchingStrategy} branch "${branchName}" already exists; ` +
781
1123
  `committing on the current branch "${currentBranch.stdout.trim()}" instead of switching.\n`);
782
1124
  }
@@ -817,11 +1159,10 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
817
1159
  // exit is a real I/O failure, not a missing file.
818
1160
  const rmResult = (0, shell_command_projection_cjs_1.execGit)(['rm', '--cached', '--ignore-unmatch', file], { cwd });
819
1161
  if (rmResult.exitCode !== 0) {
820
- const rmErr = rmResult.error;
821
1162
  stagingFailures.push({
822
1163
  file,
823
1164
  error: rmResult.stderr || rmResult.stdout,
824
- timed_out: rmResult.signal === 'SIGTERM' && rmErr?.code === 'ETIMEDOUT',
1165
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(rmResult),
825
1166
  });
826
1167
  }
827
1168
  }
@@ -834,17 +1175,13 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
834
1175
  stagedPaths.push(file);
835
1176
  }
836
1177
  else {
837
- // `SpawnResultOutput.error` is typed `Error | null`; widen to the errno
838
- // shape by ANNOTATION rather than assertion — `Error` is assignable to
839
- // `NodeJS.ErrnoException` (its extra fields are optional), so an `as`
840
- // cast here trips no-unnecessary-type-assertion.
841
- const addErr = addResult.error;
842
1178
  stagingFailures.push({
843
1179
  file,
844
1180
  error: addResult.stderr || addResult.stdout,
845
- // The projection exposes a timeout distinctly (#2608 AC5); this is the
846
- // same SIGTERM+ETIMEDOUT idiom worktree-safety.cts uses.
847
- timed_out: addResult.signal === 'SIGTERM' && addErr?.code === 'ETIMEDOUT',
1181
+ // The projection exposes a timeout distinctly (#2608 AC5); this uses
1182
+ // the shared isSpawnTimeout predicate (shell-command-projection.cts)
1183
+ // also used by worktree-safety.cts and worktree-base-ref.cts (#3050).
1184
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(addResult),
848
1185
  });
849
1186
  }
850
1187
  }
@@ -1015,11 +1352,10 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
1015
1352
  stagedRelPaths.push(relativePath);
1016
1353
  }
1017
1354
  else {
1018
- const addErr = addResult.error;
1019
1355
  subStagingFailures.push({
1020
1356
  file,
1021
1357
  error: addResult.stderr || addResult.stdout,
1022
- timed_out: addResult.signal === 'SIGTERM' && addErr?.code === 'ETIMEDOUT',
1358
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(addResult),
1023
1359
  });
1024
1360
  }
1025
1361
  }
@@ -1383,20 +1719,28 @@ async function cmdWebsearch(query, options, raw) {
1383
1719
  }
1384
1720
  function cmdProgressRender(cwd, format, raw) {
1385
1721
  const phasesDir = planningPaths(cwd).phases;
1386
- const milestone = getMilestoneInfo(cwd);
1722
+ const milestone = getMilestoneInfo(cwd).value;
1387
1723
  const phases = [];
1388
1724
  let totalPlans = 0;
1389
1725
  let totalSummaries = 0;
1726
+ let phaseScope = null;
1390
1727
  try {
1391
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
1392
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
1728
+ // #3185 (ADR-3180 Decision 1): the single owner applies the milestone
1729
+ // window AND the sentinel filter and returns dirs already sorted by
1730
+ // comparePhaseNum. This command previously read the phases directory
1731
+ // directly with neither, which is why `query progress` listed 999.*
1732
+ // backlog directories as current-milestone phases (#3167).
1733
+ const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
1734
+ phaseScope = scope;
1393
1735
  for (const dir of dirs) {
1394
1736
  const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
1395
1737
  const phaseNum = dm ? dm[1] : dir;
1396
1738
  const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
1397
- const phaseFiles = node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, dir));
1398
- const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
1399
- const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
1739
+ // #3183: canonical plan/summary counts (root+nested, superseded-excluded,
1740
+ // canonical pairing) from the single owner.
1741
+ const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
1742
+ const plans = phaseScan.planCount;
1743
+ const summaries = phaseScan.summaryCount;
1400
1744
  totalPlans += plans;
1401
1745
  totalSummaries += summaries;
1402
1746
  const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, dir), 'Pending');
@@ -1404,14 +1748,24 @@ function cmdProgressRender(cwd, format, raw) {
1404
1748
  }
1405
1749
  }
1406
1750
  catch { /* intentionally empty */ }
1407
- const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
1751
+ // #3217 (ADR-3180 §7.6 rule 4): `phaseScope` was already computed above
1752
+ // (Phase 3, #3222) but never consulted before rendering — a percentage was
1753
+ // rendered from counts the scope said were not answers (TRUNCATED /
1754
+ // UNSCOPED / UNREADABLE). Withhold the percentage itself (never `0` — a
1755
+ // real `0` under COMPLETE must still render, rule 2's territory) when the
1756
+ // scope is not COMPLETE. `phaseScope` stays `null` only if the try block
1757
+ // above threw before assigning it; treat that the same as non-COMPLETE.
1758
+ const percent = phaseScope === SCOPE.COMPLETE
1759
+ ? (0, phase_lifecycle_cjs_1.clampPercent)(totalSummaries, totalPlans)
1760
+ : null;
1408
1761
  if (format === 'table') {
1409
1762
  // Render markdown table
1410
1763
  const barWidth = 10;
1411
- const filled = Math.round((percent / 100) * barWidth);
1764
+ const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
1412
1765
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1413
- let out = `# ${milestone.version} ${milestone.name}\n\n`;
1414
- out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
1766
+ const percentSuffix = percent === null ? '' : ` (${percent}%)`;
1767
+ let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
1768
+ out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
1415
1769
  out += `| Phase | Name | Plans | Status |\n`;
1416
1770
  out += `|-------|------|-------|--------|\n`;
1417
1771
  for (const p of phases) {
@@ -1421,20 +1775,24 @@ function cmdProgressRender(cwd, format, raw) {
1421
1775
  }
1422
1776
  else if (format === 'bar') {
1423
1777
  const barWidth = 20;
1424
- const filled = Math.round((percent / 100) * barWidth);
1778
+ const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
1425
1779
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1426
- const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
1780
+ const percentSuffix = percent === null ? '' : ` (${percent}%)`;
1781
+ const text = `[${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}`;
1427
1782
  output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
1428
1783
  }
1429
1784
  else {
1430
1785
  // JSON format
1431
1786
  output({
1432
- milestone_version: milestone.version,
1433
- milestone_name: milestone.name,
1787
+ milestone_version: milestone?.version ?? null,
1788
+ milestone_name: milestone?.name ?? null,
1434
1789
  phases,
1435
1790
  total_plans: totalPlans,
1436
1791
  total_summaries: totalSummaries,
1437
1792
  percent,
1793
+ // #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
1794
+ // can tell a genuinely-empty milestone from one it could not scope.
1795
+ phase_scope: phaseScope,
1438
1796
  }, raw, undefined);
1439
1797
  }
1440
1798
  }
@@ -1491,7 +1849,9 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
1491
1849
  if (phaseInfoDisk && phaseInfoDisk['found']) {
1492
1850
  try {
1493
1851
  const phaseDir = node_path_1.default.join(cwd, phaseInfoDisk['directory']);
1494
- const planFiles = node_fs_1.default.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
1852
+ // #3183: canonical plan set (root+nested, superseded-excluded) from the
1853
+ // single owner, rather than a root-only hand-rolled readdirSync filter.
1854
+ const planFiles = scanPhasePlans(phaseDir).planFiles;
1495
1855
  for (const pf of planFiles) {
1496
1856
  const planContent = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(phaseDir, pf));
1497
1857
  if (planContent === null)
@@ -1628,12 +1988,12 @@ function cmdStats(cwd, format, raw) {
1628
1988
  const roadmapPath = planningPaths(cwd).roadmap;
1629
1989
  const reqPath = planningPaths(cwd).requirements;
1630
1990
  const statePath = planningPaths(cwd).state;
1631
- const milestone = getMilestoneInfo(cwd);
1632
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
1991
+ const milestone = getMilestoneInfo(cwd).value;
1633
1992
  // Phase & plan stats (reuse progress pattern)
1634
1993
  const phasesByNumber = new Map();
1635
1994
  let totalPlans = 0;
1636
1995
  let totalSummaries = 0;
1996
+ let phaseScope = null;
1637
1997
  try {
1638
1998
  const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1639
1999
  if (roadmapRaw === null)
@@ -1642,9 +2002,22 @@ function cmdStats(cwd, format, raw) {
1642
2002
  // Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings.
1643
2003
  // Also tolerates optional [bracket-token] scope prefix on phase headings.
1644
2004
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1645
- const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
2005
+ // #3569: the id capture is the canonical #3036 shape (digit REQUIRED — incl.
2006
+ // letter-prefixed B7, decimals, milestone 2-01), the same group roadmap.cts's
2007
+ // collectAnalyzePhases uses. The former `([\w][\w.-]*)` matched ANY word, so
2008
+ // prose mentioning `### Phase N:` inside an inline code span produced a phantom
2009
+ // Not-Started row and made phases_total disagree with roadmap analyze.
2010
+ // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
2011
+ const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
1646
2012
  let match;
1647
2013
  while ((match = headingPattern.exec(roadmapContent)) !== null) {
2014
+ // #3185: the heading seed carried no sentinel filter, so a
2015
+ // `### Phase 999.1:` backlog heading produced a stats row even with no
2016
+ // directory on disk. Uses the canonical predicate (phase-id.cts), not a
2017
+ // local literal — the rule had five copies and three regex variants
2018
+ // before this phase, disagreeing about Phase 0.
2019
+ if (isSentinelPhaseId(match[1]))
2020
+ continue;
1648
2021
  const key = normalizePhaseName(match[1]);
1649
2022
  phasesByNumber.set(key, {
1650
2023
  number: key,
@@ -1657,12 +2030,13 @@ function cmdStats(cwd, format, raw) {
1657
2030
  }
1658
2031
  catch { /* intentionally empty */ }
1659
2032
  try {
1660
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
1661
- const dirs = entries
1662
- .filter(e => e.isDirectory())
1663
- .map(e => e.name)
1664
- .filter(isDirInMilestone)
1665
- .sort((a, b) => comparePhaseNum(a, b));
2033
+ // #3185 (ADR-3180 Decision 1): route through the single owner. This
2034
+ // previously applied the milestone window but NOT a directory-level
2035
+ // sentinel filter — and getMilestonePhaseFilter degrades to a pass-all
2036
+ // predicate when its heading set is empty, at which point every directory
2037
+ // on disk passed, backlog included (#3167).
2038
+ const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
2039
+ phaseScope = scope;
1666
2040
  for (const dir of dirs) {
1667
2041
  // Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names.
1668
2042
  const phaseToken = extractPhaseToken(dir);
@@ -1670,9 +2044,11 @@ function cmdStats(cwd, format, raw) {
1670
2044
  // phaseName is everything after the token (strip leading '-')
1671
2045
  const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
1672
2046
  const phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
1673
- const phaseFiles = node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, dir));
1674
- const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
1675
- const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
2047
+ // #3183: canonical plan/summary counts (root+nested, superseded-excluded,
2048
+ // canonical pairing) from the single owner.
2049
+ const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
2050
+ const plans = phaseScan.planCount;
2051
+ const summaries = phaseScan.summaryCount;
1676
2052
  totalPlans += plans;
1677
2053
  totalSummaries += summaries;
1678
2054
  const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, dir), 'Not Started');
@@ -1695,8 +2071,12 @@ function cmdStats(cwd, format, raw) {
1695
2071
  catch { /* intentionally empty */ }
1696
2072
  const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
1697
2073
  const completedPhases = phases.filter(p => p.status === 'Complete').length;
1698
- const planPercent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
1699
- const percent = phases.length > 0 ? Math.min(100, Math.round((completedPhases / phases.length) * 100)) : 0;
2074
+ // #3217 (ADR-3180 §7.6 rule 4): both percentages here are derived from the
2075
+ // same `phaseScope`-carrying directory enumeration above (Phase 3, #3222) —
2076
+ // withhold both when that scope is not COMPLETE, same rationale as
2077
+ // cmdProgressRender above. A real `0` under COMPLETE still renders.
2078
+ const planPercent = phaseScope === SCOPE.COMPLETE ? (0, phase_lifecycle_cjs_1.clampPercent)(totalSummaries, totalPlans) : null;
2079
+ const percent = phaseScope === SCOPE.COMPLETE ? (0, phase_lifecycle_cjs_1.clampPercent)(completedPhases, phases.length) : null;
1700
2080
  // Requirements stats
1701
2081
  let requirementsTotal = 0;
1702
2082
  let requirementsComplete = 0;
@@ -1734,8 +2114,8 @@ function cmdStats(cwd, format, raw) {
1734
2114
  }
1735
2115
  }
1736
2116
  const result = {
1737
- milestone_version: milestone.version,
1738
- milestone_name: milestone.name,
2117
+ milestone_version: milestone?.version ?? null,
2118
+ milestone_name: milestone?.name ?? null,
1739
2119
  phases,
1740
2120
  phases_completed: completedPhases,
1741
2121
  phases_total: phases.length,
@@ -1748,14 +2128,18 @@ function cmdStats(cwd, format, raw) {
1748
2128
  git_commits: gitCommits,
1749
2129
  git_first_commit_date: gitFirstCommitDate,
1750
2130
  last_activity: lastActivity,
2131
+ // #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
2132
+ // can tell a genuinely-empty milestone from one it could not scope.
2133
+ phase_scope: phaseScope,
1751
2134
  };
1752
2135
  if (format === 'table') {
1753
2136
  const barWidth = 10;
1754
- const filled = Math.round((percent / 100) * barWidth);
2137
+ const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
1755
2138
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1756
- let out = `# ${milestone.version} ${milestone.name} — Statistics\n\n`;
1757
- out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
1758
- if (totalPlans > 0) {
2139
+ let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
2140
+ const percentSuffix = percent === null ? '' : ` (${percent}%)`;
2141
+ out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;
2142
+ if (totalPlans > 0 && planPercent !== null) {
1759
2143
  out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
1760
2144
  }
1761
2145
  out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
@@ -1783,30 +2167,204 @@ function cmdStats(cwd, format, raw) {
1783
2167
  }
1784
2168
  }
1785
2169
  /**
1786
- * Check whether a commit should be allowed based on commit_docs config.
1787
- * When commit_docs is false, rejects commits that stage .planning/ files.
1788
- * Intended for use as a pre-commit hook guard.
2170
+ * Check whether a commit should be allowed based on the `commit_docs`
2171
+ * precedence chain, INCLUDING any `phase_commit_docs.<phase-id>` override
2172
+ * (#3587/#3601). Rejects commits that stage `.planning/` files when the
2173
+ * resolved policy is false. Intended for use as a pre-commit hook guard —
2174
+ * see `commit-docs-guard enable` above.
2175
+ *
2176
+ * The phase is derived from the STAGED `.planning/` paths via the single-
2177
+ * owner `detectPhaseNumberFromFiles` (the same helper `cmdCommit` uses), and
2178
+ * the policy itself is resolved via the single-owner `resolveCommitDocsPolicy`
2179
+ * (also shared with `cmdCommit`) — this function never re-derives phase
2180
+ * detection or precedence, so it cannot diverge from `cmdCommit`'s decision
2181
+ * for the same staged tree (#3588 Part 1: this guard was previously
2182
+ * phase-blind, reading only project-level `commit_docs` and directly
2183
+ * contradicting `gsd-tools query commit`'s phase-aware resolution).
2184
+ *
2185
+ * Staged paths are read via `git diff --cached --name-only -z`, NUL-
2186
+ * delimited, rather than the LF-delimited default. Without `-z`, git
2187
+ * C-style-quotes (wraps in double quotes, octal-escapes) any path containing
2188
+ * a non-ASCII byte, a space-adjacent special character, or a literal quote —
2189
+ * `.planning/café.md` is reported as `".planning/caf\303\251.md"`, which
2190
+ * does not start with `.planning/`, so the old LF-based filter silently
2191
+ * missed it and allowed the commit (#3588 F2: a false negative in the harm
2192
+ * direction this guard exists to prevent). `-z` disables that quoting
2193
+ * entirely and NUL-terminates each path instead, so every staged path is
2194
+ * read as literal, unquoted bytes and no unquoting logic is needed.
1789
2195
  */
1790
2196
  function cmdCheckCommit(cwd, raw) {
1791
2197
  const config = loadConfig(cwd);
1792
- // If commit_docs is true (or not set), allow all commits
1793
- if (config['commit_docs'] !== false) {
1794
- output({ allowed: true, reason: 'commit_docs_enabled' }, raw, 'allowed');
1795
- return;
1796
- }
1797
- // commit_docs is false — check if any .planning/ files are staged
1798
- const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only'], { cwd });
2198
+ const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z'], { cwd });
1799
2199
  if (stagedResult.exitCode === 0) {
1800
- const planningFiles = stagedResult.stdout.split('\n').filter(f => f.startsWith('.planning/') || f.startsWith('.planning\\'));
2200
+ const files = stagedResult.stdout.split('\0').filter(Boolean);
2201
+ const planningFiles = files.filter(f => f.startsWith('.planning/'));
1801
2202
  if (planningFiles.length > 0) {
1802
- error(`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
1803
- planningFiles.map(f => ` ${f}`).join('\n') +
1804
- `\n\nTo unstage: git reset HEAD ${planningFiles.join(' ')}`);
2203
+ const policy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(planningFiles), () => isGitIgnored(cwd, '.planning'));
2204
+ if (!policy.resolved) {
2205
+ error(`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
2206
+ planningFiles.map(f => ` ${f}`).join('\n') +
2207
+ `\n\nTo unstage: git reset HEAD ${planningFiles.join(' ')}`);
2208
+ return;
2209
+ }
2210
+ output({ allowed: true, reason: policy.source === 'phase' ? 'phase_commit_docs_true' : 'commit_docs_enabled' }, raw, 'allowed');
2211
+ return;
1805
2212
  }
1806
2213
  }
1807
- // exitCode !== 0 → no staged files or not a git repo — allow
2214
+ // exitCode !== 0 (no staged files / not a git repo) or no .planning/ files staged — allow
1808
2215
  output({ allowed: true, reason: 'no_planning_files_staged' }, raw, 'allowed');
1809
2216
  }
2217
+ // ─── commit-docs-guard: opt-in pre-commit hook (#3588) ─────────────────────
2218
+ /**
2219
+ * Stable sentinel line identifying a `.git/hooks/pre-commit` file as ours.
2220
+ * Detection is by PRESENCE of this line, not byte-equality (design "Identifying
2221
+ * 'our' hook") — a user who appends a line to a GSD-written hook must not make
2222
+ * it unrecognizable, and a hook lacking this line must never be overwritten or
2223
+ * deleted by `commit-docs-guard enable`/`disable`.
2224
+ */
2225
+ const COMMIT_DOCS_GUARD_MARKER = '# gsd-core:commit-docs-guard';
2226
+ /**
2227
+ * Locate `gsd-core/workflows/_runtime-launcher.snippet.sh` — the SAME
2228
+ * gsd-tools-resolution chain every shipped workflow/agent bash block uses
2229
+ * (scripts/sync-runtime-launcher.cjs) — by walking up from this module's own
2230
+ * compiled location rather than a fixed literal `../..` join, so the walk
2231
+ * tolerates the module living at a different depth under an alternate build
2232
+ * or bundling layout (same defensive shape as
2233
+ * runtime-artifact-layout.cts#findInstallSourceRoot).
2234
+ */
2235
+ function findRuntimeLauncherSnippet() {
2236
+ let dir = __dirname;
2237
+ for (let i = 0; i < 8; i++) {
2238
+ const candidate = node_path_1.default.join(dir, 'workflows', '_runtime-launcher.snippet.sh');
2239
+ if (node_fs_1.default.existsSync(candidate))
2240
+ return candidate;
2241
+ const parent = node_path_1.default.dirname(dir);
2242
+ if (parent === dir)
2243
+ break;
2244
+ dir = parent;
2245
+ }
2246
+ throw new Error(`commit-docs-guard: could not locate workflows/_runtime-launcher.snippet.sh from ${__dirname}`);
2247
+ }
2248
+ /**
2249
+ * Build the literal `.git/hooks/pre-commit` content `commit-docs-guard enable`
2250
+ * writes. Reuses the canonical gsd_run resolution preamble byte-for-byte
2251
+ * (read from disk, never hand-copied — see findRuntimeLauncherSnippet) so this
2252
+ * hook resolves `gsd-tools` exactly the way every other shipped workflow bash
2253
+ * block does, and cannot drift from it.
2254
+ *
2255
+ * LF-only (#3588 A2): the snippet file and every literal line here are joined
2256
+ * with `\n`; platformWriteSync additionally normalizes CRLF→LF on write, so a
2257
+ * CRLF shebang — which is not executable under Git Bash — cannot reach disk.
2258
+ */
2259
+ function buildCommitDocsGuardHookScript() {
2260
+ const snippetPath = findRuntimeLauncherSnippet();
2261
+ const preamble = (0, text_lines_cjs_1.normalizeEol)(node_fs_1.default.readFileSync(snippetPath, 'utf8')).replace(/\n+$/, '');
2262
+ const lines = [
2263
+ '#!/usr/bin/env bash',
2264
+ COMMIT_DOCS_GUARD_MARKER,
2265
+ '# Refuses a commit that stages .planning/ files when `commit_docs` resolves',
2266
+ '# false (honoring any per-phase override). Installed by',
2267
+ '# `gsd-tools commit-docs-guard enable`; remove with',
2268
+ '# `gsd-tools commit-docs-guard disable`. See',
2269
+ '# docs/how-to/keep-planning-docs-private.md.',
2270
+ 'set -euo pipefail',
2271
+ '',
2272
+ preamble,
2273
+ '',
2274
+ 'gsd_run check-commit --raw',
2275
+ ];
2276
+ return lines.join('\n') + '\n';
2277
+ }
2278
+ /**
2279
+ * Resolve the real git hooks directory for `cwd` via `git rev-parse
2280
+ * --git-path hooks` — never a literal `.git/hooks` join (#3588 row 8: a
2281
+ * linked worktree or submodule's `.git` is a FILE pointing elsewhere, and
2282
+ * this is the one git-native call that already resolves that correctly).
2283
+ */
2284
+ function resolveCommitDocsGuardHooksDir(cwd) {
2285
+ const gitDirResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-dir'], { cwd });
2286
+ if (gitDirResult.exitCode !== 0) {
2287
+ return { ok: false, reason: 'not_a_git_repo' };
2288
+ }
2289
+ const hooksPathResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'hooks'], { cwd });
2290
+ if (hooksPathResult.exitCode !== 0) {
2291
+ return { ok: false, reason: 'not_a_git_repo' };
2292
+ }
2293
+ const hooksDirRaw = hooksPathResult.stdout.trim();
2294
+ const hooksDir = node_path_1.default.isAbsolute(hooksDirRaw) ? hooksDirRaw : node_path_1.default.join(cwd, hooksDirRaw);
2295
+ return { ok: true, dir: hooksDir };
2296
+ }
2297
+ /** Marker presence, not byte-equality (#3588 row B10). */
2298
+ function isCommitDocsGuardHook(content) {
2299
+ return content.includes(COMMIT_DOCS_GUARD_MARKER);
2300
+ }
2301
+ /**
2302
+ * `gsd-tools commit-docs-guard enable` — write `.git/hooks/pre-commit`.
2303
+ * Behavior table (40-design.md rows 1-3, 8-9): refuses to clobber a foreign
2304
+ * hook, refuses when `core.hooksPath` would make our own write inert, and is
2305
+ * idempotent when already enabled.
2306
+ */
2307
+ function cmdCommitDocsGuardEnable(cwd, raw) {
2308
+ const hooksDir = resolveCommitDocsGuardHooksDir(cwd);
2309
+ if (!hooksDir.ok || !hooksDir.dir) {
2310
+ error('not a git repository (or any of the parent directories)', ERROR_REASON.COMMIT_DOCS_GUARD_NOT_A_REPO);
2311
+ return;
2312
+ }
2313
+ // core.hooksPath already set: our .git/hooks/pre-commit would be inert —
2314
+ // git would never invoke it. Silently writing an ignored file is worse
2315
+ // than refusing (design row 9).
2316
+ const hooksPathConfig = (0, shell_command_projection_cjs_1.execGit)(['config', '--get', 'core.hooksPath'], { cwd });
2317
+ if (hooksPathConfig.exitCode === 0 && hooksPathConfig.stdout.trim() !== '') {
2318
+ const configuredPath = hooksPathConfig.stdout.trim();
2319
+ error(`core.hooksPath is set to "${configuredPath}"; a hook written to ${node_path_1.default.join(hooksDir.dir, 'pre-commit')} ` +
2320
+ `would never run. Wire commit-docs-guard into "${configuredPath}" manually, or unset core.hooksPath first.`, ERROR_REASON.COMMIT_DOCS_GUARD_HOOKS_PATH_SET);
2321
+ return;
2322
+ }
2323
+ const hookPath = node_path_1.default.join(hooksDir.dir, 'pre-commit');
2324
+ const existing = (0, shell_command_projection_cjs_1.platformReadSync)(hookPath);
2325
+ if (existing !== null) {
2326
+ if (!isCommitDocsGuardHook(existing)) {
2327
+ error(`refusing to overwrite an existing pre-commit hook at ${hookPath} that GSD did not write. ` +
2328
+ `Remove or rename it, or wire commit-docs-guard into it by hand.`, ERROR_REASON.COMMIT_DOCS_GUARD_FOREIGN_HOOK);
2329
+ }
2330
+ // Already ours — idempotent no-op (row 3). Leave any user edits intact;
2331
+ // just make sure the executable bit survived.
2332
+ try {
2333
+ node_fs_1.default.chmodSync(hookPath, 0o755);
2334
+ }
2335
+ catch { /* best-effort */ }
2336
+ output({ enabled: true, action: 'already_enabled', path: hookPath }, raw, 'already_enabled');
2337
+ return;
2338
+ }
2339
+ (0, shell_command_projection_cjs_1.platformWriteSync)(hookPath, buildCommitDocsGuardHookScript());
2340
+ node_fs_1.default.chmodSync(hookPath, 0o755);
2341
+ output({ enabled: true, action: 'written', path: hookPath }, raw, 'enabled');
2342
+ }
2343
+ /**
2344
+ * `gsd-tools commit-docs-guard disable` — remove `.git/hooks/pre-commit`
2345
+ * ONLY when it is the hook we wrote (marker presence). Never deletes a
2346
+ * foreign hook (design row 5); a missing hook is a no-op success, not an
2347
+ * error (row 6).
2348
+ */
2349
+ function cmdCommitDocsGuardDisable(cwd, raw) {
2350
+ const hooksDir = resolveCommitDocsGuardHooksDir(cwd);
2351
+ if (!hooksDir.ok || !hooksDir.dir) {
2352
+ error('not a git repository (or any of the parent directories)', ERROR_REASON.COMMIT_DOCS_GUARD_NOT_A_REPO);
2353
+ return;
2354
+ }
2355
+ const hookPath = node_path_1.default.join(hooksDir.dir, 'pre-commit');
2356
+ const existing = (0, shell_command_projection_cjs_1.platformReadSync)(hookPath);
2357
+ if (existing === null) {
2358
+ output({ disabled: true, action: 'noop', path: hookPath }, raw, 'noop');
2359
+ return;
2360
+ }
2361
+ if (!isCommitDocsGuardHook(existing)) {
2362
+ error(`refusing to remove the pre-commit hook at ${hookPath}: it does not carry the ` +
2363
+ `${COMMIT_DOCS_GUARD_MARKER} marker, so GSD did not write it.`, ERROR_REASON.COMMIT_DOCS_GUARD_FOREIGN_HOOK);
2364
+ }
2365
+ node_fs_1.default.unlinkSync(hookPath);
2366
+ output({ disabled: true, action: 'removed', path: hookPath }, raw, 'disabled');
2367
+ }
1810
2368
  module.exports = {
1811
2369
  groupFilesBySubrepo,
1812
2370
  determinePhaseStatus,
@@ -1823,6 +2381,10 @@ module.exports = {
1823
2381
  cmdResolveGranularity,
1824
2382
  cmdResolveExecution,
1825
2383
  cmdEffortSync,
2384
+ detectPhaseNumberFromFiles,
2385
+ resolvePhaseCommitDocsOverride,
2386
+ resolveCommitDocsPolicy,
2387
+ COMMIT_DOCS_SKIP_REASON,
1826
2388
  cmdCommit,
1827
2389
  cmdCommitToSubrepo,
1828
2390
  cmdPrSubrepo,
@@ -1834,5 +2396,9 @@ module.exports = {
1834
2396
  cmdScaffold,
1835
2397
  cmdStats,
1836
2398
  cmdCheckCommit,
2399
+ COMMIT_DOCS_GUARD_MARKER,
2400
+ buildCommitDocsGuardHookScript,
2401
+ cmdCommitDocsGuardEnable,
2402
+ cmdCommitDocsGuardDisable,
1837
2403
  _wsParseRetryAfter,
1838
2404
  };