@opengsd/gsd-core 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-debug-session-manager.md +11 -0
  4. package/agents/gsd-doc-synthesizer.md +2 -4
  5. package/agents/gsd-executor.md +5 -5
  6. package/agents/gsd-mempalace-curator.md +5 -2
  7. package/agents/gsd-phase-researcher.md +20 -1
  8. package/agents/gsd-plan-checker.md +37 -0
  9. package/agents/gsd-planner.md +44 -46
  10. package/agents/gsd-user-profiler.md +3 -0
  11. package/agents/gsd-verifier.md +12 -3
  12. package/bin/install.js +841 -971
  13. package/bin/lib/ui-safety-gate.cjs +2 -0
  14. package/commands/gsd/code-review.md +1 -1
  15. package/commands/gsd/execute-phase.md +1 -1
  16. package/commands/gsd/map-codebase.md +1 -1
  17. package/commands/gsd/mempalace-capture.md +1 -1
  18. package/commands/gsd/mempalace-recall.md +1 -1
  19. package/commands/gsd/new-milestone.md +1 -1
  20. package/commands/gsd/quick.md +1 -1
  21. package/commands/gsd/review-backlog.md +2 -1
  22. package/commands/gsd/verify-work.md +1 -1
  23. package/gsd-core/bin/gsd-tools.cjs +469 -88
  24. package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
  25. package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
  26. package/gsd-core/bin/lib/api-coverage.cjs +3 -5
  27. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  28. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  29. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  30. package/gsd-core/bin/lib/audit.cjs +876 -240
  31. package/gsd-core/bin/lib/broken-windows.cjs +1 -1
  32. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  33. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +575 -101
  35. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  36. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  37. package/gsd-core/bin/lib/capability-validator.cjs +495 -22
  38. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  39. package/gsd-core/bin/lib/check-command-router.cjs +71 -37
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  41. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  43. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  44. package/gsd-core/bin/lib/commands.cjs +651 -86
  45. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  47. package/gsd-core/bin/lib/config-loader.cjs +75 -0
  48. package/gsd-core/bin/lib/config.cjs +10 -1
  49. package/gsd-core/bin/lib/core-utils.cjs +127 -29
  50. package/gsd-core/bin/lib/decisions.cjs +23 -0
  51. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  52. package/gsd-core/bin/lib/frontmatter.cjs +155 -20
  53. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  54. package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
  55. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  56. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  57. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  58. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  59. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  60. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  61. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  62. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  63. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  64. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  65. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  66. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  67. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  68. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  69. package/gsd-core/bin/lib/init.cjs +321 -129
  70. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  71. package/gsd-core/bin/lib/install-engine.cjs +745 -258
  72. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  73. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  74. package/gsd-core/bin/lib/install-profiles.cjs +134 -57
  75. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  76. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  77. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  78. package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
  79. package/gsd-core/bin/lib/io.cjs +10 -0
  80. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  81. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  82. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  83. package/gsd-core/bin/lib/milestone.cjs +754 -70
  84. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  85. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  86. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  87. package/gsd-core/bin/lib/pattern.cjs +122 -0
  88. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +444 -36
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  91. package/gsd-core/bin/lib/phase-locator.cjs +125 -18
  92. package/gsd-core/bin/lib/phase.cjs +646 -143
  93. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  94. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  95. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  96. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  97. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  98. package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
  99. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  100. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  101. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  102. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  103. package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
  104. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  105. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  106. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  107. package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
  108. package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
  109. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  110. package/gsd-core/bin/lib/roadmap.cjs +385 -94
  111. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
  112. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  113. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
  114. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  115. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  116. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
  117. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  118. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  119. package/gsd-core/bin/lib/security.cjs +104 -5
  120. package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
  121. package/gsd-core/bin/lib/smart-entry.cjs +142 -22
  122. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  123. package/gsd-core/bin/lib/state-document.cjs +152 -8
  124. package/gsd-core/bin/lib/state-transition.cjs +371 -117
  125. package/gsd-core/bin/lib/state.cjs +1794 -357
  126. package/gsd-core/bin/lib/surface.cjs +23 -9
  127. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  128. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  129. package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
  130. package/gsd-core/bin/lib/uat.cjs +399 -56
  131. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  132. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  133. package/gsd-core/bin/lib/unusable-input.cjs +24 -0
  134. package/gsd-core/bin/lib/update-context.cjs +8 -2
  135. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  136. package/gsd-core/bin/lib/validate.cjs +20 -6
  137. package/gsd-core/bin/lib/vendor/README.md +37 -0
  138. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  139. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  140. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  141. package/gsd-core/bin/lib/verification.cjs +258 -8
  142. package/gsd-core/bin/lib/verify.cjs +368 -888
  143. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  144. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  145. package/gsd-core/bin/lib/workstream.cjs +2 -2
  146. package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
  147. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  148. package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
  149. package/gsd-core/references/agent-contracts.md +43 -26
  150. package/gsd-core/references/checkpoints.md +2 -2
  151. package/gsd-core/references/context-budget.md +1 -1
  152. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  153. package/gsd-core/references/doc-conflict-engine.md +1 -1
  154. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  155. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  156. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  157. package/gsd-core/references/execute-phase-response-language.md +1 -1
  158. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  159. package/gsd-core/references/gate-prompts.md +1 -1
  160. package/gsd-core/references/git-planning-commit.md +2 -1
  161. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  162. package/gsd-core/references/model-profiles.md +12 -4
  163. package/gsd-core/references/mvp-concepts.md +9 -9
  164. package/gsd-core/references/planner-guidance.md +3 -9
  165. package/gsd-core/references/planner-preconditions.md +1 -1
  166. package/gsd-core/references/planner-reviews.md +1 -1
  167. package/gsd-core/references/planning-config.md +8 -6
  168. package/gsd-core/references/revision-loop.md +1 -1
  169. package/gsd-core/references/specless-probe-fallback.md +1 -1
  170. package/gsd-core/references/universal-anti-patterns.md +3 -3
  171. package/gsd-core/references/verifier-phase-gates.md +192 -0
  172. package/gsd-core/references/verify-mvp-mode.md +1 -1
  173. package/gsd-core/references/workstream-flag.md +22 -6
  174. package/gsd-core/templates/discussion-log.md +1 -1
  175. package/gsd-core/templates/phase-prompt.md +2 -4
  176. package/gsd-core/templates/state.md +4 -4
  177. package/gsd-core/templates/verification-report.md +9 -1
  178. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  179. package/gsd-core/workflows/autonomous.md +1 -1
  180. package/gsd-core/workflows/cleanup.md +62 -3
  181. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
  182. package/gsd-core/workflows/code-review-fix.md +37 -10
  183. package/gsd-core/workflows/code-review.md +38 -12
  184. package/gsd-core/workflows/complete-milestone.md +141 -18
  185. package/gsd-core/workflows/debug.md +7 -5
  186. package/gsd-core/workflows/diagnose-issues.md +35 -9
  187. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  188. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  189. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
  190. package/gsd-core/workflows/edit-phase.md +26 -1
  191. package/gsd-core/workflows/eval-review.md +3 -5
  192. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
  193. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  194. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
  195. package/gsd-core/workflows/execute-phase.md +38 -50
  196. package/gsd-core/workflows/execute-plan.md +36 -4
  197. package/gsd-core/workflows/explore.md +131 -4
  198. package/gsd-core/workflows/fast.md +10 -2
  199. package/gsd-core/workflows/health.md +73 -4
  200. package/gsd-core/workflows/import.md +4 -4
  201. package/gsd-core/workflows/ingest-docs.md +5 -5
  202. package/gsd-core/workflows/mvp-phase.md +6 -3
  203. package/gsd-core/workflows/new-milestone.md +14 -9
  204. package/gsd-core/workflows/new-project.md +14 -14
  205. package/gsd-core/workflows/next.md +12 -0
  206. package/gsd-core/workflows/plan-phase.md +41 -17
  207. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  208. package/gsd-core/workflows/progress.md +34 -6
  209. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
  210. package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
  211. package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
  212. package/gsd-core/workflows/quick.md +35 -15
  213. package/gsd-core/workflows/review.md +26 -5
  214. package/gsd-core/workflows/secure-phase.md +1 -1
  215. package/gsd-core/workflows/session-report.md +2 -1
  216. package/gsd-core/workflows/settings.md +66 -2
  217. package/gsd-core/workflows/ship.md +104 -44
  218. package/gsd-core/workflows/spec-phase.md +30 -12
  219. package/gsd-core/workflows/sync-skills.md +63 -8
  220. package/gsd-core/workflows/transition.md +46 -11
  221. package/gsd-core/workflows/ui-phase.md +5 -5
  222. package/gsd-core/workflows/ui-review.md +2 -2
  223. package/gsd-core/workflows/update.md +1 -1
  224. package/gsd-core/workflows/validate-phase.md +1 -1
  225. package/gsd-core/workflows/verify-work.md +9 -7
  226. package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
  227. package/hooks/dist/gsd-check-update-worker.js +56 -13
  228. package/hooks/dist/gsd-check-update.js +19 -1
  229. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  230. package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
  231. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  232. package/hooks/dist/gsd-prompt-guard.js +21 -20
  233. package/hooks/dist/gsd-read-injection-scanner.js +38 -24
  234. package/hooks/dist/gsd-statusline.js +18 -0
  235. package/hooks/dist/gsd-update-banner.js +22 -1
  236. package/hooks/dist/gsd-workflow-guard.js +134 -36
  237. package/hooks/dist/lib/git-cmd.js +92 -59
  238. package/hooks/dist/lib/injection-patterns.js +45 -0
  239. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  240. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  241. package/hooks/gsd-agent-isolation-guard.js +103 -14
  242. package/hooks/gsd-check-update-worker.js +56 -13
  243. package/hooks/gsd-check-update.js +19 -1
  244. package/hooks/gsd-cursor-pre-tool.js +0 -3
  245. package/hooks/gsd-cursor-subagent-start.js +77 -2
  246. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  247. package/hooks/gsd-prompt-guard.js +21 -20
  248. package/hooks/gsd-read-injection-scanner.js +38 -24
  249. package/hooks/gsd-statusline.js +18 -0
  250. package/hooks/gsd-update-banner.js +22 -1
  251. package/hooks/gsd-workflow-guard.js +134 -36
  252. package/hooks/lib/git-cmd.js +92 -59
  253. package/hooks/lib/injection-patterns.js +45 -0
  254. package/hooks/lib/isolation-deny-reason.js +39 -0
  255. package/hooks/lib/isolation-sentinel.js +9 -0
  256. package/package.json +21 -9
  257. package/pi/gsd.cjs +19 -5
  258. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  259. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  260. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  261. package/scripts/changeset/lint.cjs +60 -5
  262. package/scripts/check-alias-drift.cjs +7 -43
  263. package/scripts/check-contract-drift.cjs +297 -0
  264. package/scripts/ci-test-scope.cjs +19 -2
  265. package/scripts/command-contract-helpers.cjs +903 -1
  266. package/scripts/gen-adr-index.cjs +728 -38
  267. package/scripts/gen-capability-registry.cjs +3 -15
  268. package/scripts/gen-context-index.cjs +2 -11
  269. package/scripts/gen-health-docs.cjs +390 -0
  270. package/scripts/gen-inventory-manifest.cjs +50 -4
  271. package/scripts/gen-loop-host-contract.cjs +4 -24
  272. package/scripts/gen-registry.cjs +3 -14
  273. package/scripts/lib/alias-drift-families.cjs +46 -0
  274. package/scripts/lib/drift-scan.cjs +278 -0
  275. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  276. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  277. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  278. package/scripts/lint-canary-version-leak.cjs +73 -0
  279. package/scripts/lint-command-contract.cjs +96 -13
  280. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  281. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  282. package/scripts/lint-default-flip-documentation.cjs +193 -0
  283. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  284. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  285. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  286. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  287. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  288. package/scripts/lint-milestone-window-drift.cjs +468 -0
  289. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  290. package/scripts/lint-plan-count-drift.cjs +318 -0
  291. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  292. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  293. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  294. package/scripts/lint-regression-test-names.cjs +15 -13
  295. package/scripts/lint-removed-but-needed.cjs +320 -0
  296. package/scripts/lint-state-field-drift.cjs +805 -0
  297. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  298. package/scripts/lint-test-file-count.allowlist.json +21 -10
  299. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  300. package/scripts/lint-vendored-deps.cjs +124 -0
  301. package/scripts/pr-changed-files.cjs +63 -0
  302. package/scripts/pr-template-policy.cjs +14 -4
  303. package/scripts/prompt-injection-scan.sh +25 -0
  304. package/scripts/require-issue-link-policy.cjs +192 -0
  305. package/scripts/state-write-path-drift-baseline.json +19 -0
  306. package/scripts/sync-runtime-launcher.cjs +2 -4
  307. package/skills/gsd-autonomous/SKILL.md +0 -1
  308. package/skills/gsd-code-review/SKILL.md +1 -1
  309. package/skills/gsd-execute-phase/SKILL.md +1 -2
  310. package/skills/gsd-map-codebase/SKILL.md +1 -1
  311. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  312. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  313. package/skills/gsd-new-milestone/SKILL.md +1 -1
  314. package/skills/gsd-next/SKILL.md +0 -1
  315. package/skills/gsd-plan-phase/SKILL.md +0 -1
  316. package/skills/gsd-progress/SKILL.md +0 -1
  317. package/skills/gsd-quick/SKILL.md +1 -1
  318. package/skills/gsd-review-backlog/SKILL.md +2 -1
  319. package/skills/gsd-stats/SKILL.md +0 -1
  320. package/skills/gsd-verify-work/SKILL.md +1 -1
  321. package/vscode/package.json +1 -1
  322. package/gsd-core/workflows/discovery-phase.md +0 -298
  323. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  324. package/gsd-core/workflows/verify-phase.md +0 -574
  325. package/scripts/affected-tests-lib.cjs +0 -554
  326. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  327. package/scripts/run-affected-tests.cjs +0 -7
  328. package/scripts/run-tests.cjs +0 -1051
@@ -23,21 +23,41 @@ 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
30
34
  const phaseIdMod = require("./phase-id.cjs");
31
- const { escapeRegex, normalizePhaseName, phaseTokenMatches, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
35
+ const { normalizePhaseName, matchPhaseDirs, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
36
+ const pattern_cjs_1 = require("./pattern.cjs");
32
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
33
38
  const roadmapParserMod = require("./roadmap-parser.cjs");
34
- const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
39
+ const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo, sliceMilestoneWindow, } = roadmapParserMod;
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const planningScopeMod = require("./planning-scope.cjs");
42
+ const { SCOPE } = planningScopeMod;
35
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
44
  const coreUtilsMod = require("./core-utils.cjs");
37
- const { extractOneLinerFromBody } = coreUtilsMod;
45
+ const { extractOneLinerFromBody, countMatchedSummaries } = coreUtilsMod;
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
47
+ const planScanMod = require("./plan-scan.cjs");
48
+ const { scanPhasePlans } = planScanMod;
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
50
+ const phaseLocatorMod = require("./phase-locator.cjs");
51
+ const { listMilestonePhaseDirs } = phaseLocatorMod;
38
52
  const { planningPaths } = planningWorkspace;
39
53
  const { extractFrontmatter } = frontmatterMod;
40
- const { writeStateMd } = stateMod;
54
+ // ADR-3408 §8.3 / #3469: `writeStateMd` gets sync and NO preservation — the
55
+ // same #3374-shaped exposure the milestone-complete write used to carry (a
56
+ // stale body value silently clobbering fresher frontmatter, with no
57
+ // divergence signal). Routed through the single write-seam composition
58
+ // (`syncAndPreserveStateMd`) instead, under `withStateLock` — see
59
+ // `cmdMilestoneComplete`'s own STATE.md-update block for the full rationale.
60
+ const { syncAndPreserveStateMd, withStateLock, readModifyWriteStateMd } = stateMod;
41
61
  // #2288 security: a milestone version label becomes a filesystem directory
42
62
  // component (`milestones/<label>-phases/`) into which phase directories are
43
63
  // MOVED. Any label used as a path segment must be a safe version token —
@@ -131,7 +151,7 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
131
151
  // table_unmatched; this only adds the structured ADR-2143 shape on top).
132
152
  const writeSet = [];
133
153
  for (const reqId of reqIds) {
134
- const reqEscaped = escapeRegex(reqId);
154
+ const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
135
155
  // Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
136
156
  // Use replace() + compare to avoid the test()+replace() global regex
137
157
  // lastIndex bug where test() advances state and replace() misses matches.
@@ -298,17 +318,20 @@ function cmdRequirementsReadyIds(cwd, args, raw) {
298
318
  return;
299
319
  }
300
320
  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
- }
321
+ // #3183: `planPathArg` may point at a root plan (`<phaseDir>/<n>-PLAN.md`)
322
+ // or a nested plan (`<phaseDir>/plans/PLAN-<n>.md`, #3139 layout) —
323
+ // scanPhasePlans always operates on the PHASE dir, so a nested plan needs
324
+ // one extra `dirname` to reach it, and its planFiles-relative identity
325
+ // carries the `plans/` prefix scanPhasePlans itself applies.
326
+ const isNestedPlanPath = node_path_1.default.basename(node_path_1.default.dirname(planAbsPath)) === 'plans';
327
+ const phaseDir = isNestedPlanPath ? node_path_1.default.dirname(node_path_1.default.dirname(planAbsPath)) : node_path_1.default.dirname(planAbsPath);
328
+ const currentRelative = isNestedPlanPath ? `plans/${node_path_1.default.basename(planAbsPath)}` : node_path_1.default.basename(planAbsPath);
329
+ // #3183: canonical plan/summary sets (root+nested, superseded-excluded)
330
+ // from the single owner, rather than a root-only hand-rolled readdirSync
331
+ // filter — a superseded sibling that still declares reqId with no SUMMARY
332
+ // used to block the ID forever (false-block); it is now excluded upstream.
333
+ const phaseScan = scanPhasePlans(phaseDir);
334
+ const siblingPlanFiles = phaseScan.planFiles.filter((f) => f !== currentRelative);
312
335
  const parseFrontmatterReqIds = (content, sourcePath) => {
313
336
  const fm = extractFrontmatter(content, sourcePath);
314
337
  const fmReq = fm.requirements;
@@ -341,9 +364,11 @@ function cmdRequirementsReadyIds(cwd, args, raw) {
341
364
  if (!siblingDeclaresId)
342
365
  continue;
343
366
  // 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)) {
367
+ // SUMMARY) before this ID is ready to mark Complete. Canonical pairing
368
+ // via countMatchedSummaries (root+nested, all three naming forms)
369
+ // instead of a bespoke -PLAN.md→-SUMMARY.md regex swap.
370
+ const siblingHasSummary = countMatchedSummaries([siblingFile], phaseScan.summaryFiles) > 0;
371
+ if (!siblingHasSummary) {
347
372
  blockedBySibling = true;
348
373
  break;
349
374
  }
@@ -388,7 +413,7 @@ function cmdRequirementsRevertPhase(cwd, reqIdsRaw, raw) {
388
413
  const reverted = [];
389
414
  const unchanged = [];
390
415
  for (const reqId of reqIds) {
391
- const reqEscaped = escapeRegex(reqId);
416
+ const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
392
417
  let idReverted = false;
393
418
  // Surface 1 — checkbox: - [x] **REQ-ID** -> - [ ] **REQ-ID**
394
419
  const checkboxPattern = new RegExp(`(-\\s*\\[)x(\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
@@ -424,6 +449,32 @@ function cmdRequirementsRevertPhase(cwd, reqIdsRaw, raw) {
424
449
  }
425
450
  output({ reverted, unchanged, total: reqIds.length }, raw, `${reverted.length}/${reqIds.length} requirement(s) reverted from Complete`);
426
451
  }
452
+ /**
453
+ * #2142 (code-review FIX 4): the single owned "should the Quick Tasks
454
+ * Completed table be reset, and is a reset failure worth a warning" decision
455
+ * — shared by `cmdMilestoneComplete` (which folds this into its own
456
+ * `withStateLock` transform, since it already holds that lock for the
457
+ * closure-transition write happening in the same block) and `cmdQuickArchive`
458
+ * (which routes through `readModifyWriteStateMd`'s own transform instead, per
459
+ * the lock-reentrancy note on that function). Only the WRITE mechanics
460
+ * differ between the two callers — the decision itself ("skip a
461
+ * `QUICK_TASKS_SECTION_ABSENT` result silently; surface any other failure")
462
+ * was previously duplicated verbatim at both call sites.
463
+ *
464
+ * Never throws: a reset failure degrades to returning `content` unchanged
465
+ * with a non-null `warning`, mirroring both callers' pre-existing
466
+ * "liberal but visible" posture.
467
+ */
468
+ function applyQuickTasksReset(content) {
469
+ const resetResult = (0, markdown_table_cjs_1.resetQuickTaskRows)(content);
470
+ if (resetResult.ok) {
471
+ return { content: resetResult.value.content, warning: null };
472
+ }
473
+ if (resetResult.reason !== markdown_table_cjs_1.QUICK_TASKS_SECTION_ABSENT) {
474
+ return { content, warning: { field: 'quick_tasks_table', reason: resetResult.reason } };
475
+ }
476
+ return { content, warning: null };
477
+ }
427
478
  function cmdMilestoneComplete(cwd, version, options, raw) {
428
479
  if (!version) {
429
480
  error('version required for milestone complete (e.g., v1.0)');
@@ -450,17 +501,56 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
450
501
  const phasesDir = planningPaths(cwd).phases;
451
502
  const today = clock_cjs_1.realClock.localToday();
452
503
  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
- }
504
+ // ADR-3408 §8.5 / #3469: "liberal but visible" — when the write-seam
505
+ // composition's preservation stage restores a curated frontmatter value
506
+ // over a disagreeing freshly-derived one, that divergence is surfaced
507
+ // here rather than silently absorbed (the direct answer to #3374's
508
+ // `warnings: []`). Structured (field + reason), not prose, so a caller can
509
+ // assert on the value rather than regex a rendered message.
510
+ //
511
+ // Named `preservation_warnings`, NOT `warnings`: `cmdPhaseComplete` already
512
+ // exposes a sibling field called `warnings` typed as prose `string[]`. Reusing
513
+ // that name here for a structured `{field, reason}[]` shape would be the
514
+ // "Generative Fix Divergence" anti-pattern — two sibling state commands
515
+ // sharing one field name with different element types. `warnings` stays
516
+ // one meaning (prose) repo-wide; this is a distinct, machine-assertable
517
+ // signal for ADR-3408 §8.5's "preservation is visible" rule. Check
518
+ // `preservation_warnings.length` rather than a companion `has_warnings`
519
+ // flag — that flag existed only to mirror `cmdPhaseComplete`'s channel,
520
+ // which this field intentionally does not claim to be.
521
+ const preservationWarnings = [];
457
522
  // Scope stats and accomplishments to only the phases belonging to the
458
523
  // current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
459
524
  // (same logic used by cmdPhasesList and other callers).
525
+ // #3184 review finding: this scope computation + refusal MUST run BEFORE
526
+ // `platformEnsureDir(archiveDir)` below — a refused run (scope not COMPLETE,
527
+ // no --force) must be a true no-op on disk, and creating the archive
528
+ // directory first left an empty directory behind even on refusal.
460
529
  const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
461
530
  if (isDirInMilestone.missingExplicitVersion) {
462
531
  error(`no phases found for milestone ${version} in ROADMAP.md`);
463
532
  }
533
+ // #3184/#3166: `milestone complete` is the ONE-WAY-DOOR consumer of the
534
+ // milestone window (ROADMAP/REQUIREMENTS archived, phase directories
535
+ // MOVED). #3166 is specifically the TRUNCATED case: the milestone's
536
+ // heading IS found but its section closes before the phase region, and the
537
+ // phase filter degrades to pass-all (see getMilestonePhaseFilter above) —
538
+ // silently archiving every phase directory on disk. UNREADABLE (no
539
+ // ROADMAP.md at all) and UNSCOPED (no section for this version) are
540
+ // pre-existing, legitimately-handled states — `missingExplicitVersion`
541
+ // above already errors where that matters, and a missing ROADMAP.md has
542
+ // its own documented graceful path — so only TRUNCATED is refused here.
543
+ // The read-path consumers keep the pass-all degrade for every scope
544
+ // (ADR-3180 Decision 3's Rejected section: deny-all there would trade one
545
+ // silent wrong answer for another); this write path refuses on TRUNCATED
546
+ // alone, positioned before `platformEnsureDir` so a refusal stays a no-op
547
+ // on disk.
548
+ if (isDirInMilestone.scope === SCOPE.TRUNCATED && !options.force) {
549
+ error(`Cannot mark milestone complete: the ROADMAP window for "${version}" is truncated ` +
550
+ `(the milestone heading was found but its section ends before reaching any phase ` +
551
+ `entries, even though the ROADMAP has phase entries elsewhere), so phase scoping ` +
552
+ `cannot be trusted for this destructive operation. Re-run with --force to override.`);
553
+ }
464
554
  // Guard: prevent marking complete when ROADMAP still lists phases that have
465
555
  // no directory on disk (disk_status: no_directory). This catches the case
466
556
  // where the active milestone was erroneously marked complete before phases
@@ -514,7 +604,21 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
514
604
  `running the unstarted-phase guard against the ROADMAP scoped for "${version}" anyway.\n`);
515
605
  }
516
606
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
517
- const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
607
+ // #3184/#2946: scope the unstarted-phase guard to the same `version`
608
+ // window `getMilestonePhaseFilter` used above, NOT to
609
+ // extractCurrentMilestone's own STATE.md-derived window — those two
610
+ // can disagree (that disagreement is exactly what the WARNING above
611
+ // detects), and scoping this guard to the wrong window under-detects
612
+ // unstarted phases on the destructive completion path. Calls the same
613
+ // sliceMilestoneWindow owner getMilestonePhaseFilter's versionOverride
614
+ // branch calls (a prior pass here re-composed locate+select+section-end
615
+ // locally, which review caught as a second, disagreeing derivation of
616
+ // the same window — ADR-3180 Decision 4(c)); falls back to
617
+ // extractCurrentMilestone's whole-document result only for the
618
+ // free-form (no versioned milestones anywhere) shape, where both
619
+ // windows converge to the same value regardless of which version drove
620
+ // the lookup.
621
+ const scopedContent = sliceMilestoneWindow(roadmapContent, version) ?? extractCurrentMilestone(roadmapContent, cwd);
518
622
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
519
623
  const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
520
624
  const noDirectoryPhases = [];
@@ -537,15 +641,15 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
537
641
  // milestone completion. Mirrors the engine-wide sentinel convention
538
642
  // (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS,
539
643
  // the #1445 /^999/ progress filters). (#1580)
540
- const major = parseInt(phaseNum, 10);
541
- if (major === 0 || major === 999)
644
+ // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this local check already covered both 0 and 999; now delegates to the single canonical owner.
645
+ if (isSentinelPhaseId(phaseNum))
542
646
  continue;
543
647
  const normalized = normalizePhaseName(phaseNum);
544
648
  // 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));
649
+ // with a matching token exists on disk. Use the same matchPhaseDirs
650
+ // owner that roadmap.analyze uses to avoid false positives on decimal
651
+ // (2.1) and letter-suffix (12A) phase IDs. (#2528)
652
+ const hasDirectory = matchPhaseDirs(phaseDirEntries, normalized).matches.length > 0;
549
653
  if (!hasDirectory) {
550
654
  noDirectoryPhases.push(phaseNum);
551
655
  }
@@ -568,20 +672,29 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
568
672
  let totalPlans = 0;
569
673
  let totalTasks = 0;
570
674
  const accomplishments = [];
675
+ // #3597 (ADR-3180 Decision 2): SINGLE resolution of "which phase
676
+ // directories belong to the current milestone" AND the SCOPE discriminator
677
+ // that resolution came from — shared verbatim by the read-only stats loop
678
+ // immediately below, the --dry-run preview, and the real archive pass, so
679
+ // none of the three can ever disagree. `listMilestonePhaseDirs` never
680
+ // throws (its own doc comment), so this is safe to call unguarded ahead of
681
+ // the try/catch that scopes the stats roll-up below.
682
+ //
683
+ // The stats loop's own ENUMERATION behavior is intentionally left
684
+ // unaffected by a non-COMPLETE scope — it reports what is actually on
685
+ // disk, same as before #3597. Only the destructive archive pass (and its
686
+ // --dry-run preview) refuses to act on a non-COMPLETE (non-answer) scope;
687
+ // see the guard built from `milestonePhaseScope` further down.
688
+ const { value: milestonePhaseDirs, scope: milestonePhaseScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: version });
571
689
  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;
690
+ for (const dir of milestonePhaseDirs) {
580
691
  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;
692
+ // #3183: canonical plan/summary sets (root+nested, superseded-excluded)
693
+ // from the single owner, rather than a root-only hand-rolled readdirSync
694
+ // filter.
695
+ const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
696
+ const summaries = phaseScan.summaryFiles;
697
+ totalPlans += phaseScan.planCount;
585
698
  // Extract one-liners from summaries
586
699
  for (const s of summaries) {
587
700
  try {
@@ -621,21 +734,49 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
621
734
  * were created). Degrades stats to phaseCount/totalPlans/totalTasks=0,
622
735
  * accomplishments=[] rather than crash `milestone complete`. */
623
736
  }
737
+ // #3597 (ADR-3180 Decision 2): `SCOPE.UNREADABLE` is the ONE classification
738
+ // where `getMilestonePhaseFilter` throws (no workstream ROADMAP of its
739
+ // own), leaves `milestonePhaseNums` empty, and the window degrades to a
740
+ // pass-all fallback — that fallback is what silently WIDENS the archive
741
+ // set past the single-derivation guarantee this block exists to protect
742
+ // (confirmed empirically: a workstream with phase dirs but no workstream
743
+ // ROADMAP.md reports UNREADABLE and used to enumerate every directory on
744
+ // disk). `SCOPE.TRUNCATED` already refuses the WHOLE command above.
745
+ // `SCOPE.UNSCOPED` is a DIFFERENT, pre-existing classification — e.g. a
746
+ // root project with no milestone asserted in STATE.md — whose
747
+ // `listMilestonePhaseDirs` resolution is a real (non-degraded) answer, and
748
+ // its rollover archive behavior predates this branch and must not change.
749
+ // The guard therefore refuses ONLY on UNREADABLE, not on "not COMPLETE" —
750
+ // widening to every non-COMPLETE scope was itself a regression (a root
751
+ // project with no active workstream resolves UNSCOPED, and refusing to
752
+ // archive there broke the ordinary `milestone complete` -> `phases clear`
753
+ // rollover). Computed once here from the single
754
+ // `milestonePhaseDirs`/`milestonePhaseScope` resolution above, so the
755
+ // --dry-run preview below and the real archive pass further down can never
756
+ // disagree about whether (or what) to archive.
757
+ const phasesArchiveSkippedForScope = options.archivePhases !== false && milestonePhaseScope === SCOPE.UNREADABLE;
758
+ const phasesArchiveSkipReason = phasesArchiveSkippedForScope
759
+ ? `milestone window scope is "${milestonePhaseScope}" — refusing to archive phase directories until the window can be resolved (ADR-3180)`
760
+ : null;
624
761
  // #2118: --dry-run preview — compute what WOULD happen without mutating.
625
762
  // The stats above are read-only; all mutations start at the archive section below.
626
763
  if (options.dryRun) {
627
764
  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 */ }
765
+ if (options.archivePhases !== false && !phasesArchiveSkippedForScope) {
766
+ // #3185 (ADR-3180 Decision 1) / #3597: same single routed derivation as
767
+ // the stats loop above — the dry-run preview must list exactly what
768
+ // the real archive pass below would move, including refusing to list
769
+ // anything when the window scope is not COMPLETE.
770
+ phaseDirsToArchive.push(...milestonePhaseDirs);
638
771
  }
772
+ // #2142 MAJOR 5 (review): dry-run preview of quick-task archival —
773
+ // read-only, routed through the SAME `listQuickTaskDirsForArchive`
774
+ // selection `archiveQuickTaskDirectories` uses for real (directory
775
+ // entries only, `requireSafePath`-guarded, sorted) so this preview can
776
+ // never disagree with what a real run actually archives. Absent
777
+ // --archive-quick this stays `[]` and nothing on disk is touched either
778
+ // way (dry-run always returns before any mutation below).
779
+ const quickDirsToArchive = options.archiveQuick ? listQuickTaskDirsForArchive(cwd) : [];
639
780
  const dryRunResult = {
640
781
  dry_run: true,
641
782
  version,
@@ -653,6 +794,9 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
653
794
  ? { 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
795
  : null,
655
796
  phases: phaseDirsToArchive,
797
+ phases_archive_skipped: phasesArchiveSkippedForScope,
798
+ phases_archive_skip_reason: phasesArchiveSkipReason,
799
+ quick: quickDirsToArchive,
656
800
  },
657
801
  would_update: {
658
802
  milestones_md: node_path_1.default.relative(cwd, milestonesPath).split(node_path_1.default.sep).join('/'),
@@ -662,6 +806,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
662
806
  output(dryRunResult, raw);
663
807
  return;
664
808
  }
809
+ // Ensure archive directory exists. Deliberately placed AFTER the dry-run
810
+ // early return and every refusal/guard above (missingExplicitVersion, the
811
+ // scope refusal, the unstarted-phase guard) — #3184 review finding: this
812
+ // used to run before those checks, so a refused run still left an empty
813
+ // archive directory behind. Reaching this point means the run is
814
+ // committed to mutating.
815
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(archiveDir);
665
816
  // Archive ROADMAP.md
666
817
  if (node_fs_1.default.existsSync(roadmapPath)) {
667
818
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
@@ -695,7 +846,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
695
846
  }
696
847
  else {
697
848
  // Insert after the header line(s) for reverse chronological order (newest first)
698
- const headerMatch = existing.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
849
+ // #3415: empirically verified linear-time up to 5MB adversarial input (worst-case
850
+ // no-newline-at-all forcing full [^\r\n]* backtrack: 0.11ms@10KB -> 6.9ms@5MB).
851
+ // Non-global, `^`-anchored (no /m) so this is a single match attempt at position 0
852
+ // only — never rescanned at every offset — with no nested repeated group, so it
853
+ // cannot exhibit the #2128-class catastrophic backtracking.
854
+ // eslint-disable-next-line local/no-unbounded-quantifier -- single ^-anchored non-global attempt at pos 0, measured linear to 5MB, no nested quantifier
855
+ const headerMatch = existing.match(/^(#{1,3}\s+[^\r\n]*\r?\n(?:\r?\n)?)/);
699
856
  if (headerMatch) {
700
857
  const header = headerMatch[1];
701
858
  const rest = existing.slice(header.length);
@@ -710,26 +867,140 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
710
867
  else {
711
868
  (0, shell_command_projection_cjs_1.platformWriteSync)(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
712
869
  }
870
+ // #2142 BLOCKER 2 (review): opt-in quick-task archival. This call MUST sit
871
+ // immediately adjacent to the STATE.md write block directly below it, with
872
+ // NO unguarded IO in between (unlike the ROADMAP/REQUIREMENTS/audit/
873
+ // MILESTONES.md writes above, none of which are wrapped in a try/catch).
874
+ // If the move ran earlier — e.g. right after `platformEnsureDir(archiveDir)`
875
+ // — and any one of those unguarded writes then threw, the quick-task
876
+ // directories would already be gone from `.planning/quick/` while the
877
+ // STATE.md Quick Tasks table reset (which lives inside `withStateLock`
878
+ // immediately below) would never be reached. That is precisely the
879
+ // STATE-vs-disk drift #2142 exists to eliminate: a table still describing
880
+ // directories that no longer exist. Keeping the move and the reset
881
+ // adjacent — separated only by this comment, never by IO that can throw —
882
+ // means either both happen or (if the move itself throws) neither does.
883
+ // `archiveQuick` is opt-in (default OFF); absent the flag this is `null`
884
+ // and every downstream read of it degrades to "no quick archival happened".
885
+ const quickArchiveResult = options.archiveQuick
886
+ ? archiveQuickTaskDirectories(cwd, version)
887
+ : null;
713
888
  // Update STATE.md — keep frontmatter/body semantically aligned after closure.
714
889
  // ADR-1769 Phase 5: dispatches to the STATE.md Transition Module. The closure
715
890
  // write (Status, Last Activity, Last Activity Description, Current Position
716
891
  // reset, Operator Next Steps reset) is the pure `milestoneCompleteCore` in
717
892
  // src/state-transition.cts, backed by the field-classification table. The
718
893
  // 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.
894
+ // via the intent so the core stays pure.
895
+ //
896
+ // ADR-3408 §8.3 / #3469: this used to write via `writeStateMd`, which gets
897
+ // sync and NO preservation — the identical shape #3374 reported for
898
+ // `phase.complete` (a stale body value silently clobbering fresher
899
+ // frontmatter). Routed through the single write-seam composition
900
+ // (`syncAndPreserveStateMd`) instead, under the same lock discipline
901
+ // `cmdPhaseComplete`'s atomic-commit adapter already uses: `withStateLock`
902
+ // wraps read + transform + sync + preserve + write so the read this
903
+ // transaction bases its transform on cannot be raced by a concurrent
904
+ // writer (closing a pre-existing TOCTOU gap `writeStateMd`'s own internal
905
+ // lock never covered, since the read used to happen before any lock was
906
+ // taken). `resync: true` mirrors `cmdPhaseComplete`'s posture (progress
907
+ // recomputed from disk; only the preserve-when-unchanged deltas apply) —
908
+ // milestone completion is the same kind of lifecycle transition.
721
909
  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);
910
+ withStateLock(statePath, () => {
911
+ const originalStateContent = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
912
+ const result = (0, state_transition_cjs_1.transitionCore)(originalStateContent, {
913
+ kind: 'milestoneComplete',
914
+ version,
915
+ nextMilestoneCommand: (0, runtime_slash_cjs_1.formatGsdSlash)('new-milestone', (0, runtime_slash_cjs_1.resolveRuntime)(cwd)),
916
+ }, { clock: clock_cjs_1.realClock, sourcePath: statePath });
917
+ const divergedFields = [];
918
+ // #2111 (found by #3471 review): `milestoneCompleteCore` never declares
919
+ // `current_phase`/`current_phase_name` among the fields it touches — but
920
+ // its ## Current Position reset REWRITES the `Phase:` prose line to a
921
+ // closure message ("Milestone vX.Y complete"), which is not a number.
922
+ // That is an unavoidable side effect of the wholesale section reset
923
+ // `resetSectionVerbatim` performs, not an intent to change the phase.
924
+ // Downstream, `current_phase`/`current_phase_name` are
925
+ // `preserve-when-unchanged` rows: the #1230 delta heuristic sees the
926
+ // body source go from a real value to unparseable and — correctly, per
927
+ // ADR-3408 §8.5 Row 2 — lets the derived (empty) value win, discarding
928
+ // the curated phase entirely. §8.5 Row 2 governs a genuine mid-write
929
+ // body edit (e.g. `state.patch` deleting the Phase line); milestone
930
+ // closure is a different shape — the transition never intended to
931
+ // touch these fields at all. Re-assert them via `authoritativeFm` (the
932
+ // same #2736 intent-first mechanism `beginPhaseCore`/`completePhaseCore`
933
+ // already use to freeze a field the transition resolved out-of-band),
934
+ // so the closure-message side effect cannot clobber the last real
935
+ // phase. Scoped to non-empty strings only, mirroring #2736's own guard.
936
+ const authoritativeFm = {};
937
+ const preFm = extractFrontmatter(originalStateContent, statePath);
938
+ const preCurrentPhase = preFm['current_phase'];
939
+ const preCurrentPhaseName = preFm['current_phase_name'];
940
+ if (typeof preCurrentPhase === 'string' && preCurrentPhase.trim().length > 0) {
941
+ authoritativeFm['current_phase'] = preCurrentPhase;
942
+ }
943
+ if (typeof preCurrentPhaseName === 'string' && preCurrentPhaseName.trim().length > 0) {
944
+ authoritativeFm['current_phase_name'] = preCurrentPhaseName;
945
+ }
946
+ // #2142: fold the Quick Tasks table reset into this SAME
947
+ // `withStateLock` transform — no second lock acquisition, no second
948
+ // `syncAndPreserveStateMd`/`platformWriteSync` pass. Only applied when
949
+ // quick archival actually MOVED something (never when the flag was
950
+ // absent, and never for a mere dry-run preview, which never reaches
951
+ // here at all). A refused reset degrades to leaving the content
952
+ // untouched and never fails milestone completion, but the two refusal
953
+ // shapes are NOT equally noteworthy (design doc §40, behavior table
954
+ // row 5): an ABSENT "Quick Tasks Completed" section is the normal,
955
+ // common case — the section is created lazily by
956
+ // `gsd-core/workflows/quick.md` Step 7b, not by
957
+ // `gsd-core/templates/state.md`, so most projects simply don't have
958
+ // one — and is silently skipped (compared via the shared
959
+ // `QUICK_TASKS_SECTION_ABSENT` sentinel, never by matching on the
960
+ // free-form reason string). A section that EXISTS but couldn't be
961
+ // reset (unparseable table, or columns matching neither registered
962
+ // QuickTasks variant) is a genuine anomaly and IS surfaced via
963
+ // `preservationWarnings`, the same "liberal but visible" posture the
964
+ // rest of this block already uses for a disagreeing derived STATE.md
965
+ // value.
966
+ let quickTasksResetContent = result.content;
967
+ if (quickArchiveResult && quickArchiveResult.archived > 0) {
968
+ const { content: resetContent, warning } = applyQuickTasksReset(quickTasksResetContent);
969
+ quickTasksResetContent = resetContent;
970
+ if (warning)
971
+ preservationWarnings.push(warning);
972
+ }
973
+ const finalContent = syncAndPreserveStateMd(originalStateContent, quickTasksResetContent, statePath, cwd, {
974
+ resync: true,
975
+ authoritativeFm: Object.keys(authoritativeFm).length > 0 ? authoritativeFm : undefined,
976
+ divergedFields,
977
+ });
978
+ (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, finalContent);
979
+ for (const field of divergedFields) {
980
+ preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
981
+ }
982
+ // The authoritativeFm re-assert above (unlike a delta-based restore) is
983
+ // invisible to `divergedFields` — #2736's re-assert runs after that
984
+ // diff — so surface it explicitly here for "liberal but visible".
985
+ for (const field of Object.keys(authoritativeFm)) {
986
+ if (!divergedFields.includes(field)) {
987
+ preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
988
+ }
989
+ }
990
+ });
728
991
  }
729
992
  // Archive phase directories if requested
730
993
  let phasesArchived = false;
731
994
  // #1871: archive phase dirs by default on milestone complete (opt out via --no-archive-phases).
732
- if (options.archivePhases !== false) {
995
+ // #3597: `phasesArchiveSkippedForScope` (computed once, above, from the SAME
996
+ // `milestonePhaseDirs`/`milestonePhaseScope` resolution the --dry-run preview
997
+ // consumed) refuses this destructive rename loop entirely when the window
998
+ // scope is UNREADABLE — the one scope whose resolution degrades to a
999
+ // pass-all fallback (ADR-3180). UNSCOPED/TRUNCATED are real, pre-existing
1000
+ // answers and archive exactly as they did before this branch.
1001
+ // `phasesArchived` stays false and the refusal is surfaced on `result` below;
1002
+ // nothing on disk moves, and `phaseArchiveDir` is never even created.
1003
+ if (options.archivePhases !== false && !phasesArchiveSkippedForScope) {
733
1004
  // #2245 audit (was ERROR-HIDING): retryRenameSync moves one phase dir at a
734
1005
  // time — a mid-loop failure (e.g. the Nth rename) used to leave
735
1006
  // `phasesArchived` at its `false` default even though the first N-1 dirs
@@ -741,11 +1012,12 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
741
1012
  try {
742
1013
  const phaseArchiveDir = node_path_1.default.join(archiveDir, `${version}-phases`);
743
1014
  (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;
1015
+ // #3185 (ADR-3180 Decision 1) / #3597: same single routed derivation as
1016
+ // the stats loop and the --dry-run preview above — only the CURRENT
1017
+ // milestone's phase directories move, never a sentinel or an
1018
+ // out-of-window directory left for a later milestone, and never a
1019
+ // pass-all degrade from a non-COMPLETE scope (refused above).
1020
+ for (const dir of milestonePhaseDirs) {
749
1021
  (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, dir), node_path_1.default.join(phaseArchiveDir, dir));
750
1022
  archivedCount++;
751
1023
  }
@@ -772,9 +1044,18 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
772
1044
  requirements: node_fs_1.default.existsSync(node_path_1.default.join(archiveDir, `${version}-REQUIREMENTS.md`)),
773
1045
  audit: node_fs_1.default.existsSync(node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
774
1046
  phases: phasesArchived,
1047
+ // #3597: machine-readable refusal signal — distinguishes "nothing to
1048
+ // archive because the milestone genuinely has no phase directories"
1049
+ // (phases: false, phases_archive_skipped: false) from "refused to
1050
+ // archive because the milestone window scope was not COMPLETE"
1051
+ // (phases: false, phases_archive_skipped: true, with a reason).
1052
+ phases_archive_skipped: phasesArchiveSkippedForScope,
1053
+ phases_archive_skip_reason: phasesArchiveSkipReason,
1054
+ quick: !!quickArchiveResult && quickArchiveResult.archived > 0,
775
1055
  },
776
1056
  milestones_updated: true,
777
1057
  state_updated: node_fs_1.default.existsSync(statePath),
1058
+ preservation_warnings: preservationWarnings,
778
1059
  };
779
1060
  output(result, raw);
780
1061
  }
@@ -813,7 +1094,13 @@ function cmdPhasesClear(cwd, raw, args) {
813
1094
  let cleared = 0;
814
1095
  if (node_fs_1.default.existsSync(phasesDir)) {
815
1096
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
816
- const dirs = entries.filter((e) => e.isDirectory() && !/^999(?:\.|$)/.test(e.name));
1097
+ // #3185 (ADR-3180 Decision 1): this carried the FIFTH copy of the
1098
+ // sentinel rule and its THIRD regex variant — `/^999(?:\.|$)/` — which
1099
+ // excluded 999 but NOT 0. Because this is the DESTRUCTIVE path, that
1100
+ // divergence meant a `0-*` directory `roadmap analyze` preserves as a
1101
+ // sentinel was DELETED here. Routed through the canonical predicate so
1102
+ // every reader of "is this a sentinel phase" agrees by construction.
1103
+ const dirs = entries.filter((e) => e.isDirectory() && !isSentinelPhaseId(e.name));
817
1104
  if (dirs.length > 0 && !confirm) {
818
1105
  error(`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` +
819
1106
  `Pass --confirm to proceed.`);
@@ -897,7 +1184,13 @@ function archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride =
897
1184
  let archiveVersion = safeOverride;
898
1185
  if (!archiveVersion) {
899
1186
  try {
900
- const liveVersion = getMilestoneInfo(cwd).version ?? null;
1187
+ // #3216 (ADR-3180 §7.2 Decision): getMilestoneInfo's version becomes a
1188
+ // DIRECTORY NAME below — only a COMPLETE scope's identity is trustworthy
1189
+ // enough to act on destructively. On any other scope, treat the version
1190
+ // as unavailable so control falls through to the dated-label fallback,
1191
+ // same as an unreadable ROADMAP/STATE.
1192
+ const info = getMilestoneInfo(cwd);
1193
+ const liveVersion = info.scope === SCOPE.COMPLETE ? (info.value?.version ?? null) : null;
901
1194
  // Defense in depth (#2288 security): getMilestoneInfo reads STATE.md's
902
1195
  // `milestone:` field, which is unvalidated file content. Only accept it
903
1196
  // as a path component if it is a safe version label; a crafted value
@@ -928,10 +1221,401 @@ function archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride =
928
1221
  }
929
1222
  return { archiveDir: archivePhasesDir, archived };
930
1223
  }
1224
+ /**
1225
+ * #2142 BLOCKER 1 (review): escape one directory-name span for insertion as
1226
+ * markdown LINK TEXT (`[...]`) — a directory name containing a literal `|`,
1227
+ * `[` or `]` must not be able to break the enclosing markdown. `mkdirSync`
1228
+ * accepts an embedded newline in a directory name on POSIX (and `isDirectory()`
1229
+ * still reports true for it), so an unescaped newline would let attacker-
1230
+ * controlled content — including a markdown HEADING — land verbatim in the
1231
+ * generated README.md, an indirect prompt-injection vector for any agent
1232
+ * workflow step that later reads that file. Mirrors `escapeCell`'s exact
1233
+ * convention (markdown-table.cts `escapeCell`): collapse `\r?\n+` to a single
1234
+ * space FIRST (so a newline can never re-enter the output as a line break),
1235
+ * THEN escape the escape char itself (before the rest, so a literal backslash
1236
+ * in the name is never mistaken for part of an escape sequence this function
1237
+ * introduces), THEN the markdown-syntax characters.
1238
+ */
1239
+ function escapeMarkdownLinkText(text) {
1240
+ return text
1241
+ .replace(/\r?\n+/g, ' ')
1242
+ .replace(/\\/g, '\\\\')
1243
+ .replace(/\|/g, '\\|')
1244
+ .replace(/\[/g, '\\[')
1245
+ .replace(/\]/g, '\\]');
1246
+ }
1247
+ /**
1248
+ * #2142 BLOCKER 1 (review): encode one path span for insertion as a markdown
1249
+ * link DESTINATION (`(...)`) — `relSummary` is built from a directory name
1250
+ * that may legally contain a space, a `(`/`)`, or a control character
1251
+ * (including an embedded newline) on POSIX. Per CommonMark, an unbracketed
1252
+ * link destination terminates at the first ASCII space/control character and
1253
+ * requires parens to be balanced or escaped — any of those would truncate or
1254
+ * corrupt the link, or let attacker-controlled content spill out of the
1255
+ * `(...)` span into the surrounding markdown (the same indirect
1256
+ * prompt-injection vector `escapeMarkdownLinkText` guards the link TEXT
1257
+ * against). Percent-encodes just the unsafe set (space, `(`, `)`, and C0
1258
+ * control chars incl. `\r`/`\n`, plus DEL) rather than switching to the
1259
+ * angle-bracket `<...>` destination form — percent-encoding is reversible (a
1260
+ * markdown viewer resolving the link still reaches the right file) and does
1261
+ * not introduce a new pair of syntax characters (`<`/`>`) that would in turn
1262
+ * need their own escaping.
1263
+ */
1264
+ function encodeMarkdownLinkTarget(target) {
1265
+ return target.replace(/[\x00-\x1f\x7f ()]/g, (ch) => `%${ch.charCodeAt(0).toString(16).padStart(2, '0').toUpperCase()}`);
1266
+ }
1267
+ /**
1268
+ * #2142 (code-review FIX 2): PURE builder — scans `archiveQuickDir` and
1269
+ * resolves each entry's summary link, but performs NO IO beyond the read
1270
+ * scan itself; never writes. Split out of the former `writeQuickArchiveReadme`
1271
+ * so tests can assert on the returned structured IR (`entries`) instead of
1272
+ * substring-matching rendered markdown (CONTRIBUTING.md "Prohibited: Raw Text
1273
+ * Matching on Test Outputs" — a generated archive index is a "Rendered file",
1274
+ * which requires a pure builder returning IR, not the `.md`-IS-the-runtime-
1275
+ * artifact exemption).
1276
+ *
1277
+ * (re)generates an index of every quick-task directory PHYSICALLY PRESENT in
1278
+ * the archive, built by scanning the ARCHIVE directory on disk. Deliberately
1279
+ * NOT built from STATE.md's Quick Tasks table (the issue evidenced that table
1280
+ * drifting — 53 rows against 49 dirs, ~22 rows pointing at absent dirs, 18
1281
+ * dirs missing from the table — the filesystem is the only source of truth)
1282
+ * and NOT from the pre-move source list either, so a RE-RUN's index includes
1283
+ * entries a PRIOR run already archived, not just this run's (design row 11).
1284
+ *
1285
+ * Each entry's summary link is resolved via `resolveQuickTaskSummaryFile`
1286
+ * (audit.cts) — the SAME rule `scanQuickTasks` uses to read a task's record
1287
+ * — imported rather than re-derived, so the read and write paths can never
1288
+ * disagree about which file is a task's summary. A task WITHOUT a summary is
1289
+ * still listed, just without a link — never omitted (an omission would
1290
+ * under-report the index, which is worse than an unlinked entry).
1291
+ *
1292
+ * Entries are sorted for deterministic output. A directory name containing
1293
+ * `|`, `[`, `]` or an embedded newline is neutralized via
1294
+ * `escapeMarkdownLinkText` (link TEXT) so it cannot break the generated
1295
+ * markdown or inject a heading — applied here, at build time, so `entries`
1296
+ * itself already carries the injection-safe name (the regression test
1297
+ * asserts on THIS, not on rendered output). The destination is separately
1298
+ * encoded via `encodeMarkdownLinkTarget` (link TARGET) at RENDER time, so a
1299
+ * space/paren/control char in the name cannot truncate or corrupt the
1300
+ * `(...)` span. The summary path is normalized to POSIX
1301
+ * (`.split(path.sep).join('/')`) so the link is stable across platforms.
1302
+ *
1303
+ * Throws when `archiveQuickDir` is unreadable — the caller (`writeQuickArchiveReadme`)
1304
+ * is the best-effort boundary, not this builder.
1305
+ */
1306
+ function buildQuickArchiveIndex(archiveQuickDir) {
1307
+ const dirEntries = node_fs_1.default.readdirSync(archiveQuickDir, { withFileTypes: true });
1308
+ const dirNames = dirEntries
1309
+ .filter((e) => e.isDirectory())
1310
+ .map((e) => e.name)
1311
+ .sort();
1312
+ const entries = dirNames.map((dirName) => {
1313
+ const taskDir = node_path_1.default.join(archiveQuickDir, dirName);
1314
+ const summaryPath = resolveQuickTaskSummaryFile(taskDir, dirName);
1315
+ const escapedName = escapeMarkdownLinkText(dirName);
1316
+ if (summaryPath) {
1317
+ const relSummary = node_path_1.default.relative(archiveQuickDir, summaryPath).split(node_path_1.default.sep).join('/');
1318
+ return { name: escapedName, summary: relSummary };
1319
+ }
1320
+ // No summary file — list the directory, but never link into it (there
1321
+ // is nothing to point at). See indexListsTaskWithoutSummaryWithoutLink.
1322
+ return { name: escapedName, summary: null };
1323
+ });
1324
+ return {
1325
+ entries,
1326
+ render() {
1327
+ const lines = ['# Archived Quick Tasks', ''];
1328
+ for (const entry of entries) {
1329
+ if (entry.summary !== null) {
1330
+ lines.push(`- [${entry.name}](${encodeMarkdownLinkTarget(entry.summary)})`);
1331
+ }
1332
+ else {
1333
+ lines.push(`- ${entry.name}`);
1334
+ }
1335
+ }
1336
+ lines.push('');
1337
+ return lines.join('\n');
1338
+ },
1339
+ };
1340
+ }
1341
+ /**
1342
+ * #2142: thin writer — calls `buildQuickArchiveIndex` and writes its
1343
+ * `render()` output to `<archiveQuickDir>/README.md`. No-ops (writes
1344
+ * nothing) when `archiveQuickDir` is unreadable — this is a best-effort
1345
+ * index, not a gate on milestone completion. #2142 MAJOR 4 (review): the
1346
+ * whole body is wrapped in a try/catch so a failure of the WRITE itself
1347
+ * (read-only archive dir, full disk, or a quick-task directory literally
1348
+ * named `README.md` colliding with the file being written) degrades the same
1349
+ * way — this function genuinely cannot throw, matching its own
1350
+ * "best-effort, not a gate" contract; the directories are already safely
1351
+ * archived by the time this runs.
1352
+ */
1353
+ function writeQuickArchiveReadme(archiveQuickDir) {
1354
+ try {
1355
+ const index = buildQuickArchiveIndex(archiveQuickDir);
1356
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(archiveQuickDir, 'README.md'), index.render());
1357
+ }
1358
+ catch {
1359
+ /* best-effort (#2142 MAJOR 4): a read-only archive dir, a full disk, or a
1360
+ * quick-task directory literally named `README.md` colliding with the
1361
+ * file this function writes must never crash `milestone complete` —
1362
+ * the quick-task directories are already safely archived on disk by the
1363
+ * time this index-generation step runs. */
1364
+ }
1365
+ }
1366
+ /**
1367
+ * #2142 MAJOR 5 (review): the single owned selection rule for "which
1368
+ * directories under `.planning/quick/` would/will move" — directory entries
1369
+ * only (symlinks are excluded here, per the MAJOR 3 note above), each
1370
+ * additionally guarded with `requireSafePath` (the same guard
1371
+ * `scanQuickTasks`/`archiveQuickTaskDirectories` use), sorted for
1372
+ * deterministic output. Extracted so `cmdMilestoneComplete`'s dry-run
1373
+ * preview, `cmdQuickArchive`'s dry-run preview, and the REAL selection inside
1374
+ * `archiveQuickTaskDirectories` all call this ONE function instead of each
1375
+ * re-deriving the rule — the "Generative Fix Divergence" anti-pattern this
1376
+ * repo explicitly guards against (a prior version of this code had the rule
1377
+ * written three times, and only the real-run copy applied `requireSafePath`,
1378
+ * so a dry-run preview could list a directory the real run would silently
1379
+ * skip).
1380
+ */
1381
+ function listQuickTaskDirsForArchive(cwd) {
1382
+ const planningBase = planningPaths(cwd).planning;
1383
+ const quickDir = planningPaths(cwd).quick;
1384
+ let sourceEntries;
1385
+ try {
1386
+ sourceEntries = node_fs_1.default.readdirSync(quickDir, { withFileTypes: true });
1387
+ }
1388
+ catch {
1389
+ // .planning/quick absent or unreadable — nothing to select.
1390
+ return [];
1391
+ }
1392
+ const names = [];
1393
+ for (const entry of sourceEntries) {
1394
+ if (!entry.isDirectory())
1395
+ continue; // excludes symlinks too — see MAJOR 3 note above
1396
+ try {
1397
+ (0, security_cjs_1.requireSafePath)(node_path_1.default.join(quickDir, entry.name), planningBase, 'quick task dir', { allowAbsolute: true });
1398
+ }
1399
+ catch {
1400
+ continue; // symlink/escape attempt — never a candidate, in preview OR real run
1401
+ }
1402
+ names.push(entry.name);
1403
+ }
1404
+ return names.sort();
1405
+ }
1406
+ /**
1407
+ * #2142: move each DIRECTORY entry under `.planning/quick/` into
1408
+ * `milestones/<version>-quick/` (collision-safe), then (re)write that
1409
+ * archive directory's README.md index. Sibling of `archivePhaseDirectories`
1410
+ * — extracted rather than inlined into `cmdMilestoneComplete` (already
1411
+ * cyclomatic 61) — mirroring its collision-safe destination-suffix loop,
1412
+ * `retryRenameSync`, and `platformEnsureDir` usage.
1413
+ *
1414
+ * `version` is ALREADY validated by `ARCHIVE_VERSION_LABEL_RE` at
1415
+ * `cmdMilestoneComplete`'s entry — this helper does not re-validate it, and
1416
+ * must only ever be called after that guard has run.
1417
+ *
1418
+ * #2142 MAJOR 3 (review): a symlink under `.planning/quick/` — even one that
1419
+ * targets a directory — is excluded by the `dirEntries` filter below
1420
+ * (`fs.Dirent.isDirectory()` returns FALSE for a symlink, regardless of what
1421
+ * it points at), so it is never a candidate `entry` in the first place and
1422
+ * `requireSafePath` below never runs against it. `requireSafePath` is
1423
+ * retained here as defense-in-depth for the NON-symlink path (a real
1424
+ * directory entry whose resolved path still needs re-validating against
1425
+ * `planningBase`) — the SAME guard `scanQuickTasks` (audit.cts) uses — so an
1426
+ * entry that fails it is skipped, never archived, never counted. See the
1427
+ * symlink regression tests in tests/milestone-archive.test.cjs
1428
+ * (`symlinkEscapeIsNeverArchivedByMilestoneComplete` /
1429
+ * `symlinkEscapeIsNeverArchivedByQuickArchive`) for a fixture proving neither
1430
+ * the symlink nor its external target is ever moved or altered — added
1431
+ * specifically so a future change to this filter cannot silently reopen the
1432
+ * escape with nothing to catch it.
1433
+ *
1434
+ * No-op (returns `{archived: 0, entries: []}`, creates NOTHING on disk) when
1435
+ * `.planning/quick/` does not exist or contains zero DIRECTORY entries — a
1436
+ * stray file with no sibling directory is neither an empty-dir case nor an
1437
+ * archive case.
1438
+ *
1439
+ * A mid-loop rename failure (or a failure to create the archive directory
1440
+ * itself) does not crash `milestone complete` — it degrades to whatever
1441
+ * `archived`/`entries` had already accumulated before the failure, mirroring
1442
+ * the `archivedCount` finally-pattern `cmdMilestoneComplete`'s own phase
1443
+ * archival uses a few hundred lines above (so a partial archive reports the
1444
+ * TRUE count, never a false `0`/`false`).
1445
+ */
1446
+ function archiveQuickTaskDirectories(cwd, version) {
1447
+ const planningBase = planningPaths(cwd).planning;
1448
+ const quickDir = planningPaths(cwd).quick;
1449
+ const archiveQuickDir = node_path_1.default.join(planningBase, 'milestones', `${version}-quick`);
1450
+ // #2142 MAJOR 5 (review): dirNames is the SAME selection
1451
+ // `listQuickTaskDirsForArchive` hands to both dry-run previews — this is
1452
+ // the real run, so it cannot disagree with what a preview reported.
1453
+ const dirNames = listQuickTaskDirsForArchive(cwd);
1454
+ if (dirNames.length === 0) {
1455
+ // Boundary 0 (#2142): zero (safe) directory entries (empty dir, only
1456
+ // stray files, or every entry excluded by the selection rule) must not
1457
+ // create the archive directory. Also covers `.planning/quick/` being
1458
+ // absent/unreadable — `listQuickTaskDirsForArchive` degrades to `[]`.
1459
+ return { archiveDir: archiveQuickDir, archived: 0, entries: [] };
1460
+ }
1461
+ let archived = 0;
1462
+ const entries = [];
1463
+ try {
1464
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(archiveQuickDir);
1465
+ for (const name of dirNames) {
1466
+ const src = node_path_1.default.join(quickDir, name);
1467
+ let safeSrc;
1468
+ try {
1469
+ // Re-validated here (not just trusted from the selection above) as
1470
+ // TOCTOU defense-in-depth: `listQuickTaskDirsForArchive` and this
1471
+ // rename are two separate filesystem observations, and an entry
1472
+ // that was a safe real directory at selection time could in theory
1473
+ // be swapped for a symlink before this loop reaches it.
1474
+ safeSrc = (0, security_cjs_1.requireSafePath)(src, planningBase, 'quick task dir', { allowAbsolute: true });
1475
+ }
1476
+ catch {
1477
+ continue; // symlink/escape attempt — skip, not archived
1478
+ }
1479
+ // Collision-safe: if a same-named archive entry exists (re-run), suffix it.
1480
+ let dest = node_path_1.default.join(archiveQuickDir, name);
1481
+ let destName = name;
1482
+ let n = 1;
1483
+ while (node_fs_1.default.existsSync(dest)) {
1484
+ destName = `${name}.${n++}`;
1485
+ dest = node_path_1.default.join(archiveQuickDir, destName);
1486
+ }
1487
+ (0, shell_command_projection_cjs_1.retryRenameSync)(safeSrc, dest);
1488
+ archived++;
1489
+ entries.push(destName);
1490
+ }
1491
+ }
1492
+ catch {
1493
+ /* best-effort: platformEnsureDir failed, or the rename loop failed
1494
+ * partway — `archived`/`entries` above already reflect exactly what
1495
+ * succeeded before the failure (accumulated incrementally, never lost
1496
+ * with the swallowed exception — mirrors the archivedCount pattern at
1497
+ * cmdMilestoneComplete's phase-archival block). */
1498
+ }
1499
+ // Regenerate the README from whatever is ACTUALLY on disk now — covers
1500
+ // both a clean full archive and a degraded partial one, and (on a re-run)
1501
+ // includes entries a PRIOR run already archived. Skipped only when the
1502
+ // archive directory itself was never created (ensureDir failed above).
1503
+ if (node_fs_1.default.existsSync(archiveQuickDir)) {
1504
+ writeQuickArchiveReadme(archiveQuickDir);
1505
+ }
1506
+ return { archiveDir: archiveQuickDir, archived, entries };
1507
+ }
1508
+ /**
1509
+ * #2142 escalation: `milestone.archive-quick` (CLI: `milestone archive-quick`,
1510
+ * renamed from the original `quick.archive` per code-review FIX 1 — folded
1511
+ * under the existing `milestone` namespace rather than adding a new top-level
1512
+ * command) — the narrow archival helper the issue's own "Scope of changes"
1513
+ * anticipated ("a `quick.archive`-style routine"), for callers (chiefly
1514
+ * `gsd-core/workflows/cleanup.md`) that need to sweep
1515
+ * `.planning/quick/*` WITHOUT the full `milestone complete` close-out.
1516
+ *
1517
+ * `milestone complete --archive-quick` cannot be reused for this: it
1518
+ * hard-errors via `missingExplicitVersion` for an already-completed
1519
+ * milestone (no `### Phase N:` headings left in its ROADMAP window),
1520
+ * re-archives ROADMAP.md over the very snapshot cleanup depends on, and
1521
+ * appends a duplicate MILESTONES.md entry on every re-run.
1522
+ *
1523
+ * This command performs ONLY the two things `archiveQuickTaskDirectories`
1524
+ * already does (move `.planning/quick/*` dirs into
1525
+ * `milestones/<version>-quick/` + (re)write that archive's README index —
1526
+ * the SAME helper `cmdMilestoneComplete` calls, so the two entry points can
1527
+ * never diverge on step 1) plus a Quick Tasks Completed table reset. It
1528
+ * NEVER touches ROADMAP.md, REQUIREMENTS.md, or MILESTONES.md, and runs
1529
+ * NEITHER the unstarted-phase guard NOR the milestone-window/TRUNCATED
1530
+ * refusal — those remain `milestone complete`'s alone.
1531
+ *
1532
+ * #2142 MAJOR 6 (review): the STATE.md write now routes through
1533
+ * `readModifyWriteStateMd` — the same owned read-transform-write composition
1534
+ * `gsd-tools.cjs`'s `quick-tasks-append` handler uses (ADR-3408 §8.3 / #3469:
1535
+ * "the single owned composition ... so the composition cannot diverge"). A
1536
+ * prior version of this function called `platformWriteSync` directly with
1537
+ * the reset result, bypassing `syncAndPreserveStateMd` entirely — the exact
1538
+ * bypass shape that ADR closed. Per `src/state.cts:3289-3330`,
1539
+ * `readModifyWriteStateMd` ALREADY acquires its own exclusive lock
1540
+ * (`acquireStateLock`/`releaseStateLock`, a real `O_CREAT|O_EXCL` file lock,
1541
+ * not reentrant) across its own read -> transform -> write cycle — so, unlike
1542
+ * `cmdMilestoneComplete` (which folds the table reset into its own
1543
+ * pre-existing `withStateLock` transform because it ALSO needs that lock for
1544
+ * the closure-transition write happening in the same block), this function
1545
+ * must NOT wrap the call in its own `withStateLock`: doing so would acquire
1546
+ * the same lock file twice in the same process, and the second acquire would
1547
+ * spin against a lock this same call already holds until it times out.
1548
+ */
1549
+ function cmdQuickArchive(cwd, version, options, raw) {
1550
+ if (!version) {
1551
+ error('version required for milestone.archive-quick (e.g., v1.0)');
1552
+ }
1553
+ // #2288-class security: `version` becomes a filesystem directory component
1554
+ // (`milestones/<version>-quick/`) that directories are MOVED into — same
1555
+ // guard + wording shape `cmdMilestoneComplete` uses for its own version arg.
1556
+ if (!ARCHIVE_VERSION_LABEL_RE.test(version)) {
1557
+ 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 "..".`);
1558
+ }
1559
+ const statePath = planningPaths(cwd).state;
1560
+ const planningBase = planningPaths(cwd).planning;
1561
+ const toPosixRel = (p) => node_path_1.default.relative(cwd, p).split(node_path_1.default.sep).join('/');
1562
+ // --dry-run: preview only, mutates nothing. #2142 MAJOR 5 (review): routed
1563
+ // through the SAME `listQuickTaskDirsForArchive` selection
1564
+ // `cmdMilestoneComplete`'s own dry-run preview and the real
1565
+ // `archiveQuickTaskDirectories` both use, so all three can never disagree.
1566
+ if (options.dryRun) {
1567
+ const quickDirsToArchive = listQuickTaskDirsForArchive(cwd);
1568
+ output({
1569
+ dry_run: true,
1570
+ version,
1571
+ would_archive: quickDirsToArchive,
1572
+ archive_dir: toPosixRel(node_path_1.default.join(planningBase, 'milestones', `${version}-quick`)),
1573
+ }, raw);
1574
+ return;
1575
+ }
1576
+ const quickArchiveResult = archiveQuickTaskDirectories(cwd, version);
1577
+ const warnings = [];
1578
+ let stateUpdated = false;
1579
+ // Same silent/surfaced rule `cmdMilestoneComplete` applies: only attempt
1580
+ // the reset when something actually moved, and treat the
1581
+ // QUICK_TASKS_SECTION_ABSENT sentinel as a silent no-op (the section is
1582
+ // created lazily by quick.md Step 7b and absent from templates/state.md,
1583
+ // so absence is the common case, not an anomaly). Any other reset failure
1584
+ // is surfaced via `warnings`, never thrown.
1585
+ //
1586
+ // #2142 MAJOR 6 (review): routed through `readModifyWriteStateMd` (see the
1587
+ // docstring above) instead of a bare `platformWriteSync` — the transform
1588
+ // returns the ORIGINAL content unchanged whenever the reset did not apply
1589
+ // (sentinel-absent or a genuine failure), so `readModifyWriteStateMd`'s own
1590
+ // no-op guard (#948, state.cts:3304) skips the write and its `false`
1591
+ // return accurately reports "nothing was written" — the same "state_updated
1592
+ // must report accurately" contract the prior direct-write version upheld.
1593
+ if (quickArchiveResult.archived > 0 && node_fs_1.default.existsSync(statePath)) {
1594
+ let resetWarning = null;
1595
+ stateUpdated = readModifyWriteStateMd(statePath, (content) => {
1596
+ const { content: nextContent, warning } = applyQuickTasksReset(content);
1597
+ resetWarning = warning;
1598
+ return nextContent;
1599
+ }, cwd);
1600
+ if (resetWarning) {
1601
+ warnings.push(resetWarning);
1602
+ }
1603
+ }
1604
+ output({
1605
+ version,
1606
+ archived: quickArchiveResult.archived,
1607
+ entries: quickArchiveResult.entries,
1608
+ archive_dir: toPosixRel(quickArchiveResult.archiveDir),
1609
+ state_updated: stateUpdated,
1610
+ warnings,
1611
+ }, raw, `${quickArchiveResult.archived} quick task director${quickArchiveResult.archived === 1 ? 'y' : 'ies'} archived`);
1612
+ }
931
1613
  module.exports = {
932
1614
  cmdRequirementsMarkComplete,
933
1615
  cmdRequirementsReadyIds,
934
1616
  cmdRequirementsRevertPhase,
935
1617
  cmdMilestoneComplete,
936
1618
  cmdPhasesClear,
1619
+ cmdQuickArchive,
1620
+ buildQuickArchiveIndex,
937
1621
  };