@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
@@ -16,18 +16,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
16
16
  };
17
17
  const node_fs_1 = __importDefault(require("node:fs"));
18
18
  const node_path_1 = __importDefault(require("node:path"));
19
+ const node_crypto_1 = __importDefault(require("node:crypto"));
19
20
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
20
21
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
22
+ const text_lines_cjs_1 = require("./text-lines.cjs");
21
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
24
  const planningWorkspace = require("./planning-workspace.cjs");
23
- const { planningDir } = planningWorkspace;
25
+ const { planningDir, quickDirFrom } = planningWorkspace;
24
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
27
  const frontmatter = require("./frontmatter.cjs");
26
- const { extractFrontmatter } = frontmatter;
28
+ const { extractFrontmatter, spliceFrontmatter } = frontmatter;
27
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
30
  const phaseIdMod = require("./phase-id.cjs");
29
- const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
31
+ const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
32
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
33
+ const phaseLocator = require("./phase-locator.cjs");
34
+ const { getArchivedPhaseDirs } = phaseLocator;
30
35
  const security_cjs_1 = require("./security.cjs");
36
+ const shell_command_projection_cjs_2 = require("./shell-command-projection.cjs");
37
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const io = require("./io.cjs");
39
+ const { output, error: ioError } = io;
40
+ const command_arg_projection_cjs_1 = require("./command-arg-projection.cjs");
31
41
  // The SCOPE BOUNDARY convention's filename (`agents/gsd-executor.md`), shared
32
42
  // verbatim with the #2287 phase-boundary reader in `uat.cts`.
33
43
  const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
@@ -35,6 +45,208 @@ const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
35
45
  // per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
36
46
  // not recreated on each loop iteration.
37
47
  const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
48
+ // ─── Acknowledgment marker (suppression) ──────────────────────────────────────
49
+ //
50
+ // #3458 follow-up: `query audit-open` now scans archived milestone phase dirs,
51
+ // so an item still unresolved when a milestone closed resurfaces at EVERY
52
+ // later close, forever — `[A] Acknowledge all` documented that decision to
53
+ // STATE.md but never suppressed it. This section is the suppression seam.
54
+ //
55
+ // The marker lives INSIDE the artifact it suppresses, as an
56
+ // `audit_acknowledged` frontmatter map (no ledger, no id minting — see
57
+ // `uat.cts:891-897`'s `deferred-items.md` in-place `status: resolved`
58
+ // convention, which this generalizes):
59
+ //
60
+ // audit_acknowledged:
61
+ // milestone: v1.0 # which milestone close acknowledged it
62
+ // at: 2026-08-15 # ISO date
63
+ // status: gaps_found # snapshot of the artifact's state AT acknowledgment
64
+ // # (named `gap_snapshot` — status + open-scenario
65
+ // # count — for `uat_gaps`, and `questions_digest`
66
+ // # — a content hash of the question set, not just
67
+ // # its count — for `context_questions`; see
68
+ // # `isAuditItemAcknowledged`'s `snapshotKey` param
69
+ // # and each category's `deriveXxx` snapshot
70
+ // # helper for why a bare status/count was not
71
+ // # enough for those two — #3458 follow-up review)
72
+ //
73
+ // It is VERDICT-PRESERVING (this section never writes `status:` itself — see
74
+ // `cmdAuditAcknowledge` below) and SELF-INVALIDATING: it suppresses ONLY while
75
+ // `snapshotKey`'s recorded value still equals the artifact's CURRENT
76
+ // effective value. Edit the artifact after acknowledging it and the item
77
+ // resurfaces automatically — no separate revive/carry-forward state, and a
78
+ // stale acknowledgment can never hide a NEW problem, PROVIDED the category's
79
+ // snapshot actually captures the dimension that changed — `uat_gaps` and
80
+ // `context_questions` snapshot more than their status/count for exactly this
81
+ // reason (see above); every other category's only tracked dimension IS its
82
+ // `status:` (or, for `todos`, presence), so a bare status/presence snapshot
83
+ // is already complete for those.
84
+ //
85
+ // `isAuditItemAcknowledged` is the ONE shared predicate every scanner below
86
+ // routes through — this file has already been through the "hand-rolled the
87
+ // same check nine times" defect family twice this PR; a tenth hand-roll here
88
+ // is exactly that class. `deferred_items` is the deliberate exception: its
89
+ // suppression key lives PER-ENTRY inside `deferred-items.md`'s own
90
+ // `status:` field (see `uat.cts`'s `parseDeferredItemsWithStatus`), not in a
91
+ // file-level `audit_acknowledged` map, because a single deferred-items.md can
92
+ // carry many independently-acknowledgeable entries.
93
+ /**
94
+ * Parse and validate an artifact's `audit_acknowledged` frontmatter marker,
95
+ * then decide whether it suppresses the item given the artifact's CURRENT
96
+ * effective state.
97
+ *
98
+ * `snapshotKey` names which sub-field of the marker map carries the snapshot
99
+ * comparison value (`'status'` for every category except CONTEXT files, which
100
+ * use `'question_count'`). `presenceOnly: true` (used only for `todos`, which
101
+ * has no natural status field to snapshot) skips the snapshot comparison
102
+ * entirely — marker PRESENCE alone suppresses.
103
+ *
104
+ * A marker that is not a plain object/map, or is missing a non-empty string
105
+ * `milestone`/`at`, or — when a snapshot comparison applies — missing a
106
+ * string at `snapshotKey`, is MALFORMED and treated as ABSENT: this function
107
+ * returns `false` and the item surfaces. A bad marker must never suppress.
108
+ */
109
+ function isAuditItemAcknowledged(fm, opts) {
110
+ const raw = fm.audit_acknowledged;
111
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
112
+ return false;
113
+ const marker = raw;
114
+ if (typeof marker.milestone !== 'string' || !marker.milestone)
115
+ return false;
116
+ if (typeof marker.at !== 'string' || !marker.at)
117
+ return false;
118
+ if (opts.presenceOnly)
119
+ return true;
120
+ const snapshot = marker[opts.snapshotKey];
121
+ if (typeof snapshot !== 'string')
122
+ return false;
123
+ return snapshot === opts.currentValue;
124
+ }
125
+ /**
126
+ * Derive a THREAD file's effective status: frontmatter `status:` when
127
+ * present, else the `## Status: OPEN|IN PROGRESS` body fallback — the same
128
+ * two-step derivation `scanThreads` already performed inline. Extracted so
129
+ * `cmdAuditAcknowledge` computes the CURRENT snapshot value with the exact
130
+ * same logic the scanner used to produce the marker's recorded value,
131
+ * instead of a second hand-derivation that could silently drift from it.
132
+ */
133
+ function deriveThreadStatus(fm, content) {
134
+ let status = (fm.status || '').toLowerCase().trim();
135
+ if (!status) {
136
+ const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
137
+ if (bodyStatusMatch) {
138
+ status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
139
+ }
140
+ }
141
+ return status;
142
+ }
143
+ /**
144
+ * Count a UAT file's still-open (`result: pending`/`[pending]`) scenarios.
145
+ * Extracted from `scanUatGaps`'s inline logic for the same reason as
146
+ * `deriveThreadStatus` — one derivation, shared by the scanner and
147
+ * `cmdAuditAcknowledge`'s `deriveUatGapSnapshotValue` below.
148
+ */
149
+ function deriveUatGapOpenScenarioCount(content) {
150
+ return (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length;
151
+ }
152
+ /**
153
+ * Stable snapshot value for a `uat_gaps` item (WARNING 2, #3458 follow-up
154
+ * review). `status` alone is COUNT-blind the other direction: a UAT file can
155
+ * stay in the SAME open status (`gaps_found`) while gaining MORE pending
156
+ * scenarios (measured: 1→6 pending, status unchanged, item stayed
157
+ * suppressed under the old status-only scheme). Composing `status` with the
158
+ * open-scenario count means either dimension changing invalidates the
159
+ * snapshot.
160
+ */
161
+ function deriveUatGapSnapshotValue(status, content) {
162
+ return `${status}::scenarios=${deriveUatGapOpenScenarioCount(content)}`;
163
+ }
164
+ /**
165
+ * Derive a CONTEXT file's FULL, UNTRUNCATED open-questions list: the
166
+ * structured `open_questions` frontmatter array when present and
167
+ * non-empty, else EVERY qualifying line of the `## Open Questions` body
168
+ * section. Extracted from `scanContextQuestions`'s inline logic for the same
169
+ * reason as `deriveThreadStatus` — one derivation, shared by the scanner and
170
+ * `cmdAuditAcknowledge`, so the acknowledged `question_count`/digest snapshot
171
+ * can never diverge from what the scanner counts.
172
+ *
173
+ * F2 (#3458 follow-up review, sibling of the deferred_items span-carrying
174
+ * fix): this used to `slice(0, 3)` the body-section list AND clamp each
175
+ * question to 200 chars BEFORE returning — a value meant for DISPLAY reused
176
+ * for the IDENTITY snapshot `deriveOpenQuestionsDigest` hashes. A 4th+
177
+ * question, or anything past char 200 of an earlier one, was invisible to
178
+ * the digest: an attacker could ship 3 innocuous questions first, then add
179
+ * real blockers afterward with zero effect on the recorded snapshot. Every
180
+ * caller that wants a bounded list for DISPLAY (`scanContextQuestions`'s
181
+ * `questions` field) truncates its OWN copy at the call site; this function
182
+ * always returns the complete, unclamped set.
183
+ */
184
+ function deriveOpenQuestions(content, fm) {
185
+ let questions = [];
186
+ if (fm.open_questions) {
187
+ if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
188
+ questions = fm.open_questions.map(q => (0, security_cjs_1.sanitizeForDisplay)(String(q)));
189
+ }
190
+ }
191
+ if (questions.length === 0) {
192
+ const oqSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('open questions'), { levelBounded: true });
193
+ if (oqSection) {
194
+ const oqBody = oqSection.body.trim();
195
+ if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
196
+ const items = oqBody.split('\n')
197
+ .map((l) => l.trim())
198
+ .filter((l) => l && l !== '-' && l !== '*')
199
+ .filter((l) => /^[-*\d]/.test(l) || l.includes('?'));
200
+ questions = items.map((q) => (0, security_cjs_1.sanitizeForDisplay)(q));
201
+ }
202
+ }
203
+ }
204
+ return questions;
205
+ }
206
+ /** Bound a question's DISPLAY text (never fed into the identity digest — see `deriveOpenQuestions`'s doc comment). */
207
+ function truncateQuestionForDisplay(question) {
208
+ return question.slice(0, 200);
209
+ }
210
+ /**
211
+ * Stable normalized digest of a CONTEXT file's open-questions set (WARNING 2,
212
+ * #3458 follow-up review). `question_count` alone is COUNT-only: replacing
213
+ * every question's TEXT with brand-new ones while holding the count steady
214
+ * left an acknowledged item permanently suppressed (measured: 2 questions
215
+ * acknowledged, then both replaced with unrelated new blockers — still
216
+ * `counts:0`). Hashing the full, ordered question text means ANY edit —
217
+ * add, remove, reword, or reorder — changes the digest and the item
218
+ * resurfaces. sha256 (not the raw joined string) keeps the marker's stored
219
+ * value bounded regardless of question length/count.
220
+ *
221
+ * `questions` MUST be the untruncated, unclamped set `deriveOpenQuestions`
222
+ * returns — never a display-sliced/-clamped copy (F2, #3458 follow-up
223
+ * review); a truncated input reintroduces exactly the blind spot this digest
224
+ * exists to close.
225
+ *
226
+ * Length-prefixed, separator-free encoding (SWEEP finding, #3458 follow-up
227
+ * review) — NOT a plain join (the prior revision joined on a literal
228
+ * embedded NUL byte, `questions.join('\\0')` written as a raw control
229
+ * character in the SOURCE FILE itself — invisible in a normal diff/editor
230
+ * and still forgeable: attacker-controlled markdown CAN contain a literal
231
+ * NUL codepoint, since the file is read as UTF-8 text, so that scheme never
232
+ * actually closed the boundary-collision gap it was reaching for). A bare
233
+ * separator-joined string has no reliably unambiguous element boundary: two
234
+ * DIFFERENT question arrays can render the identical joined string and
235
+ * collide on the same digest — e.g. `['- Is X ready?', '- Y done?']` and
236
+ * `['- Is X ready? - Y', 'done?']` both join to
237
+ * `'- Is X ready? - Y done?'` under a space-join, and both could be forced to
238
+ * collide under a NUL-join too by an attacker who embeds the separator
239
+ * itself. Prefixing each element with its own CHARACTER LENGTH
240
+ * (`<len>:<text>`, concatenated with no separator at all) makes the encoding
241
+ * self-delimiting instead: decoding always consumes exactly `<len>`
242
+ * characters after each `:` before reading the next length prefix, so no two
243
+ * distinct arrays can ever encode to the same string — regardless of what
244
+ * characters the questions themselves contain.
245
+ */
246
+ function deriveOpenQuestionsDigest(questions) {
247
+ const encoded = questions.map((q) => `${q.length}:${q}`).join('');
248
+ return node_crypto_1.default.createHash('sha256').update(encoded).digest('hex');
249
+ }
38
250
  // ─── scanDebugSessions ────────────────────────────────────────────────────────
39
251
  /**
40
252
  * Scan .planning/debug/ for open sessions.
@@ -44,14 +256,15 @@ const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
44
256
  function scanDebugSessions(planDir) {
45
257
  const debugDir = node_path_1.default.join(planDir, 'debug');
46
258
  if (!node_fs_1.default.existsSync(debugDir))
47
- return [];
259
+ return { items: [], acknowledged: 0 };
48
260
  const results = [];
261
+ let acknowledged = 0;
49
262
  let files;
50
263
  try {
51
264
  files = node_fs_1.default.readdirSync(debugDir, { withFileTypes: true });
52
265
  }
53
266
  catch {
54
- return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
267
+ return { items: [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }], acknowledged: 0 };
55
268
  }
56
269
  for (const entry of files) {
57
270
  if (!entry.isFile())
@@ -73,6 +286,10 @@ function scanDebugSessions(planDir) {
73
286
  const status = (fm.status || 'unknown').toLowerCase();
74
287
  if (status === 'resolved' || status === 'complete')
75
288
  continue;
289
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
290
+ acknowledged++;
291
+ continue;
292
+ }
76
293
  // Extract hypothesis from "Current Focus" block if parseable
77
294
  let hypothesis = '';
78
295
  const focusSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('current focus'), { levelBounded: true });
@@ -82,13 +299,55 @@ function scanDebugSessions(planDir) {
82
299
  }
83
300
  const slug = node_path_1.default.basename(entry.name, '.md');
84
301
  results.push({
85
- slug: (0, security_cjs_1.sanitizeForDisplay)(slug),
302
+ slug: (0, security_cjs_1.sanitizeLabel)(slug),
86
303
  status: (0, security_cjs_1.sanitizeForDisplay)(status),
87
304
  updated: (0, security_cjs_1.sanitizeForDisplay)(fm.updated || fm.date || ''),
88
305
  hypothesis,
89
306
  });
90
307
  }
91
- return results;
308
+ return { items: results, acknowledged };
309
+ }
310
+ // ─── resolveQuickTaskSummaryFile ───────────────────────────────────────────────
311
+ /**
312
+ * Resolve a quick task's SUMMARY file, if any exists, under its own
313
+ * directory (`taskDir`). workflows/quick.md mandates `${quick_id}-SUMMARY.md`;
314
+ * older flows used bare `SUMMARY.md` — accept either to avoid a
315
+ * false-positive "missing", preferring the per-task `${dirName}-SUMMARY.md`
316
+ * form when more than one candidate exists.
317
+ *
318
+ * #3183 (ADR-3180 Decision 4(a) — bucket B, out of scope for the
319
+ * scanPhasePlans migration): this scans a quick task's OWN directory
320
+ * (`.planning/quick/<task>/`) for THAT task's single completion record —
321
+ * "does this one quick task have a SUMMARY.md" — not a phase directory's
322
+ * live-plan/summary counting question. scanPhasePlans is the wrong tool
323
+ * here; there is no plan/summary PAIRING to derive, only a single filename
324
+ * presence check local to a non-phase directory.
325
+ *
326
+ * Extracted (#3458 follow-up) so `scanQuickTasks` (read) and
327
+ * `cmdAuditAcknowledge`'s quick_tasks writer share the ONE discovery rule —
328
+ * previously the writer would have had to hand-roll this exact filter a
329
+ * second time, which is exactly the re-derivation-drift class
330
+ * `scripts/lint-plan-count-drift.cjs` exists to catch (see its
331
+ * `FUNCTION_SCOPED_EXEMPTIONS` entry for this function).
332
+ *
333
+ * Returns `null` (never throws) on an unreadable `taskDir` or when no
334
+ * SUMMARY-shaped file exists.
335
+ */
336
+ function resolveQuickTaskSummaryFile(taskDir, dirName) {
337
+ let summaryFiles;
338
+ try {
339
+ summaryFiles = node_fs_1.default.readdirSync(taskDir, { withFileTypes: true })
340
+ .filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ if (summaryFiles.length === 0)
346
+ return null;
347
+ const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`)
348
+ || summaryFiles.find(e => e.name.endsWith('-SUMMARY.md'))
349
+ || summaryFiles[0];
350
+ return node_path_1.default.join(taskDir, preferred.name);
92
351
  }
93
352
  // ─── scanQuickTasks ───────────────────────────────────────────────────────────
94
353
  /**
@@ -96,17 +355,20 @@ function scanDebugSessions(planDir) {
96
355
  * Incomplete if SUMMARY.md missing or status !== 'complete'.
97
356
  */
98
357
  function scanQuickTasks(planDir) {
99
- const quickDir = node_path_1.default.join(planDir, 'quick');
358
+ // #2142: routed through the shared quickDirFrom composer (planning-workspace.cts)
359
+ // so `.planning/quick` has exactly ONE owner instead of two ad-hoc path.joins.
360
+ const quickDir = quickDirFrom(planDir);
100
361
  if (!node_fs_1.default.existsSync(quickDir))
101
- return [];
362
+ return { items: [], acknowledged: 0 };
102
363
  let entries;
103
364
  try {
104
365
  entries = node_fs_1.default.readdirSync(quickDir, { withFileTypes: true });
105
366
  }
106
367
  catch {
107
- return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
368
+ return { items: [{ scan_error: true, slug: '', date: '', status: '', description: '' }], acknowledged: 0 };
108
369
  }
109
370
  const results = [];
371
+ let acknowledged = 0;
110
372
  for (const entry of entries) {
111
373
  if (!entry.isDirectory())
112
374
  continue;
@@ -119,25 +381,10 @@ function scanQuickTasks(planDir) {
119
381
  catch {
120
382
  continue;
121
383
  }
122
- // workflows/quick.md mandates `${quick_id}-SUMMARY.md`; older flows used
123
- // bare `SUMMARY.md`. Accept either to avoid false-positive "missing".
124
- let summaryPath = null;
125
- try {
126
- const summaryFiles = node_fs_1.default.readdirSync(safeTaskDir, { withFileTypes: true })
127
- .filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
128
- if (summaryFiles.length > 0) {
129
- // Prefer the per-task `${quick_id}-SUMMARY.md` form when present.
130
- const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`)
131
- || summaryFiles.find(e => e.name.endsWith('-SUMMARY.md'))
132
- || summaryFiles[0];
133
- summaryPath = node_path_1.default.join(safeTaskDir, preferred.name);
134
- }
135
- }
136
- catch {
137
- // fall through with summaryPath = null → status: missing
138
- }
384
+ const summaryPath = resolveQuickTaskSummaryFile(safeTaskDir, dirName);
139
385
  let status = 'missing';
140
386
  const description = '';
387
+ let fm = null;
141
388
  if (summaryPath && node_fs_1.default.existsSync(summaryPath)) {
142
389
  let safeSum;
143
390
  try {
@@ -151,19 +398,31 @@ function scanQuickTasks(planDir) {
151
398
  status = 'unreadable';
152
399
  }
153
400
  else {
154
- const fm = extractFrontmatter(content, safeSum);
401
+ fm = extractFrontmatter(content, safeSum);
155
402
  status = (fm.status || 'unknown').toLowerCase();
156
403
  }
157
404
  }
158
405
  if (status === 'complete')
159
406
  continue;
407
+ // Acknowledgment marker only ever lives in the SUMMARY file's own
408
+ // frontmatter — a task with no summary (status: 'missing') has nowhere to
409
+ // carry one, so `fm` is null and this is skipped (never suppressed).
410
+ if (fm && isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
411
+ acknowledged++;
412
+ continue;
413
+ }
160
414
  // Parse date and slug from directory name: YYYYMMDD-slug or YYYY-MM-DD-slug
161
415
  let date = '';
162
- let slug = (0, security_cjs_1.sanitizeForDisplay)(dirName);
416
+ let slug = (0, security_cjs_1.sanitizeLabel)(dirName);
163
417
  const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/);
164
418
  if (dateMatch) {
165
- date = dateMatch[1];
166
- slug = (0, security_cjs_1.sanitizeForDisplay)(dateMatch[2]);
419
+ // dateMatch[1] is regex-constrained to `\d{4}-?\d{2}-?\d{2}` (digits and
420
+ // literal hyphens only — the same "constrained at the source" shape as
421
+ // `archived_milestone`), so it cannot itself carry a control byte.
422
+ // Still routed through sanitizeLabel as defense-in-depth for
423
+ // consistency with every other directory-name-derived field here.
424
+ date = (0, security_cjs_1.sanitizeLabel)(dateMatch[1]);
425
+ slug = (0, security_cjs_1.sanitizeLabel)(dateMatch[2]);
167
426
  }
168
427
  results.push({
169
428
  slug,
@@ -172,7 +431,7 @@ function scanQuickTasks(planDir) {
172
431
  description,
173
432
  });
174
433
  }
175
- return results;
434
+ return { items: results, acknowledged };
176
435
  }
177
436
  // ─── scanThreads ──────────────────────────────────────────────────────────────
178
437
  /**
@@ -182,16 +441,17 @@ function scanQuickTasks(planDir) {
182
441
  function scanThreads(planDir) {
183
442
  const threadsDir = node_path_1.default.join(planDir, 'threads');
184
443
  if (!node_fs_1.default.existsSync(threadsDir))
185
- return [];
444
+ return { items: [], acknowledged: 0 };
186
445
  let files;
187
446
  try {
188
447
  files = node_fs_1.default.readdirSync(threadsDir, { withFileTypes: true });
189
448
  }
190
449
  catch {
191
- return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
450
+ return { items: [{ scan_error: true, slug: '', status: '', updated: '', title: '' }], acknowledged: 0 };
192
451
  }
193
452
  const openStatuses = new Set(['open', 'in_progress', 'in progress']);
194
453
  const results = [];
454
+ let acknowledged = 0;
195
455
  for (const entry of files) {
196
456
  if (!entry.isFile())
197
457
  continue;
@@ -209,16 +469,13 @@ function scanThreads(planDir) {
209
469
  if (content === null)
210
470
  continue;
211
471
  const fm = extractFrontmatter(content, safeFilePath);
212
- let status = (fm.status || '').toLowerCase().trim();
213
- // Fall back to scanning body for ## Status: OPEN / IN PROGRESS
214
- if (!status) {
215
- const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
216
- if (bodyStatusMatch) {
217
- status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
218
- }
219
- }
472
+ const status = deriveThreadStatus(fm, content);
220
473
  if (!openStatuses.has(status))
221
474
  continue;
475
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
476
+ acknowledged++;
477
+ continue;
478
+ }
222
479
  // Extract title from # Thread: heading or frontmatter title
223
480
  let title = (0, security_cjs_1.sanitizeForDisplay)(fm.title || '');
224
481
  if (!title) {
@@ -229,13 +486,13 @@ function scanThreads(planDir) {
229
486
  }
230
487
  const slug = node_path_1.default.basename(entry.name, '.md');
231
488
  results.push({
232
- slug: (0, security_cjs_1.sanitizeForDisplay)(slug),
489
+ slug: (0, security_cjs_1.sanitizeLabel)(slug),
233
490
  status: (0, security_cjs_1.sanitizeForDisplay)(status),
234
491
  updated: (0, security_cjs_1.sanitizeForDisplay)(fm.updated || fm.date || ''),
235
492
  title,
236
493
  });
237
494
  }
238
- return results;
495
+ return { items: results, acknowledged };
239
496
  }
240
497
  // ─── scanTodos ────────────────────────────────────────────────────────────────
241
498
  /**
@@ -246,18 +503,27 @@ function scanThreads(planDir) {
246
503
  function scanTodos(planDir) {
247
504
  const pendingDir = node_path_1.default.join(planDir, 'todos', 'pending');
248
505
  if (!node_fs_1.default.existsSync(pendingDir))
249
- return [];
506
+ return { items: [], acknowledged: 0 };
250
507
  let files;
251
508
  try {
252
509
  files = node_fs_1.default.readdirSync(pendingDir, { withFileTypes: true });
253
510
  }
254
511
  catch {
255
- return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
512
+ return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 };
256
513
  }
257
514
  const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
258
515
  const results = [];
259
- const displayFiles = mdFiles.slice(0, 5);
260
- for (const entry of displayFiles) {
516
+ let acknowledged = 0;
517
+ // BLOCKER 2 (#3458 follow-up review): filter acknowledged items BEFORE
518
+ // the display cap. Capping the RAW file list to 5 first meant an
519
+ // acknowledge of one of those 5 files simply revealed the 6th on the next
520
+ // scan — files 6/7/... (never shown, never acknowledgeable via the CLI's
521
+ // own remedy) permanently vanished from every later scan once 5+ items
522
+ // existed, because `mdFiles.length` (not the post-filter open count) drove
523
+ // both the cap and the remainder count. Read every file's acknowledgment
524
+ // state first, THEN cap the OPEN (unacknowledged) set for display.
525
+ const openFiles = [];
526
+ for (const entry of mdFiles) {
261
527
  const filePath = node_path_1.default.join(pendingDir, entry.name);
262
528
  let safeFilePath;
263
529
  try {
@@ -270,21 +536,33 @@ function scanTodos(planDir) {
270
536
  if (content === null)
271
537
  continue;
272
538
  const fm = extractFrontmatter(content, safeFilePath);
539
+ // Todos carry no natural status field — presence in pending/ IS "open" by
540
+ // definition (a resolved todo is moved out, not status-flagged). So the
541
+ // acknowledgment check here is PRESENCE-ONLY: no snapshot to go stale, no
542
+ // self-invalidation on edit — see `isAuditItemAcknowledged`'s doc comment.
543
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: '', presenceOnly: true })) {
544
+ acknowledged++;
545
+ continue;
546
+ }
547
+ openFiles.push({ entry, content, fm });
548
+ }
549
+ const displayFiles = openFiles.slice(0, 5);
550
+ for (const { entry, content, fm } of displayFiles) {
273
551
  // Extract first line of body after frontmatter
274
- const bodyMatch = content.replace(/^---[\s\S]*?---\n?/, '');
275
- const firstLine = bodyMatch.trim().split('\n')[0] || '';
552
+ const bodyMatch = content.replace(/^---[\s\S]*?---\r?\n?/, '');
553
+ const firstLine = (0, text_lines_cjs_1.splitLines)(bodyMatch.trim())[0] || '';
276
554
  const summary = (0, security_cjs_1.sanitizeForDisplay)(firstLine.slice(0, 100));
277
555
  results.push({
278
- filename: (0, security_cjs_1.sanitizeForDisplay)(entry.name),
556
+ filename: (0, security_cjs_1.sanitizeLabel)(entry.name),
279
557
  priority: (0, security_cjs_1.sanitizeForDisplay)(fm.priority || ''),
280
558
  area: (0, security_cjs_1.sanitizeForDisplay)(fm.area || ''),
281
559
  summary,
282
560
  });
283
561
  }
284
- if (mdFiles.length > 5) {
285
- results.push({ _remainder_count: mdFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
562
+ if (openFiles.length > 5) {
563
+ results.push({ _remainder_count: openFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
286
564
  }
287
- return results;
565
+ return { items: results, acknowledged };
288
566
  }
289
567
  // ─── scanSeeds ────────────────────────────────────────────────────────────────
290
568
  /**
@@ -294,16 +572,17 @@ function scanTodos(planDir) {
294
572
  function scanSeeds(planDir) {
295
573
  const seedsDir = node_path_1.default.join(planDir, 'seeds');
296
574
  if (!node_fs_1.default.existsSync(seedsDir))
297
- return [];
575
+ return { items: [], acknowledged: 0 };
298
576
  let files;
299
577
  try {
300
578
  files = node_fs_1.default.readdirSync(seedsDir, { withFileTypes: true });
301
579
  }
302
580
  catch {
303
- return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
581
+ return { items: [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }], acknowledged: 0 };
304
582
  }
305
583
  const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
306
584
  const results = [];
585
+ let acknowledged = 0;
307
586
  for (const entry of files) {
308
587
  if (!entry.isFile())
309
588
  continue;
@@ -324,10 +603,20 @@ function scanSeeds(planDir) {
324
603
  const status = (fm.status || 'dormant').toLowerCase();
325
604
  if (!unimplementedStatuses.has(status))
326
605
  continue;
327
- // Extract seed_id from filename or frontmatter
606
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
607
+ acknowledged++;
608
+ continue;
609
+ }
610
+ // Extract seed_id from filename or frontmatter. The regex match is
611
+ // `\w`/hyphen-constrained (safe by construction, like `archived_milestone`)
612
+ // but the fallback taken when a filename doesn't fully match — e.g. a
613
+ // `SEED-`-prefixed, `.md`-suffixed name with a control byte SOMEWHERE in
614
+ // the middle, which still passes the `startsWith`/`endsWith` filter above
615
+ // — is the raw, unconstrained basename. Both branches are routed through
616
+ // sanitizeLabel below.
328
617
  const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
329
618
  const seed_id = seedIdMatch ? seedIdMatch[1] : node_path_1.default.basename(entry.name, '.md');
330
- const slug = (0, security_cjs_1.sanitizeForDisplay)(seed_id.replace(/^SEED-/, ''));
619
+ const slug = (0, security_cjs_1.sanitizeLabel)(seed_id.replace(/^SEED-/, ''));
331
620
  let title = (0, security_cjs_1.sanitizeForDisplay)(fm.title || '');
332
621
  if (!title) {
333
622
  const headingMatch = content.match(/^#\s*(.+)$/m);
@@ -335,46 +624,123 @@ function scanSeeds(planDir) {
335
624
  title = (0, security_cjs_1.sanitizeForDisplay)(headingMatch[1].trim().slice(0, 100));
336
625
  }
337
626
  results.push({
338
- seed_id: (0, security_cjs_1.sanitizeForDisplay)(seed_id),
627
+ seed_id: (0, security_cjs_1.sanitizeLabel)(seed_id),
339
628
  slug,
340
629
  status: (0, security_cjs_1.sanitizeForDisplay)(status),
341
630
  title,
342
631
  });
343
632
  }
344
- return results;
633
+ return { items: results, acknowledged };
345
634
  }
346
- // ─── scanUatGaps ──────────────────────────────────────────────────────────────
347
635
  /**
348
- * Scan .planning/phases for UAT gaps (UAT files with status != 'complete').
636
+ * Enumerate phase directories across BOTH the active `.planning/phases/` root
637
+ * and every archived `.planning/milestones/vX.Y-phases/` root. Shared by the
638
+ * four phase-scoped scanners below (#3458 — epic #3473 B2/F2). Previously each
639
+ * scanner hand-rolled its own active-only `readdirSync(phasesDir)` walk and
640
+ * bailed out entirely when the active root was missing, so items still
641
+ * unresolved when a milestone closed and its phase dirs archived became
642
+ * permanently invisible to every later audit.
643
+ *
644
+ * ACTIVE dirs: raw readdirSync + isDirectory filter + sort. The enumeration
645
+ * walk itself (readdirSync + isDirectory filter + sort) is UNCHANGED from the
646
+ * scanners' prior inline behavior; what IS new is that a failed read here no
647
+ * longer aborts the whole scan the way each scanner's own inline
648
+ * `if (!fs.existsSync) return []` / try-readdirSync-catch-return-sentinel
649
+ * pair used to — see `activeUnreadable` below, which is how that signal is
650
+ * now surfaced to callers instead. Deliberately NOT routed through
651
+ * listMilestonePhaseDirs: these scanners are deliberately not
652
+ * milestone-filtered today, and switching would silently add window/sentinel
653
+ * filtering — a behavior change belonging to #3372, not here.
654
+ *
655
+ * A missing/unreadable active root does NOT short-circuit the archive walk —
656
+ * the old `if (!fs.existsSync(phasesDir)) return []` was the whole bug in a
657
+ * fully-archived project; it degrades to "skip the active half" only. An
658
+ * UNREADABLE (as opposed to merely absent) active root is reported back via
659
+ * `activeUnreadable: true` so each of the four callers can still emit the
660
+ * `scan_error` sentinel they emitted pre-#3458 for this exact case (a real
661
+ * I/O failure, not "verified clean") — see each scanner's own use of it.
662
+ *
663
+ * ARCHIVED dirs: sourced from `getArchivedPhaseDirs` (phase-locator.cjs), the
664
+ * canonical archive-enumeration seam `uat.cts`'s `cmdAuditUat` already uses.
665
+ * Archived dirs are deliberately NOT milestone-filtered either — see the
666
+ * comment at src/uat.cts:107-111: listMilestonePhaseDirs derives the CURRENT
667
+ * milestone's phase dirs (window + sentinel filtered) from ROADMAP.md, and
668
+ * archived phases belong to past milestones by definition, so filtering them
669
+ * discards every one and silently reinstates the bug this function fixes.
670
+ *
671
+ * An unreadable/unresolvable ARCHIVE root does NOT get its own sentinel.
672
+ * Pre-#3458 there was no archived read at all, so — unlike the active root —
673
+ * there is no prior `scan_error` contract to preserve here, and no existing
674
+ * consumer can regress. It also degrades the same way `listArchiveVersionDirs`
675
+ * (phase-locator.cts) already treats an absent `milestones/` dir: a real
676
+ * empty, not a failure, matching this function's existing "skip that root"
677
+ * idiom for the missing-active-dir case above. Adding a second sentinel path
678
+ * would let a machine consumer conflate "no milestones archived yet" (the
679
+ * overwhelmingly common case for an active project) with an actual read
680
+ * failure, which is a worse signal-to-noise trade than the one this
681
+ * function's own fix removes for the active root.
682
+ *
683
+ * Same-named dirs in both roots (e.g. "01-alpha" active AND archived) are
684
+ * DISTINCT targets — no dedupe.
349
685
  */
350
- function scanUatGaps(planDir) {
686
+ function listAuditPhaseTargets(planDir, cwd) {
687
+ const targets = [];
688
+ let activeUnreadable = false;
351
689
  const phasesDir = node_path_1.default.join(planDir, 'phases');
352
- if (!node_fs_1.default.existsSync(phasesDir))
353
- return [];
354
- let dirs;
690
+ if (node_fs_1.default.existsSync(phasesDir)) {
691
+ try {
692
+ const dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
693
+ .filter(e => e.isDirectory())
694
+ .map(e => e.name)
695
+ .sort();
696
+ for (const dir of dirs) {
697
+ targets.push({ dir, fullPath: node_path_1.default.join(phasesDir, dir) });
698
+ }
699
+ }
700
+ catch {
701
+ // Unreadable active root: skip it, do not abort the archive walk, but
702
+ // report it so callers can emit their pre-#3458 scan_error sentinel.
703
+ activeUnreadable = true;
704
+ }
705
+ }
355
706
  try {
356
- dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
357
- .filter(e => e.isDirectory())
358
- .map(e => e.name)
359
- .sort();
707
+ for (const archived of getArchivedPhaseDirs(cwd)) {
708
+ targets.push({ dir: archived.name, fullPath: archived.fullPath, milestone: archived.milestone });
709
+ }
360
710
  }
361
711
  catch {
362
- return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }];
712
+ // Unreadable/unresolvable archive root: skip it, keep whatever active
713
+ // targets were already collected. No sentinel — see docstring above.
363
714
  }
715
+ return { targets, activeUnreadable };
716
+ }
717
+ // ─── scanUatGaps ──────────────────────────────────────────────────────────────
718
+ /**
719
+ * Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
720
+ * for UAT gaps (UAT files with status != 'complete'/'resolved').
721
+ */
722
+ function scanUatGaps(planDir, cwd) {
364
723
  const results = [];
365
- for (const dir of dirs) {
366
- const phaseDir = node_path_1.default.join(phasesDir, dir);
367
- const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
368
- const phaseNum = phaseMatch ? phaseMatch[1] : dir;
724
+ let acknowledged = 0;
725
+ const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
726
+ if (activeUnreadable) {
727
+ results.push({ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 });
728
+ }
729
+ for (const target of targets) {
730
+ const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
731
+ const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
369
732
  let files;
370
733
  try {
371
- files = node_fs_1.default.readdirSync(phaseDir);
734
+ files = node_fs_1.default.readdirSync(target.fullPath);
372
735
  }
373
736
  catch {
374
737
  continue;
375
738
  }
376
- for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
377
- const filePath = node_path_1.default.join(phaseDir, file);
739
+ // Scoped to THIS phase's own token (#3511) so a stray, cross-phase, or
740
+ // ad-hoc UAT file cannot surface as this phase's gap — same fix as
741
+ // scanVerificationGaps below.
742
+ for (const file of scopeToPhase(files.filter(f => f.includes('-UAT') && f.endsWith('.md')), target.dir)) {
743
+ const filePath = node_path_1.default.join(target.fullPath, file);
378
744
  let safeFilePath;
379
745
  try {
380
746
  safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'UAT file', { allowAbsolute: true });
@@ -394,50 +760,52 @@ function scanUatGaps(planDir) {
394
760
  continue;
395
761
  if (status === 'unknown' && result === 'all_pass')
396
762
  continue;
397
- // Count open scenarios
398
- const pendingMatches = (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length;
399
- results.push({
400
- phase: (0, security_cjs_1.sanitizeForDisplay)(phaseNum),
401
- file: (0, security_cjs_1.sanitizeForDisplay)(file),
763
+ // Count open scenarios — computed BEFORE the acknowledged check
764
+ // (WARNING 2) so the snapshot comparison sees it too, not just status.
765
+ const pendingMatches = deriveUatGapOpenScenarioCount(content);
766
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'gap_snapshot', currentValue: deriveUatGapSnapshotValue(status, content) })) {
767
+ acknowledged++;
768
+ continue;
769
+ }
770
+ const item = {
771
+ phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
772
+ file: (0, security_cjs_1.sanitizeLabel)(file),
402
773
  status: (0, security_cjs_1.sanitizeForDisplay)(status),
403
774
  open_scenario_count: pendingMatches,
404
- });
775
+ };
776
+ if (target.milestone !== undefined)
777
+ item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
778
+ results.push(item);
405
779
  }
406
780
  }
407
- return results;
781
+ return { items: results, acknowledged };
408
782
  }
409
783
  // ─── scanVerificationGaps ─────────────────────────────────────────────────────
410
784
  /**
411
- * Scan .planning/phases for VERIFICATION gaps.
785
+ * Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
786
+ * for VERIFICATION gaps.
412
787
  */
413
- function scanVerificationGaps(planDir) {
414
- const phasesDir = node_path_1.default.join(planDir, 'phases');
415
- if (!node_fs_1.default.existsSync(phasesDir))
416
- return [];
417
- let dirs;
418
- try {
419
- dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
420
- .filter(e => e.isDirectory())
421
- .map(e => e.name)
422
- .sort();
423
- }
424
- catch {
425
- return [{ scan_error: true, phase: '', file: '', status: '' }];
426
- }
788
+ function scanVerificationGaps(planDir, cwd) {
427
789
  const results = [];
428
- for (const dir of dirs) {
429
- const phaseDir = node_path_1.default.join(phasesDir, dir);
430
- const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
431
- const phaseNum = phaseMatch ? phaseMatch[1] : dir;
790
+ let acknowledged = 0;
791
+ const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
792
+ if (activeUnreadable) {
793
+ results.push({ scan_error: true, phase: '', file: '', status: '' });
794
+ }
795
+ for (const target of targets) {
796
+ const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
797
+ const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
432
798
  let files;
433
799
  try {
434
- files = node_fs_1.default.readdirSync(phaseDir);
800
+ files = node_fs_1.default.readdirSync(target.fullPath);
435
801
  }
436
802
  catch {
437
803
  continue;
438
804
  }
439
- for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
440
- const filePath = node_path_1.default.join(phaseDir, file);
805
+ // Scoped to THIS phase's own token (#3511) so a stray, cross-phase, or
806
+ // ad-hoc VERIFICATION file cannot surface as this phase's gap.
807
+ for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), target.dir)) {
808
+ const filePath = node_path_1.default.join(target.fullPath, file);
441
809
  let safeFilePath;
442
810
  try {
443
811
  safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
@@ -452,47 +820,46 @@ function scanVerificationGaps(planDir) {
452
820
  const status = (fm.status || 'unknown').toLowerCase();
453
821
  if (status !== 'gaps_found' && status !== 'human_needed')
454
822
  continue;
455
- results.push({
456
- phase: (0, security_cjs_1.sanitizeForDisplay)(phaseNum),
457
- file: (0, security_cjs_1.sanitizeForDisplay)(file),
823
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
824
+ acknowledged++;
825
+ continue;
826
+ }
827
+ const item = {
828
+ phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
829
+ file: (0, security_cjs_1.sanitizeLabel)(file),
458
830
  status: (0, security_cjs_1.sanitizeForDisplay)(status),
459
- });
831
+ };
832
+ if (target.milestone !== undefined)
833
+ item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
834
+ results.push(item);
460
835
  }
461
836
  }
462
- return results;
837
+ return { items: results, acknowledged };
463
838
  }
464
839
  // ─── scanContextQuestions ─────────────────────────────────────────────────────
465
840
  /**
466
- * Scan .planning/phases for CONTEXT files with open_questions.
841
+ * Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
842
+ * for CONTEXT files with open_questions.
467
843
  */
468
- function scanContextQuestions(planDir) {
469
- const phasesDir = node_path_1.default.join(planDir, 'phases');
470
- if (!node_fs_1.default.existsSync(phasesDir))
471
- return [];
472
- let dirs;
473
- try {
474
- dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
475
- .filter(e => e.isDirectory())
476
- .map(e => e.name)
477
- .sort();
478
- }
479
- catch {
480
- return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
481
- }
844
+ function scanContextQuestions(planDir, cwd) {
482
845
  const results = [];
483
- for (const dir of dirs) {
484
- const phaseDir = node_path_1.default.join(phasesDir, dir);
485
- const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
486
- const phaseNum = phaseMatch ? phaseMatch[1] : dir;
846
+ let acknowledged = 0;
847
+ const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
848
+ if (activeUnreadable) {
849
+ results.push({ scan_error: true, phase: '', file: '', question_count: 0, questions: [] });
850
+ }
851
+ for (const target of targets) {
852
+ const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
853
+ const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
487
854
  let files;
488
855
  try {
489
- files = node_fs_1.default.readdirSync(phaseDir);
856
+ files = node_fs_1.default.readdirSync(target.fullPath);
490
857
  }
491
858
  catch {
492
859
  continue;
493
860
  }
494
861
  for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
495
- const filePath = node_path_1.default.join(phaseDir, file);
862
+ const filePath = node_path_1.default.join(target.fullPath, file);
496
863
  let safeFilePath;
497
864
  try {
498
865
  safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
@@ -504,42 +871,36 @@ function scanContextQuestions(planDir) {
504
871
  if (content === null)
505
872
  continue;
506
873
  const fm = extractFrontmatter(content, safeFilePath);
507
- // Check frontmatter open_questions field
508
- let questions = [];
509
- if (fm.open_questions) {
510
- if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
511
- questions = fm.open_questions.map(q => (0, security_cjs_1.sanitizeForDisplay)(String(q).slice(0, 200)));
512
- }
513
- }
514
- // Also check for ## Open Questions section in body
515
- if (questions.length === 0) {
516
- const oqSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('open questions'), { levelBounded: true });
517
- if (oqSection) {
518
- const oqBody = oqSection.body.trim();
519
- if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
520
- const items = oqBody.split('\n')
521
- .map((l) => l.trim())
522
- .filter((l) => l && l !== '-' && l !== '*')
523
- .filter((l) => /^[-*\d]/.test(l) || l.includes('?'));
524
- questions = items.slice(0, 3).map((q) => (0, security_cjs_1.sanitizeForDisplay)(q.slice(0, 200)));
525
- }
526
- }
527
- }
874
+ const questions = deriveOpenQuestions(content, fm);
528
875
  if (questions.length === 0)
529
876
  continue;
530
- results.push({
531
- phase: (0, security_cjs_1.sanitizeForDisplay)(phaseNum),
532
- file: (0, security_cjs_1.sanitizeForDisplay)(file),
877
+ // WARNING 2 (#3458 follow-up review): snapshot the QUESTIONS
878
+ // THEMSELVES (a content digest), not just their count — a count-only
879
+ // snapshot cannot see the same-count REPLACEMENT of every question
880
+ // with brand-new ones (measured: 2 acknowledged, then both swapped for
881
+ // unrelated new blockers, still suppressed under the old scheme).
882
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'questions_digest', currentValue: deriveOpenQuestionsDigest(questions) })) {
883
+ acknowledged++;
884
+ continue;
885
+ }
886
+ const item = {
887
+ phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
888
+ file: (0, security_cjs_1.sanitizeLabel)(file),
533
889
  question_count: questions.length,
534
- questions: questions.slice(0, 3),
535
- });
890
+ questions: questions.slice(0, 3).map(truncateQuestionForDisplay),
891
+ };
892
+ if (target.milestone !== undefined)
893
+ item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
894
+ results.push(item);
536
895
  }
537
896
  }
538
- return results;
897
+ return { items: results, acknowledged };
539
898
  }
540
899
  // ─── scanDeferredItems ────────────────────────────────────────────────────────
541
900
  /**
542
- * Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646).
901
+ * Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646),
902
+ * across both .planning/phases (active) and .planning/milestones/vX.Y-phases
903
+ * (archived).
543
904
  *
544
905
  * The SCOPE BOUNDARY convention (`agents/gsd-executor.md`) has a phase agent
545
906
  * log an out-of-scope discovery here rather than fix it. #2287 made that file
@@ -551,36 +912,32 @@ function scanContextQuestions(planDir) {
551
912
  * and the entry leaves the live tree having never been triaged.
552
913
  *
553
914
  * The resolved/unresolved predicate is NOT reimplemented here: `uat.cjs`
554
- * already exports `parseDeferredItems`, which owns the parsing rule (entries
555
- * under a `## Deferred Items` level-2 heading, else the whole file fail-safe;
556
- * RESOLVED only on an explicit case-insensitive `status: resolved` field).
557
- * Duplicating that inequality is how two readers of the same file drift into
558
- * disagreeing about what "open" means. The require is deliberately LAZY,
559
- * inside the scan, to preserve `audit-command-router.cts`'s property that a
560
- * route never loads the module it does not need.
915
+ * already exports `parseDeferredItemsWithStatus`, which owns the parsing rule
916
+ * (entries under a `## Deferred Items` level-2 heading, else the whole file
917
+ * fail-safe) and — unlike `parseDeferredItems` — surfaces each entry's raw
918
+ * `status:` field instead of filtering `resolved` internally, so THIS scanner
919
+ * can apply the three-way split (#3458 follow-up): `resolved` (fixed for
920
+ * real — dropped, never counted, matching pre-existing behavior exactly),
921
+ * `acknowledged` (suppressed AND tallied — the new deferred_items marker;
922
+ * see the module doc comment above `isAuditItemAcknowledged`), else open.
923
+ * Duplicating either inequality is how two readers of the same file drift
924
+ * into disagreeing about what "open" means. The require is deliberately
925
+ * LAZY, inside the scan, to preserve `audit-command-router.cts`'s property
926
+ * that a route never loads the module it does not need.
561
927
  */
562
- function scanDeferredItems(planDir) {
563
- const phasesDir = node_path_1.default.join(planDir, 'phases');
564
- if (!node_fs_1.default.existsSync(phasesDir))
565
- return [];
566
- let dirs;
567
- try {
568
- dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
569
- .filter(e => e.isDirectory())
570
- .map(e => e.name)
571
- .sort();
572
- }
573
- catch {
574
- return [{ scan_error: true, phase: '', file: '', text: '' }];
575
- }
928
+ function scanDeferredItems(planDir, cwd) {
929
+ const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
576
930
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
577
931
  const uat = require('./uat.cjs');
578
932
  const results = [];
579
- for (const dir of dirs) {
580
- const phaseDir = node_path_1.default.join(phasesDir, dir);
581
- const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
582
- const phaseNum = phaseMatch ? phaseMatch[1] : dir;
583
- const filePath = node_path_1.default.join(phaseDir, DEFERRED_ITEMS_FILENAME);
933
+ let acknowledged = 0;
934
+ if (activeUnreadable) {
935
+ results.push({ scan_error: true, phase: '', file: '', text: '' });
936
+ }
937
+ for (const target of targets) {
938
+ const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
939
+ const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
940
+ const filePath = node_path_1.default.join(target.fullPath, DEFERRED_ITEMS_FILENAME);
584
941
  if (!node_fs_1.default.existsSync(filePath))
585
942
  continue;
586
943
  let safeFilePath;
@@ -593,15 +950,25 @@ function scanDeferredItems(planDir) {
593
950
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
594
951
  if (content === null)
595
952
  continue;
596
- for (const item of uat.parseDeferredItems(content)) {
597
- results.push({
598
- phase: (0, security_cjs_1.sanitizeForDisplay)(phaseNum),
599
- file: DEFERRED_ITEMS_FILENAME,
953
+ for (const item of uat.parseDeferredItemsWithStatus(content)) {
954
+ const rawStatus = (item.status || '').toLowerCase();
955
+ if (rawStatus === 'resolved')
956
+ continue; // fixed for real — never counted
957
+ if (rawStatus === 'acknowledged') {
958
+ acknowledged++;
959
+ continue;
960
+ }
961
+ const resultItem = {
962
+ phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
963
+ file: (0, security_cjs_1.sanitizeLabel)(DEFERRED_ITEMS_FILENAME),
600
964
  text: (0, security_cjs_1.sanitizeForDisplay)(item.name),
601
- });
965
+ };
966
+ if (target.milestone !== undefined)
967
+ resultItem.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
968
+ results.push(resultItem);
602
969
  }
603
970
  }
604
- return results;
971
+ return { items: results, acknowledged };
605
972
  }
606
973
  // ─── auditOpenArtifacts ───────────────────────────────────────────────────────
607
974
  /**
@@ -617,7 +984,7 @@ function auditOpenArtifacts(cwd) {
617
984
  return scanDebugSessions(planDir);
618
985
  }
619
986
  catch {
620
- return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
987
+ return { items: [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }], acknowledged: 0 };
621
988
  }
622
989
  })();
623
990
  const quickTasks = (() => {
@@ -625,7 +992,7 @@ function auditOpenArtifacts(cwd) {
625
992
  return scanQuickTasks(planDir);
626
993
  }
627
994
  catch {
628
- return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
995
+ return { items: [{ scan_error: true, slug: '', date: '', status: '', description: '' }], acknowledged: 0 };
629
996
  }
630
997
  })();
631
998
  const threads = (() => {
@@ -633,7 +1000,7 @@ function auditOpenArtifacts(cwd) {
633
1000
  return scanThreads(planDir);
634
1001
  }
635
1002
  catch {
636
- return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
1003
+ return { items: [{ scan_error: true, slug: '', status: '', updated: '', title: '' }], acknowledged: 0 };
637
1004
  }
638
1005
  })();
639
1006
  const todos = (() => {
@@ -641,7 +1008,7 @@ function auditOpenArtifacts(cwd) {
641
1008
  return scanTodos(planDir);
642
1009
  }
643
1010
  catch {
644
- return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
1011
+ return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 };
645
1012
  }
646
1013
  })();
647
1014
  const seeds = (() => {
@@ -649,70 +1016,87 @@ function auditOpenArtifacts(cwd) {
649
1016
  return scanSeeds(planDir);
650
1017
  }
651
1018
  catch {
652
- return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
1019
+ return { items: [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }], acknowledged: 0 };
653
1020
  }
654
1021
  })();
655
1022
  const uatGaps = (() => {
656
1023
  try {
657
- return scanUatGaps(planDir);
1024
+ return scanUatGaps(planDir, cwd);
658
1025
  }
659
1026
  catch {
660
- return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }];
1027
+ return { items: [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }], acknowledged: 0 };
661
1028
  }
662
1029
  })();
663
1030
  const verificationGaps = (() => {
664
1031
  try {
665
- return scanVerificationGaps(planDir);
1032
+ return scanVerificationGaps(planDir, cwd);
666
1033
  }
667
1034
  catch {
668
- return [{ scan_error: true, phase: '', file: '', status: '' }];
1035
+ return { items: [{ scan_error: true, phase: '', file: '', status: '' }], acknowledged: 0 };
669
1036
  }
670
1037
  })();
671
1038
  const contextQuestions = (() => {
672
1039
  try {
673
- return scanContextQuestions(planDir);
1040
+ return scanContextQuestions(planDir, cwd);
674
1041
  }
675
1042
  catch {
676
- return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
1043
+ return { items: [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }], acknowledged: 0 };
677
1044
  }
678
1045
  })();
679
1046
  const deferredItems = (() => {
680
1047
  try {
681
- return scanDeferredItems(planDir);
1048
+ return scanDeferredItems(planDir, cwd);
682
1049
  }
683
1050
  catch {
684
- return [{ scan_error: true, phase: '', file: '', text: '' }];
1051
+ return { items: [{ scan_error: true, phase: '', file: '', text: '' }], acknowledged: 0 };
685
1052
  }
686
1053
  })();
687
1054
  // Count real items (not scan_error sentinels)
688
1055
  const countReal = (arr) => arr.filter(i => !i.scan_error && !i._remainder_count).length;
689
1056
  const counts = {
690
- debug_sessions: countReal(debugSessions),
691
- quick_tasks: countReal(quickTasks),
692
- threads: countReal(threads),
693
- todos: countReal(todos),
694
- seeds: countReal(seeds),
695
- uat_gaps: countReal(uatGaps),
696
- verification_gaps: countReal(verificationGaps),
697
- context_questions: countReal(contextQuestions),
698
- deferred_items: countReal(deferredItems),
1057
+ debug_sessions: countReal(debugSessions.items),
1058
+ quick_tasks: countReal(quickTasks.items),
1059
+ threads: countReal(threads.items),
1060
+ todos: countReal(todos.items),
1061
+ seeds: countReal(seeds.items),
1062
+ uat_gaps: countReal(uatGaps.items),
1063
+ verification_gaps: countReal(verificationGaps.items),
1064
+ context_questions: countReal(contextQuestions.items),
1065
+ deferred_items: countReal(deferredItems.items),
699
1066
  total: 0,
700
1067
  };
701
1068
  counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions + counts.deferred_items;
1069
+ // #3458 follow-up (A5): mirrors `counts`'s shape exactly, so a reviewer can
1070
+ // tell "clean because fixed" apart from "clean because silenced" without a
1071
+ // second output contract to learn.
1072
+ const acknowledged = {
1073
+ debug_sessions: debugSessions.acknowledged,
1074
+ quick_tasks: quickTasks.acknowledged,
1075
+ threads: threads.acknowledged,
1076
+ todos: todos.acknowledged,
1077
+ seeds: seeds.acknowledged,
1078
+ uat_gaps: uatGaps.acknowledged,
1079
+ verification_gaps: verificationGaps.acknowledged,
1080
+ context_questions: contextQuestions.acknowledged,
1081
+ deferred_items: deferredItems.acknowledged,
1082
+ total: 0,
1083
+ };
1084
+ acknowledged.total = acknowledged.debug_sessions + acknowledged.quick_tasks + acknowledged.threads + acknowledged.todos + acknowledged.seeds + acknowledged.uat_gaps + acknowledged.verification_gaps + acknowledged.context_questions + acknowledged.deferred_items;
702
1085
  return {
703
1086
  scanned_at: new Date().toISOString(),
704
1087
  has_open_items: counts.total > 0,
705
1088
  counts,
1089
+ acknowledged,
706
1090
  items: {
707
- debug_sessions: debugSessions,
708
- quick_tasks: quickTasks,
709
- threads,
710
- todos,
711
- seeds,
712
- uat_gaps: uatGaps,
713
- verification_gaps: verificationGaps,
714
- context_questions: contextQuestions,
715
- deferred_items: deferredItems,
1091
+ debug_sessions: debugSessions.items,
1092
+ quick_tasks: quickTasks.items,
1093
+ threads: threads.items,
1094
+ todos: todos.items,
1095
+ seeds: seeds.items,
1096
+ uat_gaps: uatGaps.items,
1097
+ verification_gaps: verificationGaps.items,
1098
+ context_questions: contextQuestions.items,
1099
+ deferred_items: deferredItems.items,
716
1100
  },
717
1101
  };
718
1102
  }
@@ -724,23 +1108,36 @@ function auditOpenArtifacts(cwd) {
724
1108
  * @returns Formatted report
725
1109
  */
726
1110
  function formatAuditReport(auditResult) {
727
- const { counts, items, has_open_items } = auditResult;
1111
+ const { counts, items, has_open_items, acknowledged } = auditResult;
728
1112
  const lines = [];
729
1113
  const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
730
1114
  lines.push(hr);
731
1115
  lines.push(' Milestone Close: Open Artifact Audit');
732
1116
  lines.push(hr);
1117
+ // WARNING 3 (#3458 follow-up review): the acknowledged tally previously
1118
+ // existed only in `--json` output — the human report could not tell
1119
+ // "clean because fixed" apart from "clean because silenced", which is the
1120
+ // exact distinction the acknowledged/counts split exists to preserve.
733
1121
  if (!has_open_items) {
734
1122
  lines.push('');
735
- lines.push(' All artifact types clear. Safe to proceed.');
1123
+ if (acknowledged.total > 0) {
1124
+ lines.push(` All artifact types clear (${acknowledged.total} previously acknowledged item${acknowledged.total !== 1 ? 's' : ''} still suppressed).`);
1125
+ }
1126
+ else {
1127
+ lines.push(' All artifact types clear. Safe to proceed.');
1128
+ }
736
1129
  lines.push('');
737
1130
  lines.push(hr);
738
1131
  return lines.join('\n');
739
1132
  }
1133
+ // WARNING 3: per-category "N previously acknowledged" suffix, so the
1134
+ // human report carries the same "clean vs silenced" signal `--json`
1135
+ // already did via `acknowledged`.
1136
+ const ackSuffix = (n) => (n > 0 ? `, ${n} previously acknowledged` : '');
740
1137
  // Debug sessions (blocking quality — red)
741
1138
  if (counts.debug_sessions > 0) {
742
1139
  lines.push('');
743
- lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open)`);
1140
+ lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open${ackSuffix(acknowledged.debug_sessions)})`);
744
1141
  for (const item of items.debug_sessions.filter(i => !i.scan_error)) {
745
1142
  const hyp = item.hypothesis ? ` — ${item.hypothesis}` : '';
746
1143
  lines.push(` • ${item.slug} [${item.status}]${hyp}`);
@@ -749,23 +1146,25 @@ function formatAuditReport(auditResult) {
749
1146
  // UAT gaps (blocking quality — red)
750
1147
  if (counts.uat_gaps > 0) {
751
1148
  lines.push('');
752
- lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT)`);
1149
+ lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT${ackSuffix(acknowledged.uat_gaps)})`);
753
1150
  for (const item of items.uat_gaps.filter(i => !i.scan_error)) {
754
- lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}] — ${item.open_scenario_count} pending scenarios`);
1151
+ const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
1152
+ lines.push(` • Phase ${item.phase}${archived}: ${item.file} [${item.status}] — ${item.open_scenario_count} pending scenarios`);
755
1153
  }
756
1154
  }
757
1155
  // Verification gaps (blocking quality — red)
758
1156
  if (counts.verification_gaps > 0) {
759
1157
  lines.push('');
760
- lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved)`);
1158
+ lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved${ackSuffix(acknowledged.verification_gaps)})`);
761
1159
  for (const item of items.verification_gaps.filter(i => !i.scan_error)) {
762
- lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}]`);
1160
+ const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
1161
+ lines.push(` • Phase ${item.phase}${archived}: ${item.file} [${item.status}]`);
763
1162
  }
764
1163
  }
765
1164
  // Quick tasks (incomplete work — yellow)
766
1165
  if (counts.quick_tasks > 0) {
767
1166
  lines.push('');
768
- lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete)`);
1167
+ lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete${ackSuffix(acknowledged.quick_tasks)})`);
769
1168
  for (const item of items.quick_tasks.filter(i => !i.scan_error)) {
770
1169
  const d = item.date ? ` (${item.date})` : '';
771
1170
  lines.push(` • ${item.slug}${d} [${item.status}]`);
@@ -776,7 +1175,7 @@ function formatAuditReport(auditResult) {
776
1175
  const realTodos = items.todos.filter(i => !i.scan_error && !i._remainder_count);
777
1176
  const remainder = items.todos.find(i => i._remainder_count);
778
1177
  lines.push('');
779
- lines.push(`🟡 Pending Todos (${counts.todos} pending)`);
1178
+ lines.push(`🟡 Pending Todos (${counts.todos} pending${ackSuffix(acknowledged.todos)})`);
780
1179
  for (const item of realTodos) {
781
1180
  const area = item.area ? ` [${item.area}]` : '';
782
1181
  const pri = item.priority ? ` (${item.priority})` : '';
@@ -791,7 +1190,7 @@ function formatAuditReport(auditResult) {
791
1190
  // Threads (deferred decisions — blue)
792
1191
  if (counts.threads > 0) {
793
1192
  lines.push('');
794
- lines.push(`🔵 Open Threads (${counts.threads} active)`);
1193
+ lines.push(`🔵 Open Threads (${counts.threads} active${ackSuffix(acknowledged.threads)})`);
795
1194
  for (const item of items.threads.filter(i => !i.scan_error)) {
796
1195
  const title = item.title ? ` — ${item.title}` : '';
797
1196
  lines.push(` • ${item.slug} [${item.status}]${title}`);
@@ -800,7 +1199,7 @@ function formatAuditReport(auditResult) {
800
1199
  // Seeds (deferred decisions — blue)
801
1200
  if (counts.seeds > 0) {
802
1201
  lines.push('');
803
- lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending)`);
1202
+ lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending${ackSuffix(acknowledged.seeds)})`);
804
1203
  for (const item of items.seeds.filter(i => !i.scan_error)) {
805
1204
  const title = item.title ? ` — ${item.title}` : '';
806
1205
  lines.push(` • ${item.seed_id} [${item.status}]${title}`);
@@ -809,9 +1208,10 @@ function formatAuditReport(auditResult) {
809
1208
  // Context questions (deferred decisions — blue)
810
1209
  if (counts.context_questions > 0) {
811
1210
  lines.push('');
812
- lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions)`);
1211
+ lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions${ackSuffix(acknowledged.context_questions)})`);
813
1212
  for (const item of items.context_questions.filter(i => !i.scan_error)) {
814
- lines.push(` • Phase ${item.phase}: ${item.file} (${item.question_count} question${item.question_count !== 1 ? 's' : ''})`);
1213
+ const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
1214
+ lines.push(` • Phase ${item.phase}${archived}: ${item.file} (${item.question_count} question${item.question_count !== 1 ? 's' : ''})`);
815
1215
  for (const q of item.questions) {
816
1216
  lines.push(` - ${q}`);
817
1217
  }
@@ -821,15 +1221,251 @@ function formatAuditReport(auditResult) {
821
1221
  // phase agent recorded rather than fixed, still unresolved at close (#2646).
822
1222
  if (counts.deferred_items > 0) {
823
1223
  lines.push('');
824
- lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved)`);
1224
+ lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved${ackSuffix(acknowledged.deferred_items)})`);
825
1225
  for (const item of items.deferred_items.filter(i => !i.scan_error)) {
826
- lines.push(` • Phase ${item.phase}: ${item.text}`);
1226
+ const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
1227
+ lines.push(` • Phase ${item.phase}${archived}: ${item.text}`);
827
1228
  }
828
1229
  }
829
1230
  lines.push('');
830
1231
  lines.push(hr);
831
1232
  lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`);
1233
+ if (acknowledged.total > 0) {
1234
+ lines.push(` ${acknowledged.total} previously acknowledged item${acknowledged.total !== 1 ? 's' : ''} also suppressed above the ${counts.total} open item${counts.total !== 1 ? 's' : ''}.`);
1235
+ }
832
1236
  lines.push(hr);
833
1237
  return lines.join('\n');
834
1238
  }
835
- module.exports = { auditOpenArtifacts, formatAuditReport };
1239
+ // ─── resolvePhaseTargetDir ─────────────────────────────────────────────────────
1240
+ /**
1241
+ * Resolve ONE phase directory (active or archived) by its phase token, for
1242
+ * `cmdAuditAcknowledge`'s `--phase [--archived-milestone]` identification of
1243
+ * a uat_gaps/verification_gaps/context_questions/deferred_items item. Built
1244
+ * on `listAuditPhaseTargets` — the same enumeration the four phase-scoped
1245
+ * scanners use — so the writer can never resolve a DIFFERENT directory than
1246
+ * the one the audit actually scanned.
1247
+ *
1248
+ * `archivedMilestone` absent → matches the ACTIVE `.planning/phases/<dir>`
1249
+ * (a target with no `milestone`). Present → matches the archived target
1250
+ * whose `milestone` equals it exactly — the same disambiguator the audit
1251
+ * output's `archived_milestone` field carries.
1252
+ */
1253
+ function resolvePhaseTargetDir(planDir, cwd, phase, archivedMilestone) {
1254
+ const { targets } = listAuditPhaseTargets(planDir, cwd);
1255
+ const phaseTokenRe = new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i');
1256
+ for (const target of targets) {
1257
+ const phaseMatch = target.dir.match(phaseTokenRe);
1258
+ const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
1259
+ if (phaseNum !== phase)
1260
+ continue;
1261
+ if (archivedMilestone) {
1262
+ if (target.milestone === archivedMilestone)
1263
+ return target.fullPath;
1264
+ }
1265
+ else if (target.milestone === undefined) {
1266
+ return target.fullPath;
1267
+ }
1268
+ }
1269
+ return null;
1270
+ }
1271
+ // ─── cmdAuditAcknowledge ────────────────────────────────────────────────────────
1272
+ /**
1273
+ * CLI writer for the #3458 follow-up suppression seam (design point A4). Sets
1274
+ * (or refreshes) the `audit_acknowledged` marker on ONE identified artifact,
1275
+ * snapshotting its CURRENT effective state itself so the marker is never
1276
+ * hand-authored and can never drift from what the scanners actually compute.
1277
+ *
1278
+ * `--category` selects which of the nine audit categories is being
1279
+ * acknowledged, and which OTHER flags are required to identify the artifact —
1280
+ * mirroring the fields the audit's OWN JSON output already carries per
1281
+ * category (phase/file/archived_milestone for the four phase-scoped
1282
+ * categories; slug/seed-id/dir/filename for the five flat ones), the same
1283
+ * convention `frontmatter get/set/merge/validate` uses for `--file`/`--field`.
1284
+ *
1285
+ * VERDICT-PRESERVING: this function never writes to the artifact's own
1286
+ * `status:` field (the audit's real verdict) for the 8 frontmatter-marker
1287
+ * categories — only the sibling `audit_acknowledged` map. `deferred_items` is
1288
+ * the sole, deliberate exception (see `uat.cts`'s `acknowledgeDeferredItem`):
1289
+ * there, the marker IS the entry's own `status:` field, because a
1290
+ * deferred-items.md entry carries no OTHER meaning for that field.
1291
+ *
1292
+ * Every path this function writes is routed through `requireSafePath`, so an
1293
+ * artifact identifier that resolves outside the project is refused before
1294
+ * any read or write is attempted.
1295
+ */
1296
+ function cmdAuditAcknowledge(cwd, args, raw) {
1297
+ const { category, milestone, at: atFlag, phase, file, 'archived-milestone': archivedMilestone, slug, 'seed-id': seedId, dir: quickDir, filename, text, } = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, [
1298
+ 'category', 'milestone', 'at',
1299
+ 'phase', 'file', 'archived-milestone',
1300
+ 'slug', 'seed-id', 'dir', 'filename', 'text',
1301
+ ]);
1302
+ if (!category)
1303
+ ioError('--category is required');
1304
+ if (!milestone)
1305
+ ioError('--milestone is required');
1306
+ const at = atFlag || new Date().toISOString().slice(0, 10);
1307
+ const planDir = planningDir(cwd);
1308
+ const markerBase = { milestone: milestone, at };
1309
+ // ── The four phase-scoped categories: --phase --file [--archived-milestone] ──
1310
+ const PHASE_SCOPED = new Set(['uat_gaps', 'verification_gaps', 'context_questions', 'deferred_items']);
1311
+ if (PHASE_SCOPED.has(category)) {
1312
+ if (!phase)
1313
+ ioError('--phase is required for this --category');
1314
+ if (!file)
1315
+ ioError('--file is required for this --category');
1316
+ const targetDir = resolvePhaseTargetDir(planDir, cwd, phase, archivedMilestone);
1317
+ if (!targetDir) {
1318
+ ioError(`no phase directory found for phase "${phase}"${archivedMilestone ? ` (archived-milestone "${archivedMilestone}")` : ''}`);
1319
+ }
1320
+ const filePath = node_path_1.default.join(targetDir, file);
1321
+ const safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'audit acknowledge target', { allowAbsolute: true });
1322
+ if (!node_fs_1.default.existsSync(safeFilePath))
1323
+ ioError(`file not found: ${file}`);
1324
+ if (category === 'deferred_items') {
1325
+ if (!text)
1326
+ ioError('--text is required for --category deferred_items');
1327
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
1328
+ const uat = require('./uat.cjs');
1329
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1330
+ const result = uat.acknowledgeDeferredItem(content, text);
1331
+ if (result.status === 'not_found')
1332
+ ioError(`no deferred item matched --text "${text}"`);
1333
+ if (result.status === 'ambiguous')
1334
+ ioError(`--text "${text}" matches more than one deferred item — text must be unique`);
1335
+ if (result.status === 'already_resolved')
1336
+ ioError(`deferred item is already "status: resolved" — acknowledging a resolved item is a no-op`);
1337
+ if (result.status === 'unsupported_heading_shape') {
1338
+ ioError('this deferred-items.md uses the heading-delimited (#3457) entry shape, which the CLI writer does not yet support — edit the file directly');
1339
+ }
1340
+ if (result.status === 'match_verification_failed') {
1341
+ ioError(`internal error: matched span for --text "${text}" did not re-verify before write — refused rather than risk writing the wrong entry`);
1342
+ }
1343
+ (0, shell_command_projection_cjs_2.platformWriteSync)(safeFilePath, result.content);
1344
+ output({ acknowledged: true, category, phase, file, text }, raw, 'true');
1345
+ return;
1346
+ }
1347
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1348
+ const fm = extractFrontmatter(content, safeFilePath);
1349
+ let snapshotKey;
1350
+ let currentValue;
1351
+ if (category === 'uat_gaps') {
1352
+ // WARNING 2 (#3458 follow-up review): status alone can't see MORE
1353
+ // pending scenarios added under the same status — snapshot the
1354
+ // composite `deriveUatGapSnapshotValue` instead (see its doc comment).
1355
+ snapshotKey = 'gap_snapshot';
1356
+ currentValue = deriveUatGapSnapshotValue((fm.status || 'unknown').toLowerCase(), content);
1357
+ }
1358
+ else if (category === 'verification_gaps') {
1359
+ snapshotKey = 'status';
1360
+ currentValue = (fm.status || 'unknown').toLowerCase();
1361
+ }
1362
+ else {
1363
+ // context_questions — WARNING 2: snapshot a content digest of the
1364
+ // question set, not just its count (see `deriveOpenQuestionsDigest`'s
1365
+ // doc comment).
1366
+ snapshotKey = 'questions_digest';
1367
+ currentValue = deriveOpenQuestionsDigest(deriveOpenQuestions(content, fm));
1368
+ }
1369
+ fm.audit_acknowledged = { ...markerBase, [snapshotKey]: currentValue };
1370
+ const newContent = spliceFrontmatter(content, fm);
1371
+ (0, shell_command_projection_cjs_2.platformWriteSync)(safeFilePath, newContent);
1372
+ output({ acknowledged: true, category, phase, file, [snapshotKey]: currentValue }, raw, 'true');
1373
+ return;
1374
+ }
1375
+ // ── The five flat categories: category-specific identifier flag ──
1376
+ // `status` for all five per the architecture's per-category table (`todos`
1377
+ // is presence-only and never reads `snapshotKey`, so it stays a constant).
1378
+ const snapshotKey = 'status';
1379
+ let safeFilePath;
1380
+ let currentValue;
1381
+ let createIfMissing = false;
1382
+ // Same value shape `Frontmatter`/`extractFrontmatter` use (frontmatter.cts
1383
+ // does not export the `Frontmatter` type name itself, so it is spelled out
1384
+ // structurally here) — keeps this and `extractFrontmatter`'s return type
1385
+ // unifying to the SAME type below instead of a lossy `Record<string,
1386
+ // unknown>` that `spliceFrontmatter`'s `Frontmatter` parameter would reject.
1387
+ let fmForCreate = {};
1388
+ if (category === 'debug_sessions') {
1389
+ if (!slug)
1390
+ ioError('--slug is required for --category debug_sessions');
1391
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'debug', `${slug}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1392
+ if (!node_fs_1.default.existsSync(safeFilePath))
1393
+ ioError(`file not found: debug/${slug}.md`);
1394
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1395
+ currentValue = (extractFrontmatter(content, safeFilePath).status || 'unknown').toLowerCase();
1396
+ }
1397
+ else if (category === 'threads') {
1398
+ if (!slug)
1399
+ ioError('--slug is required for --category threads');
1400
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'threads', `${slug}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1401
+ if (!node_fs_1.default.existsSync(safeFilePath))
1402
+ ioError(`file not found: threads/${slug}.md`);
1403
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1404
+ currentValue = deriveThreadStatus(extractFrontmatter(content, safeFilePath), content);
1405
+ }
1406
+ else if (category === 'seeds') {
1407
+ if (!seedId)
1408
+ ioError('--seed-id is required for --category seeds');
1409
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'seeds', `${seedId}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1410
+ if (!node_fs_1.default.existsSync(safeFilePath))
1411
+ ioError(`file not found: seeds/${seedId}.md`);
1412
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1413
+ currentValue = (extractFrontmatter(content, safeFilePath).status || 'dormant').toLowerCase();
1414
+ }
1415
+ else if (category === 'todos') {
1416
+ if (!filename)
1417
+ ioError('--filename is required for --category todos');
1418
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'todos', 'pending', filename), planDir, 'audit acknowledge target', { allowAbsolute: true });
1419
+ if (!node_fs_1.default.existsSync(safeFilePath))
1420
+ ioError(`file not found: todos/pending/${filename}`);
1421
+ currentValue = ''; // presence-only — see scanTodos
1422
+ }
1423
+ else if (category === 'quick_tasks') {
1424
+ if (!quickDir)
1425
+ ioError('--dir is required for --category quick_tasks');
1426
+ const taskDir = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'quick', quickDir), planDir, 'audit acknowledge target dir', { allowAbsolute: true });
1427
+ if (!node_fs_1.default.existsSync(taskDir))
1428
+ ioError(`directory not found: quick/${quickDir}`);
1429
+ // Shared with scanQuickTasks (#3458 follow-up) so the reader and this
1430
+ // writer can never disagree about which file is the task's record.
1431
+ const resolvedSummaryPath = resolveQuickTaskSummaryFile(taskDir, quickDir);
1432
+ if (resolvedSummaryPath) {
1433
+ safeFilePath = (0, security_cjs_1.requireSafePath)(resolvedSummaryPath, planDir, 'audit acknowledge target', { allowAbsolute: true });
1434
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1435
+ currentValue = (extractFrontmatter(content, safeFilePath).status || 'unknown').toLowerCase();
1436
+ }
1437
+ else {
1438
+ // No SUMMARY.md at all — the audit's own observed status is 'missing'.
1439
+ // There is nowhere to carry the marker, so create the canonical
1440
+ // `${dir}-SUMMARY.md` with ONLY `status: missing` + the marker — the
1441
+ // acknowledgment's own snapshot of "no summary exists yet", which
1442
+ // self-invalidates the moment a real SUMMARY.md is written (the
1443
+ // scanner then reads THAT file's own status instead).
1444
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(taskDir, `${quickDir}-SUMMARY.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1445
+ currentValue = 'missing';
1446
+ createIfMissing = true;
1447
+ fmForCreate = { status: 'missing' };
1448
+ }
1449
+ }
1450
+ else {
1451
+ ioError(`unknown --category "${category}". Available: debug_sessions, quick_tasks, threads, todos, seeds, uat_gaps, verification_gaps, context_questions, deferred_items`);
1452
+ return; // unreachable — ioError throws — satisfies TS control-flow analysis
1453
+ }
1454
+ const presenceOnly = category === 'todos';
1455
+ const fm = createIfMissing ? fmForCreate : extractFrontmatter(node_fs_1.default.readFileSync(safeFilePath, 'utf-8'), safeFilePath);
1456
+ fm.audit_acknowledged = presenceOnly ? { ...markerBase } : { ...markerBase, [snapshotKey]: currentValue };
1457
+ const newContent = createIfMissing
1458
+ ? spliceFrontmatter('', fm)
1459
+ : spliceFrontmatter(node_fs_1.default.readFileSync(safeFilePath, 'utf-8'), fm);
1460
+ (0, shell_command_projection_cjs_2.platformWriteSync)(safeFilePath, newContent);
1461
+ output({ acknowledged: true, category, ...(presenceOnly ? {} : { [snapshotKey]: currentValue }) }, raw, 'true');
1462
+ }
1463
+ module.exports = {
1464
+ auditOpenArtifacts,
1465
+ formatAuditReport,
1466
+ listAuditPhaseTargets,
1467
+ cmdAuditAcknowledge,
1468
+ // #2142: exported so src/milestone.cts's archiveQuickTaskDirectories README
1469
+ // index generator shares this ONE discovery rule rather than re-deriving it.
1470
+ resolveQuickTaskSummaryFile,
1471
+ };