@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
@@ -23,21 +23,47 @@ const clock_cjs_1 = require("./clock.cjs");
23
23
  const state_transition_cjs_1 = require("./state-transition.cjs");
24
24
  const write_set_cjs_1 = require("./write-set.cjs");
25
25
  const markdown_table_cjs_1 = require("./markdown-table.cjs");
26
+ const security_cjs_1 = require("./security.cjs");
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- audit.cjs is an export= CommonJS module
28
+ const auditMod = require("./audit.cjs");
29
+ const { resolveQuickTaskSummaryFile } = auditMod;
26
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
31
  const ioMod = require("./io.cjs");
28
32
  const { output, error } = ioMod;
29
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports
34
+ const cliExitMod = require("./cli-exit.cjs");
35
+ const { ExitError } = cliExitMod;
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const stateContract = require("./state-contract.cjs");
38
+ const { publishStateContract } = stateContract;
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
30
40
  const phaseIdMod = require("./phase-id.cjs");
31
- const { escapeRegex, normalizePhaseName, phaseTokenMatches, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
41
+ const { normalizePhaseName, matchPhaseDirs, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, isSentinelPhaseDir } = phaseIdMod;
42
+ const pattern_cjs_1 = require("./pattern.cjs");
32
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports
33
44
  const roadmapParserMod = require("./roadmap-parser.cjs");
34
- const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
45
+ const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo, sliceMilestoneWindow, } = roadmapParserMod;
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const planningScopeMod = require("./planning-scope.cjs");
48
+ const { SCOPE } = planningScopeMod;
35
49
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
50
  const coreUtilsMod = require("./core-utils.cjs");
37
- const { extractOneLinerFromBody } = coreUtilsMod;
51
+ const { extractOneLinerFromBody, countMatchedSummaries } = coreUtilsMod;
52
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
53
+ const planScanMod = require("./plan-scan.cjs");
54
+ const { scanPhasePlans } = planScanMod;
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
56
+ const phaseLocatorMod = require("./phase-locator.cjs");
57
+ const { listMilestonePhaseDirs } = phaseLocatorMod;
38
58
  const { planningPaths } = planningWorkspace;
39
59
  const { extractFrontmatter } = frontmatterMod;
40
- const { writeStateMd } = stateMod;
60
+ // ADR-3408 §8.3 / #3469: `writeStateMd` gets sync and NO preservation — the
61
+ // same #3374-shaped exposure the milestone-complete write used to carry (a
62
+ // stale body value silently clobbering fresher frontmatter, with no
63
+ // divergence signal). Routed through the single write-seam composition
64
+ // (`syncAndPreserveStateMd`) instead, under `withStateLock` — see
65
+ // `cmdMilestoneComplete`'s own STATE.md-update block for the full rationale.
66
+ const { syncAndPreserveStateMd, withStateLock, readModifyWriteStateMd } = stateMod;
41
67
  // #2288 security: a milestone version label becomes a filesystem directory
42
68
  // component (`milestones/<label>-phases/`) into which phase directories are
43
69
  // MOVED. Any label used as a path segment must be a safe version token —
@@ -131,7 +157,7 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
131
157
  // table_unmatched; this only adds the structured ADR-2143 shape on top).
132
158
  const writeSet = [];
133
159
  for (const reqId of reqIds) {
134
- const reqEscaped = escapeRegex(reqId);
160
+ const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
135
161
  // Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
136
162
  // Use replace() + compare to avoid the test()+replace() global regex
137
163
  // lastIndex bug where test() advances state and replace() misses matches.
@@ -298,17 +324,20 @@ function cmdRequirementsReadyIds(cwd, args, raw) {
298
324
  return;
299
325
  }
300
326
  const planAbsPath = node_path_1.default.resolve(cwd, planPathArg);
301
- const phaseDir = node_path_1.default.dirname(planAbsPath);
302
- const currentBasename = node_path_1.default.basename(planAbsPath);
303
- let siblingPlanFiles = [];
304
- try {
305
- siblingPlanFiles = node_fs_1.default
306
- .readdirSync(phaseDir)
307
- .filter((f) => f.endsWith('-PLAN.md') && f !== currentBasename);
308
- }
309
- catch {
310
- siblingPlanFiles = [];
311
- }
327
+ // #3183: `planPathArg` may point at a root plan (`<phaseDir>/<n>-PLAN.md`)
328
+ // or a nested plan (`<phaseDir>/plans/PLAN-<n>.md`, #3139 layout) —
329
+ // scanPhasePlans always operates on the PHASE dir, so a nested plan needs
330
+ // one extra `dirname` to reach it, and its planFiles-relative identity
331
+ // carries the `plans/` prefix scanPhasePlans itself applies.
332
+ const isNestedPlanPath = node_path_1.default.basename(node_path_1.default.dirname(planAbsPath)) === 'plans';
333
+ const phaseDir = isNestedPlanPath ? node_path_1.default.dirname(node_path_1.default.dirname(planAbsPath)) : node_path_1.default.dirname(planAbsPath);
334
+ const currentRelative = isNestedPlanPath ? `plans/${node_path_1.default.basename(planAbsPath)}` : node_path_1.default.basename(planAbsPath);
335
+ // #3183: canonical plan/summary sets (root+nested, superseded-excluded)
336
+ // from the single owner, rather than a root-only hand-rolled readdirSync
337
+ // filter — a superseded sibling that still declares reqId with no SUMMARY
338
+ // used to block the ID forever (false-block); it is now excluded upstream.
339
+ const phaseScan = scanPhasePlans(phaseDir);
340
+ const siblingPlanFiles = phaseScan.planFiles.filter((f) => f !== currentRelative);
312
341
  const parseFrontmatterReqIds = (content, sourcePath) => {
313
342
  const fm = extractFrontmatter(content, sourcePath);
314
343
  const fmReq = fm.requirements;
@@ -341,9 +370,11 @@ function cmdRequirementsReadyIds(cwd, args, raw) {
341
370
  if (!siblingDeclaresId)
342
371
  continue;
343
372
  // Sibling declares the SAME ID — it must have finished (produced a
344
- // SUMMARY) before this ID is ready to mark Complete.
345
- const siblingSummaryPath = siblingPath.replace(/-PLAN\.md$/, '-SUMMARY.md');
346
- if (!node_fs_1.default.existsSync(siblingSummaryPath)) {
373
+ // SUMMARY) before this ID is ready to mark Complete. Canonical pairing
374
+ // via countMatchedSummaries (root+nested, all three naming forms)
375
+ // instead of a bespoke -PLAN.md→-SUMMARY.md regex swap.
376
+ const siblingHasSummary = countMatchedSummaries([siblingFile], phaseScan.summaryFiles) > 0;
377
+ if (!siblingHasSummary) {
347
378
  blockedBySibling = true;
348
379
  break;
349
380
  }
@@ -388,7 +419,7 @@ function cmdRequirementsRevertPhase(cwd, reqIdsRaw, raw) {
388
419
  const reverted = [];
389
420
  const unchanged = [];
390
421
  for (const reqId of reqIds) {
391
- const reqEscaped = escapeRegex(reqId);
422
+ const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
392
423
  let idReverted = false;
393
424
  // Surface 1 — checkbox: - [x] **REQ-ID** -> - [ ] **REQ-ID**
394
425
  const checkboxPattern = new RegExp(`(-\\s*\\[)x(\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
@@ -424,9 +455,35 @@ function cmdRequirementsRevertPhase(cwd, reqIdsRaw, raw) {
424
455
  }
425
456
  output({ reverted, unchanged, total: reqIds.length }, raw, `${reverted.length}/${reqIds.length} requirement(s) reverted from Complete`);
426
457
  }
458
+ /**
459
+ * #2142 (code-review FIX 4): the single owned "should the Quick Tasks
460
+ * Completed table be reset, and is a reset failure worth a warning" decision
461
+ * — shared by `cmdMilestoneComplete` (which folds this into its own
462
+ * `withStateLock` transform, since it already holds that lock for the
463
+ * closure-transition write happening in the same block) and `cmdQuickArchive`
464
+ * (which routes through `readModifyWriteStateMd`'s own transform instead, per
465
+ * the lock-reentrancy note on that function). Only the WRITE mechanics
466
+ * differ between the two callers — the decision itself ("skip a
467
+ * `QUICK_TASKS_SECTION_ABSENT` result silently; surface any other failure")
468
+ * was previously duplicated verbatim at both call sites.
469
+ *
470
+ * Never throws: a reset failure degrades to returning `content` unchanged
471
+ * with a non-null `warning`, mirroring both callers' pre-existing
472
+ * "liberal but visible" posture.
473
+ */
474
+ function applyQuickTasksReset(content) {
475
+ const resetResult = (0, markdown_table_cjs_1.resetQuickTaskRows)(content);
476
+ if (resetResult.ok) {
477
+ return { content: resetResult.value.content, warning: null };
478
+ }
479
+ if (resetResult.reason !== markdown_table_cjs_1.QUICK_TASKS_SECTION_ABSENT) {
480
+ return { content, warning: { field: 'quick_tasks_table', reason: resetResult.reason } };
481
+ }
482
+ return { content, warning: null };
483
+ }
427
484
  function cmdMilestoneComplete(cwd, version, options, raw) {
428
485
  if (!version) {
429
- error('version required for milestone complete (e.g., v1.0)');
486
+ error('version required for milestone complete (e.g., v1.0) — and --confirm to mutate');
430
487
  }
431
488
  // #2288 security: `version` is a CLI positional that is interpolated into
432
489
  // multiple filesystem sinks below — `path.join(archiveDir, `${version}-ROADMAP.md`)`,
@@ -437,6 +494,23 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
437
494
  if (!ARCHIVE_VERSION_LABEL_RE.test(version)) {
438
495
  error(`milestone complete: version "${version}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
439
496
  }
497
+ // #3726: confirmation gate — refuse before ANY read of the tree beyond the
498
+ // arg checks above, so an unconfirmed invocation is a guaranteed no-op on
499
+ // disk. The threat model is the NEVER_VALID_FLAGS one (gsd-tools.cjs): a
500
+ // caller supplies a token it believes is inert and a destructive operation
501
+ // proceeds unchecked — here the caller-side belief was that the `query`
502
+ // meta-prefix implies a read, and `query milestone.complete <v>` archived
503
+ // the milestone with no confirmation. The prefix is an intentional
504
+ // invocation-compatibility mechanism, not a permission boundary, so the
505
+ // gate lives on the destructive command itself and covers every invocation
506
+ // path. --dry-run needs no confirmation (it mutates nothing and is the
507
+ // recommended first step); --force does NOT imply it (see
508
+ // MilestoneCompleteOptions.confirm).
509
+ if (!options.dryRun && !options.confirm) {
510
+ error(`milestone complete is irreversible: it archives ROADMAP.md and REQUIREMENTS.md, MOVES every phase ` +
511
+ `directory for ${version} into .planning/milestones/, and rewrites STATE.md. ` +
512
+ `Nothing has been changed. Re-run with --confirm to proceed, or --dry-run to preview exactly what would move.`);
513
+ }
440
514
  const roadmapPath = planningPaths(cwd).roadmap;
441
515
  const reqPath = planningPaths(cwd).requirements;
442
516
  const statePath = planningPaths(cwd).state;
@@ -450,17 +524,56 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
450
524
  const phasesDir = planningPaths(cwd).phases;
451
525
  const today = clock_cjs_1.realClock.localToday();
452
526
  const milestoneName = options.name || version;
453
- // Ensure archive directory exists (skipped in dry-run — no mutations)
454
- if (!options.dryRun) {
455
- (0, shell_command_projection_cjs_1.platformEnsureDir)(archiveDir);
456
- }
527
+ // ADR-3408 §8.5 / #3469: "liberal but visible" — when the write-seam
528
+ // composition's preservation stage restores a curated frontmatter value
529
+ // over a disagreeing freshly-derived one, that divergence is surfaced
530
+ // here rather than silently absorbed (the direct answer to #3374's
531
+ // `warnings: []`). Structured (field + reason), not prose, so a caller can
532
+ // assert on the value rather than regex a rendered message.
533
+ //
534
+ // Named `preservation_warnings`, NOT `warnings`: `cmdPhaseComplete` already
535
+ // exposes a sibling field called `warnings` typed as prose `string[]`. Reusing
536
+ // that name here for a structured `{field, reason}[]` shape would be the
537
+ // "Generative Fix Divergence" anti-pattern — two sibling state commands
538
+ // sharing one field name with different element types. `warnings` stays
539
+ // one meaning (prose) repo-wide; this is a distinct, machine-assertable
540
+ // signal for ADR-3408 §8.5's "preservation is visible" rule. Check
541
+ // `preservation_warnings.length` rather than a companion `has_warnings`
542
+ // flag — that flag existed only to mirror `cmdPhaseComplete`'s channel,
543
+ // which this field intentionally does not claim to be.
544
+ const preservationWarnings = [];
457
545
  // Scope stats and accomplishments to only the phases belonging to the
458
546
  // current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
459
547
  // (same logic used by cmdPhasesList and other callers).
548
+ // #3184 review finding: this scope computation + refusal MUST run BEFORE
549
+ // `platformEnsureDir(archiveDir)` below — a refused run (scope not COMPLETE,
550
+ // no --force) must be a true no-op on disk, and creating the archive
551
+ // directory first left an empty directory behind even on refusal.
460
552
  const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
461
553
  if (isDirInMilestone.missingExplicitVersion) {
462
554
  error(`no phases found for milestone ${version} in ROADMAP.md`);
463
555
  }
556
+ // #3184/#3166: `milestone complete` is the ONE-WAY-DOOR consumer of the
557
+ // milestone window (ROADMAP/REQUIREMENTS archived, phase directories
558
+ // MOVED). #3166 is specifically the TRUNCATED case: the milestone's
559
+ // heading IS found but its section closes before the phase region, and the
560
+ // phase filter degrades to pass-all (see getMilestonePhaseFilter above) —
561
+ // silently archiving every phase directory on disk. UNREADABLE (no
562
+ // ROADMAP.md at all) and UNSCOPED (no section for this version) are
563
+ // pre-existing, legitimately-handled states — `missingExplicitVersion`
564
+ // above already errors where that matters, and a missing ROADMAP.md has
565
+ // its own documented graceful path — so only TRUNCATED is refused here.
566
+ // The read-path consumers keep the pass-all degrade for every scope
567
+ // (ADR-3180 Decision 3's Rejected section: deny-all there would trade one
568
+ // silent wrong answer for another); this write path refuses on TRUNCATED
569
+ // alone, positioned before `platformEnsureDir` so a refusal stays a no-op
570
+ // on disk.
571
+ if (isDirInMilestone.scope === SCOPE.TRUNCATED && !options.force) {
572
+ error(`Cannot mark milestone complete: the ROADMAP window for "${version}" is truncated ` +
573
+ `(the milestone heading was found but its section ends before reaching any phase ` +
574
+ `entries, even though the ROADMAP has phase entries elsewhere), so phase scoping ` +
575
+ `cannot be trusted for this destructive operation. Re-run with --force to override.`);
576
+ }
464
577
  // Guard: prevent marking complete when ROADMAP still lists phases that have
465
578
  // no directory on disk (disk_status: no_directory). This catches the case
466
579
  // where the active milestone was erroneously marked complete before phases
@@ -514,7 +627,21 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
514
627
  `running the unstarted-phase guard against the ROADMAP scoped for "${version}" anyway.\n`);
515
628
  }
516
629
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
517
- const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
630
+ // #3184/#2946: scope the unstarted-phase guard to the same `version`
631
+ // window `getMilestonePhaseFilter` used above, NOT to
632
+ // extractCurrentMilestone's own STATE.md-derived window — those two
633
+ // can disagree (that disagreement is exactly what the WARNING above
634
+ // detects), and scoping this guard to the wrong window under-detects
635
+ // unstarted phases on the destructive completion path. Calls the same
636
+ // sliceMilestoneWindow owner getMilestonePhaseFilter's versionOverride
637
+ // branch calls (a prior pass here re-composed locate+select+section-end
638
+ // locally, which review caught as a second, disagreeing derivation of
639
+ // the same window — ADR-3180 Decision 4(c)); falls back to
640
+ // extractCurrentMilestone's whole-document result only for the
641
+ // free-form (no versioned milestones anywhere) shape, where both
642
+ // windows converge to the same value regardless of which version drove
643
+ // the lookup.
644
+ const scopedContent = sliceMilestoneWindow(roadmapContent, version) ?? extractCurrentMilestone(roadmapContent, cwd);
518
645
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
519
646
  const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
520
647
  const noDirectoryPhases = [];
@@ -537,15 +664,15 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
537
664
  // milestone completion. Mirrors the engine-wide sentinel convention
538
665
  // (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS,
539
666
  // the #1445 /^999/ progress filters). (#1580)
540
- const major = parseInt(phaseNum, 10);
541
- if (major === 0 || major === 999)
667
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this local check already covered both 0 and 999; now delegates to the single canonical owner.
668
+ if (isSentinelPhaseId(phaseNum))
542
669
  continue;
543
670
  const normalized = normalizePhaseName(phaseNum);
544
671
  // A phase has disk_status: 'no_directory' when no phase directory
545
- // with a matching token exists on disk. Use the same phaseTokenMatches
546
- // helper that roadmap.analyze uses to avoid false positives on decimal
547
- // (2.1) and letter-suffix (12A) phase IDs.
548
- const hasDirectory = phaseDirEntries.some((d) => phaseTokenMatches(d, normalized));
672
+ // with a matching token exists on disk. Use the same matchPhaseDirs
673
+ // owner that roadmap.analyze uses to avoid false positives on decimal
674
+ // (2.1) and letter-suffix (12A) phase IDs. (#2528)
675
+ const hasDirectory = matchPhaseDirs(phaseDirEntries, normalized).matches.length > 0;
549
676
  if (!hasDirectory) {
550
677
  noDirectoryPhases.push(phaseNum);
551
678
  }
@@ -556,6 +683,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
556
683
  }
557
684
  }
558
685
  catch (e) {
686
+ // ADR-3889: error() now throws ExitError (carries no message) instead
687
+ // of calling process.exit() directly, so it can no longer be detected
688
+ // by sniffing e.message — an ExitError from our own guard above must be
689
+ // re-thrown UNCONDITIONALLY, before any message inspection, or the
690
+ // guard silently stops blocking milestone completion.
691
+ if (e instanceof ExitError)
692
+ throw e;
559
693
  // If the error came from our guard, re-throw it; otherwise skip silently.
560
694
  const message = e instanceof Error ? e.message : String(e);
561
695
  if (message && message.startsWith('Cannot mark milestone complete:'))
@@ -568,20 +702,29 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
568
702
  let totalPlans = 0;
569
703
  let totalTasks = 0;
570
704
  const accomplishments = [];
705
+ // #3597 (ADR-3180 Decision 2): SINGLE resolution of "which phase
706
+ // directories belong to the current milestone" AND the SCOPE discriminator
707
+ // that resolution came from — shared verbatim by the read-only stats loop
708
+ // immediately below, the --dry-run preview, and the real archive pass, so
709
+ // none of the three can ever disagree. `listMilestonePhaseDirs` never
710
+ // throws (its own doc comment), so this is safe to call unguarded ahead of
711
+ // the try/catch that scopes the stats roll-up below.
712
+ //
713
+ // The stats loop's own ENUMERATION behavior is intentionally left
714
+ // unaffected by a non-COMPLETE scope — it reports what is actually on
715
+ // disk, same as before #3597. Only the destructive archive pass (and its
716
+ // --dry-run preview) refuses to act on a non-COMPLETE (non-answer) scope;
717
+ // see the guard built from `milestonePhaseScope` further down.
718
+ const { value: milestonePhaseDirs, scope: milestonePhaseScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: version });
571
719
  try {
572
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
573
- const dirs = entries
574
- .filter((e) => e.isDirectory())
575
- .map((e) => e.name)
576
- .sort();
577
- for (const dir of dirs) {
578
- if (!isDirInMilestone(dir))
579
- continue;
720
+ for (const dir of milestonePhaseDirs) {
580
721
  phaseCount++;
581
- const phaseFiles = node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, dir));
582
- const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
583
- const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
584
- totalPlans += plans.length;
722
+ // #3183: canonical plan/summary sets (root+nested, superseded-excluded)
723
+ // from the single owner, rather than a root-only hand-rolled readdirSync
724
+ // filter.
725
+ const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
726
+ const summaries = phaseScan.summaryFiles;
727
+ totalPlans += phaseScan.planCount;
585
728
  // Extract one-liners from summaries
586
729
  for (const s of summaries) {
587
730
  try {
@@ -621,21 +764,49 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
621
764
  * were created). Degrades stats to phaseCount/totalPlans/totalTasks=0,
622
765
  * accomplishments=[] rather than crash `milestone complete`. */
623
766
  }
767
+ // #3597 (ADR-3180 Decision 2): `SCOPE.UNREADABLE` is the ONE classification
768
+ // where `getMilestonePhaseFilter` throws (no workstream ROADMAP of its
769
+ // own), leaves `milestonePhaseNums` empty, and the window degrades to a
770
+ // pass-all fallback — that fallback is what silently WIDENS the archive
771
+ // set past the single-derivation guarantee this block exists to protect
772
+ // (confirmed empirically: a workstream with phase dirs but no workstream
773
+ // ROADMAP.md reports UNREADABLE and used to enumerate every directory on
774
+ // disk). `SCOPE.TRUNCATED` already refuses the WHOLE command above.
775
+ // `SCOPE.UNSCOPED` is a DIFFERENT, pre-existing classification — e.g. a
776
+ // root project with no milestone asserted in STATE.md — whose
777
+ // `listMilestonePhaseDirs` resolution is a real (non-degraded) answer, and
778
+ // its rollover archive behavior predates this branch and must not change.
779
+ // The guard therefore refuses ONLY on UNREADABLE, not on "not COMPLETE" —
780
+ // widening to every non-COMPLETE scope was itself a regression (a root
781
+ // project with no active workstream resolves UNSCOPED, and refusing to
782
+ // archive there broke the ordinary `milestone complete` -> `phases clear`
783
+ // rollover). Computed once here from the single
784
+ // `milestonePhaseDirs`/`milestonePhaseScope` resolution above, so the
785
+ // --dry-run preview below and the real archive pass further down can never
786
+ // disagree about whether (or what) to archive.
787
+ const phasesArchiveSkippedForScope = options.archivePhases !== false && milestonePhaseScope === SCOPE.UNREADABLE;
788
+ const phasesArchiveSkipReason = phasesArchiveSkippedForScope
789
+ ? `milestone window scope is "${milestonePhaseScope}" — refusing to archive phase directories until the window can be resolved (ADR-3180)`
790
+ : null;
624
791
  // #2118: --dry-run preview — compute what WOULD happen without mutating.
625
792
  // The stats above are read-only; all mutations start at the archive section below.
626
793
  if (options.dryRun) {
627
794
  const phaseDirsToArchive = [];
628
- if (options.archivePhases !== false) {
629
- try {
630
- const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
631
- for (const e of entries) {
632
- if (e.isDirectory() && isDirInMilestone(e.name)) {
633
- phaseDirsToArchive.push(e.name);
634
- }
635
- }
636
- }
637
- catch { /* phasesDir missing — nothing to archive */ }
795
+ if (options.archivePhases !== false && !phasesArchiveSkippedForScope) {
796
+ // #3185 (ADR-3180 Decision 1) / #3597: same single routed derivation as
797
+ // the stats loop above — the dry-run preview must list exactly what
798
+ // the real archive pass below would move, including refusing to list
799
+ // anything when the window scope is not COMPLETE.
800
+ phaseDirsToArchive.push(...milestonePhaseDirs);
638
801
  }
802
+ // #2142 MAJOR 5 (review): dry-run preview of quick-task archival —
803
+ // read-only, routed through the SAME `listQuickTaskDirsForArchive`
804
+ // selection `archiveQuickTaskDirectories` uses for real (directory
805
+ // entries only, `requireSafePath`-guarded, sorted) so this preview can
806
+ // never disagree with what a real run actually archives. Absent
807
+ // --archive-quick this stays `[]` and nothing on disk is touched either
808
+ // way (dry-run always returns before any mutation below).
809
+ const quickDirsToArchive = options.archiveQuick ? listQuickTaskDirsForArchive(cwd) : [];
639
810
  const dryRunResult = {
640
811
  dry_run: true,
641
812
  version,
@@ -653,6 +824,9 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
653
824
  ? { source: node_path_1.default.relative(cwd, node_path_1.default.join(planningBase, `${version}-MILESTONE-AUDIT.md`)).split(node_path_1.default.sep).join('/'), target: node_path_1.default.relative(cwd, node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)).split(node_path_1.default.sep).join('/') }
654
825
  : null,
655
826
  phases: phaseDirsToArchive,
827
+ phases_archive_skipped: phasesArchiveSkippedForScope,
828
+ phases_archive_skip_reason: phasesArchiveSkipReason,
829
+ quick: quickDirsToArchive,
656
830
  },
657
831
  would_update: {
658
832
  milestones_md: node_path_1.default.relative(cwd, milestonesPath).split(node_path_1.default.sep).join('/'),
@@ -662,6 +836,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
662
836
  output(dryRunResult, raw);
663
837
  return;
664
838
  }
839
+ // Ensure archive directory exists. Deliberately placed AFTER the dry-run
840
+ // early return and every refusal/guard above (missingExplicitVersion, the
841
+ // scope refusal, the unstarted-phase guard) — #3184 review finding: this
842
+ // used to run before those checks, so a refused run still left an empty
843
+ // archive directory behind. Reaching this point means the run is
844
+ // committed to mutating.
845
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(archiveDir);
665
846
  // Archive ROADMAP.md
666
847
  if (node_fs_1.default.existsSync(roadmapPath)) {
667
848
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
@@ -687,6 +868,12 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
687
868
  // Create/append MILESTONES.md entry
688
869
  const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
689
870
  const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
871
+ // #3685: mirror requirementsUpdated's diff-tracking contract — the result
872
+ // below used to report `milestones_updated: true` hardcoded, never
873
+ // consulting whether the MILESTONES.md write actually changed anything.
874
+ // Captured before the write branches below so the after-comparison reports
875
+ // a real content diff instead of an assumed one.
876
+ const milestonesBefore = node_fs_1.default.existsSync(milestonesPath) ? node_fs_1.default.readFileSync(milestonesPath, 'utf-8') : null;
690
877
  if (node_fs_1.default.existsSync(milestonesPath)) {
691
878
  const existing = node_fs_1.default.readFileSync(milestonesPath, 'utf-8');
692
879
  if (!existing.trim()) {
@@ -695,7 +882,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
695
882
  }
696
883
  else {
697
884
  // Insert after the header line(s) for reverse chronological order (newest first)
698
- const headerMatch = existing.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
885
+ // #3415: empirically verified linear-time up to 5MB adversarial input (worst-case
886
+ // no-newline-at-all forcing full [^\r\n]* backtrack: 0.11ms@10KB -> 6.9ms@5MB).
887
+ // Non-global, `^`-anchored (no /m) so this is a single match attempt at position 0
888
+ // only — never rescanned at every offset — with no nested repeated group, so it
889
+ // cannot exhibit the #2128-class catastrophic backtracking.
890
+ // eslint-disable-next-line local/no-unbounded-quantifier -- single ^-anchored non-global attempt at pos 0, measured linear to 5MB, no nested quantifier
891
+ const headerMatch = existing.match(/^(#{1,3}\s+[^\r\n]*\r?\n(?:\r?\n)?)/);
699
892
  if (headerMatch) {
700
893
  const header = headerMatch[1];
701
894
  const rest = existing.slice(header.length);
@@ -710,26 +903,169 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
710
903
  else {
711
904
  (0, shell_command_projection_cjs_1.platformWriteSync)(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
712
905
  }
906
+ // #3685: real content diff, not the hardcoded `true` this used to report —
907
+ // see the `milestonesBefore` capture above.
908
+ const milestonesAfter = node_fs_1.default.existsSync(milestonesPath) ? node_fs_1.default.readFileSync(milestonesPath, 'utf-8') : null;
909
+ const milestonesUpdated = milestonesAfter !== milestonesBefore;
910
+ // #2142 BLOCKER 2 (review): opt-in quick-task archival. This call MUST sit
911
+ // immediately adjacent to the STATE.md write block directly below it, with
912
+ // NO unguarded IO in between (unlike the ROADMAP/REQUIREMENTS/audit/
913
+ // MILESTONES.md writes above, none of which are wrapped in a try/catch).
914
+ // If the move ran earlier — e.g. right after `platformEnsureDir(archiveDir)`
915
+ // — and any one of those unguarded writes then threw, the quick-task
916
+ // directories would already be gone from `.planning/quick/` while the
917
+ // STATE.md Quick Tasks table reset (which lives inside `withStateLock`
918
+ // immediately below) would never be reached. That is precisely the
919
+ // STATE-vs-disk drift #2142 exists to eliminate: a table still describing
920
+ // directories that no longer exist. Keeping the move and the reset
921
+ // adjacent — separated only by this comment, never by IO that can throw —
922
+ // means either both happen or (if the move itself throws) neither does.
923
+ // `archiveQuick` is opt-in (default OFF); absent the flag this is `null`
924
+ // and every downstream read of it degrades to "no quick archival happened".
925
+ const quickArchiveResult = options.archiveQuick
926
+ ? archiveQuickTaskDirectories(cwd, version)
927
+ : null;
713
928
  // Update STATE.md — keep frontmatter/body semantically aligned after closure.
714
929
  // ADR-1769 Phase 5: dispatches to the STATE.md Transition Module. The closure
715
930
  // write (Status, Last Activity, Last Activity Description, Current Position
716
931
  // reset, Operator Next Steps reset) is the pure `milestoneCompleteCore` in
717
932
  // src/state-transition.cts, backed by the field-classification table. The
718
933
  // runtime-specific next-milestone slash command is resolved here and injected
719
- // via the intent so the core stays pure. writeStateMd still owns the lock and
720
- // the steady-state syncStateFrontmatter post-sync.
934
+ // via the intent so the core stays pure.
935
+ //
936
+ // ADR-3408 §8.3 / #3469: this used to write via `writeStateMd`, which gets
937
+ // sync and NO preservation — the identical shape #3374 reported for
938
+ // `phase.complete` (a stale body value silently clobbering fresher
939
+ // frontmatter). Routed through the single write-seam composition
940
+ // (`syncAndPreserveStateMd`) instead, under the same lock discipline
941
+ // `cmdPhaseComplete`'s atomic-commit adapter already uses: `withStateLock`
942
+ // wraps read + transform + sync + preserve + write so the read this
943
+ // transaction bases its transform on cannot be raced by a concurrent
944
+ // writer (closing a pre-existing TOCTOU gap `writeStateMd`'s own internal
945
+ // lock never covered, since the read used to happen before any lock was
946
+ // taken). `resync: true` mirrors `cmdPhaseComplete`'s posture (progress
947
+ // recomputed from disk; only the preserve-when-unchanged deltas apply) —
948
+ // milestone completion is the same kind of lifecycle transition.
949
+ // #3685: mirror requirementsUpdated's diff-tracking contract — this used to
950
+ // report `state_updated: fs.existsSync(statePath)`, true even on a no-op
951
+ // transaction. Declared here beside `stateUpdated`'s sibling flags and
952
+ // defaulted to `false` so the "STATE.md absent" case keeps today's answer
953
+ // (existsSync also returns false there) reached via a real content
954
+ // comparison instead.
955
+ let stateUpdated = false;
721
956
  if (node_fs_1.default.existsSync(statePath)) {
722
- const result = (0, state_transition_cjs_1.transitionCore)(node_fs_1.default.readFileSync(statePath, 'utf-8'), {
723
- kind: 'milestoneComplete',
724
- version,
725
- nextMilestoneCommand: (0, runtime_slash_cjs_1.formatGsdSlash)('new-milestone', (0, runtime_slash_cjs_1.resolveRuntime)(cwd)),
726
- }, { clock: clock_cjs_1.realClock, sourcePath: statePath });
727
- writeStateMd(statePath, result.content, cwd);
957
+ withStateLock(statePath, () => {
958
+ const originalStateContent = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
959
+ const result = (0, state_transition_cjs_1.transitionCore)(originalStateContent, {
960
+ kind: 'milestoneComplete',
961
+ version,
962
+ nextMilestoneCommand: (0, runtime_slash_cjs_1.formatGsdSlash)('new-milestone', (0, runtime_slash_cjs_1.resolveRuntime)(cwd)),
963
+ }, { clock: clock_cjs_1.realClock, sourcePath: statePath });
964
+ const divergedFields = [];
965
+ // #2111 (found by #3471 review): `milestoneCompleteCore` never declares
966
+ // `current_phase`/`current_phase_name` among the fields it touches — but
967
+ // its ## Current Position reset REWRITES the `Phase:` prose line to a
968
+ // closure message ("Milestone vX.Y complete"), which is not a number.
969
+ // That is an unavoidable side effect of the wholesale section reset
970
+ // `resetSectionVerbatim` performs, not an intent to change the phase.
971
+ // Downstream, `current_phase`/`current_phase_name` are
972
+ // `preserve-when-unchanged` rows: the #1230 delta heuristic sees the
973
+ // body source go from a real value to unparseable and — correctly, per
974
+ // ADR-3408 §8.5 Row 2 — lets the derived (empty) value win, discarding
975
+ // the curated phase entirely. §8.5 Row 2 governs a genuine mid-write
976
+ // body edit (e.g. `state.patch` deleting the Phase line); milestone
977
+ // closure is a different shape — the transition never intended to
978
+ // touch these fields at all. Re-assert them via `authoritativeFm` (the
979
+ // same #2736 intent-first mechanism `beginPhaseCore`/`completePhaseCore`
980
+ // already use to freeze a field the transition resolved out-of-band),
981
+ // so the closure-message side effect cannot clobber the last real
982
+ // phase. Scoped to non-empty strings only, mirroring #2736's own guard.
983
+ const authoritativeFm = {};
984
+ const preFm = extractFrontmatter(originalStateContent, statePath);
985
+ const preCurrentPhase = preFm['current_phase'];
986
+ const preCurrentPhaseName = preFm['current_phase_name'];
987
+ if (typeof preCurrentPhase === 'string' && preCurrentPhase.trim().length > 0) {
988
+ authoritativeFm['current_phase'] = preCurrentPhase;
989
+ }
990
+ if (typeof preCurrentPhaseName === 'string' && preCurrentPhaseName.trim().length > 0) {
991
+ authoritativeFm['current_phase_name'] = preCurrentPhaseName;
992
+ }
993
+ // #2142: fold the Quick Tasks table reset into this SAME
994
+ // `withStateLock` transform — no second lock acquisition, no second
995
+ // `syncAndPreserveStateMd`/`platformWriteSync` pass. Only applied when
996
+ // quick archival actually MOVED something (never when the flag was
997
+ // absent, and never for a mere dry-run preview, which never reaches
998
+ // here at all). A refused reset degrades to leaving the content
999
+ // untouched and never fails milestone completion, but the two refusal
1000
+ // shapes are NOT equally noteworthy (design doc §40, behavior table
1001
+ // row 5): an ABSENT "Quick Tasks Completed" section is the normal,
1002
+ // common case — the section is created lazily by
1003
+ // `gsd-core/workflows/quick.md` Step 7b, not by
1004
+ // `gsd-core/templates/state.md`, so most projects simply don't have
1005
+ // one — and is silently skipped (compared via the shared
1006
+ // `QUICK_TASKS_SECTION_ABSENT` sentinel, never by matching on the
1007
+ // free-form reason string). A section that EXISTS but couldn't be
1008
+ // reset (unparseable table, or columns matching neither registered
1009
+ // QuickTasks variant) is a genuine anomaly and IS surfaced via
1010
+ // `preservationWarnings`, the same "liberal but visible" posture the
1011
+ // rest of this block already uses for a disagreeing derived STATE.md
1012
+ // value.
1013
+ let quickTasksResetContent = result.content;
1014
+ if (quickArchiveResult && quickArchiveResult.archived > 0) {
1015
+ const { content: resetContent, warning } = applyQuickTasksReset(quickTasksResetContent);
1016
+ quickTasksResetContent = resetContent;
1017
+ if (warning)
1018
+ preservationWarnings.push(warning);
1019
+ }
1020
+ const finalContent = syncAndPreserveStateMd(originalStateContent, quickTasksResetContent, statePath, cwd, {
1021
+ resync: true,
1022
+ authoritativeFm: Object.keys(authoritativeFm).length > 0 ? authoritativeFm : undefined,
1023
+ divergedFields,
1024
+ });
1025
+ (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, finalContent);
1026
+ // #3685 / #3691: compare NORMALIZED bytes, not the pre-normalize
1027
+ // `finalContent` string, against the pre-normalize `originalStateContent`
1028
+ // read above. `platformWriteSync` runs Markdown normalization (blank-line
1029
+ // insertion around headings/fences/lists) before persisting — the
1030
+ // transition core (`transitionCore`'s `## Current Position` section
1031
+ // reset) regenerates that section fresh on every call, including on a
1032
+ // genuine no-op re-run, and its raw un-normalized output differs from
1033
+ // the already-normalized on-disk original even though the write
1034
+ // converges to byte-identical content. Comparing pre-normalize strings
1035
+ // (mirroring cmdPhaseComplete's shape verbatim) was verified live to
1036
+ // report `true` on three consecutive byte-identical writes.
1037
+ // `contentChangedAfterNormalize` runs BOTH sides through the exact same
1038
+ // normalizer `platformWriteSync` used to persist (no extra disk I/O,
1039
+ // and immune by construction to this ordering artifact) — this used to
1040
+ // re-read the file to get the same answer; #3691 hoisted that seam so
1041
+ // this site, `updateRoadmapAfterPhaseRemoval`, and `cmdPhaseComplete`'s
1042
+ // roadmap/state/requirements flags all agree by construction.
1043
+ stateUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(statePath, originalStateContent, finalContent);
1044
+ for (const field of divergedFields) {
1045
+ preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
1046
+ }
1047
+ // The authoritativeFm re-assert above (unlike a delta-based restore) is
1048
+ // invisible to `divergedFields` — #2736's re-assert runs after that
1049
+ // diff — so surface it explicitly here for "liberal but visible".
1050
+ for (const field of Object.keys(authoritativeFm)) {
1051
+ if (!divergedFields.includes(field)) {
1052
+ preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
1053
+ }
1054
+ }
1055
+ });
728
1056
  }
729
1057
  // Archive phase directories if requested
730
1058
  let phasesArchived = false;
731
1059
  // #1871: archive phase dirs by default on milestone complete (opt out via --no-archive-phases).
732
- if (options.archivePhases !== false) {
1060
+ // #3597: `phasesArchiveSkippedForScope` (computed once, above, from the SAME
1061
+ // `milestonePhaseDirs`/`milestonePhaseScope` resolution the --dry-run preview
1062
+ // consumed) refuses this destructive rename loop entirely when the window
1063
+ // scope is UNREADABLE — the one scope whose resolution degrades to a
1064
+ // pass-all fallback (ADR-3180). UNSCOPED/TRUNCATED are real, pre-existing
1065
+ // answers and archive exactly as they did before this branch.
1066
+ // `phasesArchived` stays false and the refusal is surfaced on `result` below;
1067
+ // nothing on disk moves, and `phaseArchiveDir` is never even created.
1068
+ if (options.archivePhases !== false && !phasesArchiveSkippedForScope) {
733
1069
  // #2245 audit (was ERROR-HIDING): retryRenameSync moves one phase dir at a
734
1070
  // time — a mid-loop failure (e.g. the Nth rename) used to leave
735
1071
  // `phasesArchived` at its `false` default even though the first N-1 dirs
@@ -741,11 +1077,12 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
741
1077
  try {
742
1078
  const phaseArchiveDir = node_path_1.default.join(archiveDir, `${version}-phases`);
743
1079
  (0, shell_command_projection_cjs_1.platformEnsureDir)(phaseArchiveDir);
744
- const phaseEntries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
745
- const phaseDirNames = phaseEntries.filter((e) => e.isDirectory()).map((e) => e.name);
746
- for (const dir of phaseDirNames) {
747
- if (!isDirInMilestone(dir))
748
- continue;
1080
+ // #3185 (ADR-3180 Decision 1) / #3597: same single routed derivation as
1081
+ // the stats loop and the --dry-run preview above — only the CURRENT
1082
+ // milestone's phase directories move, never a sentinel or an
1083
+ // out-of-window directory left for a later milestone, and never a
1084
+ // pass-all degrade from a non-COMPLETE scope (refused above).
1085
+ for (const dir of milestonePhaseDirs) {
749
1086
  (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, dir), node_path_1.default.join(phaseArchiveDir, dir));
750
1087
  archivedCount++;
751
1088
  }
@@ -772,11 +1109,36 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
772
1109
  requirements: node_fs_1.default.existsSync(node_path_1.default.join(archiveDir, `${version}-REQUIREMENTS.md`)),
773
1110
  audit: node_fs_1.default.existsSync(node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
774
1111
  phases: phasesArchived,
1112
+ // #3597: machine-readable refusal signal — distinguishes "nothing to
1113
+ // archive because the milestone genuinely has no phase directories"
1114
+ // (phases: false, phases_archive_skipped: false) from "refused to
1115
+ // archive because the milestone window scope was not COMPLETE"
1116
+ // (phases: false, phases_archive_skipped: true, with a reason).
1117
+ phases_archive_skipped: phasesArchiveSkippedForScope,
1118
+ phases_archive_skip_reason: phasesArchiveSkipReason,
1119
+ quick: !!quickArchiveResult && quickArchiveResult.archived > 0,
775
1120
  },
776
- milestones_updated: true,
777
- state_updated: node_fs_1.default.existsSync(statePath),
1121
+ // #3685: mirror requirementsUpdated's diff-tracking contract — both flags
1122
+ // now report a real before/after content diff instead of the previous
1123
+ // hardcoded `true` (milestones_updated) / bare fs.existsSync (state_updated).
1124
+ milestones_updated: milestonesUpdated,
1125
+ state_updated: stateUpdated,
1126
+ preservation_warnings: preservationWarnings,
778
1127
  };
779
1128
  output(result, raw);
1129
+ // #3227 (design doc §40 row 26 / "Not-corruption" rule): a refreshed
1130
+ // state.json `updated_at` must always mean something on disk actually
1131
+ // moved. This site is unconditional because every reachable path either
1132
+ // exits via `error()` (process.exit — refusals like a truncated milestone
1133
+ // window, an unstarted phase, or an invalid version never reach here) or
1134
+ // returns early on `--dry-run` (before any mutation, see the `dry_run:
1135
+ // true` branch above) — the only way execution reaches this line is after
1136
+ // the unconditional MILESTONES.md `platformWriteSync` a few lines above,
1137
+ // which always runs (new file, empty file, or append) once the run is
1138
+ // committed to mutating. Best-effort — cannot throw, cannot change this
1139
+ // command's exit code or output. publishStateContract resolves the
1140
+ // workstream planning root itself via planningPaths.
1141
+ publishStateContract(cwd);
780
1142
  }
781
1143
  function cmdPhasesClear(cwd, raw, args) {
782
1144
  const phasesDir = planningPaths(cwd).phases;
@@ -813,7 +1175,17 @@ function cmdPhasesClear(cwd, raw, args) {
813
1175
  let cleared = 0;
814
1176
  if (node_fs_1.default.existsSync(phasesDir)) {
815
1177
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
816
- const dirs = entries.filter((e) => e.isDirectory() && !/^999(?:\.|$)/.test(e.name));
1178
+ // #3185 (ADR-3180 Decision 1): this carried the FIFTH copy of the
1179
+ // sentinel rule and its THIRD regex variant — `/^999(?:\.|$)/` — which
1180
+ // excluded 999 but NOT 0. Because this is the DESTRUCTIVE path, that
1181
+ // divergence meant a `0-*` directory `roadmap analyze` preserves as a
1182
+ // sentinel was DELETED here. Routed through the canonical predicate so
1183
+ // every reader of "is this a sentinel phase" agrees by construction.
1184
+ // #3639: the DIR-AWARE recognizer — the convention-less id predicate
1185
+ // never saw bracket sentinel dirs (GSD.999-07-icebox), so they were
1186
+ // counted for deletion here while the disk guards (post-#3639) preserve
1187
+ // them; the destructive path must not be the one blind reader left.
1188
+ const dirs = entries.filter((e) => e.isDirectory() && !isSentinelPhaseDir(e.name));
817
1189
  if (dirs.length > 0 && !confirm) {
818
1190
  error(`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` +
819
1191
  `Pass --confirm to proceed.`);
@@ -897,7 +1269,13 @@ function archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride =
897
1269
  let archiveVersion = safeOverride;
898
1270
  if (!archiveVersion) {
899
1271
  try {
900
- const liveVersion = getMilestoneInfo(cwd).version ?? null;
1272
+ // #3216 (ADR-3180 §7.2 Decision): getMilestoneInfo's version becomes a
1273
+ // DIRECTORY NAME below — only a COMPLETE scope's identity is trustworthy
1274
+ // enough to act on destructively. On any other scope, treat the version
1275
+ // as unavailable so control falls through to the dated-label fallback,
1276
+ // same as an unreadable ROADMAP/STATE.
1277
+ const info = getMilestoneInfo(cwd);
1278
+ const liveVersion = info.scope === SCOPE.COMPLETE ? (info.value?.version ?? null) : null;
901
1279
  // Defense in depth (#2288 security): getMilestoneInfo reads STATE.md's
902
1280
  // `milestone:` field, which is unvalidated file content. Only accept it
903
1281
  // as a path component if it is a safe version label; a crafted value
@@ -928,10 +1306,401 @@ function archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride =
928
1306
  }
929
1307
  return { archiveDir: archivePhasesDir, archived };
930
1308
  }
1309
+ /**
1310
+ * #2142 BLOCKER 1 (review): escape one directory-name span for insertion as
1311
+ * markdown LINK TEXT (`[...]`) — a directory name containing a literal `|`,
1312
+ * `[` or `]` must not be able to break the enclosing markdown. `mkdirSync`
1313
+ * accepts an embedded newline in a directory name on POSIX (and `isDirectory()`
1314
+ * still reports true for it), so an unescaped newline would let attacker-
1315
+ * controlled content — including a markdown HEADING — land verbatim in the
1316
+ * generated README.md, an indirect prompt-injection vector for any agent
1317
+ * workflow step that later reads that file. Mirrors `escapeCell`'s exact
1318
+ * convention (markdown-table.cts `escapeCell`): collapse `\r?\n+` to a single
1319
+ * space FIRST (so a newline can never re-enter the output as a line break),
1320
+ * THEN escape the escape char itself (before the rest, so a literal backslash
1321
+ * in the name is never mistaken for part of an escape sequence this function
1322
+ * introduces), THEN the markdown-syntax characters.
1323
+ */
1324
+ function escapeMarkdownLinkText(text) {
1325
+ return text
1326
+ .replace(/\r?\n+/g, ' ')
1327
+ .replace(/\\/g, '\\\\')
1328
+ .replace(/\|/g, '\\|')
1329
+ .replace(/\[/g, '\\[')
1330
+ .replace(/\]/g, '\\]');
1331
+ }
1332
+ /**
1333
+ * #2142 BLOCKER 1 (review): encode one path span for insertion as a markdown
1334
+ * link DESTINATION (`(...)`) — `relSummary` is built from a directory name
1335
+ * that may legally contain a space, a `(`/`)`, or a control character
1336
+ * (including an embedded newline) on POSIX. Per CommonMark, an unbracketed
1337
+ * link destination terminates at the first ASCII space/control character and
1338
+ * requires parens to be balanced or escaped — any of those would truncate or
1339
+ * corrupt the link, or let attacker-controlled content spill out of the
1340
+ * `(...)` span into the surrounding markdown (the same indirect
1341
+ * prompt-injection vector `escapeMarkdownLinkText` guards the link TEXT
1342
+ * against). Percent-encodes just the unsafe set (space, `(`, `)`, and C0
1343
+ * control chars incl. `\r`/`\n`, plus DEL) rather than switching to the
1344
+ * angle-bracket `<...>` destination form — percent-encoding is reversible (a
1345
+ * markdown viewer resolving the link still reaches the right file) and does
1346
+ * not introduce a new pair of syntax characters (`<`/`>`) that would in turn
1347
+ * need their own escaping.
1348
+ */
1349
+ function encodeMarkdownLinkTarget(target) {
1350
+ return target.replace(/[\x00-\x1f\x7f ()]/g, (ch) => `%${ch.charCodeAt(0).toString(16).padStart(2, '0').toUpperCase()}`);
1351
+ }
1352
+ /**
1353
+ * #2142 (code-review FIX 2): PURE builder — scans `archiveQuickDir` and
1354
+ * resolves each entry's summary link, but performs NO IO beyond the read
1355
+ * scan itself; never writes. Split out of the former `writeQuickArchiveReadme`
1356
+ * so tests can assert on the returned structured IR (`entries`) instead of
1357
+ * substring-matching rendered markdown (CONTRIBUTING.md "Prohibited: Raw Text
1358
+ * Matching on Test Outputs" — a generated archive index is a "Rendered file",
1359
+ * which requires a pure builder returning IR, not the `.md`-IS-the-runtime-
1360
+ * artifact exemption).
1361
+ *
1362
+ * (re)generates an index of every quick-task directory PHYSICALLY PRESENT in
1363
+ * the archive, built by scanning the ARCHIVE directory on disk. Deliberately
1364
+ * NOT built from STATE.md's Quick Tasks table (the issue evidenced that table
1365
+ * drifting — 53 rows against 49 dirs, ~22 rows pointing at absent dirs, 18
1366
+ * dirs missing from the table — the filesystem is the only source of truth)
1367
+ * and NOT from the pre-move source list either, so a RE-RUN's index includes
1368
+ * entries a PRIOR run already archived, not just this run's (design row 11).
1369
+ *
1370
+ * Each entry's summary link is resolved via `resolveQuickTaskSummaryFile`
1371
+ * (audit.cts) — the SAME rule `scanQuickTasks` uses to read a task's record
1372
+ * — imported rather than re-derived, so the read and write paths can never
1373
+ * disagree about which file is a task's summary. A task WITHOUT a summary is
1374
+ * still listed, just without a link — never omitted (an omission would
1375
+ * under-report the index, which is worse than an unlinked entry).
1376
+ *
1377
+ * Entries are sorted for deterministic output. A directory name containing
1378
+ * `|`, `[`, `]` or an embedded newline is neutralized via
1379
+ * `escapeMarkdownLinkText` (link TEXT) so it cannot break the generated
1380
+ * markdown or inject a heading — applied here, at build time, so `entries`
1381
+ * itself already carries the injection-safe name (the regression test
1382
+ * asserts on THIS, not on rendered output). The destination is separately
1383
+ * encoded via `encodeMarkdownLinkTarget` (link TARGET) at RENDER time, so a
1384
+ * space/paren/control char in the name cannot truncate or corrupt the
1385
+ * `(...)` span. The summary path is normalized to POSIX
1386
+ * (`.split(path.sep).join('/')`) so the link is stable across platforms.
1387
+ *
1388
+ * Throws when `archiveQuickDir` is unreadable — the caller (`writeQuickArchiveReadme`)
1389
+ * is the best-effort boundary, not this builder.
1390
+ */
1391
+ function buildQuickArchiveIndex(archiveQuickDir) {
1392
+ const dirEntries = node_fs_1.default.readdirSync(archiveQuickDir, { withFileTypes: true });
1393
+ const dirNames = dirEntries
1394
+ .filter((e) => e.isDirectory())
1395
+ .map((e) => e.name)
1396
+ .sort();
1397
+ const entries = dirNames.map((dirName) => {
1398
+ const taskDir = node_path_1.default.join(archiveQuickDir, dirName);
1399
+ const summaryPath = resolveQuickTaskSummaryFile(taskDir, dirName);
1400
+ const escapedName = escapeMarkdownLinkText(dirName);
1401
+ if (summaryPath) {
1402
+ const relSummary = node_path_1.default.relative(archiveQuickDir, summaryPath).split(node_path_1.default.sep).join('/');
1403
+ return { name: escapedName, summary: relSummary };
1404
+ }
1405
+ // No summary file — list the directory, but never link into it (there
1406
+ // is nothing to point at). See indexListsTaskWithoutSummaryWithoutLink.
1407
+ return { name: escapedName, summary: null };
1408
+ });
1409
+ return {
1410
+ entries,
1411
+ render() {
1412
+ const lines = ['# Archived Quick Tasks', ''];
1413
+ for (const entry of entries) {
1414
+ if (entry.summary !== null) {
1415
+ lines.push(`- [${entry.name}](${encodeMarkdownLinkTarget(entry.summary)})`);
1416
+ }
1417
+ else {
1418
+ lines.push(`- ${entry.name}`);
1419
+ }
1420
+ }
1421
+ lines.push('');
1422
+ return lines.join('\n');
1423
+ },
1424
+ };
1425
+ }
1426
+ /**
1427
+ * #2142: thin writer — calls `buildQuickArchiveIndex` and writes its
1428
+ * `render()` output to `<archiveQuickDir>/README.md`. No-ops (writes
1429
+ * nothing) when `archiveQuickDir` is unreadable — this is a best-effort
1430
+ * index, not a gate on milestone completion. #2142 MAJOR 4 (review): the
1431
+ * whole body is wrapped in a try/catch so a failure of the WRITE itself
1432
+ * (read-only archive dir, full disk, or a quick-task directory literally
1433
+ * named `README.md` colliding with the file being written) degrades the same
1434
+ * way — this function genuinely cannot throw, matching its own
1435
+ * "best-effort, not a gate" contract; the directories are already safely
1436
+ * archived by the time this runs.
1437
+ */
1438
+ function writeQuickArchiveReadme(archiveQuickDir) {
1439
+ try {
1440
+ const index = buildQuickArchiveIndex(archiveQuickDir);
1441
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(archiveQuickDir, 'README.md'), index.render());
1442
+ }
1443
+ catch {
1444
+ /* best-effort (#2142 MAJOR 4): a read-only archive dir, a full disk, or a
1445
+ * quick-task directory literally named `README.md` colliding with the
1446
+ * file this function writes must never crash `milestone complete` —
1447
+ * the quick-task directories are already safely archived on disk by the
1448
+ * time this index-generation step runs. */
1449
+ }
1450
+ }
1451
+ /**
1452
+ * #2142 MAJOR 5 (review): the single owned selection rule for "which
1453
+ * directories under `.planning/quick/` would/will move" — directory entries
1454
+ * only (symlinks are excluded here, per the MAJOR 3 note above), each
1455
+ * additionally guarded with `requireSafePath` (the same guard
1456
+ * `scanQuickTasks`/`archiveQuickTaskDirectories` use), sorted for
1457
+ * deterministic output. Extracted so `cmdMilestoneComplete`'s dry-run
1458
+ * preview, `cmdQuickArchive`'s dry-run preview, and the REAL selection inside
1459
+ * `archiveQuickTaskDirectories` all call this ONE function instead of each
1460
+ * re-deriving the rule — the "Generative Fix Divergence" anti-pattern this
1461
+ * repo explicitly guards against (a prior version of this code had the rule
1462
+ * written three times, and only the real-run copy applied `requireSafePath`,
1463
+ * so a dry-run preview could list a directory the real run would silently
1464
+ * skip).
1465
+ */
1466
+ function listQuickTaskDirsForArchive(cwd) {
1467
+ const planningBase = planningPaths(cwd).planning;
1468
+ const quickDir = planningPaths(cwd).quick;
1469
+ let sourceEntries;
1470
+ try {
1471
+ sourceEntries = node_fs_1.default.readdirSync(quickDir, { withFileTypes: true });
1472
+ }
1473
+ catch {
1474
+ // .planning/quick absent or unreadable — nothing to select.
1475
+ return [];
1476
+ }
1477
+ const names = [];
1478
+ for (const entry of sourceEntries) {
1479
+ if (!entry.isDirectory())
1480
+ continue; // excludes symlinks too — see MAJOR 3 note above
1481
+ try {
1482
+ (0, security_cjs_1.requireSafePath)(node_path_1.default.join(quickDir, entry.name), planningBase, 'quick task dir', { allowAbsolute: true });
1483
+ }
1484
+ catch {
1485
+ continue; // symlink/escape attempt — never a candidate, in preview OR real run
1486
+ }
1487
+ names.push(entry.name);
1488
+ }
1489
+ return names.sort();
1490
+ }
1491
+ /**
1492
+ * #2142: move each DIRECTORY entry under `.planning/quick/` into
1493
+ * `milestones/<version>-quick/` (collision-safe), then (re)write that
1494
+ * archive directory's README.md index. Sibling of `archivePhaseDirectories`
1495
+ * — extracted rather than inlined into `cmdMilestoneComplete` (already
1496
+ * cyclomatic 61) — mirroring its collision-safe destination-suffix loop,
1497
+ * `retryRenameSync`, and `platformEnsureDir` usage.
1498
+ *
1499
+ * `version` is ALREADY validated by `ARCHIVE_VERSION_LABEL_RE` at
1500
+ * `cmdMilestoneComplete`'s entry — this helper does not re-validate it, and
1501
+ * must only ever be called after that guard has run.
1502
+ *
1503
+ * #2142 MAJOR 3 (review): a symlink under `.planning/quick/` — even one that
1504
+ * targets a directory — is excluded by the `dirEntries` filter below
1505
+ * (`fs.Dirent.isDirectory()` returns FALSE for a symlink, regardless of what
1506
+ * it points at), so it is never a candidate `entry` in the first place and
1507
+ * `requireSafePath` below never runs against it. `requireSafePath` is
1508
+ * retained here as defense-in-depth for the NON-symlink path (a real
1509
+ * directory entry whose resolved path still needs re-validating against
1510
+ * `planningBase`) — the SAME guard `scanQuickTasks` (audit.cts) uses — so an
1511
+ * entry that fails it is skipped, never archived, never counted. See the
1512
+ * symlink regression tests in tests/milestone-archive.test.cjs
1513
+ * (`symlinkEscapeIsNeverArchivedByMilestoneComplete` /
1514
+ * `symlinkEscapeIsNeverArchivedByQuickArchive`) for a fixture proving neither
1515
+ * the symlink nor its external target is ever moved or altered — added
1516
+ * specifically so a future change to this filter cannot silently reopen the
1517
+ * escape with nothing to catch it.
1518
+ *
1519
+ * No-op (returns `{archived: 0, entries: []}`, creates NOTHING on disk) when
1520
+ * `.planning/quick/` does not exist or contains zero DIRECTORY entries — a
1521
+ * stray file with no sibling directory is neither an empty-dir case nor an
1522
+ * archive case.
1523
+ *
1524
+ * A mid-loop rename failure (or a failure to create the archive directory
1525
+ * itself) does not crash `milestone complete` — it degrades to whatever
1526
+ * `archived`/`entries` had already accumulated before the failure, mirroring
1527
+ * the `archivedCount` finally-pattern `cmdMilestoneComplete`'s own phase
1528
+ * archival uses a few hundred lines above (so a partial archive reports the
1529
+ * TRUE count, never a false `0`/`false`).
1530
+ */
1531
+ function archiveQuickTaskDirectories(cwd, version) {
1532
+ const planningBase = planningPaths(cwd).planning;
1533
+ const quickDir = planningPaths(cwd).quick;
1534
+ const archiveQuickDir = node_path_1.default.join(planningBase, 'milestones', `${version}-quick`);
1535
+ // #2142 MAJOR 5 (review): dirNames is the SAME selection
1536
+ // `listQuickTaskDirsForArchive` hands to both dry-run previews — this is
1537
+ // the real run, so it cannot disagree with what a preview reported.
1538
+ const dirNames = listQuickTaskDirsForArchive(cwd);
1539
+ if (dirNames.length === 0) {
1540
+ // Boundary 0 (#2142): zero (safe) directory entries (empty dir, only
1541
+ // stray files, or every entry excluded by the selection rule) must not
1542
+ // create the archive directory. Also covers `.planning/quick/` being
1543
+ // absent/unreadable — `listQuickTaskDirsForArchive` degrades to `[]`.
1544
+ return { archiveDir: archiveQuickDir, archived: 0, entries: [] };
1545
+ }
1546
+ let archived = 0;
1547
+ const entries = [];
1548
+ try {
1549
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(archiveQuickDir);
1550
+ for (const name of dirNames) {
1551
+ const src = node_path_1.default.join(quickDir, name);
1552
+ let safeSrc;
1553
+ try {
1554
+ // Re-validated here (not just trusted from the selection above) as
1555
+ // TOCTOU defense-in-depth: `listQuickTaskDirsForArchive` and this
1556
+ // rename are two separate filesystem observations, and an entry
1557
+ // that was a safe real directory at selection time could in theory
1558
+ // be swapped for a symlink before this loop reaches it.
1559
+ safeSrc = (0, security_cjs_1.requireSafePath)(src, planningBase, 'quick task dir', { allowAbsolute: true });
1560
+ }
1561
+ catch {
1562
+ continue; // symlink/escape attempt — skip, not archived
1563
+ }
1564
+ // Collision-safe: if a same-named archive entry exists (re-run), suffix it.
1565
+ let dest = node_path_1.default.join(archiveQuickDir, name);
1566
+ let destName = name;
1567
+ let n = 1;
1568
+ while (node_fs_1.default.existsSync(dest)) {
1569
+ destName = `${name}.${n++}`;
1570
+ dest = node_path_1.default.join(archiveQuickDir, destName);
1571
+ }
1572
+ (0, shell_command_projection_cjs_1.retryRenameSync)(safeSrc, dest);
1573
+ archived++;
1574
+ entries.push(destName);
1575
+ }
1576
+ }
1577
+ catch {
1578
+ /* best-effort: platformEnsureDir failed, or the rename loop failed
1579
+ * partway — `archived`/`entries` above already reflect exactly what
1580
+ * succeeded before the failure (accumulated incrementally, never lost
1581
+ * with the swallowed exception — mirrors the archivedCount pattern at
1582
+ * cmdMilestoneComplete's phase-archival block). */
1583
+ }
1584
+ // Regenerate the README from whatever is ACTUALLY on disk now — covers
1585
+ // both a clean full archive and a degraded partial one, and (on a re-run)
1586
+ // includes entries a PRIOR run already archived. Skipped only when the
1587
+ // archive directory itself was never created (ensureDir failed above).
1588
+ if (node_fs_1.default.existsSync(archiveQuickDir)) {
1589
+ writeQuickArchiveReadme(archiveQuickDir);
1590
+ }
1591
+ return { archiveDir: archiveQuickDir, archived, entries };
1592
+ }
1593
+ /**
1594
+ * #2142 escalation: `milestone.archive-quick` (CLI: `milestone archive-quick`,
1595
+ * renamed from the original `quick.archive` per code-review FIX 1 — folded
1596
+ * under the existing `milestone` namespace rather than adding a new top-level
1597
+ * command) — the narrow archival helper the issue's own "Scope of changes"
1598
+ * anticipated ("a `quick.archive`-style routine"), for callers (chiefly
1599
+ * `gsd-core/workflows/cleanup.md`) that need to sweep
1600
+ * `.planning/quick/*` WITHOUT the full `milestone complete` close-out.
1601
+ *
1602
+ * `milestone complete --archive-quick` cannot be reused for this: it
1603
+ * hard-errors via `missingExplicitVersion` for an already-completed
1604
+ * milestone (no `### Phase N:` headings left in its ROADMAP window),
1605
+ * re-archives ROADMAP.md over the very snapshot cleanup depends on, and
1606
+ * appends a duplicate MILESTONES.md entry on every re-run.
1607
+ *
1608
+ * This command performs ONLY the two things `archiveQuickTaskDirectories`
1609
+ * already does (move `.planning/quick/*` dirs into
1610
+ * `milestones/<version>-quick/` + (re)write that archive's README index —
1611
+ * the SAME helper `cmdMilestoneComplete` calls, so the two entry points can
1612
+ * never diverge on step 1) plus a Quick Tasks Completed table reset. It
1613
+ * NEVER touches ROADMAP.md, REQUIREMENTS.md, or MILESTONES.md, and runs
1614
+ * NEITHER the unstarted-phase guard NOR the milestone-window/TRUNCATED
1615
+ * refusal — those remain `milestone complete`'s alone.
1616
+ *
1617
+ * #2142 MAJOR 6 (review): the STATE.md write now routes through
1618
+ * `readModifyWriteStateMd` — the same owned read-transform-write composition
1619
+ * `gsd-tools.cjs`'s `quick-tasks-append` handler uses (ADR-3408 §8.3 / #3469:
1620
+ * "the single owned composition ... so the composition cannot diverge"). A
1621
+ * prior version of this function called `platformWriteSync` directly with
1622
+ * the reset result, bypassing `syncAndPreserveStateMd` entirely — the exact
1623
+ * bypass shape that ADR closed. Per `src/state.cts:3289-3330`,
1624
+ * `readModifyWriteStateMd` ALREADY acquires its own exclusive lock
1625
+ * (`acquireStateLock`/`releaseStateLock`, a real `O_CREAT|O_EXCL` file lock,
1626
+ * not reentrant) across its own read -> transform -> write cycle — so, unlike
1627
+ * `cmdMilestoneComplete` (which folds the table reset into its own
1628
+ * pre-existing `withStateLock` transform because it ALSO needs that lock for
1629
+ * the closure-transition write happening in the same block), this function
1630
+ * must NOT wrap the call in its own `withStateLock`: doing so would acquire
1631
+ * the same lock file twice in the same process, and the second acquire would
1632
+ * spin against a lock this same call already holds until it times out.
1633
+ */
1634
+ function cmdQuickArchive(cwd, version, options, raw) {
1635
+ if (!version) {
1636
+ error('version required for milestone.archive-quick (e.g., v1.0)');
1637
+ }
1638
+ // #2288-class security: `version` becomes a filesystem directory component
1639
+ // (`milestones/<version>-quick/`) that directories are MOVED into — same
1640
+ // guard + wording shape `cmdMilestoneComplete` uses for its own version arg.
1641
+ if (!ARCHIVE_VERSION_LABEL_RE.test(version)) {
1642
+ error(`milestone.archive-quick: version "${version}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
1643
+ }
1644
+ const statePath = planningPaths(cwd).state;
1645
+ const planningBase = planningPaths(cwd).planning;
1646
+ const toPosixRel = (p) => node_path_1.default.relative(cwd, p).split(node_path_1.default.sep).join('/');
1647
+ // --dry-run: preview only, mutates nothing. #2142 MAJOR 5 (review): routed
1648
+ // through the SAME `listQuickTaskDirsForArchive` selection
1649
+ // `cmdMilestoneComplete`'s own dry-run preview and the real
1650
+ // `archiveQuickTaskDirectories` both use, so all three can never disagree.
1651
+ if (options.dryRun) {
1652
+ const quickDirsToArchive = listQuickTaskDirsForArchive(cwd);
1653
+ output({
1654
+ dry_run: true,
1655
+ version,
1656
+ would_archive: quickDirsToArchive,
1657
+ archive_dir: toPosixRel(node_path_1.default.join(planningBase, 'milestones', `${version}-quick`)),
1658
+ }, raw);
1659
+ return;
1660
+ }
1661
+ const quickArchiveResult = archiveQuickTaskDirectories(cwd, version);
1662
+ const warnings = [];
1663
+ let stateUpdated = false;
1664
+ // Same silent/surfaced rule `cmdMilestoneComplete` applies: only attempt
1665
+ // the reset when something actually moved, and treat the
1666
+ // QUICK_TASKS_SECTION_ABSENT sentinel as a silent no-op (the section is
1667
+ // created lazily by quick.md Step 7b and absent from templates/state.md,
1668
+ // so absence is the common case, not an anomaly). Any other reset failure
1669
+ // is surfaced via `warnings`, never thrown.
1670
+ //
1671
+ // #2142 MAJOR 6 (review): routed through `readModifyWriteStateMd` (see the
1672
+ // docstring above) instead of a bare `platformWriteSync` — the transform
1673
+ // returns the ORIGINAL content unchanged whenever the reset did not apply
1674
+ // (sentinel-absent or a genuine failure), so `readModifyWriteStateMd`'s own
1675
+ // no-op guard (#948, state.cts:3304) skips the write and its `false`
1676
+ // return accurately reports "nothing was written" — the same "state_updated
1677
+ // must report accurately" contract the prior direct-write version upheld.
1678
+ if (quickArchiveResult.archived > 0 && node_fs_1.default.existsSync(statePath)) {
1679
+ let resetWarning = null;
1680
+ stateUpdated = readModifyWriteStateMd(statePath, (content) => {
1681
+ const { content: nextContent, warning } = applyQuickTasksReset(content);
1682
+ resetWarning = warning;
1683
+ return nextContent;
1684
+ }, cwd);
1685
+ if (resetWarning) {
1686
+ warnings.push(resetWarning);
1687
+ }
1688
+ }
1689
+ output({
1690
+ version,
1691
+ archived: quickArchiveResult.archived,
1692
+ entries: quickArchiveResult.entries,
1693
+ archive_dir: toPosixRel(quickArchiveResult.archiveDir),
1694
+ state_updated: stateUpdated,
1695
+ warnings,
1696
+ }, raw, `${quickArchiveResult.archived} quick task director${quickArchiveResult.archived === 1 ? 'y' : 'ies'} archived`);
1697
+ }
931
1698
  module.exports = {
932
1699
  cmdRequirementsMarkComplete,
933
1700
  cmdRequirementsReadyIds,
934
1701
  cmdRequirementsRevertPhase,
935
1702
  cmdMilestoneComplete,
936
1703
  cmdPhasesClear,
1704
+ cmdQuickArchive,
1705
+ buildQuickArchiveIndex,
937
1706
  };