@opengsd/gsd-core 1.10.0 → 1.12.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 (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -11,11 +11,13 @@ 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");
16
+ const pattern_cjs_1 = require("./pattern.cjs");
15
17
  const security_cjs_1 = require("./security.cjs");
16
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
17
19
  const ioMod = require("./io.cjs");
18
- const { output, error } = ioMod;
20
+ const { output, error, ERROR_REASON } = ioMod;
19
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
20
22
  const configLoaderMod = require("./config-loader.cjs");
21
23
  const { loadConfig, isGitIgnored } = configLoaderMod;
@@ -24,20 +26,28 @@ const coreUtilsMod = require("./core-utils.cjs");
24
26
  const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
25
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
26
28
  const phaseIdMod = require("./phase-id.cjs");
27
- const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
29
+ const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
28
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
31
  const phaseLocatorMod = require("./phase-locator.cjs");
30
- const { getArchivedPhaseDirs, findPhaseInternal } = phaseLocatorMod;
32
+ const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
31
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
34
  const roadmapParserMod = require("./roadmap-parser.cjs");
33
- const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo, getMilestonePhaseFilter, getRoadmapPhaseInternal } = roadmapParserMod;
35
+ const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo, getRoadmapPhaseInternal } = roadmapParserMod;
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const planningScopeMod = require("./planning-scope.cjs");
38
+ const { SCOPE } = planningScopeMod;
34
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
40
  const modelResolverMod = require("./model-resolver.cjs");
36
- const { resolveModelInternal, resolveModelForTier, resolveProviderEscalation, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
41
+ const { resolveModelInternal, resolveTierInternal, resolveModelForTier, resolveProviderEscalation, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
37
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports
38
43
  const agentCommandRouterMod = require("./agent-command-router.cjs");
39
44
  const { AGENT_FAILURE_CLASSES } = agentCommandRouterMod;
40
45
  const model_catalog_cjs_1 = require("./model-catalog.cjs");
46
+ // #3243 (ADR-2313 D7) — the Codex `.toml` sync's typed IR: parse/render/strip
47
+ // primitives moved from agent-install-check.cts's Phase-2 parsing into this
48
+ // leaf so both consumers share one block-range detector. See
49
+ // codex-agent-toml.cts's module header for the reader/writer reconciliation.
50
+ const codex_agent_toml_cjs_1 = require("./codex-agent-toml.cjs");
41
51
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
52
  const hostIntegrationMod = require("./host-integration.cjs");
43
53
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -45,12 +55,19 @@ const planningWorkspace = require("./planning-workspace.cjs");
45
55
  const { planningDir, planningPaths } = planningWorkspace;
46
56
  // eslint-disable-next-line @typescript-eslint/no-require-imports
47
57
  const frontmatter = require("./frontmatter.cjs");
48
- const { extractFrontmatter } = frontmatter;
58
+ const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter;
49
59
  // eslint-disable-next-line @typescript-eslint/no-require-imports
50
60
  const modelProfiles = require("./model-profiles.cjs");
51
61
  const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles;
52
62
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
53
63
  const clock_cjs_1 = require("./clock.cjs");
64
+ const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
65
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
66
+ const planScanMod = require("./plan-scan.cjs");
67
+ const { scanPhasePlans } = planScanMod;
68
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
69
+ const verificationMod = require("./verification.cjs");
70
+ const { resolveVerificationFile } = verificationMod;
54
71
  // ─── Phase Status ─────────────────────────────────────────────────────────────
55
72
  /**
56
73
  * Phase-status precedence ladder — furthest-along wins (#2408).
@@ -104,7 +121,16 @@ function determinePhaseStatus(plans, summaries, phaseDir, defaultPending) {
104
121
  // summaries >= plans — check verification
105
122
  try {
106
123
  const files = node_fs_1.default.readdirSync(phaseDir);
107
- const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
124
+ // #3473 F2: routed through the shared resolver (readdir order is
125
+ // filesystem-dependent, so the prior hand-rolled `.find()` could pick
126
+ // either file when a phase held both a canonical report and an ad-hoc
127
+ // `-CORRECTION-VERIFICATION.md` worksheet — see #3357).
128
+ // #3492: pin selection to THIS phase's own token so a stray cross-phase
129
+ // or sentinel-numbered canonically-shaped file cannot outrank this
130
+ // phase's own (possibly non-canonical) report.
131
+ const phaseDirName = node_path_1.default.basename(phaseDir);
132
+ const phaseToken = extractPhaseToken(phaseDirName);
133
+ const verificationFile = resolveVerificationFile(files, { allowBare: true, phaseToken, phaseDirName });
108
134
  if (verificationFile) {
109
135
  const verificationFilePath = node_path_1.default.join(phaseDir, verificationFile);
110
136
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(verificationFilePath) || '';
@@ -134,16 +160,16 @@ function cmdGenerateSlug(text, raw) {
134
160
  if (!text) {
135
161
  error('text required for slug generation');
136
162
  }
137
- const slug = text
138
- .toLowerCase()
139
- .replace(/[^a-z0-9]+/g, '-')
140
- .replace(/^-+|-+$/g, '')
141
- .substring(0, 60);
163
+ // #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
164
+ // (generateSlugInternal, core-utils.cts) instead of re-implementing it —
165
+ // this call site previously diverged from it (Cyrillic collapsed to "",
166
+ // and truncation could leave a trailing hyphen; #2848/#2849).
167
+ const slug = coreUtilsMod.generateSlugInternal(text) ?? '';
142
168
  const result = { slug };
143
169
  output(result, raw, slug);
144
170
  }
145
171
  function cmdCurrentTimestamp(format, raw) {
146
- const now = new Date();
172
+ const now = new Date(clock_cjs_1.realClock.now());
147
173
  let result;
148
174
  switch (format) {
149
175
  case 'date':
@@ -348,7 +374,14 @@ function cmdHistoryDigest(cwd, raw) {
348
374
  }
349
375
  try {
350
376
  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');
377
+ // #3183: canonical summary set (root+nested) from the single owner.
378
+ // This call also opens every plan file's frontmatter to check
379
+ // superseded status even though cmdHistoryDigest never uses planFiles
380
+ // or the superseded distinction — that per-phase-dir cost is accepted
381
+ // deliberately (correctness/single-ownership over micro-optimization;
382
+ // summaryFiles itself is not superseded-filtered either way). Do not
383
+ // "optimize" this back into a second hand-rolled summary derivation.
384
+ const summaries = scanPhasePlans(dirPath).summaryFiles;
352
385
  for (const summary of summaries) {
353
386
  const summaryFilePath = node_path_1.default.join(dirPath, summary);
354
387
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(summaryFilePath);
@@ -419,10 +452,20 @@ function cmdResolveModel(cwd, agentType, raw) {
419
452
  const profile = config['model_profile'] || 'balanced';
420
453
  const model = resolveModelInternal(cwd, agentType);
421
454
  const effort = resolveEffortInternal(cwd, agentType);
422
- const agentModels = MODEL_PROFILES[agentType];
455
+ // Own-property guard: agentType is an unvalidated CLI positional, so a
456
+ // prototype-chain value ("toString", "constructor") would otherwise return
457
+ // an inherited truthy member from this plain object and misreport a
458
+ // genuinely unknown agent as known (unknown_agent dropped from the result).
459
+ const agentModelsMap = MODEL_PROFILES;
460
+ const agentModels = Object.hasOwn(agentModelsMap, agentType) ? agentModelsMap[agentType] : undefined;
461
+ // #2229: `tier` is additive — existing keys and their values are untouched, so
462
+ // every `--pick model` / `--pick profile` / `--raw` consumer is unaffected. It
463
+ // exists because the model id is deliberately blank under resolve_model_ids:"omit",
464
+ // which leaves a tier-sensitive guard with nothing to read.
465
+ const tier = resolveTierInternal(cwd, agentType);
423
466
  const result = agentModels
424
- ? { model, profile, effort }
425
- : { model, profile, effort, unknown_agent: true };
467
+ ? { model, profile, effort, tier }
468
+ : { model, profile, effort, tier, unknown_agent: true };
426
469
  output(result, raw, model);
427
470
  }
428
471
  function cmdResolveGranularity(cwd, phaseType, raw, override) {
@@ -487,9 +530,59 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
487
530
  : resolveEffortInternal(cwd, agentType, effortOpts);
488
531
  const fastMode = resolveFastModeInternal(cwd, agentType, fastModeOpts);
489
532
  const runtime = config['runtime'] || 'claude';
490
- const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, effort);
533
+ // #3007: pass the resolved model so the per-model advertised-effort ceiling
534
+ // (CODEX_MODEL_EFFORT) is reachable from this production seam. `model` may
535
+ // be a tier alias or a non-Codex id for other runtimes — that's fine and
536
+ // must not be special-cased here: advertisedCodexEffort() falls back to the
537
+ // family baseline for any id it doesn't recognize.
538
+ const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, effort, model);
491
539
  const fastModeSupported = model_catalog_cjs_1.RUNTIMES_WITH_FAST_MODE.has(runtime);
492
- const agentModels = MODEL_PROFILES[agentType];
540
+ // #3534 (10a): the effective effort — what the installed agent will actually
541
+ // run at. `effort` above is the config cascade; for the claude runtime the
542
+ // per-agent frontmatter key is the source of truth (Claude Code's Agent tool
543
+ // has no per-spawn effort parameter), so the query reads the installed file.
544
+ // An ABSENT key is a real state — the agent follows the session effort
545
+ // ('inherit'), not drift. No file / no frontmatter / any read failure means
546
+ // no evidence: the resolved value is reported, flagged 'resolved' so a
547
+ // consumer can tell evidence from echo. Additive only — every existing key
548
+ // is unchanged.
549
+ let effortEffectiveSource = 'resolved';
550
+ let effortEffective = effort;
551
+ if (runtime === 'claude') {
552
+ try {
553
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
554
+ const { getGlobalConfigDir } = require('./runtime-homes.cjs');
555
+ const agentsDirEff = node_path_1.default.join(getGlobalConfigDir(runtime), 'agents');
556
+ const agentPath = node_path_1.default.join(agentsDirEff, `${agentType}.md`);
557
+ // agentType is an unvalidated CLI positional: keep the read inside the
558
+ // agents dir so `../../x` cannot point it elsewhere (defense in depth —
559
+ // the reflected surface is only a frontmatter effort line).
560
+ if (!node_path_1.default.resolve(agentPath).startsWith(node_path_1.default.resolve(agentsDirEff) + node_path_1.default.sep)) {
561
+ throw new Error('agent path escapes the agents directory');
562
+ }
563
+ const agentContent = node_fs_1.default.readFileSync(agentPath, 'utf8');
564
+ // eslint-disable-next-line local/no-unbounded-quantifier -- same lazy `*?` bounded by the `^---$/m` closing anchor as the sibling frontmatter regexes in this file
565
+ const fmMatchEff = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(agentContent);
566
+ if (fmMatchEff) {
567
+ const effortLine = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatchEff[1]);
568
+ if (effortLine) {
569
+ effortEffective = effortLine[1];
570
+ effortEffectiveSource = 'frontmatter';
571
+ }
572
+ else {
573
+ effortEffective = 'inherit';
574
+ effortEffectiveSource = 'frontmatter-absent';
575
+ }
576
+ }
577
+ }
578
+ catch { /* no frontmatter evidence — stay on the resolved value */ }
579
+ }
580
+ // Own-property guard: agentType is an unvalidated CLI positional, so a
581
+ // prototype-chain value ("toString", "constructor") would otherwise return
582
+ // an inherited truthy member from this plain object and misreport a
583
+ // genuinely unknown agent as known (unknown_agent dropped from the result).
584
+ const agentModelsMap = MODEL_PROFILES;
585
+ const agentModels = Object.hasOwn(agentModelsMap, agentType) ? agentModelsMap[agentType] : undefined;
493
586
  const result = {
494
587
  model,
495
588
  profile,
@@ -497,6 +590,11 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
497
590
  effort_rendered: rendered.value,
498
591
  effort_param: rendered.param,
499
592
  effort_propagation: rendered.channel,
593
+ effort_requested: rendered.requested,
594
+ effort_clamped: rendered.clamped,
595
+ effort_clamp_reason: rendered.reason,
596
+ effort_effective: effortEffective,
597
+ effort_effective_source: effortEffectiveSource,
500
598
  fast_mode: fastMode,
501
599
  fast_mode_supported: fastModeSupported,
502
600
  };
@@ -549,22 +647,108 @@ function effortSurfaceForHost(cwd, host) {
549
647
  }
550
648
  }
551
649
  /**
552
- * #488 — Replace or inject the `effort:` value in YAML frontmatter.
650
+ * #488 — Replace or inject the `<key>:` value in YAML frontmatter.
553
651
  * Unlike injectEffortFrontmatter (install.js), this overwrites an existing value.
652
+ * #3706: key-parameterised so the same line-editor serves both claude's
653
+ * `effort:` and OpenCode's `variant:`. #3706: all offsets (eol, openLen,
654
+ * closingStart) are derived from the MATCHED BLOCK, not the start of the
655
+ * file, and the existing-key replace is scoped to the frontmatter span only.
554
656
  */
555
- function setEffortFrontmatter(content, effortValue) {
556
- const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
657
+ function setFrontmatterKeyLine(content, key, value) {
557
658
  const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
558
659
  const match = fmRe.exec(content);
559
660
  if (!match)
560
661
  return content;
561
662
  const fmBody = match[1];
562
- if (/^effort:/m.test(fmBody)) {
563
- return content.replace(/^(effort:)[ \t]*.*$/m, `$1 ${effortValue}`);
663
+ // Both writers of these frontmatter keys — this sync path and the
664
+ // install-side `frontmatterScalar` in runtime-artifact-conversion.cts —
665
+ // now share one escaping rule: quote via `agentScalarNeedsDoubleQuoting` +
666
+ // `escapeDoubleQuotedScalar` (both from frontmatter.cts) rather than each
667
+ // interpolating `value` raw/differently.
668
+ const renderedValue = agentScalarNeedsDoubleQuoting(value) ? `"${escapeDoubleQuotedScalar(value)}"` : value;
669
+ // EOL comes from the MATCHED BLOCK, not the start of the file. With a
670
+ // preamble the two can disagree, and on a CRLF document that misaligns every
671
+ // offset below by one byte and mangles the opening fence.
672
+ const eol = /^---\r\n/.test(match[0]) ? '\r\n' : '\n';
673
+ const openLen = 3 + eol.length;
674
+ const bodyStart = match.index + openLen;
675
+ const closingStart = bodyStart + fmBody.length;
676
+ // #3706: key is now generic (not just the literal 'effort'/'variant'
677
+ // callers happen to pass today) — escape it before interpolating into the
678
+ // RegExp so a future caller can't have its key metacharacters reinterpreted.
679
+ const keyLineRe = new RegExp(`^(${(0, pattern_cjs_1.escapeRegex)(key)}:)[ \\t]*.*$`, 'm');
680
+ if (keyLineRe.test(fmBody)) {
681
+ // #3706: a duplicated `<key>:` line is already invalid YAML, but a
682
+ // non-first-wins reader (last-wins) would otherwise honour a stale
683
+ // second occurrence left behind by a naive single-hit replace, while
684
+ // this function's own single-hit read reports "in sync" — a
685
+ // permanently non-converging state. Use a GLOBAL replace with a
686
+ // first-hit flag so every occurrence collapses to exactly one, IN THE
687
+ // POSITION of the first occurrence (never delete-then-append, which
688
+ // would move the key to the end of the frontmatter and churn every
689
+ // already-generated single-occurrence file).
690
+ const escaped = (0, pattern_cjs_1.escapeRegex)(key);
691
+ let seen = false;
692
+ const newBody = fmBody.replace(new RegExp(`^${escaped}:[ \\t]*.*(\\r?\\n?)`, 'gm'), (_m, nl) => {
693
+ if (!seen) {
694
+ seen = true;
695
+ return `${key}: ${renderedValue}${nl}`;
696
+ }
697
+ return '';
698
+ });
699
+ // Replace INSIDE the frontmatter span only: a whole-file /m replace would
700
+ // rewrite an earlier preamble line that happens to start with this key.
701
+ return content.slice(0, bodyStart) + newBody + content.slice(closingStart);
564
702
  }
703
+ return content.slice(0, closingStart) + `${key}: ${renderedValue}${eol}` + content.slice(closingStart);
704
+ }
705
+ /**
706
+ * #3533 (10d) — remove exactly the frontmatter `<key>:` line (and its line
707
+ * ending) so an agent configured for `inherit` carries NO key. Mirrors the
708
+ * codex-agent-toml strip discipline: targeted line removal, EOL-aware, every
709
+ * other byte (comments, sibling keys, the body) untouched.
710
+ * #3706: key-parameterised so the same line-editor serves both claude's
711
+ * `effort:` and OpenCode's `variant:`. #3706: openLen is derived from the
712
+ * MATCHED BLOCK, not the start of the file — a preamble on a CRLF document
713
+ * would otherwise misalign every offset below.
714
+ */
715
+ function removeFrontmatterKeyLine(content, key) {
716
+ // Scoped to the FIRST frontmatter block (not a whole-file /m match): a
717
+ // preamble or body line starting with `<key>:` (a fenced config example,
718
+ // a thematic-break flanked fragment) must never be the line removed.
719
+ const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
720
+ const match = fmRe.exec(content);
721
+ if (!match)
722
+ return content;
723
+ const fmBody = match[1];
724
+ // #3706: same generic-key escape as setFrontmatterKeyLine above.
725
+ const lineRe = new RegExp(`^${(0, pattern_cjs_1.escapeRegex)(key)}:[ \\t]*.*\\r?\\n?`, 'm');
726
+ if (!lineRe.test(fmBody))
727
+ return content;
728
+ // A duplicate `<key>:` mapping key is already invalid YAML (a document with
729
+ // two `effort:`/`variant:` lines does not parse), so this is robustness
730
+ // against a malformed document, not a live corruption path. Still, "a null
731
+ // target means the key must not exist" is an invariant this function must
732
+ // leave true on disk — a non-global replace here would strip only the
733
+ // FIRST occurrence and require a second run to converge. Use a fresh
734
+ // global RegExp for the strip so every occurrence in the frontmatter body
735
+ // is removed in one pass.
736
+ const stripAllRe = new RegExp(`^${(0, pattern_cjs_1.escapeRegex)(key)}:[ \\t]*.*\\r?\\n?`, 'gm');
737
+ const strippedFm = fmBody.replace(stripAllRe, '');
738
+ // Same rule as setFrontmatterKeyLine: the EOL must come from the matched
739
+ // block, not the start of the file, or a preambled CRLF document misaligns.
740
+ const eol = /^---\r\n/.test(match[0]) ? '\r\n' : '\n';
565
741
  const openLen = 3 + eol.length;
566
742
  const closingStart = match.index + openLen + fmBody.length;
567
- return content.slice(0, closingStart) + `effort: ${effortValue}${eol}` + content.slice(closingStart);
743
+ return content.slice(0, match.index + openLen) + strippedFm + content.slice(closingStart);
744
+ }
745
+ /** #488 — Replace or inject the `effort:` value in YAML frontmatter. */
746
+ function setEffortFrontmatter(content, effortValue) {
747
+ return setFrontmatterKeyLine(content, 'effort', effortValue);
748
+ }
749
+ /** #3533 (10d) — remove exactly the frontmatter `effort:` line (and its line ending). */
750
+ function removeEffortFrontmatter(content) {
751
+ return removeFrontmatterKeyLine(content, 'effort');
568
752
  }
569
753
  /**
570
754
  * #488 — Re-sync effort: frontmatter in all installed gsd-*.md agent files to
@@ -581,6 +765,22 @@ function cmdEffortSync(cwd, raw, opts) {
581
765
  const dryRun = opts.dryRun !== false;
582
766
  const config = loadConfig(cwd);
583
767
  const runtime = opts.runtime || config['runtime'] || 'claude';
768
+ // ADR-2313 D7 (#3243) — Codex gets its own `.toml` sync path (strip a stale
769
+ // Anthropic/tier `model` and an orphaned `model_reasoning_effort`, leaving a
770
+ // legal pin untouched). Every other non-claude runtime keeps the prior
771
+ // early-return; the claude branch below is untouched byte-for-byte.
772
+ if (runtime === 'codex') {
773
+ cmdEffortSyncCodex(raw, dryRun, opts.configDir);
774
+ return;
775
+ }
776
+ // #3706: install now bakes OpenCode's resolved effort into agent
777
+ // frontmatter under the `variant:` key (not `effort:`), so OpenCode gets
778
+ // its own sync path — mirroring the codex branch above — rather than
779
+ // falling into the generic "does not use effort: frontmatter" skip.
780
+ if (runtime === 'opencode') {
781
+ cmdEffortSyncOpencode(cwd, raw, dryRun, opts.configDir);
782
+ return;
783
+ }
584
784
  if (runtime !== 'claude') {
585
785
  output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, reason: `runtime '${runtime}' does not use effort: frontmatter` }, raw, '');
586
786
  return;
@@ -609,16 +809,126 @@ function cmdEffortSync(cwd, raw, opts) {
609
809
  catch {
610
810
  return false;
611
811
  }
612
- });
812
+ }).sort(); // #3706: sorted like the codex and
813
+ // opencode branches — readdir order is platform-dependent, so leaving it unsorted makes the
814
+ // reported `changes` ordering differ across machines for identical inputs.
613
815
  const changes = [];
614
816
  let synced = 0;
615
817
  let skipped = 0;
818
+ // Local-only counter: reads AND writes are both guarded in this loop (an
819
+ // unreadable or unwritable agent file must not abort the whole sweep), but
820
+ // this result shape (`{synced, skipped, changes, dry_run, agents_dir}`) is
821
+ // long-standing and widely consumed, so it deliberately gains NO new key
822
+ // (no `read_failures`/`write_failures`, unlike the codex/opencode branches
823
+ // below). Instead every per-file failure — read or write — is folded into
824
+ // `skipped` and rides the raw-mode summary token below — `output()`'s
825
+ // third argument is never merged into the emitted JSON object (see io.cts
826
+ // `output()`: it is only read when `raw === true`, entirely replacing the
827
+ // JSON payload), so flipping it to `'failed'` costs nothing in the wire
828
+ // shape while still surfacing the failure to a raw-mode caller. The three
829
+ // branches differ on that reporting shape, but are now also consistent in
830
+ // HOW they publish: every write below goes through the same tmp-file +
831
+ // chmod + retryRenameSync atomic-publish sequence used by
832
+ // cmdEffortSyncCodex and cmdEffortSyncOpencode, so a fault mid-write can
833
+ // never leave an agent file truncated or empty.
834
+ let fileFailureCount = 0;
616
835
  for (const file of files) {
617
836
  const agentName = file.replace(/\.md$/, '');
618
837
  const filePath = node_path_1.default.join(agentsDir, file);
619
- const content = node_fs_1.default.readFileSync(filePath, 'utf8');
838
+ let content;
839
+ try {
840
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
841
+ }
842
+ catch {
843
+ // An unreadable agent file must not abort the whole sweep. Deliberately
844
+ // NOT adding a new field here: this result shape (`{synced, skipped,
845
+ // changes, dry_run, agents_dir}`) is long-standing and widely consumed,
846
+ // so the failure is folded into `skipped` only, with no
847
+ // read_failures/write_failures list — see `fileFailureCount` above.
848
+ skipped++;
849
+ fileFailureCount++;
850
+ continue;
851
+ }
620
852
  // Resolve using install-time logic: home defaults merged with project config.
621
853
  const universalEffort = resolveInstallTimeEffort(effortCfg, agentName);
854
+ // #3533 (10d): 'inherit' means the key must NOT exist. An absent key is
855
+ // the CORRECT state (in sync, skipped) — before #3533 absence read as null
856
+ // drift and the sync re-added a hand-stripped key on every apply. A
857
+ // present key under inherit is stripped, reported as {from, to: null}.
858
+ if (universalEffort === 'inherit') {
859
+ const fmMatchInherit = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
860
+ if (!fmMatchInherit) {
861
+ skipped++;
862
+ continue;
863
+ }
864
+ // Presence and value are distinct questions: `effort:` with an EMPTY
865
+ // value is a key that IS present but whose captured value is null (the
866
+ // `(.+?)` group requires at least one char). Deciding "already correct"
867
+ // from a null value alone is wrong here — it would leave an
868
+ // unresolvable `effort: null` key on disk forever. Test presence with
869
+ // its own regex, and only compare values once presence is known.
870
+ const effortPresentInherit = /^effort:/m.test(fmMatchInherit[1]);
871
+ if (!effortPresentInherit) {
872
+ skipped++;
873
+ continue;
874
+ }
875
+ const effortMatchInherit = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatchInherit[1]);
876
+ // `effortPresentInherit` is guaranteed true here (checked above), so a
877
+ // failed value match means the key is present with an EMPTY value —
878
+ // report `''`, not `null`, so "present-but-empty" is never conflated
879
+ // with "absent" in the sync output.
880
+ if (!dryRun) {
881
+ // Atomic publish AND mode preservation, same discipline as
882
+ // cmdEffortSyncCodex/cmdEffortSyncOpencode: write to a sibling tmp
883
+ // file, chmod it to match filePath's existing (masked) mode, then
884
+ // retryRenameSync it over the target so filePath is either the old
885
+ // bytes or the new ones, never half-written and never dropped to a
886
+ // default mode. On any failure the tmp file is unlinked (best-effort)
887
+ // and the write is reported (folded into `skipped`/`fileFailureCount`,
888
+ // no new field), not thrown, so the remaining agents still get
889
+ // processed. ONE failure path for this site — no nested try/catch.
890
+ const tmpPathInherit = `${filePath}.tmp.${process.pid}`;
891
+ // Stat filePath BEFORE the write so its mode can be passed at
892
+ // CREATION time — a plain `writeFileSync(tmpPath, data)` creates the
893
+ // tmp file at the default `0666 & ~umask` even when filePath is more
894
+ // restrictive. Best-effort only: a stat failure must not abort the
895
+ // sync, since the content write is what matters, not the mode.
896
+ let originalModeInherit;
897
+ try {
898
+ originalModeInherit = node_fs_1.default.statSync(filePath).mode & 0o7777;
899
+ }
900
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
901
+ try {
902
+ node_fs_1.default.writeFileSync(tmpPathInherit, removeEffortFrontmatter(content), originalModeInherit !== undefined ? { mode: originalModeInherit } : undefined);
903
+ // Not redundant with the `mode` option above: `mode` only applies
904
+ // when the file is actually created (O_CREAT). A leftover tmp file
905
+ // from an earlier crashed run would be reused (truncated) at its
906
+ // OLD mode instead, and this chmod is what corrects that case.
907
+ // Best-effort only: a chmod failure must not abort the sync, since
908
+ // the content write is what matters, not the mode.
909
+ try {
910
+ if (originalModeInherit !== undefined)
911
+ node_fs_1.default.chmodSync(tmpPathInherit, originalModeInherit);
912
+ }
913
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
914
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathInherit, filePath);
915
+ }
916
+ catch {
917
+ try {
918
+ node_fs_1.default.unlinkSync(tmpPathInherit);
919
+ }
920
+ catch { /* already gone or never created */ }
921
+ skipped++;
922
+ fileFailureCount++;
923
+ continue;
924
+ }
925
+ }
926
+ changes.push({ agent: agentName, from: effortMatchInherit ? effortMatchInherit[1] : '', to: null });
927
+ synced++;
928
+ continue;
929
+ }
930
+ // `runtime` is guaranteed 'claude' by the guard above (#3007: only
931
+ // codex's 'ultra' rejection can produce a null value).
622
932
  const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, universalEffort);
623
933
  const newEffortValue = rendered.value;
624
934
  const fmMatch = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
@@ -626,19 +936,388 @@ function cmdEffortSync(cwd, raw, opts) {
626
936
  skipped++;
627
937
  continue;
628
938
  }
939
+ // Presence and value are distinct questions here too: `currentEffort`
940
+ // reads null both when the key is ABSENT and when it is present with an
941
+ // EMPTY value. `effortPresent` disambiguates those two for the reported
942
+ // `from` below (never `null` when the key is present but empty) — but it
943
+ // has no bearing on the skip check that follows: `newEffortValue` is
944
+ // never null on this path (guarded above), so an absent key already
945
+ // yields `currentEffort === null !== newEffortValue` without consulting
946
+ // presence separately.
947
+ const effortPresent = /^effort:/m.test(fmMatch[1]);
629
948
  const effortMatch = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatch[1]);
630
- const currentEffort = effortMatch ? effortMatch[1] : null;
949
+ // `null` (key absent) and `''` (key present, value empty) are distinct
950
+ // states `effortPresent` deliberately disambiguates — collapsing both to
951
+ // `null` here would make the reported `from` lie about which case fired.
952
+ const currentEffort = effortPresent ? (effortMatch ? effortMatch[1] : '') : null;
631
953
  if (currentEffort === newEffortValue) {
632
954
  skipped++;
633
955
  continue;
634
956
  }
957
+ if (!dryRun) {
958
+ // Atomic publish AND mode preservation, same discipline as
959
+ // cmdEffortSyncCodex/cmdEffortSyncOpencode: write to a sibling tmp
960
+ // file, chmod it to match filePath's existing (masked) mode, then
961
+ // retryRenameSync it over the target so filePath is either the old
962
+ // bytes or the new ones, never half-written and never dropped to a
963
+ // default mode. On any failure the tmp file is unlinked (best-effort)
964
+ // and the write is reported (folded into `skipped`/`fileFailureCount`,
965
+ // no new field), not thrown, so the remaining agents still get
966
+ // processed. ONE failure path for this site — no nested try/catch.
967
+ const tmpPathSet = `${filePath}.tmp.${process.pid}`;
968
+ // Stat filePath BEFORE the write so its mode can be passed at CREATION
969
+ // time — a plain `writeFileSync(tmpPath, data)` creates the tmp file
970
+ // at the default `0666 & ~umask` even when filePath is more
971
+ // restrictive. Best-effort only: a stat failure must not abort the
972
+ // sync, since the content write is what matters, not the mode.
973
+ let originalModeSet;
974
+ try {
975
+ originalModeSet = node_fs_1.default.statSync(filePath).mode & 0o7777;
976
+ }
977
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
978
+ try {
979
+ node_fs_1.default.writeFileSync(tmpPathSet, setEffortFrontmatter(content, newEffortValue), originalModeSet !== undefined ? { mode: originalModeSet } : undefined);
980
+ // Not redundant with the `mode` option above: `mode` only applies
981
+ // when the file is actually created (O_CREAT). A leftover tmp file
982
+ // from an earlier crashed run would be reused (truncated) at its OLD
983
+ // mode instead, and this chmod is what corrects that case.
984
+ // Best-effort only: a chmod failure must not abort the sync, since
985
+ // the content write is what matters, not the mode.
986
+ try {
987
+ if (originalModeSet !== undefined)
988
+ node_fs_1.default.chmodSync(tmpPathSet, originalModeSet);
989
+ }
990
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
991
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathSet, filePath);
992
+ }
993
+ catch {
994
+ try {
995
+ node_fs_1.default.unlinkSync(tmpPathSet);
996
+ }
997
+ catch { /* already gone or never created */ }
998
+ skipped++;
999
+ fileFailureCount++;
1000
+ continue;
1001
+ }
1002
+ }
635
1003
  changes.push({ agent: agentName, from: currentEffort, to: newEffortValue });
636
1004
  synced++;
1005
+ }
1006
+ output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, fileFailureCount > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
1007
+ }
1008
+ /**
1009
+ * ADR-2313 D7 (#3243) — the Codex branch of `cmdEffortSync`. Strips a stale
1010
+ * Anthropic-flavored/tier `model` pin and an orphaned `model_reasoning_effort`
1011
+ * from every installed `~/.codex/agents/<agent>.toml`, leaving a legal
1012
+ * real-Codex pin (and its coupled effort) untouched. Dry-run by default; every
1013
+ * strip reported as a structured `{agent, field, from}` change; an unparseable
1014
+ * document is refused and reported, never partially rewritten (40-design.md
1015
+ * "Reconciliation" — parseCodexAgentToml is the STRICT half of the reader/
1016
+ * writer split). Result shape is additive over the claude branch's
1017
+ * `{synced, skipped, changes, dry_run, agents_dir}` — `refused`,
1018
+ * `write_failures`, and `read_failures` are new fields, never a reshape of
1019
+ * the existing ones.
1020
+ */
1021
+ function cmdEffortSyncCodex(raw, dryRun, configDir) {
1022
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1023
+ const { getGlobalConfigDir } = require('./runtime-homes.cjs');
1024
+ const agentsDir = node_path_1.default.join(configDir || getGlobalConfigDir('codex'), 'agents');
1025
+ if (!node_fs_1.default.existsSync(agentsDir)) {
1026
+ output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, agents_dir: agentsDir, reason: 'agents directory not found' }, raw, '');
1027
+ return;
1028
+ }
1029
+ // Skip symlinks — matches the claude branch's existing guard above (only
1030
+ // write regular files, never follow a symlink into clobbering its target).
1031
+ const files = node_fs_1.default
1032
+ .readdirSync(agentsDir)
1033
+ .filter(f => {
1034
+ if (!f.endsWith('.toml'))
1035
+ return false;
1036
+ try {
1037
+ return node_fs_1.default.lstatSync(node_path_1.default.join(agentsDir, f)).isFile();
1038
+ }
1039
+ catch {
1040
+ return false;
1041
+ }
1042
+ })
1043
+ .sort();
1044
+ const changes = [];
1045
+ const refused = [];
1046
+ const writeFailures = [];
1047
+ const readFailures = [];
1048
+ let synced = 0;
1049
+ let skipped = 0;
1050
+ for (const file of files) {
1051
+ const agentName = file.replace(/\.toml$/, '');
1052
+ const filePath = node_path_1.default.join(agentsDir, file);
1053
+ let content;
1054
+ try {
1055
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
1056
+ }
1057
+ catch (err) {
1058
+ // An unreadable agent file must not abort the whole sweep — mirrors the
1059
+ // opencode branch's own read guard, reported under its own
1060
+ // `read_failures` key so a caller can tell "never read" apart from
1061
+ // "read but write failed".
1062
+ skipped++;
1063
+ readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1064
+ continue;
1065
+ }
1066
+ const parsed = (0, codex_agent_toml_cjs_1.parseCodexAgentToml)(content);
1067
+ if (!parsed.ok) {
1068
+ // Never partially rewritten (40-design.md, ADR-2313 reader/writer
1069
+ // boundary): an unparseable document is skipped and reported, not
1070
+ // guessed at.
1071
+ skipped++;
1072
+ refused.push({ agent: agentName, file: filePath, reason: parsed.reason });
1073
+ continue;
1074
+ }
1075
+ let doc = parsed.doc;
1076
+ const stripModelNeeded = doc.model !== null && (0, model_catalog_cjs_1.isAnthropicFlavoredModel)(doc.model);
1077
+ // #838 coupling: an orphaned effort (no model) is always stale; a stale
1078
+ // model's effort is coupled to it and strips with it. A legal pin's effort
1079
+ // (model present, not Anthropic-flavored) is left untouched (rows 4-5).
1080
+ const stripEffortNeeded = doc.reasoningEffort !== null && (stripModelNeeded || doc.model === null);
1081
+ if (!stripModelNeeded && !stripEffortNeeded) {
1082
+ // Posture-clean, OR a legal pin (and its coupled effort) — reported
1083
+ // skipped, never synced (ADR-2313 reader/writer boundary).
1084
+ skipped++;
1085
+ continue;
1086
+ }
1087
+ const pendingChanges = [];
1088
+ if (stripModelNeeded) {
1089
+ pendingChanges.push({ agent: agentName, field: 'model', from: doc.model, to: null });
1090
+ doc = (0, codex_agent_toml_cjs_1.stripModel)(doc);
1091
+ }
1092
+ if (stripEffortNeeded) {
1093
+ pendingChanges.push({ agent: agentName, field: 'model_reasoning_effort', from: doc.reasoningEffort, to: null });
1094
+ doc = (0, codex_agent_toml_cjs_1.stripReasoningEffort)(doc);
1095
+ }
637
1096
  if (!dryRun) {
638
- node_fs_1.default.writeFileSync(filePath, setEffortFrontmatter(content, newEffortValue));
1097
+ // Atomic publish (ADR-2313 "never partially rewritten"): write the
1098
+ // rendered TOML to a sibling tmp file, then rename it over the target.
1099
+ // Same-filesystem rename is atomic, so filePath is either the old bytes
1100
+ // or the new ones, never truncated/half-written mid-crash. Deliberately
1101
+ // NOT platformWriteSync — its normalizeContent step rewrites CRLF/
1102
+ // trailing-newline bytes, which would break the byte-identical
1103
+ // round-trip (A14) this writer must preserve. retryRenameSync (not a
1104
+ // bare fs.renameSync) carries the transient-Windows-lock retry per
1105
+ // DEFECT.WINDOWS-FS-OPS.
1106
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
1107
+ // Stat filePath BEFORE the write so the original mode is available to
1108
+ // pass at creation time, not just at chmod time afterward — otherwise
1109
+ // the tmp file is briefly created at the default `0666 & ~umask`
1110
+ // (world-readable under a typical 022 umask) even when filePath is
1111
+ // e.g. 0600, exposing its contents for the window between creation and
1112
+ // chmod. Best-effort: a stat failure must not abort the sync, since the
1113
+ // content write is what matters, not the mode.
1114
+ let originalMode;
1115
+ try {
1116
+ originalMode = node_fs_1.default.statSync(filePath).mode & 0o7777;
1117
+ }
1118
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
1119
+ try {
1120
+ node_fs_1.default.writeFileSync(tmpPath, (0, codex_agent_toml_cjs_1.renderCodexAgentToml)(doc), originalMode !== undefined ? { mode: originalMode } : undefined);
1121
+ // Not redundant with the `mode` option above: `mode` only applies
1122
+ // when the file is actually created (O_CREAT). A leftover tmp file
1123
+ // from an earlier crashed run would be reused (truncated) at its OLD
1124
+ // mode instead, and this chmod is what corrects that case. Mask off
1125
+ // the file-type bits fs.statSync().mode carries (POSIX leaves
1126
+ // chmod's handling of those unspecified); best-effort only, since
1127
+ // the content write is what matters, not the mode.
1128
+ try {
1129
+ if (originalMode !== undefined)
1130
+ node_fs_1.default.chmodSync(tmpPath, originalMode);
1131
+ }
1132
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
1133
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
1134
+ }
1135
+ catch (err) {
1136
+ // Reported, not thrown — the remaining agents still get processed.
1137
+ // Clean up the orphaned tmp file; filePath itself was never touched.
1138
+ try {
1139
+ node_fs_1.default.unlinkSync(tmpPath);
1140
+ }
1141
+ catch { /* already gone or never created */ }
1142
+ skipped++;
1143
+ writeFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1144
+ continue;
1145
+ }
639
1146
  }
1147
+ changes.push(...pendingChanges);
1148
+ synced++;
640
1149
  }
641
- output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, synced > 0 ? 'changed' : 'ok');
1150
+ output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, refused, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
1151
+ }
1152
+ /**
1153
+ * #3706 — the OpenCode branch of `cmdEffortSync`. Maintains the `variant:`
1154
+ * frontmatter key install now bakes into every `~/.config/opencode/agents/
1155
+ * gsd-*.md` (or configDir-relative equivalent), mirroring exactly what
1156
+ * install writes: a resolved universal effort clamped through
1157
+ * `clampEffortForHost('opencode', ...)`. Null means the key must be ABSENT —
1158
+ * #3533 (10d): an absent key is the correct state under `inherit`, and a
1159
+ * level OpenCode does not accept must never be written, so both collapse to
1160
+ * the same `target: null` and the same removal path. Result shape is
1161
+ * additive over the claude branch, matching the CODEX branch's
1162
+ * `{synced, skipped, changes, dry_run, agents_dir, write_failures}`.
1163
+ */
1164
+ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1165
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1166
+ const { getGlobalConfigDir } = require('./runtime-homes.cjs');
1167
+ const agentsDir = node_path_1.default.join(configDir || getGlobalConfigDir('opencode'), 'agents');
1168
+ if (!node_fs_1.default.existsSync(agentsDir)) {
1169
+ output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, agents_dir: agentsDir, reason: 'agents directory not found' }, raw, '');
1170
+ return;
1171
+ }
1172
+ // Skip symlinks — matches the claude branch's existing guard (only write
1173
+ // regular files, never follow a symlink into clobbering its target).
1174
+ const files = node_fs_1.default
1175
+ .readdirSync(agentsDir)
1176
+ .filter(f => {
1177
+ if (!f.startsWith('gsd-') || !f.endsWith('.md'))
1178
+ return false;
1179
+ try {
1180
+ return node_fs_1.default.lstatSync(node_path_1.default.join(agentsDir, f)).isFile();
1181
+ }
1182
+ catch {
1183
+ return false;
1184
+ }
1185
+ })
1186
+ .sort();
1187
+ // Use install-time resolvers: they merge ~/.gsd/defaults.json with project
1188
+ // config, matching the exact logic used when agents were originally
1189
+ // installed. Resolved once, outside the loop, like the claude branch.
1190
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1191
+ const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('./install-effort-resolver.cjs');
1192
+ const effortCfg = readGsdEffectiveEffortConfig(cwd);
1193
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1194
+ const { clampEffortForHost } = require('./model-catalog.cjs');
1195
+ const changes = [];
1196
+ const writeFailures = [];
1197
+ const readFailures = [];
1198
+ let synced = 0;
1199
+ let skipped = 0;
1200
+ for (const file of files) {
1201
+ const agentName = file.replace(/\.md$/, '');
1202
+ const filePath = node_path_1.default.join(agentsDir, file);
1203
+ let content;
1204
+ try {
1205
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
1206
+ }
1207
+ catch (err) {
1208
+ // An unreadable agent file must not abort the whole sweep — degrade
1209
+ // like the write path below does, and report it under its own
1210
+ // `read_failures` key so a caller can tell "never read" apart from
1211
+ // "read but write failed".
1212
+ skipped++;
1213
+ readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1214
+ continue;
1215
+ }
1216
+ // `target === null` covers both "no effort configured" (inherit) and "a
1217
+ // level OpenCode does not accept" — both must produce NO `variant:` key,
1218
+ // exactly what install writes.
1219
+ const universal = effortCfg ? resolveInstallTimeEffort(effortCfg, agentName) : null;
1220
+ const target = universal ? clampEffortForHost('opencode', universal) : null;
1221
+ const fmMatch = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
1222
+ if (!fmMatch) {
1223
+ skipped++;
1224
+ continue;
1225
+ }
1226
+ // Presence and value are distinct questions: `variant:` with an EMPTY
1227
+ // value is a key that IS present but whose captured value is null (the
1228
+ // `(.+?)` group requires at least one char). Deciding "already correct"
1229
+ // from a null-vs-null comparison alone is wrong when target is also
1230
+ // null — it would leave an unresolvable `variant: null` key on disk
1231
+ // forever. Test presence with its own regex, and only compare values
1232
+ // once presence is known.
1233
+ const variantPresent = /^variant:/m.test(fmMatch[1]);
1234
+ const variantMatch = /^variant:[ \t]*(.+?)[ \t]*$/m.exec(fmMatch[1]);
1235
+ // `null` (key absent) and `''` (key present, value empty) are distinct
1236
+ // states this code deliberately tracks via `variantPresent` above — a
1237
+ // reported `from` that collapses both to `null` would make "no key" and
1238
+ // "empty key" indistinguishable in the sync output, even though only one
1239
+ // of them actually has a `variant:` line to remove.
1240
+ const currentVariant = variantPresent ? (variantMatch ? variantMatch[1] : '') : null;
1241
+ if (target === null) {
1242
+ if (!variantPresent) {
1243
+ skipped++;
1244
+ continue;
1245
+ }
1246
+ }
1247
+ else if (variantPresent && currentVariant === target) {
1248
+ skipped++;
1249
+ continue;
1250
+ }
1251
+ changes.push({ agent: agentName, from: currentVariant, to: target });
1252
+ synced++;
1253
+ if (!dryRun) {
1254
+ // Atomic publish AND mode preservation, same discipline as
1255
+ // cmdEffortSyncCodex above: write to a sibling tmp file, chmod it to
1256
+ // match filePath's existing (masked) mode, then retryRenameSync it over
1257
+ // the target so filePath is either the old bytes or the new ones, never
1258
+ // half-written and never dropped to a default mode. On failure the
1259
+ // write is reported, not thrown, so the remaining agents still get
1260
+ // processed.
1261
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
1262
+ // Stat filePath BEFORE the write so its mode can be passed at CREATION
1263
+ // time — a plain `writeFileSync(tmpPath, data)` creates the tmp file at
1264
+ // the default `0666 & ~umask` (world-readable under a typical 022
1265
+ // umask) even when filePath is e.g. 0600, exposing its contents for
1266
+ // the window between creation and the chmod below. Mask off the
1267
+ // file-type bits (e.g. S_IFREG 0o100000) that fs.statSync().mode
1268
+ // carries alongside the permission bits — POSIX leaves chmod's
1269
+ // handling of those bits unspecified, and the remote matrix runs Linux
1270
+ // only (Darwin tolerating the full mode is not evidence it is safe
1271
+ // there). Best-effort only: a stat failure must not abort the sync,
1272
+ // since the content write is what matters, not the mode.
1273
+ let originalMode;
1274
+ try {
1275
+ originalMode = node_fs_1.default.statSync(filePath).mode & 0o7777;
1276
+ }
1277
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
1278
+ try {
1279
+ node_fs_1.default.writeFileSync(tmpPath, target === null ? removeFrontmatterKeyLine(content, 'variant') : setFrontmatterKeyLine(content, 'variant', target), originalMode !== undefined ? { mode: originalMode } : undefined);
1280
+ // Not redundant with the `mode` option above: `mode` only applies
1281
+ // when the file is actually created (O_CREAT). A leftover tmp file
1282
+ // from an earlier crashed run would be reused (truncated) at its OLD
1283
+ // mode instead, and this chmod is what corrects that case.
1284
+ // Best-effort only: a chmod failure must not abort the sync, since
1285
+ // the content write is what matters, not the mode.
1286
+ try {
1287
+ if (originalMode !== undefined)
1288
+ node_fs_1.default.chmodSync(tmpPath, originalMode);
1289
+ }
1290
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
1291
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
1292
+ }
1293
+ catch (err) {
1294
+ try {
1295
+ node_fs_1.default.unlinkSync(tmpPath);
1296
+ }
1297
+ catch { /* already gone or never created */ }
1298
+ changes.pop();
1299
+ synced--;
1300
+ skipped++;
1301
+ writeFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1302
+ continue;
1303
+ }
1304
+ }
1305
+ }
1306
+ // Any failure — a write OR a read — must not report 'ok' or 'changed':
1307
+ // either would hide that at least one agent's on-disk state is now unknown
1308
+ // (unread) or unchanged despite being reported as a pending change (write
1309
+ // failed after being pushed onto `changes`/`synced`). `write_failures` and
1310
+ // `read_failures` take priority over the synced-count-derived summary below,
1311
+ // even when other agents in the same run succeeded.
1312
+ //
1313
+ // Known limitation, deliberately not fixed here: `output()` only honors its
1314
+ // third argument when `raw === true`, and this command's process always
1315
+ // exits 0 regardless of the summary string — so `if gsd-tools effort sync;
1316
+ // then` reads success in a shell even on a run where every write failed.
1317
+ // Making the exit code reflect failure would be a CLI-contract change
1318
+ // affecting all three cmdEffortSync* branches (claude, codex, opencode) and
1319
+ // is out of scope for this fix.
1320
+ output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
642
1321
  }
643
1322
  /**
644
1323
  * Detect the phase number for a commit from its `--files` path list.
@@ -693,6 +1372,71 @@ function detectPhaseNumberFromFiles(files) {
693
1372
  }
694
1373
  return null;
695
1374
  }
1375
+ /**
1376
+ * #3587: resolve the `phase_commit_docs.<phase-id>` override for `phaseNum`
1377
+ * against `config['phase_commit_docs']` (a `{ "<phase-id>": boolean }` map, the
1378
+ * same shape `agent_skills`/`features` use for their dynamic key families).
1379
+ * Returns `undefined` — "no override applies" — when: no phase is known (B7),
1380
+ * the map carries no entry for THIS phase (B5: no cross-phase leak), or the
1381
+ * entry exists but is not a boolean (B6: never silently coerced). Both sides of
1382
+ * the comparison route through `normalizePhaseName` so `3`, `03`, and `PROJ-03`
1383
+ * all resolve to the same entry (B4/B9), reusing the single-owner phase-id
1384
+ * normalizer rather than a second, looser string-equality rule.
1385
+ */
1386
+ function resolvePhaseCommitDocsOverride(config, phaseNum) {
1387
+ if (!phaseNum)
1388
+ return undefined;
1389
+ const overrides = config['phase_commit_docs'];
1390
+ if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides))
1391
+ return undefined;
1392
+ const target = normalizePhaseName(phaseNum);
1393
+ for (const [key, value] of Object.entries(overrides)) {
1394
+ if (normalizePhaseName(key) === target) {
1395
+ return typeof value === 'boolean' ? value : undefined;
1396
+ }
1397
+ }
1398
+ return undefined;
1399
+ }
1400
+ /**
1401
+ * #3587: the four-tier `commit_docs` precedence chain for a single commit —
1402
+ * `phase_commit_docs.<phase-id>` (tier 1, resolved HERE because this call site
1403
+ * is the one place that knows the phase — see 40-design.md "Rejected" §1: NOT
1404
+ * inside `loadConfig`, which has no phase context and is called by nearly every
1405
+ * command), then the pre-existing explicit `commit_docs` (tier 2), `.gitignore`
1406
+ * auto-detect (tier 3), and manifest default (tier 4). Tiers 2-4 are byte-for-
1407
+ * behaviour identical to the pre-#3587 inline checks (epic #2292 AC4): when no
1408
+ * phase override applies, `resolved` matches exactly what those checks computed
1409
+ * and `source` merely labels which of the three decided it.
1410
+ *
1411
+ * `isPlanningGitIgnored` is a thunk, not a plain boolean, so the pre-existing
1412
+ * short-circuit is preserved byte-for-behaviour: the original inline checks
1413
+ * only ever ran `isGitIgnored` (a real `git check-ignore` subprocess) when
1414
+ * `commit_docs` was truthy, and a phase override or an explicit `commit_docs:
1415
+ * false` must keep skipping that call entirely, not just its result. Passing
1416
+ * a thunk also keeps this function pure and directly property-testable
1417
+ * (test matrix F1) without spawning git.
1418
+ */
1419
+ function resolveCommitDocsPolicy(config, phaseNum, isPlanningGitIgnored) {
1420
+ const phaseOverride = resolvePhaseCommitDocsOverride(config, phaseNum);
1421
+ if (phaseOverride !== undefined)
1422
+ return { resolved: phaseOverride, source: 'phase' };
1423
+ if (!config['commit_docs'])
1424
+ return { resolved: false, source: 'config' };
1425
+ if (isPlanningGitIgnored())
1426
+ return { resolved: false, source: 'gitignore' };
1427
+ return { resolved: true, source: 'default' };
1428
+ }
1429
+ // Reason string per commit_docs-resolution source, for the tier-1/tier-2 skip
1430
+ // envelope below. `phase` gets its OWN reason (`skipped_commit_docs_phase_false`)
1431
+ // rather than reusing `skipped_commit_docs_false` — telling a user "commit_docs
1432
+ // is false" when their project setting is actually `true` would be actively
1433
+ // misleading (design "Rejected" §3). `config` keeps the pre-existing string
1434
+ // unchanged: `agents/gsd-executor.md` pattern-matches on it (D2).
1435
+ const COMMIT_DOCS_SKIP_REASON = {
1436
+ phase: 'skipped_commit_docs_phase_false',
1437
+ config: 'skipped_commit_docs_false',
1438
+ gitignore: 'skipped_gitignored',
1439
+ };
696
1440
  function cmdCommit(cwd, message, files, raw, amend, noVerify) {
697
1441
  if (!message && !amend) {
698
1442
  error('commit message required');
@@ -706,18 +1450,20 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
706
1450
  sanitizedMessage = sanitizeForPrompt(sanitizedMessage);
707
1451
  }
708
1452
  const config = loadConfig(cwd);
709
- // Check commit_docs config
1453
+ // Check commit_docs config — #3587: resolved through the tier 1
1454
+ // (phase_commit_docs.<phase-id>) → tier 2 (commit_docs) → tier 3 (.gitignore)
1455
+ // → tier 4 (default) precedence chain; see resolveCommitDocsPolicy above.
710
1456
  // `skipped: true` is explicit so agent prompts can match on a first-class
711
1457
  // success signal rather than inferring "skip" from "committed is missing"
712
1458
  // 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' };
1459
+ const commitDocsPolicy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(files), () => isGitIgnored(cwd, '.planning'));
1460
+ if (!commitDocsPolicy.resolved) {
1461
+ const result = {
1462
+ committed: false,
1463
+ skipped: true,
1464
+ hash: null,
1465
+ reason: COMMIT_DOCS_SKIP_REASON[commitDocsPolicy.source],
1466
+ };
721
1467
  output(result, raw, 'skipped');
722
1468
  return;
723
1469
  }
@@ -742,7 +1488,10 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
742
1488
  // — see #2528 for the parallel drift problem in phase-locator/phase),
743
1489
  // so this is the canonical path-segment-bound read, not a fourth copy.
744
1490
  const phaseNum = detectPhaseNumberFromFiles(files);
745
- if (phaseNum) {
1491
+ // #3734: a 999.x/0.x backlog sentinel is a parking-lot entry, not a real
1492
+ // phase — the phase arm must never branch-mutate for it (isSentinelPhaseId
1493
+ // is the invariant's single owner, src/phase-id.cts).
1494
+ if (phaseNum && !isSentinelPhaseId(phaseNum)) {
746
1495
  const phaseInfo = findPhaseInternal(cwd, phaseNum);
747
1496
  if (phaseInfo) {
748
1497
  branchName = config['phase_branch_template']
@@ -752,7 +1501,19 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
752
1501
  }
753
1502
  }
754
1503
  else if (branchingStrategy === 'milestone') {
755
- const milestone = getMilestoneInfo(cwd);
1504
+ const milestoneInfo = getMilestoneInfo(cwd);
1505
+ // #3216 review Finding 3: explicit scope gate instead of plain truthiness.
1506
+ // COMPLETE and TRUNCATED both carry a real `version` (ADR-3180 §7.2 rule
1507
+ // 6 — TRUNCATED means the version resolved but the milestone's NAME did
1508
+ // not), so a TRUNCATED identity is acceptable here: `milestone.version`
1509
+ // only feeds a BRANCH NAME, and `generateSlugInternal(null) || 'milestone'`
1510
+ // already degrades the missing name to the literal "milestone" slug on
1511
+ // purpose. This differs from `archivePhaseDirectories` (milestone.cts),
1512
+ // which uses the same value as a DIRECTORY NAME and therefore demands
1513
+ // COMPLETE only — a real-but-unnamed version is not safe enough there.
1514
+ const milestone = milestoneInfo.scope === SCOPE.COMPLETE || milestoneInfo.scope === SCOPE.TRUNCATED
1515
+ ? milestoneInfo.value
1516
+ : null;
756
1517
  if (milestone && milestone.version) {
757
1518
  branchName = config['milestone_branch_template']
758
1519
  .replace('{milestone}', milestone.version)
@@ -762,22 +1523,28 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
762
1523
  if (branchName) {
763
1524
  const currentBranch = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
764
1525
  if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
765
- // #2539/#3079: 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 `git checkout -b` both created AND switched (silently moving
769
- // HEAD), which resurrected merged-and-deleted phase branches (#3079).
770
- // Now: create-if-absent WITHOUT switching, using `git branch` instead
771
- // of `git checkout -b`. The commit always lands on the current branch.
772
- // If the resolved branch already exists, log the resolution so the
773
- // operator sees that the phase branch was resolved and deliberately
774
- // not switched to (#2539 AC2: an auto-checkout mid-commit must never
775
- // happen silently).
1526
+ // #2539/#3079/#3207: two cases the prior (#3079) code collapsed into one.
1527
+ // #1278 intent: CREATE the phase/milestone branch before the FIRST commit
1528
+ // on it so the phase's work accumulates there. #3079/#2539 hazard: never
1529
+ // silently switch an already-checked-out working branch onto a DIFFERENT
1530
+ // EXISTING branch — that resurrects merged-and-deleted phase branches and
1531
+ // silently moves HEAD onto a stale ref (#2539 AC2: an auto-checkout
1532
+ // mid-commit must never happen silently).
1533
+ // Reconciliation (#3207): a brand-new branch has no resurrection target,
1534
+ // so create-and-switch is safe here and is exactly the #1278 intent; an
1535
+ // EXISTING branch is never switched to (the else arm logs + commits in
1536
+ // place). The fresh create is logged so the first phase-scoped commit is
1537
+ // not silent about where the work is landing (#3207 AC3).
776
1538
  const verify = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
777
1539
  if (verify.exitCode !== 0) {
778
- // Branch does not exist — create it WITHOUT switching.
779
- const create = (0, shell_command_projection_cjs_1.execGit)(['branch', branchName], { cwd });
780
- if (create.exitCode !== 0) {
1540
+ // Branch does not exist — CREATE AND SWITCH (the #1278 first-commit
1541
+ // case). checkout -b cannot resurrect anything: the branch was just
1542
+ // verified absent, so it is created fresh at HEAD.
1543
+ const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
1544
+ if (create.exitCode === 0) {
1545
+ process.stderr.write(`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`);
1546
+ }
1547
+ else {
781
1548
  process.stderr.write(`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
782
1549
  `(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`);
783
1550
  }
@@ -904,8 +1671,26 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
904
1671
  if (canScope) {
905
1672
  commitArgs.push('--', ...stagedPaths);
906
1673
  }
907
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd });
1674
+ // #3886: `git commit` runs pre-commit hooks (husky/lint-staged routinely
1675
+ // idles ~4s on Windows before any task) — 10s is too tight, and a timeout
1676
+ // kill is NOT an ordinary failure. Same band as the push call below.
1677
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd, timeout: COMMIT_TIMEOUT_MS });
908
1678
  if (commitResult.exitCode !== 0) {
1679
+ // #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the
1680
+ // partial stderr it flushed (often incidental CRLF warnings) is noise,
1681
+ // and the kill can leave a stale index.lock that blocks the next
1682
+ // attempt. Report the distinct reason and surface the lock path.
1683
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
1684
+ const result = {
1685
+ committed: false,
1686
+ hash: null,
1687
+ reason: 'commit_timeout',
1688
+ timed_out: true,
1689
+ error: commitTimeoutMessage(cwd, commitResult.stderr, commitResult.stdout),
1690
+ };
1691
+ output(result, raw, 'failed');
1692
+ return;
1693
+ }
909
1694
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
910
1695
  const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
911
1696
  output(result, raw, 'nothing');
@@ -1045,8 +1830,21 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
1045
1830
  const commitArgs = canScopeSub
1046
1831
  ? ['commit', '-m', message, '--', ...stagedRelPaths]
1047
1832
  : ['commit', '-m', message];
1048
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd });
1833
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
1049
1834
  if (commitResult.exitCode !== 0) {
1835
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
1836
+ // #3886 (subrepo counterpart): timeout ≠ error; surface the stale-lock
1837
+ // path a killed commit can leave in the subrepo.
1838
+ repos[repo] = {
1839
+ committed: false,
1840
+ hash: null,
1841
+ files: repoFiles,
1842
+ reason: 'commit_timeout',
1843
+ timed_out: true,
1844
+ error: commitTimeoutMessage(repoCwd, commitResult.stderr, commitResult.stdout),
1845
+ };
1846
+ continue;
1847
+ }
1050
1848
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
1051
1849
  repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'nothing_to_commit' };
1052
1850
  continue;
@@ -1176,9 +1974,15 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
1176
1974
  const commitArgs = canScopePr
1177
1975
  ? ['commit', '-m', commitMessage, '--', ...changedFiles]
1178
1976
  : ['commit', '-m', commitMessage];
1179
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd });
1977
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
1180
1978
  if (commitResult.exitCode !== 0) {
1181
1979
  rollback();
1980
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
1981
+ // #3886 (PR-subrepo counterpart): name the timeout and the stale lock
1982
+ // instead of echoing the killed hook's partial stderr.
1983
+ error(`git commit timed out after ${COMMIT_TIMEOUT_MS / 1000}s in ${repo} (killed mid-hook; ` +
1984
+ `a stale lock may remain at ${resolveIndexLockPath(repoCwd)} — remove it if no git process is running)`);
1985
+ }
1182
1986
  error(`Failed to commit in ${repo}: ${commitResult.stderr}`);
1183
1987
  }
1184
1988
  // 6. Capture commit hash
@@ -1384,20 +2188,28 @@ async function cmdWebsearch(query, options, raw) {
1384
2188
  }
1385
2189
  function cmdProgressRender(cwd, format, raw) {
1386
2190
  const phasesDir = planningPaths(cwd).phases;
1387
- const milestone = getMilestoneInfo(cwd);
2191
+ const milestone = getMilestoneInfo(cwd).value;
1388
2192
  const phases = [];
1389
2193
  let totalPlans = 0;
1390
2194
  let totalSummaries = 0;
2195
+ let phaseScope = null;
1391
2196
  try {
1392
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
1393
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
2197
+ // #3185 (ADR-3180 Decision 1): the single owner applies the milestone
2198
+ // window AND the sentinel filter and returns dirs already sorted by
2199
+ // comparePhaseNum. This command previously read the phases directory
2200
+ // directly with neither, which is why `query progress` listed 999.*
2201
+ // backlog directories as current-milestone phases (#3167).
2202
+ const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
2203
+ phaseScope = scope;
1394
2204
  for (const dir of dirs) {
1395
2205
  const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
1396
2206
  const phaseNum = dm ? dm[1] : dir;
1397
2207
  const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
1398
- const phaseFiles = node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, dir));
1399
- const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
1400
- const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
2208
+ // #3183: canonical plan/summary counts (root+nested, superseded-excluded,
2209
+ // canonical pairing) from the single owner.
2210
+ const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
2211
+ const plans = phaseScan.planCount;
2212
+ const summaries = phaseScan.summaryCount;
1401
2213
  totalPlans += plans;
1402
2214
  totalSummaries += summaries;
1403
2215
  const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, dir), 'Pending');
@@ -1405,14 +2217,24 @@ function cmdProgressRender(cwd, format, raw) {
1405
2217
  }
1406
2218
  }
1407
2219
  catch { /* intentionally empty */ }
1408
- const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
2220
+ // #3217 (ADR-3180 §7.6 rule 4): `phaseScope` was already computed above
2221
+ // (Phase 3, #3222) but never consulted before rendering — a percentage was
2222
+ // rendered from counts the scope said were not answers (TRUNCATED /
2223
+ // UNSCOPED / UNREADABLE). Withhold the percentage itself (never `0` — a
2224
+ // real `0` under COMPLETE must still render, rule 2's territory) when the
2225
+ // scope is not COMPLETE. `phaseScope` stays `null` only if the try block
2226
+ // above threw before assigning it; treat that the same as non-COMPLETE.
2227
+ const percent = phaseScope === SCOPE.COMPLETE
2228
+ ? (0, phase_lifecycle_cjs_1.clampPercent)(totalSummaries, totalPlans)
2229
+ : null;
1409
2230
  if (format === 'table') {
1410
2231
  // Render markdown table
1411
2232
  const barWidth = 10;
1412
- const filled = Math.round((percent / 100) * barWidth);
2233
+ const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
1413
2234
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1414
- let out = `# ${milestone.version} ${milestone.name}\n\n`;
1415
- out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
2235
+ const percentSuffix = percent === null ? '' : ` (${percent}%)`;
2236
+ let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
2237
+ out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
1416
2238
  out += `| Phase | Name | Plans | Status |\n`;
1417
2239
  out += `|-------|------|-------|--------|\n`;
1418
2240
  for (const p of phases) {
@@ -1422,20 +2244,24 @@ function cmdProgressRender(cwd, format, raw) {
1422
2244
  }
1423
2245
  else if (format === 'bar') {
1424
2246
  const barWidth = 20;
1425
- const filled = Math.round((percent / 100) * barWidth);
2247
+ const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
1426
2248
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1427
- const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
2249
+ const percentSuffix = percent === null ? '' : ` (${percent}%)`;
2250
+ const text = `[${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}`;
1428
2251
  output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
1429
2252
  }
1430
2253
  else {
1431
2254
  // JSON format
1432
2255
  output({
1433
- milestone_version: milestone.version,
1434
- milestone_name: milestone.name,
2256
+ milestone_version: milestone?.version ?? null,
2257
+ milestone_name: milestone?.name ?? null,
1435
2258
  phases,
1436
2259
  total_plans: totalPlans,
1437
2260
  total_summaries: totalSummaries,
1438
2261
  percent,
2262
+ // #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
2263
+ // can tell a genuinely-empty milestone from one it could not scope.
2264
+ phase_scope: phaseScope,
1439
2265
  }, raw, undefined);
1440
2266
  }
1441
2267
  }
@@ -1492,7 +2318,9 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
1492
2318
  if (phaseInfoDisk && phaseInfoDisk['found']) {
1493
2319
  try {
1494
2320
  const phaseDir = node_path_1.default.join(cwd, phaseInfoDisk['directory']);
1495
- const planFiles = node_fs_1.default.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
2321
+ // #3183: canonical plan set (root+nested, superseded-excluded) from the
2322
+ // single owner, rather than a root-only hand-rolled readdirSync filter.
2323
+ const planFiles = scanPhasePlans(phaseDir).planFiles;
1496
2324
  for (const pf of planFiles) {
1497
2325
  const planContent = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(phaseDir, pf));
1498
2326
  if (planContent === null)
@@ -1629,12 +2457,12 @@ function cmdStats(cwd, format, raw) {
1629
2457
  const roadmapPath = planningPaths(cwd).roadmap;
1630
2458
  const reqPath = planningPaths(cwd).requirements;
1631
2459
  const statePath = planningPaths(cwd).state;
1632
- const milestone = getMilestoneInfo(cwd);
1633
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
2460
+ const milestone = getMilestoneInfo(cwd).value;
1634
2461
  // Phase & plan stats (reuse progress pattern)
1635
2462
  const phasesByNumber = new Map();
1636
2463
  let totalPlans = 0;
1637
2464
  let totalSummaries = 0;
2465
+ let phaseScope = null;
1638
2466
  try {
1639
2467
  const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1640
2468
  if (roadmapRaw === null)
@@ -1643,9 +2471,22 @@ function cmdStats(cwd, format, raw) {
1643
2471
  // Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings.
1644
2472
  // Also tolerates optional [bracket-token] scope prefix on phase headings.
1645
2473
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1646
- const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
2474
+ // #3569: the id capture is the canonical #3036 shape (digit REQUIRED — incl.
2475
+ // letter-prefixed B7, decimals, milestone 2-01), the same group roadmap.cts's
2476
+ // collectAnalyzePhases uses. The former `([\w][\w.-]*)` matched ANY word, so
2477
+ // prose mentioning `### Phase N:` inside an inline code span produced a phantom
2478
+ // Not-Started row and made phases_total disagree with roadmap analyze.
2479
+ // 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.
2480
+ 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;
1647
2481
  let match;
1648
2482
  while ((match = headingPattern.exec(roadmapContent)) !== null) {
2483
+ // #3185: the heading seed carried no sentinel filter, so a
2484
+ // `### Phase 999.1:` backlog heading produced a stats row even with no
2485
+ // directory on disk. Uses the canonical predicate (phase-id.cts), not a
2486
+ // local literal — the rule had five copies and three regex variants
2487
+ // before this phase, disagreeing about Phase 0.
2488
+ if (isSentinelPhaseId(match[1]))
2489
+ continue;
1649
2490
  const key = normalizePhaseName(match[1]);
1650
2491
  phasesByNumber.set(key, {
1651
2492
  number: key,
@@ -1658,12 +2499,13 @@ function cmdStats(cwd, format, raw) {
1658
2499
  }
1659
2500
  catch { /* intentionally empty */ }
1660
2501
  try {
1661
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
1662
- const dirs = entries
1663
- .filter(e => e.isDirectory())
1664
- .map(e => e.name)
1665
- .filter(isDirInMilestone)
1666
- .sort((a, b) => comparePhaseNum(a, b));
2502
+ // #3185 (ADR-3180 Decision 1): route through the single owner. This
2503
+ // previously applied the milestone window but NOT a directory-level
2504
+ // sentinel filter — and getMilestonePhaseFilter degrades to a pass-all
2505
+ // predicate when its heading set is empty, at which point every directory
2506
+ // on disk passed, backlog included (#3167).
2507
+ const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
2508
+ phaseScope = scope;
1667
2509
  for (const dir of dirs) {
1668
2510
  // Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names.
1669
2511
  const phaseToken = extractPhaseToken(dir);
@@ -1671,9 +2513,11 @@ function cmdStats(cwd, format, raw) {
1671
2513
  // phaseName is everything after the token (strip leading '-')
1672
2514
  const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
1673
2515
  const phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
1674
- const phaseFiles = node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, dir));
1675
- const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
1676
- const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
2516
+ // #3183: canonical plan/summary counts (root+nested, superseded-excluded,
2517
+ // canonical pairing) from the single owner.
2518
+ const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
2519
+ const plans = phaseScan.planCount;
2520
+ const summaries = phaseScan.summaryCount;
1677
2521
  totalPlans += plans;
1678
2522
  totalSummaries += summaries;
1679
2523
  const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, dir), 'Not Started');
@@ -1696,8 +2540,12 @@ function cmdStats(cwd, format, raw) {
1696
2540
  catch { /* intentionally empty */ }
1697
2541
  const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
1698
2542
  const completedPhases = phases.filter(p => p.status === 'Complete').length;
1699
- const planPercent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
1700
- const percent = phases.length > 0 ? Math.min(100, Math.round((completedPhases / phases.length) * 100)) : 0;
2543
+ // #3217 (ADR-3180 §7.6 rule 4): both percentages here are derived from the
2544
+ // same `phaseScope`-carrying directory enumeration above (Phase 3, #3222) —
2545
+ // withhold both when that scope is not COMPLETE, same rationale as
2546
+ // cmdProgressRender above. A real `0` under COMPLETE still renders.
2547
+ const planPercent = phaseScope === SCOPE.COMPLETE ? (0, phase_lifecycle_cjs_1.clampPercent)(totalSummaries, totalPlans) : null;
2548
+ const percent = phaseScope === SCOPE.COMPLETE ? (0, phase_lifecycle_cjs_1.clampPercent)(completedPhases, phases.length) : null;
1701
2549
  // Requirements stats
1702
2550
  let requirementsTotal = 0;
1703
2551
  let requirementsComplete = 0;
@@ -1735,8 +2583,8 @@ function cmdStats(cwd, format, raw) {
1735
2583
  }
1736
2584
  }
1737
2585
  const result = {
1738
- milestone_version: milestone.version,
1739
- milestone_name: milestone.name,
2586
+ milestone_version: milestone?.version ?? null,
2587
+ milestone_name: milestone?.name ?? null,
1740
2588
  phases,
1741
2589
  phases_completed: completedPhases,
1742
2590
  phases_total: phases.length,
@@ -1749,14 +2597,18 @@ function cmdStats(cwd, format, raw) {
1749
2597
  git_commits: gitCommits,
1750
2598
  git_first_commit_date: gitFirstCommitDate,
1751
2599
  last_activity: lastActivity,
2600
+ // #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
2601
+ // can tell a genuinely-empty milestone from one it could not scope.
2602
+ phase_scope: phaseScope,
1752
2603
  };
1753
2604
  if (format === 'table') {
1754
2605
  const barWidth = 10;
1755
- const filled = Math.round((percent / 100) * barWidth);
2606
+ const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
1756
2607
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1757
- let out = `# ${milestone.version} ${milestone.name} — Statistics\n\n`;
1758
- out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
1759
- if (totalPlans > 0) {
2608
+ let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
2609
+ const percentSuffix = percent === null ? '' : ` (${percent}%)`;
2610
+ out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;
2611
+ if (totalPlans > 0 && planPercent !== null) {
1760
2612
  out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
1761
2613
  }
1762
2614
  out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
@@ -1784,30 +2636,234 @@ function cmdStats(cwd, format, raw) {
1784
2636
  }
1785
2637
  }
1786
2638
  /**
1787
- * Check whether a commit should be allowed based on commit_docs config.
1788
- * When commit_docs is false, rejects commits that stage .planning/ files.
1789
- * Intended for use as a pre-commit hook guard.
2639
+ * Check whether a commit should be allowed based on the `commit_docs`
2640
+ * precedence chain, INCLUDING any `phase_commit_docs.<phase-id>` override
2641
+ * (#3587/#3601). Rejects commits that stage `.planning/` files when the
2642
+ * resolved policy is false. Intended for use as a pre-commit hook guard —
2643
+ * see `commit-docs-guard enable` above.
2644
+ *
2645
+ * The phase is derived from the STAGED `.planning/` paths via the single-
2646
+ * owner `detectPhaseNumberFromFiles` (the same helper `cmdCommit` uses), and
2647
+ * the policy itself is resolved via the single-owner `resolveCommitDocsPolicy`
2648
+ * (also shared with `cmdCommit`) — this function never re-derives phase
2649
+ * detection or precedence, so it cannot diverge from `cmdCommit`'s decision
2650
+ * for the same staged tree (#3588 Part 1: this guard was previously
2651
+ * phase-blind, reading only project-level `commit_docs` and directly
2652
+ * contradicting `gsd-tools query commit`'s phase-aware resolution).
2653
+ *
2654
+ * Staged paths are read via `git diff --cached --name-only -z`, NUL-
2655
+ * delimited, rather than the LF-delimited default. Without `-z`, git
2656
+ * C-style-quotes (wraps in double quotes, octal-escapes) any path containing
2657
+ * a non-ASCII byte, a space-adjacent special character, or a literal quote —
2658
+ * `.planning/café.md` is reported as `".planning/caf\303\251.md"`, which
2659
+ * does not start with `.planning/`, so the old LF-based filter silently
2660
+ * missed it and allowed the commit (#3588 F2: a false negative in the harm
2661
+ * direction this guard exists to prevent). `-z` disables that quoting
2662
+ * entirely and NUL-terminates each path instead, so every staged path is
2663
+ * read as literal, unquoted bytes and no unquoting logic is needed.
1790
2664
  */
1791
2665
  function cmdCheckCommit(cwd, raw) {
1792
2666
  const config = loadConfig(cwd);
1793
- // If commit_docs is true (or not set), allow all commits
1794
- if (config['commit_docs'] !== false) {
1795
- output({ allowed: true, reason: 'commit_docs_enabled' }, raw, 'allowed');
1796
- return;
1797
- }
1798
- // commit_docs is false — check if any .planning/ files are staged
1799
- const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only'], { cwd });
2667
+ const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z'], { cwd });
1800
2668
  if (stagedResult.exitCode === 0) {
1801
- const planningFiles = stagedResult.stdout.split('\n').filter(f => f.startsWith('.planning/') || f.startsWith('.planning\\'));
2669
+ const files = stagedResult.stdout.split('\0').filter(Boolean);
2670
+ const planningFiles = files.filter(f => f.startsWith('.planning/'));
1802
2671
  if (planningFiles.length > 0) {
1803
- error(`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
1804
- planningFiles.map(f => ` ${f}`).join('\n') +
1805
- `\n\nTo unstage: git reset HEAD ${planningFiles.join(' ')}`);
2672
+ const policy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(planningFiles), () => isGitIgnored(cwd, '.planning'));
2673
+ if (!policy.resolved) {
2674
+ error(`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
2675
+ planningFiles.map(f => ` ${f}`).join('\n') +
2676
+ `\n\nTo unstage: git reset HEAD ${planningFiles.join(' ')}`);
2677
+ return;
2678
+ }
2679
+ output({ allowed: true, reason: policy.source === 'phase' ? 'phase_commit_docs_true' : 'commit_docs_enabled' }, raw, 'allowed');
2680
+ return;
1806
2681
  }
1807
2682
  }
1808
- // exitCode !== 0 → no staged files or not a git repo — allow
2683
+ // exitCode !== 0 (no staged files / not a git repo) or no .planning/ files staged — allow
1809
2684
  output({ allowed: true, reason: 'no_planning_files_staged' }, raw, 'allowed');
1810
2685
  }
2686
+ // ─── commit-docs-guard: opt-in pre-commit hook (#3588) ─────────────────────
2687
+ /**
2688
+ * Stable sentinel line identifying a `.git/hooks/pre-commit` file as ours.
2689
+ * Detection is by PRESENCE of this line, not byte-equality (design "Identifying
2690
+ * 'our' hook") — a user who appends a line to a GSD-written hook must not make
2691
+ * it unrecognizable, and a hook lacking this line must never be overwritten or
2692
+ * deleted by `commit-docs-guard enable`/`disable`.
2693
+ */
2694
+ const COMMIT_DOCS_GUARD_MARKER = '# gsd-core:commit-docs-guard';
2695
+ /**
2696
+ * Locate `gsd-core/workflows/_runtime-launcher.snippet.sh` — the SAME
2697
+ * gsd-tools-resolution chain every shipped workflow/agent bash block uses
2698
+ * (scripts/sync-runtime-launcher.cjs) — by walking up from this module's own
2699
+ * compiled location rather than a fixed literal `../..` join, so the walk
2700
+ * tolerates the module living at a different depth under an alternate build
2701
+ * or bundling layout (same defensive shape as
2702
+ * runtime-artifact-layout.cts#findInstallSourceRoot).
2703
+ */
2704
+ function findRuntimeLauncherSnippet() {
2705
+ let dir = __dirname;
2706
+ for (let i = 0; i < 8; i++) {
2707
+ const candidate = node_path_1.default.join(dir, 'workflows', '_runtime-launcher.snippet.sh');
2708
+ if (node_fs_1.default.existsSync(candidate))
2709
+ return candidate;
2710
+ const parent = node_path_1.default.dirname(dir);
2711
+ if (parent === dir)
2712
+ break;
2713
+ dir = parent;
2714
+ }
2715
+ throw new Error(`commit-docs-guard: could not locate workflows/_runtime-launcher.snippet.sh from ${__dirname}`);
2716
+ }
2717
+ /**
2718
+ * Build the literal `.git/hooks/pre-commit` content `commit-docs-guard enable`
2719
+ * writes. Reuses the canonical gsd_run resolution preamble byte-for-byte
2720
+ * (read from disk, never hand-copied — see findRuntimeLauncherSnippet) so this
2721
+ * hook resolves `gsd-tools` exactly the way every other shipped workflow bash
2722
+ * block does, and cannot drift from it.
2723
+ *
2724
+ * LF-only (#3588 A2): the snippet file and every literal line here are joined
2725
+ * with `\n`; platformWriteSync additionally normalizes CRLF→LF on write, so a
2726
+ * CRLF shebang — which is not executable under Git Bash — cannot reach disk.
2727
+ */
2728
+ function buildCommitDocsGuardHookScript() {
2729
+ const snippetPath = findRuntimeLauncherSnippet();
2730
+ const preamble = (0, text_lines_cjs_1.normalizeEol)(node_fs_1.default.readFileSync(snippetPath, 'utf8')).replace(/\n+$/, '');
2731
+ const lines = [
2732
+ '#!/usr/bin/env bash',
2733
+ COMMIT_DOCS_GUARD_MARKER,
2734
+ '# Refuses a commit that stages .planning/ files when `commit_docs` resolves',
2735
+ '# false (honoring any per-phase override). Installed by',
2736
+ '# `gsd-tools commit-docs-guard enable`; remove with',
2737
+ '# `gsd-tools commit-docs-guard disable`. See',
2738
+ '# docs/how-to/keep-planning-docs-private.md.',
2739
+ 'set -euo pipefail',
2740
+ '',
2741
+ preamble,
2742
+ '',
2743
+ 'gsd_run check-commit --raw',
2744
+ ];
2745
+ return lines.join('\n') + '\n';
2746
+ }
2747
+ /**
2748
+ * #3886: the timeout band for `git commit` — pre-commit hooks (husky +
2749
+ * lint-staged idles ~4s on Windows before any task) routinely exceed the 10s
2750
+ * plumbing default; 30s is the same band the push call uses. Shared by all
2751
+ * three commit sites AND their timeout messages, so the number and the text
2752
+ * cannot drift apart.
2753
+ */
2754
+ const COMMIT_TIMEOUT_MS = 30_000;
2755
+ /**
2756
+ * #3886: resolve where a killed `git commit` would leave its stale
2757
+ * index.lock — via `git rev-parse --git-path index.lock`, never a literal
2758
+ * `.git/index.lock` join (#3588 row 8's class: a linked worktree's `.git` is
2759
+ * a FILE pointing at `<gitdir>/worktrees/<name>/`, so the literal path
2760
+ * cannot exist there while the real lock blocks the next commit). Best
2761
+ * effort: any resolution failure falls back to the literal join, and the
2762
+ * message already hedges with "may remain".
2763
+ */
2764
+ function resolveIndexLockPath(cwd) {
2765
+ const result = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'index.lock'], { cwd });
2766
+ if (result.exitCode !== 0)
2767
+ return node_path_1.default.join(cwd, '.git', 'index.lock');
2768
+ const raw = result.stdout.trim();
2769
+ return raw ? (node_path_1.default.isAbsolute(raw) ? raw : node_path_1.default.join(cwd, raw)) : node_path_1.default.join(cwd, '.git', 'index.lock');
2770
+ }
2771
+ /** #3886: shared timeout message shape for all three commit sites. */
2772
+ function commitTimeoutMessage(cwd, stderr, stdout) {
2773
+ return (`git commit timed out after ${COMMIT_TIMEOUT_MS / 1000}s (killed mid-hook; a stale lock may remain at ` +
2774
+ `${resolveIndexLockPath(cwd)} — remove it if no git process is running). ` +
2775
+ `Partial stderr: ${stderr || stdout || '(none)'}`);
2776
+ }
2777
+ /**
2778
+ * Resolve the real git hooks directory for `cwd` via `git rev-parse
2779
+ * --git-path hooks` — never a literal `.git/hooks` join (#3588 row 8: a
2780
+ * linked worktree or submodule's `.git` is a FILE pointing elsewhere, and
2781
+ * this is the one git-native call that already resolves that correctly).
2782
+ */
2783
+ function resolveCommitDocsGuardHooksDir(cwd) {
2784
+ const gitDirResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-dir'], { cwd });
2785
+ if (gitDirResult.exitCode !== 0) {
2786
+ return { ok: false, reason: 'not_a_git_repo' };
2787
+ }
2788
+ const hooksPathResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'hooks'], { cwd });
2789
+ if (hooksPathResult.exitCode !== 0) {
2790
+ return { ok: false, reason: 'not_a_git_repo' };
2791
+ }
2792
+ const hooksDirRaw = hooksPathResult.stdout.trim();
2793
+ const hooksDir = node_path_1.default.isAbsolute(hooksDirRaw) ? hooksDirRaw : node_path_1.default.join(cwd, hooksDirRaw);
2794
+ return { ok: true, dir: hooksDir };
2795
+ }
2796
+ /** Marker presence, not byte-equality (#3588 row B10). */
2797
+ function isCommitDocsGuardHook(content) {
2798
+ return content.includes(COMMIT_DOCS_GUARD_MARKER);
2799
+ }
2800
+ /**
2801
+ * `gsd-tools commit-docs-guard enable` — write `.git/hooks/pre-commit`.
2802
+ * Behavior table (40-design.md rows 1-3, 8-9): refuses to clobber a foreign
2803
+ * hook, refuses when `core.hooksPath` would make our own write inert, and is
2804
+ * idempotent when already enabled.
2805
+ */
2806
+ function cmdCommitDocsGuardEnable(cwd, raw) {
2807
+ const hooksDir = resolveCommitDocsGuardHooksDir(cwd);
2808
+ if (!hooksDir.ok || !hooksDir.dir) {
2809
+ error('not a git repository (or any of the parent directories)', ERROR_REASON.COMMIT_DOCS_GUARD_NOT_A_REPO);
2810
+ return;
2811
+ }
2812
+ // core.hooksPath already set: our .git/hooks/pre-commit would be inert —
2813
+ // git would never invoke it. Silently writing an ignored file is worse
2814
+ // than refusing (design row 9).
2815
+ const hooksPathConfig = (0, shell_command_projection_cjs_1.execGit)(['config', '--get', 'core.hooksPath'], { cwd });
2816
+ if (hooksPathConfig.exitCode === 0 && hooksPathConfig.stdout.trim() !== '') {
2817
+ const configuredPath = hooksPathConfig.stdout.trim();
2818
+ error(`core.hooksPath is set to "${configuredPath}"; a hook written to ${node_path_1.default.join(hooksDir.dir, 'pre-commit')} ` +
2819
+ `would never run. Wire commit-docs-guard into "${configuredPath}" manually, or unset core.hooksPath first.`, ERROR_REASON.COMMIT_DOCS_GUARD_HOOKS_PATH_SET);
2820
+ return;
2821
+ }
2822
+ const hookPath = node_path_1.default.join(hooksDir.dir, 'pre-commit');
2823
+ const existing = (0, shell_command_projection_cjs_1.platformReadSync)(hookPath);
2824
+ if (existing !== null) {
2825
+ if (!isCommitDocsGuardHook(existing)) {
2826
+ error(`refusing to overwrite an existing pre-commit hook at ${hookPath} that GSD did not write. ` +
2827
+ `Remove or rename it, or wire commit-docs-guard into it by hand.`, ERROR_REASON.COMMIT_DOCS_GUARD_FOREIGN_HOOK);
2828
+ }
2829
+ // Already ours — idempotent no-op (row 3). Leave any user edits intact;
2830
+ // just make sure the executable bit survived.
2831
+ try {
2832
+ node_fs_1.default.chmodSync(hookPath, 0o755);
2833
+ }
2834
+ catch { /* best-effort */ }
2835
+ output({ enabled: true, action: 'already_enabled', path: hookPath }, raw, 'already_enabled');
2836
+ return;
2837
+ }
2838
+ (0, shell_command_projection_cjs_1.platformWriteSync)(hookPath, buildCommitDocsGuardHookScript());
2839
+ node_fs_1.default.chmodSync(hookPath, 0o755);
2840
+ output({ enabled: true, action: 'written', path: hookPath }, raw, 'enabled');
2841
+ }
2842
+ /**
2843
+ * `gsd-tools commit-docs-guard disable` — remove `.git/hooks/pre-commit`
2844
+ * ONLY when it is the hook we wrote (marker presence). Never deletes a
2845
+ * foreign hook (design row 5); a missing hook is a no-op success, not an
2846
+ * error (row 6).
2847
+ */
2848
+ function cmdCommitDocsGuardDisable(cwd, raw) {
2849
+ const hooksDir = resolveCommitDocsGuardHooksDir(cwd);
2850
+ if (!hooksDir.ok || !hooksDir.dir) {
2851
+ error('not a git repository (or any of the parent directories)', ERROR_REASON.COMMIT_DOCS_GUARD_NOT_A_REPO);
2852
+ return;
2853
+ }
2854
+ const hookPath = node_path_1.default.join(hooksDir.dir, 'pre-commit');
2855
+ const existing = (0, shell_command_projection_cjs_1.platformReadSync)(hookPath);
2856
+ if (existing === null) {
2857
+ output({ disabled: true, action: 'noop', path: hookPath }, raw, 'noop');
2858
+ return;
2859
+ }
2860
+ if (!isCommitDocsGuardHook(existing)) {
2861
+ error(`refusing to remove the pre-commit hook at ${hookPath}: it does not carry the ` +
2862
+ `${COMMIT_DOCS_GUARD_MARKER} marker, so GSD did not write it.`, ERROR_REASON.COMMIT_DOCS_GUARD_FOREIGN_HOOK);
2863
+ }
2864
+ node_fs_1.default.unlinkSync(hookPath);
2865
+ output({ disabled: true, action: 'removed', path: hookPath }, raw, 'disabled');
2866
+ }
1811
2867
  module.exports = {
1812
2868
  groupFilesBySubrepo,
1813
2869
  determinePhaseStatus,
@@ -1824,6 +2880,10 @@ module.exports = {
1824
2880
  cmdResolveGranularity,
1825
2881
  cmdResolveExecution,
1826
2882
  cmdEffortSync,
2883
+ detectPhaseNumberFromFiles,
2884
+ resolvePhaseCommitDocsOverride,
2885
+ resolveCommitDocsPolicy,
2886
+ COMMIT_DOCS_SKIP_REASON,
1827
2887
  cmdCommit,
1828
2888
  cmdCommitToSubrepo,
1829
2889
  cmdPrSubrepo,
@@ -1835,5 +2895,9 @@ module.exports = {
1835
2895
  cmdScaffold,
1836
2896
  cmdStats,
1837
2897
  cmdCheckCommit,
2898
+ COMMIT_DOCS_GUARD_MARKER,
2899
+ buildCommitDocsGuardHookScript,
2900
+ cmdCommitDocsGuardEnable,
2901
+ cmdCommitDocsGuardDisable,
1838
2902
  _wsParseRetryAfter,
1839
2903
  };