@opengsd/gsd-core 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -16,18 +16,31 @@ 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");
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ const coreUtils = require("./core-utils.cjs");
25
+ const { normalizeLineEndings } = coreUtils;
21
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
27
  const planningWorkspace = require("./planning-workspace.cjs");
23
- const { planningDir } = planningWorkspace;
28
+ const { planningDir, quickDirFrom } = planningWorkspace;
24
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
30
  const frontmatter = require("./frontmatter.cjs");
26
- const { extractFrontmatter } = frontmatter;
31
+ const { extractFrontmatter, spliceFrontmatter } = frontmatter;
27
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
33
  const phaseIdMod = require("./phase-id.cjs");
29
- const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
34
+ const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const phaseLocator = require("./phase-locator.cjs");
37
+ const { getAllArchivedPhaseDirs } = phaseLocator;
30
38
  const security_cjs_1 = require("./security.cjs");
39
+ const shell_command_projection_cjs_2 = require("./shell-command-projection.cjs");
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const io = require("./io.cjs");
42
+ const { output, error: ioError } = io;
43
+ const command_arg_projection_cjs_1 = require("./command-arg-projection.cjs");
31
44
  // The SCOPE BOUNDARY convention's filename (`agents/gsd-executor.md`), shared
32
45
  // verbatim with the #2287 phase-boundary reader in `uat.cts`.
33
46
  const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
@@ -35,6 +48,208 @@ const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
35
48
  // per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
36
49
  // not recreated on each loop iteration.
37
50
  const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
51
+ // ─── Acknowledgment marker (suppression) ──────────────────────────────────────
52
+ //
53
+ // #3458 follow-up: `query audit-open` now scans archived milestone phase dirs,
54
+ // so an item still unresolved when a milestone closed resurfaces at EVERY
55
+ // later close, forever — `[A] Acknowledge all` documented that decision to
56
+ // STATE.md but never suppressed it. This section is the suppression seam.
57
+ //
58
+ // The marker lives INSIDE the artifact it suppresses, as an
59
+ // `audit_acknowledged` frontmatter map (no ledger, no id minting — see
60
+ // `uat.cts:891-897`'s `deferred-items.md` in-place `status: resolved`
61
+ // convention, which this generalizes):
62
+ //
63
+ // audit_acknowledged:
64
+ // milestone: v1.0 # which milestone close acknowledged it
65
+ // at: 2026-08-15 # ISO date
66
+ // status: gaps_found # snapshot of the artifact's state AT acknowledgment
67
+ // # (named `gap_snapshot` — status + open-scenario
68
+ // # count — for `uat_gaps`, and `questions_digest`
69
+ // # — a content hash of the question set, not just
70
+ // # its count — for `context_questions`; see
71
+ // # `isAuditItemAcknowledged`'s `snapshotKey` param
72
+ // # and each category's `deriveXxx` snapshot
73
+ // # helper for why a bare status/count was not
74
+ // # enough for those two — #3458 follow-up review)
75
+ //
76
+ // It is VERDICT-PRESERVING (this section never writes `status:` itself — see
77
+ // `cmdAuditAcknowledge` below) and SELF-INVALIDATING: it suppresses ONLY while
78
+ // `snapshotKey`'s recorded value still equals the artifact's CURRENT
79
+ // effective value. Edit the artifact after acknowledging it and the item
80
+ // resurfaces automatically — no separate revive/carry-forward state, and a
81
+ // stale acknowledgment can never hide a NEW problem, PROVIDED the category's
82
+ // snapshot actually captures the dimension that changed — `uat_gaps` and
83
+ // `context_questions` snapshot more than their status/count for exactly this
84
+ // reason (see above); every other category's only tracked dimension IS its
85
+ // `status:` (or, for `todos`, presence), so a bare status/presence snapshot
86
+ // is already complete for those.
87
+ //
88
+ // `isAuditItemAcknowledged` is the ONE shared predicate every scanner below
89
+ // routes through — this file has already been through the "hand-rolled the
90
+ // same check nine times" defect family twice this PR; a tenth hand-roll here
91
+ // is exactly that class. `deferred_items` is the deliberate exception: its
92
+ // suppression key lives PER-ENTRY inside `deferred-items.md`'s own
93
+ // `status:` field (see `uat.cts`'s `parseDeferredItemsWithStatus`), not in a
94
+ // file-level `audit_acknowledged` map, because a single deferred-items.md can
95
+ // carry many independently-acknowledgeable entries.
96
+ /**
97
+ * Parse and validate an artifact's `audit_acknowledged` frontmatter marker,
98
+ * then decide whether it suppresses the item given the artifact's CURRENT
99
+ * effective state.
100
+ *
101
+ * `snapshotKey` names which sub-field of the marker map carries the snapshot
102
+ * comparison value (`'status'` for every category except CONTEXT files, which
103
+ * use `'question_count'`). `presenceOnly: true` (used only for `todos`, which
104
+ * has no natural status field to snapshot) skips the snapshot comparison
105
+ * entirely — marker PRESENCE alone suppresses.
106
+ *
107
+ * A marker that is not a plain object/map, or is missing a non-empty string
108
+ * `milestone`/`at`, or — when a snapshot comparison applies — missing a
109
+ * string at `snapshotKey`, is MALFORMED and treated as ABSENT: this function
110
+ * returns `false` and the item surfaces. A bad marker must never suppress.
111
+ */
112
+ function isAuditItemAcknowledged(fm, opts) {
113
+ const raw = fm.audit_acknowledged;
114
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
115
+ return false;
116
+ const marker = raw;
117
+ if (typeof marker.milestone !== 'string' || !marker.milestone)
118
+ return false;
119
+ if (typeof marker.at !== 'string' || !marker.at)
120
+ return false;
121
+ if (opts.presenceOnly)
122
+ return true;
123
+ const snapshot = marker[opts.snapshotKey];
124
+ if (typeof snapshot !== 'string')
125
+ return false;
126
+ return snapshot === opts.currentValue;
127
+ }
128
+ /**
129
+ * Derive a THREAD file's effective status: frontmatter `status:` when
130
+ * present, else the `## Status: OPEN|IN PROGRESS` body fallback — the same
131
+ * two-step derivation `scanThreads` already performed inline. Extracted so
132
+ * `cmdAuditAcknowledge` computes the CURRENT snapshot value with the exact
133
+ * same logic the scanner used to produce the marker's recorded value,
134
+ * instead of a second hand-derivation that could silently drift from it.
135
+ */
136
+ function deriveThreadStatus(fm, content) {
137
+ let status = (fm.status || '').toLowerCase().trim();
138
+ if (!status) {
139
+ const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
140
+ if (bodyStatusMatch) {
141
+ status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
142
+ }
143
+ }
144
+ return status;
145
+ }
146
+ /**
147
+ * Count a UAT file's still-open (`result: pending`/`[pending]`) scenarios.
148
+ * Extracted from `scanUatGaps`'s inline logic for the same reason as
149
+ * `deriveThreadStatus` — one derivation, shared by the scanner and
150
+ * `cmdAuditAcknowledge`'s `deriveUatGapSnapshotValue` below.
151
+ */
152
+ function deriveUatGapOpenScenarioCount(content) {
153
+ return (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length;
154
+ }
155
+ /**
156
+ * Stable snapshot value for a `uat_gaps` item (WARNING 2, #3458 follow-up
157
+ * review). `status` alone is COUNT-blind the other direction: a UAT file can
158
+ * stay in the SAME open status (`gaps_found`) while gaining MORE pending
159
+ * scenarios (measured: 1→6 pending, status unchanged, item stayed
160
+ * suppressed under the old status-only scheme). Composing `status` with the
161
+ * open-scenario count means either dimension changing invalidates the
162
+ * snapshot.
163
+ */
164
+ function deriveUatGapSnapshotValue(status, content) {
165
+ return `${status}::scenarios=${deriveUatGapOpenScenarioCount(content)}`;
166
+ }
167
+ /**
168
+ * Derive a CONTEXT file's FULL, UNTRUNCATED open-questions list: the
169
+ * structured `open_questions` frontmatter array when present and
170
+ * non-empty, else EVERY qualifying line of the `## Open Questions` body
171
+ * section. Extracted from `scanContextQuestions`'s inline logic for the same
172
+ * reason as `deriveThreadStatus` — one derivation, shared by the scanner and
173
+ * `cmdAuditAcknowledge`, so the acknowledged `question_count`/digest snapshot
174
+ * can never diverge from what the scanner counts.
175
+ *
176
+ * F2 (#3458 follow-up review, sibling of the deferred_items span-carrying
177
+ * fix): this used to `slice(0, 3)` the body-section list AND clamp each
178
+ * question to 200 chars BEFORE returning — a value meant for DISPLAY reused
179
+ * for the IDENTITY snapshot `deriveOpenQuestionsDigest` hashes. A 4th+
180
+ * question, or anything past char 200 of an earlier one, was invisible to
181
+ * the digest: an attacker could ship 3 innocuous questions first, then add
182
+ * real blockers afterward with zero effect on the recorded snapshot. Every
183
+ * caller that wants a bounded list for DISPLAY (`scanContextQuestions`'s
184
+ * `questions` field) truncates its OWN copy at the call site; this function
185
+ * always returns the complete, unclamped set.
186
+ */
187
+ function deriveOpenQuestions(content, fm) {
188
+ let questions = [];
189
+ if (fm.open_questions) {
190
+ if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
191
+ questions = fm.open_questions.map(q => (0, security_cjs_1.sanitizeForDisplay)(String(q)));
192
+ }
193
+ }
194
+ if (questions.length === 0) {
195
+ const oqSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('open questions'), { levelBounded: true });
196
+ if (oqSection) {
197
+ const oqBody = oqSection.body.trim();
198
+ if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
199
+ const items = oqBody.split('\n')
200
+ .map((l) => l.trim())
201
+ .filter((l) => l && l !== '-' && l !== '*')
202
+ .filter((l) => /^[-*\d]/.test(l) || l.includes('?'));
203
+ questions = items.map((q) => (0, security_cjs_1.sanitizeForDisplay)(q));
204
+ }
205
+ }
206
+ }
207
+ return questions;
208
+ }
209
+ /** Bound a question's DISPLAY text (never fed into the identity digest — see `deriveOpenQuestions`'s doc comment). */
210
+ function truncateQuestionForDisplay(question) {
211
+ return question.slice(0, 200);
212
+ }
213
+ /**
214
+ * Stable normalized digest of a CONTEXT file's open-questions set (WARNING 2,
215
+ * #3458 follow-up review). `question_count` alone is COUNT-only: replacing
216
+ * every question's TEXT with brand-new ones while holding the count steady
217
+ * left an acknowledged item permanently suppressed (measured: 2 questions
218
+ * acknowledged, then both replaced with unrelated new blockers — still
219
+ * `counts:0`). Hashing the full, ordered question text means ANY edit —
220
+ * add, remove, reword, or reorder — changes the digest and the item
221
+ * resurfaces. sha256 (not the raw joined string) keeps the marker's stored
222
+ * value bounded regardless of question length/count.
223
+ *
224
+ * `questions` MUST be the untruncated, unclamped set `deriveOpenQuestions`
225
+ * returns — never a display-sliced/-clamped copy (F2, #3458 follow-up
226
+ * review); a truncated input reintroduces exactly the blind spot this digest
227
+ * exists to close.
228
+ *
229
+ * Length-prefixed, separator-free encoding (SWEEP finding, #3458 follow-up
230
+ * review) — NOT a plain join (the prior revision joined on a literal
231
+ * embedded NUL byte, `questions.join('\\0')` written as a raw control
232
+ * character in the SOURCE FILE itself — invisible in a normal diff/editor
233
+ * and still forgeable: attacker-controlled markdown CAN contain a literal
234
+ * NUL codepoint, since the file is read as UTF-8 text, so that scheme never
235
+ * actually closed the boundary-collision gap it was reaching for). A bare
236
+ * separator-joined string has no reliably unambiguous element boundary: two
237
+ * DIFFERENT question arrays can render the identical joined string and
238
+ * collide on the same digest — e.g. `['- Is X ready?', '- Y done?']` and
239
+ * `['- Is X ready? - Y', 'done?']` both join to
240
+ * `'- Is X ready? - Y done?'` under a space-join, and both could be forced to
241
+ * collide under a NUL-join too by an attacker who embeds the separator
242
+ * itself. Prefixing each element with its own CHARACTER LENGTH
243
+ * (`<len>:<text>`, concatenated with no separator at all) makes the encoding
244
+ * self-delimiting instead: decoding always consumes exactly `<len>`
245
+ * characters after each `:` before reading the next length prefix, so no two
246
+ * distinct arrays can ever encode to the same string — regardless of what
247
+ * characters the questions themselves contain.
248
+ */
249
+ function deriveOpenQuestionsDigest(questions) {
250
+ const encoded = questions.map((q) => `${q.length}:${q}`).join('');
251
+ return node_crypto_1.default.createHash('sha256').update(encoded).digest('hex');
252
+ }
38
253
  // ─── scanDebugSessions ────────────────────────────────────────────────────────
39
254
  /**
40
255
  * Scan .planning/debug/ for open sessions.
@@ -44,14 +259,15 @@ const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
44
259
  function scanDebugSessions(planDir) {
45
260
  const debugDir = node_path_1.default.join(planDir, 'debug');
46
261
  if (!node_fs_1.default.existsSync(debugDir))
47
- return [];
262
+ return { items: [], acknowledged: 0 };
48
263
  const results = [];
264
+ let acknowledged = 0;
49
265
  let files;
50
266
  try {
51
267
  files = node_fs_1.default.readdirSync(debugDir, { withFileTypes: true });
52
268
  }
53
269
  catch {
54
- return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
270
+ return { items: [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }], acknowledged: 0 };
55
271
  }
56
272
  for (const entry of files) {
57
273
  if (!entry.isFile())
@@ -66,13 +282,25 @@ function scanDebugSessions(planDir) {
66
282
  catch {
67
283
  continue;
68
284
  }
69
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
70
- if (content === null)
285
+ // #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
286
+ // document at this read boundary, same seam as `src/uat.cts`'s
287
+ // `readNormalizedDocument` — `platformReadSync` performs no line-ending
288
+ // normalization itself, and extractFrontmatter/status-derivation below
289
+ // degrade a lone-CR file's frontmatter to `unknown`, which every scan
290
+ // in this module treats as "not open" (fail-open, the permissive
291
+ // direction) rather than a real parse gap.
292
+ const rawContent = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
293
+ if (rawContent === null)
71
294
  continue;
295
+ const content = normalizeLineEndings(rawContent);
72
296
  const fm = extractFrontmatter(content, safeFilePath);
73
297
  const status = (fm.status || 'unknown').toLowerCase();
74
298
  if (status === 'resolved' || status === 'complete')
75
299
  continue;
300
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
301
+ acknowledged++;
302
+ continue;
303
+ }
76
304
  // Extract hypothesis from "Current Focus" block if parseable
77
305
  let hypothesis = '';
78
306
  const focusSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('current focus'), { levelBounded: true });
@@ -82,13 +310,55 @@ function scanDebugSessions(planDir) {
82
310
  }
83
311
  const slug = node_path_1.default.basename(entry.name, '.md');
84
312
  results.push({
85
- slug: (0, security_cjs_1.sanitizeForDisplay)(slug),
313
+ slug: (0, security_cjs_1.sanitizeLabel)(slug),
86
314
  status: (0, security_cjs_1.sanitizeForDisplay)(status),
87
315
  updated: (0, security_cjs_1.sanitizeForDisplay)(fm.updated || fm.date || ''),
88
316
  hypothesis,
89
317
  });
90
318
  }
91
- return results;
319
+ return { items: results, acknowledged };
320
+ }
321
+ // ─── resolveQuickTaskSummaryFile ───────────────────────────────────────────────
322
+ /**
323
+ * Resolve a quick task's SUMMARY file, if any exists, under its own
324
+ * directory (`taskDir`). workflows/quick.md mandates `${quick_id}-SUMMARY.md`;
325
+ * older flows used bare `SUMMARY.md` — accept either to avoid a
326
+ * false-positive "missing", preferring the per-task `${dirName}-SUMMARY.md`
327
+ * form when more than one candidate exists.
328
+ *
329
+ * #3183 (ADR-3180 Decision 4(a) — bucket B, out of scope for the
330
+ * scanPhasePlans migration): this scans a quick task's OWN directory
331
+ * (`.planning/quick/<task>/`) for THAT task's single completion record —
332
+ * "does this one quick task have a SUMMARY.md" — not a phase directory's
333
+ * live-plan/summary counting question. scanPhasePlans is the wrong tool
334
+ * here; there is no plan/summary PAIRING to derive, only a single filename
335
+ * presence check local to a non-phase directory.
336
+ *
337
+ * Extracted (#3458 follow-up) so `scanQuickTasks` (read) and
338
+ * `cmdAuditAcknowledge`'s quick_tasks writer share the ONE discovery rule —
339
+ * previously the writer would have had to hand-roll this exact filter a
340
+ * second time, which is exactly the re-derivation-drift class
341
+ * `scripts/lint-plan-count-drift.cjs` exists to catch (see its
342
+ * `FUNCTION_SCOPED_EXEMPTIONS` entry for this function).
343
+ *
344
+ * Returns `null` (never throws) on an unreadable `taskDir` or when no
345
+ * SUMMARY-shaped file exists.
346
+ */
347
+ function resolveQuickTaskSummaryFile(taskDir, dirName) {
348
+ let summaryFiles;
349
+ try {
350
+ summaryFiles = node_fs_1.default.readdirSync(taskDir, { withFileTypes: true })
351
+ .filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
352
+ }
353
+ catch {
354
+ return null;
355
+ }
356
+ if (summaryFiles.length === 0)
357
+ return null;
358
+ const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`)
359
+ || summaryFiles.find(e => e.name.endsWith('-SUMMARY.md'))
360
+ || summaryFiles[0];
361
+ return node_path_1.default.join(taskDir, preferred.name);
92
362
  }
93
363
  // ─── scanQuickTasks ───────────────────────────────────────────────────────────
94
364
  /**
@@ -96,17 +366,20 @@ function scanDebugSessions(planDir) {
96
366
  * Incomplete if SUMMARY.md missing or status !== 'complete'.
97
367
  */
98
368
  function scanQuickTasks(planDir) {
99
- const quickDir = node_path_1.default.join(planDir, 'quick');
369
+ // #2142: routed through the shared quickDirFrom composer (planning-workspace.cts)
370
+ // so `.planning/quick` has exactly ONE owner instead of two ad-hoc path.joins.
371
+ const quickDir = quickDirFrom(planDir);
100
372
  if (!node_fs_1.default.existsSync(quickDir))
101
- return [];
373
+ return { items: [], acknowledged: 0 };
102
374
  let entries;
103
375
  try {
104
376
  entries = node_fs_1.default.readdirSync(quickDir, { withFileTypes: true });
105
377
  }
106
378
  catch {
107
- return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
379
+ return { items: [{ scan_error: true, slug: '', date: '', status: '', description: '' }], acknowledged: 0 };
108
380
  }
109
381
  const results = [];
382
+ let acknowledged = 0;
110
383
  for (const entry of entries) {
111
384
  if (!entry.isDirectory())
112
385
  continue;
@@ -119,25 +392,10 @@ function scanQuickTasks(planDir) {
119
392
  catch {
120
393
  continue;
121
394
  }
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
- }
395
+ const summaryPath = resolveQuickTaskSummaryFile(safeTaskDir, dirName);
139
396
  let status = 'missing';
140
397
  const description = '';
398
+ let fm = null;
141
399
  if (summaryPath && node_fs_1.default.existsSync(summaryPath)) {
142
400
  let safeSum;
143
401
  try {
@@ -146,24 +404,40 @@ function scanQuickTasks(planDir) {
146
404
  catch {
147
405
  continue;
148
406
  }
149
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeSum);
150
- if (content === null) {
407
+ // #3078-CR MEDIUM 2: same normalize-at-read-boundary fix as the other
408
+ // scans in this module — see the comment above `scanDebugSessions`'s
409
+ // read.
410
+ const rawContent = (0, shell_command_projection_cjs_1.platformReadSync)(safeSum);
411
+ if (rawContent === null) {
151
412
  status = 'unreadable';
152
413
  }
153
414
  else {
154
- const fm = extractFrontmatter(content, safeSum);
415
+ const content = normalizeLineEndings(rawContent);
416
+ fm = extractFrontmatter(content, safeSum);
155
417
  status = (fm.status || 'unknown').toLowerCase();
156
418
  }
157
419
  }
158
420
  if (status === 'complete')
159
421
  continue;
422
+ // Acknowledgment marker only ever lives in the SUMMARY file's own
423
+ // frontmatter — a task with no summary (status: 'missing') has nowhere to
424
+ // carry one, so `fm` is null and this is skipped (never suppressed).
425
+ if (fm && isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
426
+ acknowledged++;
427
+ continue;
428
+ }
160
429
  // Parse date and slug from directory name: YYYYMMDD-slug or YYYY-MM-DD-slug
161
430
  let date = '';
162
- let slug = (0, security_cjs_1.sanitizeForDisplay)(dirName);
431
+ let slug = (0, security_cjs_1.sanitizeLabel)(dirName);
163
432
  const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/);
164
433
  if (dateMatch) {
165
- date = dateMatch[1];
166
- slug = (0, security_cjs_1.sanitizeForDisplay)(dateMatch[2]);
434
+ // dateMatch[1] is regex-constrained to `\d{4}-?\d{2}-?\d{2}` (digits and
435
+ // literal hyphens only — the same "constrained at the source" shape as
436
+ // `archived_milestone`), so it cannot itself carry a control byte.
437
+ // Still routed through sanitizeLabel as defense-in-depth for
438
+ // consistency with every other directory-name-derived field here.
439
+ date = (0, security_cjs_1.sanitizeLabel)(dateMatch[1]);
440
+ slug = (0, security_cjs_1.sanitizeLabel)(dateMatch[2]);
167
441
  }
168
442
  results.push({
169
443
  slug,
@@ -172,7 +446,7 @@ function scanQuickTasks(planDir) {
172
446
  description,
173
447
  });
174
448
  }
175
- return results;
449
+ return { items: results, acknowledged };
176
450
  }
177
451
  // ─── scanThreads ──────────────────────────────────────────────────────────────
178
452
  /**
@@ -182,16 +456,17 @@ function scanQuickTasks(planDir) {
182
456
  function scanThreads(planDir) {
183
457
  const threadsDir = node_path_1.default.join(planDir, 'threads');
184
458
  if (!node_fs_1.default.existsSync(threadsDir))
185
- return [];
459
+ return { items: [], acknowledged: 0 };
186
460
  let files;
187
461
  try {
188
462
  files = node_fs_1.default.readdirSync(threadsDir, { withFileTypes: true });
189
463
  }
190
464
  catch {
191
- return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
465
+ return { items: [{ scan_error: true, slug: '', status: '', updated: '', title: '' }], acknowledged: 0 };
192
466
  }
193
467
  const openStatuses = new Set(['open', 'in_progress', 'in progress']);
194
468
  const results = [];
469
+ let acknowledged = 0;
195
470
  for (const entry of files) {
196
471
  if (!entry.isFile())
197
472
  continue;
@@ -205,20 +480,25 @@ function scanThreads(planDir) {
205
480
  catch {
206
481
  continue;
207
482
  }
208
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
209
- if (content === null)
483
+ // #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
484
+ // document at this read boundary, same seam as `src/uat.cts`'s
485
+ // `readNormalizedDocument` — `platformReadSync` performs no line-ending
486
+ // normalization itself, and extractFrontmatter/status-derivation below
487
+ // degrade a lone-CR file's frontmatter to `unknown`, which every scan
488
+ // in this module treats as "not open" (fail-open, the permissive
489
+ // direction) rather than a real parse gap.
490
+ const rawContent = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
491
+ if (rawContent === null)
210
492
  continue;
493
+ const content = normalizeLineEndings(rawContent);
211
494
  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
- }
495
+ const status = deriveThreadStatus(fm, content);
220
496
  if (!openStatuses.has(status))
221
497
  continue;
498
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
499
+ acknowledged++;
500
+ continue;
501
+ }
222
502
  // Extract title from # Thread: heading or frontmatter title
223
503
  let title = (0, security_cjs_1.sanitizeForDisplay)(fm.title || '');
224
504
  if (!title) {
@@ -229,13 +509,13 @@ function scanThreads(planDir) {
229
509
  }
230
510
  const slug = node_path_1.default.basename(entry.name, '.md');
231
511
  results.push({
232
- slug: (0, security_cjs_1.sanitizeForDisplay)(slug),
512
+ slug: (0, security_cjs_1.sanitizeLabel)(slug),
233
513
  status: (0, security_cjs_1.sanitizeForDisplay)(status),
234
514
  updated: (0, security_cjs_1.sanitizeForDisplay)(fm.updated || fm.date || ''),
235
515
  title,
236
516
  });
237
517
  }
238
- return results;
518
+ return { items: results, acknowledged };
239
519
  }
240
520
  // ─── scanTodos ────────────────────────────────────────────────────────────────
241
521
  /**
@@ -246,18 +526,27 @@ function scanThreads(planDir) {
246
526
  function scanTodos(planDir) {
247
527
  const pendingDir = node_path_1.default.join(planDir, 'todos', 'pending');
248
528
  if (!node_fs_1.default.existsSync(pendingDir))
249
- return [];
529
+ return { items: [], acknowledged: 0 };
250
530
  let files;
251
531
  try {
252
532
  files = node_fs_1.default.readdirSync(pendingDir, { withFileTypes: true });
253
533
  }
254
534
  catch {
255
- return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
535
+ return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 };
256
536
  }
257
537
  const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
258
538
  const results = [];
259
- const displayFiles = mdFiles.slice(0, 5);
260
- for (const entry of displayFiles) {
539
+ let acknowledged = 0;
540
+ // BLOCKER 2 (#3458 follow-up review): filter acknowledged items BEFORE
541
+ // the display cap. Capping the RAW file list to 5 first meant an
542
+ // acknowledge of one of those 5 files simply revealed the 6th on the next
543
+ // scan — files 6/7/... (never shown, never acknowledgeable via the CLI's
544
+ // own remedy) permanently vanished from every later scan once 5+ items
545
+ // existed, because `mdFiles.length` (not the post-filter open count) drove
546
+ // both the cap and the remainder count. Read every file's acknowledgment
547
+ // state first, THEN cap the OPEN (unacknowledged) set for display.
548
+ const openFiles = [];
549
+ for (const entry of mdFiles) {
261
550
  const filePath = node_path_1.default.join(pendingDir, entry.name);
262
551
  let safeFilePath;
263
552
  try {
@@ -266,25 +555,45 @@ function scanTodos(planDir) {
266
555
  catch {
267
556
  continue;
268
557
  }
269
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
270
- if (content === null)
558
+ // #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
559
+ // document at this read boundary, same seam as `src/uat.cts`'s
560
+ // `readNormalizedDocument` — `platformReadSync` performs no line-ending
561
+ // normalization itself, and extractFrontmatter/status-derivation below
562
+ // degrade a lone-CR file's frontmatter to `unknown`, which every scan
563
+ // in this module treats as "not open" (fail-open, the permissive
564
+ // direction) rather than a real parse gap.
565
+ const rawContent = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
566
+ if (rawContent === null)
271
567
  continue;
568
+ const content = normalizeLineEndings(rawContent);
272
569
  const fm = extractFrontmatter(content, safeFilePath);
570
+ // Todos carry no natural status field — presence in pending/ IS "open" by
571
+ // definition (a resolved todo is moved out, not status-flagged). So the
572
+ // acknowledgment check here is PRESENCE-ONLY: no snapshot to go stale, no
573
+ // self-invalidation on edit — see `isAuditItemAcknowledged`'s doc comment.
574
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: '', presenceOnly: true })) {
575
+ acknowledged++;
576
+ continue;
577
+ }
578
+ openFiles.push({ entry, content, fm });
579
+ }
580
+ const displayFiles = openFiles.slice(0, 5);
581
+ for (const { entry, content, fm } of displayFiles) {
273
582
  // Extract first line of body after frontmatter
274
- const bodyMatch = content.replace(/^---[\s\S]*?---\n?/, '');
275
- const firstLine = bodyMatch.trim().split('\n')[0] || '';
583
+ const bodyMatch = content.replace(/^---[\s\S]*?---\r?\n?/, '');
584
+ const firstLine = (0, text_lines_cjs_1.splitLines)(bodyMatch.trim())[0] || '';
276
585
  const summary = (0, security_cjs_1.sanitizeForDisplay)(firstLine.slice(0, 100));
277
586
  results.push({
278
- filename: (0, security_cjs_1.sanitizeForDisplay)(entry.name),
587
+ filename: (0, security_cjs_1.sanitizeLabel)(entry.name),
279
588
  priority: (0, security_cjs_1.sanitizeForDisplay)(fm.priority || ''),
280
589
  area: (0, security_cjs_1.sanitizeForDisplay)(fm.area || ''),
281
590
  summary,
282
591
  });
283
592
  }
284
- if (mdFiles.length > 5) {
285
- results.push({ _remainder_count: mdFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
593
+ if (openFiles.length > 5) {
594
+ results.push({ _remainder_count: openFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
286
595
  }
287
- return results;
596
+ return { items: results, acknowledged };
288
597
  }
289
598
  // ─── scanSeeds ────────────────────────────────────────────────────────────────
290
599
  /**
@@ -294,16 +603,17 @@ function scanTodos(planDir) {
294
603
  function scanSeeds(planDir) {
295
604
  const seedsDir = node_path_1.default.join(planDir, 'seeds');
296
605
  if (!node_fs_1.default.existsSync(seedsDir))
297
- return [];
606
+ return { items: [], acknowledged: 0 };
298
607
  let files;
299
608
  try {
300
609
  files = node_fs_1.default.readdirSync(seedsDir, { withFileTypes: true });
301
610
  }
302
611
  catch {
303
- return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
612
+ return { items: [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }], acknowledged: 0 };
304
613
  }
305
614
  const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
306
615
  const results = [];
616
+ let acknowledged = 0;
307
617
  for (const entry of files) {
308
618
  if (!entry.isFile())
309
619
  continue;
@@ -317,17 +627,35 @@ function scanSeeds(planDir) {
317
627
  catch {
318
628
  continue;
319
629
  }
320
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
321
- if (content === null)
630
+ // #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
631
+ // document at this read boundary, same seam as `src/uat.cts`'s
632
+ // `readNormalizedDocument` — `platformReadSync` performs no line-ending
633
+ // normalization itself, and extractFrontmatter/status-derivation below
634
+ // degrade a lone-CR file's frontmatter to `unknown`, which every scan
635
+ // in this module treats as "not open" (fail-open, the permissive
636
+ // direction) rather than a real parse gap.
637
+ const rawContent = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
638
+ if (rawContent === null)
322
639
  continue;
640
+ const content = normalizeLineEndings(rawContent);
323
641
  const fm = extractFrontmatter(content, safeFilePath);
324
642
  const status = (fm.status || 'dormant').toLowerCase();
325
643
  if (!unimplementedStatuses.has(status))
326
644
  continue;
327
- // Extract seed_id from filename or frontmatter
645
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
646
+ acknowledged++;
647
+ continue;
648
+ }
649
+ // Extract seed_id from filename or frontmatter. The regex match is
650
+ // `\w`/hyphen-constrained (safe by construction, like `archived_milestone`)
651
+ // but the fallback taken when a filename doesn't fully match — e.g. a
652
+ // `SEED-`-prefixed, `.md`-suffixed name with a control byte SOMEWHERE in
653
+ // the middle, which still passes the `startsWith`/`endsWith` filter above
654
+ // — is the raw, unconstrained basename. Both branches are routed through
655
+ // sanitizeLabel below.
328
656
  const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
329
657
  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-/, ''));
658
+ const slug = (0, security_cjs_1.sanitizeLabel)(seed_id.replace(/^SEED-/, ''));
331
659
  let title = (0, security_cjs_1.sanitizeForDisplay)(fm.title || '');
332
660
  if (!title) {
333
661
  const headingMatch = content.match(/^#\s*(.+)$/m);
@@ -335,46 +663,126 @@ function scanSeeds(planDir) {
335
663
  title = (0, security_cjs_1.sanitizeForDisplay)(headingMatch[1].trim().slice(0, 100));
336
664
  }
337
665
  results.push({
338
- seed_id: (0, security_cjs_1.sanitizeForDisplay)(seed_id),
666
+ seed_id: (0, security_cjs_1.sanitizeLabel)(seed_id),
339
667
  slug,
340
668
  status: (0, security_cjs_1.sanitizeForDisplay)(status),
341
669
  title,
342
670
  });
343
671
  }
344
- return results;
672
+ return { items: results, acknowledged };
345
673
  }
346
- // ─── scanUatGaps ──────────────────────────────────────────────────────────────
347
674
  /**
348
- * Scan .planning/phases for UAT gaps (UAT files with status != 'complete').
675
+ * Enumerate phase directories across BOTH the active `.planning/phases/` root
676
+ * and every archived `.planning/milestones/vX.Y-phases/` root. Shared by the
677
+ * four phase-scoped scanners below (#3458 — epic #3473 B2/F2). Previously each
678
+ * scanner hand-rolled its own active-only `readdirSync(phasesDir)` walk and
679
+ * bailed out entirely when the active root was missing, so items still
680
+ * unresolved when a milestone closed and its phase dirs archived became
681
+ * permanently invisible to every later audit.
682
+ *
683
+ * ACTIVE dirs: raw readdirSync + isDirectory filter + sort. The enumeration
684
+ * walk itself (readdirSync + isDirectory filter + sort) is UNCHANGED from the
685
+ * scanners' prior inline behavior; what IS new is that a failed read here no
686
+ * longer aborts the whole scan the way each scanner's own inline
687
+ * `if (!fs.existsSync) return []` / try-readdirSync-catch-return-sentinel
688
+ * pair used to — see `activeUnreadable` below, which is how that signal is
689
+ * now surfaced to callers instead. Deliberately NOT routed through
690
+ * listMilestonePhaseDirs: these scanners are deliberately not
691
+ * milestone-filtered today, and switching would silently add window/sentinel
692
+ * filtering — a behavior change belonging to #3372, not here.
693
+ *
694
+ * A missing/unreadable active root does NOT short-circuit the archive walk —
695
+ * the old `if (!fs.existsSync(phasesDir)) return []` was the whole bug in a
696
+ * fully-archived project; it degrades to "skip the active half" only. An
697
+ * UNREADABLE (as opposed to merely absent) active root is reported back via
698
+ * `activeUnreadable: true` so each of the four callers can still emit the
699
+ * `scan_error` sentinel they emitted pre-#3458 for this exact case (a real
700
+ * I/O failure, not "verified clean") — see each scanner's own use of it.
701
+ *
702
+ * ARCHIVED dirs: sourced from `getArchivedPhaseDirs` (phase-locator.cjs), the
703
+ * canonical archive-enumeration seam `uat.cts`'s `cmdAuditUat` already uses.
704
+ * Archived dirs are deliberately NOT milestone-filtered either — see the
705
+ * comment at src/uat.cts:107-111: listMilestonePhaseDirs derives the CURRENT
706
+ * milestone's phase dirs (window + sentinel filtered) from ROADMAP.md, and
707
+ * archived phases belong to past milestones by definition, so filtering them
708
+ * discards every one and silently reinstates the bug this function fixes.
709
+ *
710
+ * An unreadable/unresolvable ARCHIVE root does NOT get its own sentinel.
711
+ * Pre-#3458 there was no archived read at all, so — unlike the active root —
712
+ * there is no prior `scan_error` contract to preserve here, and no existing
713
+ * consumer can regress. It also degrades the same way `listArchiveVersionDirs`
714
+ * (phase-locator.cts) already treats an absent `milestones/` dir: a real
715
+ * empty, not a failure, matching this function's existing "skip that root"
716
+ * idiom for the missing-active-dir case above. Adding a second sentinel path
717
+ * would let a machine consumer conflate "no milestones archived yet" (the
718
+ * overwhelmingly common case for an active project) with an actual read
719
+ * failure, which is a worse signal-to-noise trade than the one this
720
+ * function's own fix removes for the active root.
721
+ *
722
+ * Same-named dirs in both roots (e.g. "01-alpha" active AND archived) are
723
+ * DISTINCT targets — no dedupe.
349
724
  */
350
- function scanUatGaps(planDir) {
725
+ function listAuditPhaseTargets(planDir, cwd) {
726
+ const targets = [];
727
+ let activeUnreadable = false;
351
728
  const phasesDir = node_path_1.default.join(planDir, 'phases');
352
- if (!node_fs_1.default.existsSync(phasesDir))
353
- return [];
354
- let dirs;
729
+ if (node_fs_1.default.existsSync(phasesDir)) {
730
+ try {
731
+ const dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
732
+ .filter(e => e.isDirectory())
733
+ .map(e => e.name)
734
+ .sort();
735
+ for (const dir of dirs) {
736
+ targets.push({ dir, fullPath: node_path_1.default.join(phasesDir, dir) });
737
+ }
738
+ }
739
+ catch {
740
+ // Unreadable active root: skip it, do not abort the archive walk, but
741
+ // report it so callers can emit their pre-#3458 scan_error sentinel.
742
+ activeUnreadable = true;
743
+ }
744
+ }
745
+ // #3804: the audit is cross-workstream by design — the shared
746
+ // getAllArchivedPhaseDirs helper (root + every workstream, distinct
747
+ // '<ws>/<version>' labels) owns that walk.
355
748
  try {
356
- dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
357
- .filter(e => e.isDirectory())
358
- .map(e => e.name)
359
- .sort();
749
+ for (const archived of getAllArchivedPhaseDirs(cwd)) {
750
+ targets.push({ dir: archived.name, fullPath: archived.fullPath, milestone: archived.milestone });
751
+ }
360
752
  }
361
753
  catch {
362
- return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }];
754
+ // Unreadable/unresolvable archive root: skip it, keep whatever active
755
+ // targets were already collected. No sentinel — see docstring above.
363
756
  }
757
+ return { targets, activeUnreadable };
758
+ }
759
+ // ─── scanUatGaps ──────────────────────────────────────────────────────────────
760
+ /**
761
+ * Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
762
+ * for UAT gaps (UAT files with status != 'complete'/'resolved').
763
+ */
764
+ function scanUatGaps(planDir, cwd) {
364
765
  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;
766
+ let acknowledged = 0;
767
+ const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
768
+ if (activeUnreadable) {
769
+ results.push({ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 });
770
+ }
771
+ for (const target of targets) {
772
+ const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
773
+ const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
369
774
  let files;
370
775
  try {
371
- files = node_fs_1.default.readdirSync(phaseDir);
776
+ files = node_fs_1.default.readdirSync(target.fullPath);
372
777
  }
373
778
  catch {
374
779
  continue;
375
780
  }
376
- for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
377
- const filePath = node_path_1.default.join(phaseDir, file);
781
+ // Scoped to THIS phase's own token (#3511) so a stray, cross-phase, or
782
+ // ad-hoc UAT file cannot surface as this phase's gap — same fix as
783
+ // scanVerificationGaps below.
784
+ for (const file of scopeToPhase(files.filter(f => f.includes('-UAT') && f.endsWith('.md')), target.dir)) {
785
+ const filePath = node_path_1.default.join(target.fullPath, file);
378
786
  let safeFilePath;
379
787
  try {
380
788
  safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'UAT file', { allowAbsolute: true });
@@ -382,9 +790,17 @@ function scanUatGaps(planDir) {
382
790
  catch {
383
791
  continue;
384
792
  }
385
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
386
- if (content === null)
793
+ // #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
794
+ // document at this read boundary, same seam as `src/uat.cts`'s
795
+ // `readNormalizedDocument` — `platformReadSync` performs no line-ending
796
+ // normalization itself, and extractFrontmatter/status-derivation below
797
+ // degrade a lone-CR file's frontmatter to `unknown`, which every scan
798
+ // in this module treats as "not open" (fail-open, the permissive
799
+ // direction) rather than a real parse gap.
800
+ const rawContent = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
801
+ if (rawContent === null)
387
802
  continue;
803
+ const content = normalizeLineEndings(rawContent);
388
804
  const fm = extractFrontmatter(content, safeFilePath);
389
805
  const status = (fm.status || 'unknown').toLowerCase();
390
806
  const result = (fm.result || '').toLowerCase();
@@ -394,50 +810,52 @@ function scanUatGaps(planDir) {
394
810
  continue;
395
811
  if (status === 'unknown' && result === 'all_pass')
396
812
  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),
813
+ // Count open scenarios — computed BEFORE the acknowledged check
814
+ // (WARNING 2) so the snapshot comparison sees it too, not just status.
815
+ const pendingMatches = deriveUatGapOpenScenarioCount(content);
816
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'gap_snapshot', currentValue: deriveUatGapSnapshotValue(status, content) })) {
817
+ acknowledged++;
818
+ continue;
819
+ }
820
+ const item = {
821
+ phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
822
+ file: (0, security_cjs_1.sanitizeLabel)(file),
402
823
  status: (0, security_cjs_1.sanitizeForDisplay)(status),
403
824
  open_scenario_count: pendingMatches,
404
- });
825
+ };
826
+ if (target.milestone !== undefined)
827
+ item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
828
+ results.push(item);
405
829
  }
406
830
  }
407
- return results;
831
+ return { items: results, acknowledged };
408
832
  }
409
833
  // ─── scanVerificationGaps ─────────────────────────────────────────────────────
410
834
  /**
411
- * Scan .planning/phases for VERIFICATION gaps.
835
+ * Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
836
+ * for VERIFICATION gaps.
412
837
  */
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
- }
838
+ function scanVerificationGaps(planDir, cwd) {
427
839
  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;
840
+ let acknowledged = 0;
841
+ const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
842
+ if (activeUnreadable) {
843
+ results.push({ scan_error: true, phase: '', file: '', status: '' });
844
+ }
845
+ for (const target of targets) {
846
+ const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
847
+ const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
432
848
  let files;
433
849
  try {
434
- files = node_fs_1.default.readdirSync(phaseDir);
850
+ files = node_fs_1.default.readdirSync(target.fullPath);
435
851
  }
436
852
  catch {
437
853
  continue;
438
854
  }
439
- for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
440
- const filePath = node_path_1.default.join(phaseDir, file);
855
+ // Scoped to THIS phase's own token (#3511) so a stray, cross-phase, or
856
+ // ad-hoc VERIFICATION file cannot surface as this phase's gap.
857
+ for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), target.dir)) {
858
+ const filePath = node_path_1.default.join(target.fullPath, file);
441
859
  let safeFilePath;
442
860
  try {
443
861
  safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
@@ -445,54 +863,61 @@ function scanVerificationGaps(planDir) {
445
863
  catch {
446
864
  continue;
447
865
  }
448
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
449
- if (content === null)
866
+ // #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
867
+ // document at this read boundary, same seam as `src/uat.cts`'s
868
+ // `readNormalizedDocument` — `platformReadSync` performs no line-ending
869
+ // normalization itself, and extractFrontmatter/status-derivation below
870
+ // degrade a lone-CR file's frontmatter to `unknown`, which every scan
871
+ // in this module treats as "not open" (fail-open, the permissive
872
+ // direction) rather than a real parse gap.
873
+ const rawContent = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
874
+ if (rawContent === null)
450
875
  continue;
876
+ const content = normalizeLineEndings(rawContent);
451
877
  const fm = extractFrontmatter(content, safeFilePath);
452
878
  const status = (fm.status || 'unknown').toLowerCase();
453
879
  if (status !== 'gaps_found' && status !== 'human_needed')
454
880
  continue;
455
- results.push({
456
- phase: (0, security_cjs_1.sanitizeForDisplay)(phaseNum),
457
- file: (0, security_cjs_1.sanitizeForDisplay)(file),
881
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
882
+ acknowledged++;
883
+ continue;
884
+ }
885
+ const item = {
886
+ phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
887
+ file: (0, security_cjs_1.sanitizeLabel)(file),
458
888
  status: (0, security_cjs_1.sanitizeForDisplay)(status),
459
- });
889
+ };
890
+ if (target.milestone !== undefined)
891
+ item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
892
+ results.push(item);
460
893
  }
461
894
  }
462
- return results;
895
+ return { items: results, acknowledged };
463
896
  }
464
897
  // ─── scanContextQuestions ─────────────────────────────────────────────────────
465
898
  /**
466
- * Scan .planning/phases for CONTEXT files with open_questions.
899
+ * Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
900
+ * for CONTEXT files with open_questions.
467
901
  */
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
- }
902
+ function scanContextQuestions(planDir, cwd) {
482
903
  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;
904
+ let acknowledged = 0;
905
+ const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
906
+ if (activeUnreadable) {
907
+ results.push({ scan_error: true, phase: '', file: '', question_count: 0, questions: [] });
908
+ }
909
+ for (const target of targets) {
910
+ const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
911
+ const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
487
912
  let files;
488
913
  try {
489
- files = node_fs_1.default.readdirSync(phaseDir);
914
+ files = node_fs_1.default.readdirSync(target.fullPath);
490
915
  }
491
916
  catch {
492
917
  continue;
493
918
  }
494
919
  for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
495
- const filePath = node_path_1.default.join(phaseDir, file);
920
+ const filePath = node_path_1.default.join(target.fullPath, file);
496
921
  let safeFilePath;
497
922
  try {
498
923
  safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
@@ -500,46 +925,48 @@ function scanContextQuestions(planDir) {
500
925
  catch {
501
926
  continue;
502
927
  }
503
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
504
- if (content === null)
928
+ // #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
929
+ // document at this read boundary, same seam as `src/uat.cts`'s
930
+ // `readNormalizedDocument` — `platformReadSync` performs no line-ending
931
+ // normalization itself, and extractFrontmatter/status-derivation below
932
+ // degrade a lone-CR file's frontmatter to `unknown`, which every scan
933
+ // in this module treats as "not open" (fail-open, the permissive
934
+ // direction) rather than a real parse gap.
935
+ const rawContent = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
936
+ if (rawContent === null)
505
937
  continue;
938
+ const content = normalizeLineEndings(rawContent);
506
939
  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
- }
940
+ const questions = deriveOpenQuestions(content, fm);
528
941
  if (questions.length === 0)
529
942
  continue;
530
- results.push({
531
- phase: (0, security_cjs_1.sanitizeForDisplay)(phaseNum),
532
- file: (0, security_cjs_1.sanitizeForDisplay)(file),
943
+ // WARNING 2 (#3458 follow-up review): snapshot the QUESTIONS
944
+ // THEMSELVES (a content digest), not just their count — a count-only
945
+ // snapshot cannot see the same-count REPLACEMENT of every question
946
+ // with brand-new ones (measured: 2 acknowledged, then both swapped for
947
+ // unrelated new blockers, still suppressed under the old scheme).
948
+ if (isAuditItemAcknowledged(fm, { snapshotKey: 'questions_digest', currentValue: deriveOpenQuestionsDigest(questions) })) {
949
+ acknowledged++;
950
+ continue;
951
+ }
952
+ const item = {
953
+ phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
954
+ file: (0, security_cjs_1.sanitizeLabel)(file),
533
955
  question_count: questions.length,
534
- questions: questions.slice(0, 3),
535
- });
956
+ questions: questions.slice(0, 3).map(truncateQuestionForDisplay),
957
+ };
958
+ if (target.milestone !== undefined)
959
+ item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
960
+ results.push(item);
536
961
  }
537
962
  }
538
- return results;
963
+ return { items: results, acknowledged };
539
964
  }
540
965
  // ─── scanDeferredItems ────────────────────────────────────────────────────────
541
966
  /**
542
- * Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646).
967
+ * Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646),
968
+ * across both .planning/phases (active) and .planning/milestones/vX.Y-phases
969
+ * (archived).
543
970
  *
544
971
  * The SCOPE BOUNDARY convention (`agents/gsd-executor.md`) has a phase agent
545
972
  * log an out-of-scope discovery here rather than fix it. #2287 made that file
@@ -551,36 +978,32 @@ function scanContextQuestions(planDir) {
551
978
  * and the entry leaves the live tree having never been triaged.
552
979
  *
553
980
  * 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.
981
+ * already exports `parseDeferredItemsWithStatus`, which owns the parsing rule
982
+ * (entries under a `## Deferred Items` level-2 heading, else the whole file
983
+ * fail-safe) and — unlike `parseDeferredItems` — surfaces each entry's raw
984
+ * `status:` field instead of filtering `resolved` internally, so THIS scanner
985
+ * can apply the three-way split (#3458 follow-up): `resolved` (fixed for
986
+ * real — dropped, never counted, matching pre-existing behavior exactly),
987
+ * `acknowledged` (suppressed AND tallied — the new deferred_items marker;
988
+ * see the module doc comment above `isAuditItemAcknowledged`), else open.
989
+ * Duplicating either inequality is how two readers of the same file drift
990
+ * into disagreeing about what "open" means. The require is deliberately
991
+ * LAZY, inside the scan, to preserve `audit-command-router.cts`'s property
992
+ * that a route never loads the module it does not need.
561
993
  */
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
- }
994
+ function scanDeferredItems(planDir, cwd) {
995
+ const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
576
996
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
577
997
  const uat = require('./uat.cjs');
578
998
  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);
999
+ let acknowledged = 0;
1000
+ if (activeUnreadable) {
1001
+ results.push({ scan_error: true, phase: '', file: '', text: '' });
1002
+ }
1003
+ for (const target of targets) {
1004
+ const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
1005
+ const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
1006
+ const filePath = node_path_1.default.join(target.fullPath, DEFERRED_ITEMS_FILENAME);
584
1007
  if (!node_fs_1.default.existsSync(filePath))
585
1008
  continue;
586
1009
  let safeFilePath;
@@ -590,18 +1013,34 @@ function scanDeferredItems(planDir) {
590
1013
  catch {
591
1014
  continue;
592
1015
  }
593
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
594
- if (content === null)
1016
+ // #3078-CR MEDIUM 2: normalize at this read boundary too —
1017
+ // `parseDeferredItemsWithStatus` performs no normalization of its own
1018
+ // (unlike `src/uat.cts`'s callers, which route through
1019
+ // `readNormalizedDocument`), so a lone-CR `deferred-items.md` was read as
1020
+ // one unbroken line and every entry in it silently vanished.
1021
+ const rawContent = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
1022
+ if (rawContent === null)
595
1023
  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,
1024
+ const content = normalizeLineEndings(rawContent);
1025
+ for (const item of uat.parseDeferredItemsWithStatus(content)) {
1026
+ const rawStatus = (item.status || '').toLowerCase();
1027
+ if (rawStatus === 'resolved')
1028
+ continue; // fixed for real — never counted
1029
+ if (rawStatus === 'acknowledged') {
1030
+ acknowledged++;
1031
+ continue;
1032
+ }
1033
+ const resultItem = {
1034
+ phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
1035
+ file: (0, security_cjs_1.sanitizeLabel)(DEFERRED_ITEMS_FILENAME),
600
1036
  text: (0, security_cjs_1.sanitizeForDisplay)(item.name),
601
- });
1037
+ };
1038
+ if (target.milestone !== undefined)
1039
+ resultItem.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
1040
+ results.push(resultItem);
602
1041
  }
603
1042
  }
604
- return results;
1043
+ return { items: results, acknowledged };
605
1044
  }
606
1045
  // ─── auditOpenArtifacts ───────────────────────────────────────────────────────
607
1046
  /**
@@ -617,7 +1056,7 @@ function auditOpenArtifacts(cwd) {
617
1056
  return scanDebugSessions(planDir);
618
1057
  }
619
1058
  catch {
620
- return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
1059
+ return { items: [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }], acknowledged: 0 };
621
1060
  }
622
1061
  })();
623
1062
  const quickTasks = (() => {
@@ -625,7 +1064,7 @@ function auditOpenArtifacts(cwd) {
625
1064
  return scanQuickTasks(planDir);
626
1065
  }
627
1066
  catch {
628
- return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
1067
+ return { items: [{ scan_error: true, slug: '', date: '', status: '', description: '' }], acknowledged: 0 };
629
1068
  }
630
1069
  })();
631
1070
  const threads = (() => {
@@ -633,7 +1072,7 @@ function auditOpenArtifacts(cwd) {
633
1072
  return scanThreads(planDir);
634
1073
  }
635
1074
  catch {
636
- return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
1075
+ return { items: [{ scan_error: true, slug: '', status: '', updated: '', title: '' }], acknowledged: 0 };
637
1076
  }
638
1077
  })();
639
1078
  const todos = (() => {
@@ -641,7 +1080,7 @@ function auditOpenArtifacts(cwd) {
641
1080
  return scanTodos(planDir);
642
1081
  }
643
1082
  catch {
644
- return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
1083
+ return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 };
645
1084
  }
646
1085
  })();
647
1086
  const seeds = (() => {
@@ -649,70 +1088,97 @@ function auditOpenArtifacts(cwd) {
649
1088
  return scanSeeds(planDir);
650
1089
  }
651
1090
  catch {
652
- return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
1091
+ return { items: [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }], acknowledged: 0 };
653
1092
  }
654
1093
  })();
655
1094
  const uatGaps = (() => {
656
1095
  try {
657
- return scanUatGaps(planDir);
1096
+ return scanUatGaps(planDir, cwd);
658
1097
  }
659
1098
  catch {
660
- return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }];
1099
+ return { items: [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }], acknowledged: 0 };
661
1100
  }
662
1101
  })();
663
1102
  const verificationGaps = (() => {
664
1103
  try {
665
- return scanVerificationGaps(planDir);
1104
+ return scanVerificationGaps(planDir, cwd);
666
1105
  }
667
1106
  catch {
668
- return [{ scan_error: true, phase: '', file: '', status: '' }];
1107
+ return { items: [{ scan_error: true, phase: '', file: '', status: '' }], acknowledged: 0 };
669
1108
  }
670
1109
  })();
671
1110
  const contextQuestions = (() => {
672
1111
  try {
673
- return scanContextQuestions(planDir);
1112
+ return scanContextQuestions(planDir, cwd);
674
1113
  }
675
1114
  catch {
676
- return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
1115
+ return { items: [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }], acknowledged: 0 };
677
1116
  }
678
1117
  })();
679
1118
  const deferredItems = (() => {
680
1119
  try {
681
- return scanDeferredItems(planDir);
1120
+ return scanDeferredItems(planDir, cwd);
682
1121
  }
683
1122
  catch {
684
- return [{ scan_error: true, phase: '', file: '', text: '' }];
1123
+ return { items: [{ scan_error: true, phase: '', file: '', text: '' }], acknowledged: 0 };
685
1124
  }
686
1125
  })();
687
- // Count real items (not scan_error sentinels)
688
- const countReal = (arr) => arr.filter(i => !i.scan_error && !i._remainder_count).length;
1126
+ // Count real items (not scan_error sentinels). #3817: a `_remainder_count`
1127
+ // marker is not a phantom — it RECORDS real items the detail list truncated
1128
+ // away for display, so its value counts toward the total. Truncation limits
1129
+ // display, never counting; only scan_error (a read failure, not an item)
1130
+ // contributes zero.
1131
+ const countReal = (arr) => arr.reduce((sum, i) => {
1132
+ if (i.scan_error)
1133
+ return sum;
1134
+ if (typeof i._remainder_count === 'number')
1135
+ return sum + i._remainder_count;
1136
+ return sum + 1;
1137
+ }, 0);
689
1138
  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),
1139
+ debug_sessions: countReal(debugSessions.items),
1140
+ quick_tasks: countReal(quickTasks.items),
1141
+ threads: countReal(threads.items),
1142
+ todos: countReal(todos.items),
1143
+ seeds: countReal(seeds.items),
1144
+ uat_gaps: countReal(uatGaps.items),
1145
+ verification_gaps: countReal(verificationGaps.items),
1146
+ context_questions: countReal(contextQuestions.items),
1147
+ deferred_items: countReal(deferredItems.items),
699
1148
  total: 0,
700
1149
  };
701
1150
  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;
1151
+ // #3458 follow-up (A5): mirrors `counts`'s shape exactly, so a reviewer can
1152
+ // tell "clean because fixed" apart from "clean because silenced" without a
1153
+ // second output contract to learn.
1154
+ const acknowledged = {
1155
+ debug_sessions: debugSessions.acknowledged,
1156
+ quick_tasks: quickTasks.acknowledged,
1157
+ threads: threads.acknowledged,
1158
+ todos: todos.acknowledged,
1159
+ seeds: seeds.acknowledged,
1160
+ uat_gaps: uatGaps.acknowledged,
1161
+ verification_gaps: verificationGaps.acknowledged,
1162
+ context_questions: contextQuestions.acknowledged,
1163
+ deferred_items: deferredItems.acknowledged,
1164
+ total: 0,
1165
+ };
1166
+ 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
1167
  return {
703
1168
  scanned_at: new Date().toISOString(),
704
1169
  has_open_items: counts.total > 0,
705
1170
  counts,
1171
+ acknowledged,
706
1172
  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,
1173
+ debug_sessions: debugSessions.items,
1174
+ quick_tasks: quickTasks.items,
1175
+ threads: threads.items,
1176
+ todos: todos.items,
1177
+ seeds: seeds.items,
1178
+ uat_gaps: uatGaps.items,
1179
+ verification_gaps: verificationGaps.items,
1180
+ context_questions: contextQuestions.items,
1181
+ deferred_items: deferredItems.items,
716
1182
  },
717
1183
  };
718
1184
  }
@@ -724,23 +1190,33 @@ function auditOpenArtifacts(cwd) {
724
1190
  * @returns Formatted report
725
1191
  */
726
1192
  function formatAuditReport(auditResult) {
727
- const { counts, items, has_open_items } = auditResult;
1193
+ const { counts, items, has_open_items, acknowledged } = auditResult;
728
1194
  const lines = [];
729
- const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
730
- lines.push(hr);
731
- lines.push(' Milestone Close: Open Artifact Audit');
732
- lines.push(hr);
1195
+ lines.push('### Milestone Close: Open Artifact Audit');
1196
+ // WARNING 3 (#3458 follow-up review): the acknowledged tally previously
1197
+ // existed only in `--json` output — the human report could not tell
1198
+ // "clean because fixed" apart from "clean because silenced", which is the
1199
+ // exact distinction the acknowledged/counts split exists to preserve.
733
1200
  if (!has_open_items) {
734
1201
  lines.push('');
735
- lines.push(' All artifact types clear. Safe to proceed.');
1202
+ if (acknowledged.total > 0) {
1203
+ lines.push(`All artifact types clear (${acknowledged.total} previously acknowledged item${acknowledged.total !== 1 ? 's' : ''} still suppressed).`);
1204
+ }
1205
+ else {
1206
+ lines.push('All artifact types clear. Safe to proceed.');
1207
+ }
736
1208
  lines.push('');
737
- lines.push(hr);
1209
+ lines.push('---');
738
1210
  return lines.join('\n');
739
1211
  }
1212
+ // WARNING 3: per-category "N previously acknowledged" suffix, so the
1213
+ // human report carries the same "clean vs silenced" signal `--json`
1214
+ // already did via `acknowledged`.
1215
+ const ackSuffix = (n) => (n > 0 ? `, ${n} previously acknowledged` : '');
740
1216
  // Debug sessions (blocking quality — red)
741
1217
  if (counts.debug_sessions > 0) {
742
1218
  lines.push('');
743
- lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open)`);
1219
+ lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open${ackSuffix(acknowledged.debug_sessions)})`);
744
1220
  for (const item of items.debug_sessions.filter(i => !i.scan_error)) {
745
1221
  const hyp = item.hypothesis ? ` — ${item.hypothesis}` : '';
746
1222
  lines.push(` • ${item.slug} [${item.status}]${hyp}`);
@@ -749,23 +1225,25 @@ function formatAuditReport(auditResult) {
749
1225
  // UAT gaps (blocking quality — red)
750
1226
  if (counts.uat_gaps > 0) {
751
1227
  lines.push('');
752
- lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT)`);
1228
+ lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT${ackSuffix(acknowledged.uat_gaps)})`);
753
1229
  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`);
1230
+ const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
1231
+ lines.push(` • Phase ${item.phase}${archived}: ${item.file} [${item.status}] — ${item.open_scenario_count} pending scenarios`);
755
1232
  }
756
1233
  }
757
1234
  // Verification gaps (blocking quality — red)
758
1235
  if (counts.verification_gaps > 0) {
759
1236
  lines.push('');
760
- lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved)`);
1237
+ lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved${ackSuffix(acknowledged.verification_gaps)})`);
761
1238
  for (const item of items.verification_gaps.filter(i => !i.scan_error)) {
762
- lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}]`);
1239
+ const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
1240
+ lines.push(` • Phase ${item.phase}${archived}: ${item.file} [${item.status}]`);
763
1241
  }
764
1242
  }
765
1243
  // Quick tasks (incomplete work — yellow)
766
1244
  if (counts.quick_tasks > 0) {
767
1245
  lines.push('');
768
- lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete)`);
1246
+ lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete${ackSuffix(acknowledged.quick_tasks)})`);
769
1247
  for (const item of items.quick_tasks.filter(i => !i.scan_error)) {
770
1248
  const d = item.date ? ` (${item.date})` : '';
771
1249
  lines.push(` • ${item.slug}${d} [${item.status}]`);
@@ -776,7 +1254,7 @@ function formatAuditReport(auditResult) {
776
1254
  const realTodos = items.todos.filter(i => !i.scan_error && !i._remainder_count);
777
1255
  const remainder = items.todos.find(i => i._remainder_count);
778
1256
  lines.push('');
779
- lines.push(`🟡 Pending Todos (${counts.todos} pending)`);
1257
+ lines.push(`🟡 Pending Todos (${counts.todos} pending${ackSuffix(acknowledged.todos)})`);
780
1258
  for (const item of realTodos) {
781
1259
  const area = item.area ? ` [${item.area}]` : '';
782
1260
  const pri = item.priority ? ` (${item.priority})` : '';
@@ -791,7 +1269,7 @@ function formatAuditReport(auditResult) {
791
1269
  // Threads (deferred decisions — blue)
792
1270
  if (counts.threads > 0) {
793
1271
  lines.push('');
794
- lines.push(`🔵 Open Threads (${counts.threads} active)`);
1272
+ lines.push(`🔵 Open Threads (${counts.threads} active${ackSuffix(acknowledged.threads)})`);
795
1273
  for (const item of items.threads.filter(i => !i.scan_error)) {
796
1274
  const title = item.title ? ` — ${item.title}` : '';
797
1275
  lines.push(` • ${item.slug} [${item.status}]${title}`);
@@ -800,7 +1278,7 @@ function formatAuditReport(auditResult) {
800
1278
  // Seeds (deferred decisions — blue)
801
1279
  if (counts.seeds > 0) {
802
1280
  lines.push('');
803
- lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending)`);
1281
+ lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending${ackSuffix(acknowledged.seeds)})`);
804
1282
  for (const item of items.seeds.filter(i => !i.scan_error)) {
805
1283
  const title = item.title ? ` — ${item.title}` : '';
806
1284
  lines.push(` • ${item.seed_id} [${item.status}]${title}`);
@@ -809,9 +1287,10 @@ function formatAuditReport(auditResult) {
809
1287
  // Context questions (deferred decisions — blue)
810
1288
  if (counts.context_questions > 0) {
811
1289
  lines.push('');
812
- lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions)`);
1290
+ lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions${ackSuffix(acknowledged.context_questions)})`);
813
1291
  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' : ''})`);
1292
+ const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
1293
+ lines.push(` • Phase ${item.phase}${archived}: ${item.file} (${item.question_count} question${item.question_count !== 1 ? 's' : ''})`);
815
1294
  for (const q of item.questions) {
816
1295
  lines.push(` - ${q}`);
817
1296
  }
@@ -821,15 +1300,294 @@ function formatAuditReport(auditResult) {
821
1300
  // phase agent recorded rather than fixed, still unresolved at close (#2646).
822
1301
  if (counts.deferred_items > 0) {
823
1302
  lines.push('');
824
- lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved)`);
1303
+ lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved${ackSuffix(acknowledged.deferred_items)})`);
825
1304
  for (const item of items.deferred_items.filter(i => !i.scan_error)) {
826
- lines.push(` • Phase ${item.phase}: ${item.text}`);
1305
+ const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
1306
+ lines.push(` • Phase ${item.phase}${archived}: ${item.text}`);
827
1307
  }
828
1308
  }
829
1309
  lines.push('');
830
- lines.push(hr);
831
- lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`);
832
- lines.push(hr);
1310
+ lines.push('---');
1311
+ lines.push('');
1312
+ lines.push(`**${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.**`);
1313
+ if (acknowledged.total > 0) {
1314
+ lines.push(`${acknowledged.total} previously acknowledged item${acknowledged.total !== 1 ? 's' : ''} also suppressed above the ${counts.total} open item${counts.total !== 1 ? 's' : ''}.`);
1315
+ }
833
1316
  return lines.join('\n');
834
1317
  }
835
- module.exports = { auditOpenArtifacts, formatAuditReport };
1318
+ // ─── resolvePhaseTargetDir ─────────────────────────────────────────────────────
1319
+ /**
1320
+ * Resolve ONE phase directory (active or archived) by its phase token, for
1321
+ * `cmdAuditAcknowledge`'s `--phase [--archived-milestone]` identification of
1322
+ * a uat_gaps/verification_gaps/context_questions/deferred_items item. Built
1323
+ * on `listAuditPhaseTargets` — the same enumeration the four phase-scoped
1324
+ * scanners use — so the writer can never resolve a DIFFERENT directory than
1325
+ * the one the audit actually scanned.
1326
+ *
1327
+ * `archivedMilestone` absent → matches the ACTIVE `.planning/phases/<dir>`
1328
+ * (a target with no `milestone`). Present → matches the archived target
1329
+ * whose `milestone` equals it exactly — the same disambiguator the audit
1330
+ * output's `archived_milestone` field carries.
1331
+ */
1332
+ function resolvePhaseTargetDir(planDir, cwd, phase, archivedMilestone) {
1333
+ const { targets } = listAuditPhaseTargets(planDir, cwd);
1334
+ const phaseTokenRe = new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i');
1335
+ for (const target of targets) {
1336
+ const phaseMatch = target.dir.match(phaseTokenRe);
1337
+ const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
1338
+ if (phaseNum !== phase)
1339
+ continue;
1340
+ if (archivedMilestone) {
1341
+ if (target.milestone === archivedMilestone)
1342
+ return target.fullPath;
1343
+ }
1344
+ else if (target.milestone === undefined) {
1345
+ return target.fullPath;
1346
+ }
1347
+ }
1348
+ return null;
1349
+ }
1350
+ // ─── cmdAuditAcknowledge ────────────────────────────────────────────────────────
1351
+ /**
1352
+ * CLI writer for the #3458 follow-up suppression seam (design point A4). Sets
1353
+ * (or refreshes) the `audit_acknowledged` marker on ONE identified artifact,
1354
+ * snapshotting its CURRENT effective state itself so the marker is never
1355
+ * hand-authored and can never drift from what the scanners actually compute.
1356
+ *
1357
+ * `--category` selects which of the nine audit categories is being
1358
+ * acknowledged, and which OTHER flags are required to identify the artifact —
1359
+ * mirroring the fields the audit's OWN JSON output already carries per
1360
+ * category (phase/file/archived_milestone for the four phase-scoped
1361
+ * categories; slug/seed-id/dir/filename for the five flat ones), the same
1362
+ * convention `frontmatter get/set/merge/validate` uses for `--file`/`--field`.
1363
+ *
1364
+ * VERDICT-PRESERVING: this function never writes to the artifact's own
1365
+ * `status:` field (the audit's real verdict) for the 8 frontmatter-marker
1366
+ * categories — only the sibling `audit_acknowledged` map. `deferred_items` is
1367
+ * the sole, deliberate exception (see `uat.cts`'s `acknowledgeDeferredItem`):
1368
+ * there, the marker IS the entry's own `status:` field, because a
1369
+ * deferred-items.md entry carries no OTHER meaning for that field.
1370
+ *
1371
+ * Every path this function writes is routed through `requireSafePath`, so an
1372
+ * artifact identifier that resolves outside the project is refused before
1373
+ * any read or write is attempted.
1374
+ */
1375
+ function cmdAuditAcknowledge(cwd, args, raw) {
1376
+ // args already has the family + subcommand tokens stripped by the caller
1377
+ // (audit-command-router.cts:147 passes `hubArgs.slice(2)`), so validation
1378
+ // begins at index 0 — there is no positional this handler owns itself.
1379
+ const { category, milestone, at: atFlag, phase, file, 'archived-milestone': archivedMilestone, slug, 'seed-id': seedId, dir: quickDir, filename, text, } = (0, command_arg_projection_cjs_1.parseNamedArgsOrExit)(args, {
1380
+ valueFlags: [
1381
+ 'category', 'milestone', 'at',
1382
+ 'phase', 'file', 'archived-milestone',
1383
+ 'slug', 'seed-id', 'dir', 'filename', 'text',
1384
+ ],
1385
+ positionals: 0,
1386
+ }, ioError);
1387
+ if (!category)
1388
+ ioError('--category is required');
1389
+ if (!milestone)
1390
+ ioError('--milestone is required');
1391
+ // All declared flags above are value flags, so each resolves to `string |
1392
+ // null` at runtime; the cast narrows away the `boolean` arm of
1393
+ // ParsedNamedArgs's value type that this call site never produces.
1394
+ const at = atFlag || new Date().toISOString().slice(0, 10);
1395
+ const planDir = planningDir(cwd);
1396
+ const markerBase = { milestone: milestone, at };
1397
+ // #3078-CR MEDIUM 2: every `fs.readFileSync` in this function (below, and
1398
+ // in the flat-category branch further down) is DELIBERATELY left raw,
1399
+ // unlike `auditOpenArtifacts`'s scan reads (which now route through
1400
+ // `normalizeLineEndings`). This function splices frontmatter into the
1401
+ // EXISTING content and writes the result back via `platformWriteSync` /
1402
+ // `uat.acknowledgeDeferredItem` — both `spliceFrontmatter` and
1403
+ // `acknowledgeDeferredItem` locate and rewrite a specific byte span
1404
+ // (frontmatter block / matched deferred-item text) in the file exactly as
1405
+ // it exists on disk. Normalizing first would rewrite the file's line
1406
+ // endings as a side effect of an unrelated acknowledge operation, and a
1407
+ // splice computed against normalized text can land at the wrong offset
1408
+ // when written back over the RAW (un-normalized) original. The snapshot
1409
+ // VALUE computed below IS normalized (on a separate in-memory copy, never
1410
+ // the spliced one) so it agrees with the scanner's frame — see the comment
1411
+ // at `normalizedContent` further down.
1412
+ // ── The four phase-scoped categories: --phase --file [--archived-milestone] ──
1413
+ const PHASE_SCOPED = new Set(['uat_gaps', 'verification_gaps', 'context_questions', 'deferred_items']);
1414
+ if (PHASE_SCOPED.has(category)) {
1415
+ if (!phase)
1416
+ ioError('--phase is required for this --category');
1417
+ if (!file)
1418
+ ioError('--file is required for this --category');
1419
+ const targetDir = resolvePhaseTargetDir(planDir, cwd, phase, archivedMilestone);
1420
+ if (!targetDir) {
1421
+ ioError(`no phase directory found for phase "${phase}"${archivedMilestone ? ` (archived-milestone "${archivedMilestone}")` : ''}`);
1422
+ }
1423
+ const filePath = node_path_1.default.join(targetDir, file);
1424
+ const safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'audit acknowledge target', { allowAbsolute: true });
1425
+ if (!node_fs_1.default.existsSync(safeFilePath))
1426
+ ioError(`file not found: ${file}`);
1427
+ if (category === 'deferred_items') {
1428
+ if (!text)
1429
+ ioError('--text is required for --category deferred_items');
1430
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
1431
+ const uat = require('./uat.cjs');
1432
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1433
+ const result = uat.acknowledgeDeferredItem(content, text);
1434
+ if (result.status === 'not_found')
1435
+ ioError(`no deferred item matched --text "${text}"`);
1436
+ if (result.status === 'ambiguous')
1437
+ ioError(`--text "${text}" matches more than one deferred item — text must be unique`);
1438
+ if (result.status === 'already_resolved')
1439
+ ioError(`deferred item is already "status: resolved" — acknowledging a resolved item is a no-op`);
1440
+ if (result.status === 'unsupported_heading_shape') {
1441
+ // #3781: heading-shaped entries are supported; the remaining refusal
1442
+ // cause is a GFM table row embedded in the entry's span (non-contiguous
1443
+ // — a write cannot be anchored safely).
1444
+ ioError('this deferred item\'s span embeds a GFM table row, so the CLI writer cannot anchor a safe write to it — edit the file directly');
1445
+ }
1446
+ if (result.status === 'match_verification_failed') {
1447
+ ioError(`internal error: matched span for --text "${text}" did not re-verify before write — refused rather than risk writing the wrong entry`);
1448
+ }
1449
+ (0, shell_command_projection_cjs_2.platformWriteSync)(safeFilePath, result.content);
1450
+ output({ acknowledged: true, category, phase, file, text }, raw, 'true');
1451
+ return;
1452
+ }
1453
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1454
+ const fm = extractFrontmatter(content, safeFilePath);
1455
+ // Mixed-frame fix (security review, 4th instance on this branch): the
1456
+ // splice above and below stays keyed to RAW `content` (raw byte offsets
1457
+ // must not shift), but `scanUatGaps`/`scanContextQuestions` now derive
1458
+ // their comparison values from `normalizeLineEndings`d content. Deriving
1459
+ // the snapshot here from raw `content` would make a lone-CR file's
1460
+ // stored value permanently disagree with what the scanner recomputes —
1461
+ // `audit acknowledge` would be a silent no-op for lone-CR artifacts. Feed
1462
+ // the derive functions a normalized COPY; never splice from it.
1463
+ const normalizedContent = normalizeLineEndings(content);
1464
+ let snapshotKey;
1465
+ let currentValue;
1466
+ if (category === 'uat_gaps') {
1467
+ // WARNING 2 (#3458 follow-up review): status alone can't see MORE
1468
+ // pending scenarios added under the same status — snapshot the
1469
+ // composite `deriveUatGapSnapshotValue` instead (see its doc comment).
1470
+ snapshotKey = 'gap_snapshot';
1471
+ currentValue = deriveUatGapSnapshotValue((fm.status || 'unknown').toLowerCase(), normalizedContent);
1472
+ }
1473
+ else if (category === 'verification_gaps') {
1474
+ snapshotKey = 'status';
1475
+ currentValue = (fm.status || 'unknown').toLowerCase();
1476
+ }
1477
+ else {
1478
+ // context_questions — WARNING 2: snapshot a content digest of the
1479
+ // question set, not just its count (see `deriveOpenQuestionsDigest`'s
1480
+ // doc comment).
1481
+ snapshotKey = 'questions_digest';
1482
+ currentValue = deriveOpenQuestionsDigest(deriveOpenQuestions(normalizedContent, fm));
1483
+ }
1484
+ fm.audit_acknowledged = { ...markerBase, [snapshotKey]: currentValue };
1485
+ const newContent = spliceFrontmatter(content, fm);
1486
+ (0, shell_command_projection_cjs_2.platformWriteSync)(safeFilePath, newContent);
1487
+ output({ acknowledged: true, category, phase, file, [snapshotKey]: currentValue }, raw, 'true');
1488
+ return;
1489
+ }
1490
+ // ── The five flat categories: category-specific identifier flag ──
1491
+ // `status` for all five per the architecture's per-category table (`todos`
1492
+ // is presence-only and never reads `snapshotKey`, so it stays a constant).
1493
+ const snapshotKey = 'status';
1494
+ let safeFilePath;
1495
+ let currentValue;
1496
+ let createIfMissing = false;
1497
+ // Same value shape `Frontmatter`/`extractFrontmatter` use (frontmatter.cts
1498
+ // does not export the `Frontmatter` type name itself, so it is spelled out
1499
+ // structurally here) — keeps this and `extractFrontmatter`'s return type
1500
+ // unifying to the SAME type below instead of a lossy `Record<string,
1501
+ // unknown>` that `spliceFrontmatter`'s `Frontmatter` parameter would reject.
1502
+ let fmForCreate = {};
1503
+ if (category === 'debug_sessions') {
1504
+ if (!slug)
1505
+ ioError('--slug is required for --category debug_sessions');
1506
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'debug', `${slug}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1507
+ if (!node_fs_1.default.existsSync(safeFilePath))
1508
+ ioError(`file not found: debug/${slug}.md`);
1509
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1510
+ currentValue = (extractFrontmatter(content, safeFilePath).status || 'unknown').toLowerCase();
1511
+ }
1512
+ else if (category === 'threads') {
1513
+ if (!slug)
1514
+ ioError('--slug is required for --category threads');
1515
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'threads', `${slug}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1516
+ if (!node_fs_1.default.existsSync(safeFilePath))
1517
+ ioError(`file not found: threads/${slug}.md`);
1518
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1519
+ currentValue = deriveThreadStatus(extractFrontmatter(content, safeFilePath), content);
1520
+ }
1521
+ else if (category === 'seeds') {
1522
+ if (!seedId)
1523
+ ioError('--seed-id is required for --category seeds');
1524
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'seeds', `${seedId}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1525
+ if (!node_fs_1.default.existsSync(safeFilePath))
1526
+ ioError(`file not found: seeds/${seedId}.md`);
1527
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1528
+ currentValue = (extractFrontmatter(content, safeFilePath).status || 'dormant').toLowerCase();
1529
+ }
1530
+ else if (category === 'todos') {
1531
+ if (!filename)
1532
+ ioError('--filename is required for --category todos');
1533
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'todos', 'pending', filename), planDir, 'audit acknowledge target', { allowAbsolute: true });
1534
+ if (!node_fs_1.default.existsSync(safeFilePath))
1535
+ ioError(`file not found: todos/pending/${filename}`);
1536
+ currentValue = ''; // presence-only — see scanTodos
1537
+ }
1538
+ else if (category === 'quick_tasks') {
1539
+ if (!quickDir)
1540
+ ioError('--dir is required for --category quick_tasks');
1541
+ const taskDir = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'quick', quickDir), planDir, 'audit acknowledge target dir', { allowAbsolute: true });
1542
+ if (!node_fs_1.default.existsSync(taskDir))
1543
+ ioError(`directory not found: quick/${quickDir}`);
1544
+ // Shared with scanQuickTasks (#3458 follow-up) so the reader and this
1545
+ // writer can never disagree about which file is the task's record.
1546
+ const resolvedSummaryPath = resolveQuickTaskSummaryFile(taskDir, quickDir);
1547
+ if (resolvedSummaryPath) {
1548
+ safeFilePath = (0, security_cjs_1.requireSafePath)(resolvedSummaryPath, planDir, 'audit acknowledge target', { allowAbsolute: true });
1549
+ const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
1550
+ currentValue = (extractFrontmatter(content, safeFilePath).status || 'unknown').toLowerCase();
1551
+ }
1552
+ else {
1553
+ // No SUMMARY.md at all — the audit's own observed status is 'missing'.
1554
+ // There is nowhere to carry the marker, so create the canonical
1555
+ // `${dir}-SUMMARY.md` with ONLY `status: missing` + the marker — the
1556
+ // acknowledgment's own snapshot of "no summary exists yet", which
1557
+ // self-invalidates the moment a real SUMMARY.md is written (the
1558
+ // scanner then reads THAT file's own status instead).
1559
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(taskDir, `${quickDir}-SUMMARY.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
1560
+ currentValue = 'missing';
1561
+ createIfMissing = true;
1562
+ fmForCreate = { status: 'missing' };
1563
+ }
1564
+ }
1565
+ else {
1566
+ ioError(`unknown --category "${category}". Available: debug_sessions, quick_tasks, threads, todos, seeds, uat_gaps, verification_gaps, context_questions, deferred_items`);
1567
+ return; // unreachable — ioError throws — satisfies TS control-flow analysis
1568
+ }
1569
+ const presenceOnly = category === 'todos';
1570
+ const fm = createIfMissing ? fmForCreate : extractFrontmatter(node_fs_1.default.readFileSync(safeFilePath, 'utf-8'), safeFilePath);
1571
+ fm.audit_acknowledged = presenceOnly ? { ...markerBase } : { ...markerBase, [snapshotKey]: currentValue };
1572
+ const newContent = createIfMissing
1573
+ ? spliceFrontmatter('', fm)
1574
+ : spliceFrontmatter(node_fs_1.default.readFileSync(safeFilePath, 'utf-8'), fm);
1575
+ (0, shell_command_projection_cjs_2.platformWriteSync)(safeFilePath, newContent);
1576
+ output({ acknowledged: true, category, ...(presenceOnly ? {} : { [snapshotKey]: currentValue }) }, raw, 'true');
1577
+ }
1578
+ module.exports = {
1579
+ auditOpenArtifacts,
1580
+ formatAuditReport,
1581
+ listAuditPhaseTargets,
1582
+ cmdAuditAcknowledge,
1583
+ // #3805: exported so uat.cts's cmdAuditUat routes the SAME artifacts'
1584
+ // suppression through the ONE predicate instead of hand-rolling a tenth
1585
+ // copy outside this file's visibility (the exact defect class the
1586
+ // predicate's own header warns about). The snapshot derivations ride
1587
+ // along so the snapshotKeys cannot drift between the two consumers.
1588
+ isAuditItemAcknowledged,
1589
+ deriveUatGapSnapshotValue,
1590
+ // #2142: exported so src/milestone.cts's archiveQuickTaskDirectories README
1591
+ // index generator shares this ONE discovery rule rather than re-deriving it.
1592
+ resolveQuickTaskSummaryFile,
1593
+ };