@opengsd/gsd-core 1.9.1 → 1.11.0

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