@opengsd/gsd-core 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -19,16 +19,13 @@ const io = require("./io.cjs");
19
19
  const { output, error } = io;
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
21
  const markdownSectionizer = require("./markdown-sectionizer.cjs");
22
- const { collectSection, tokenizeHeadings } = markdownSectionizer;
22
+ const { collectSection, tokenizeHeadings, stripFencedCode, scanFencedBlocks } = markdownSectionizer;
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const markdownTable = require("./markdown-table.cjs");
25
25
  const { splitTableRow, isDelimiterRow } = markdownTable;
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
- const roadmapParser = require("./roadmap-parser.cjs");
28
- const { getMilestonePhaseFilter } = roadmapParser;
29
- // eslint-disable-next-line @typescript-eslint/no-require-imports
30
27
  const coreUtils = require("./core-utils.cjs");
31
- const { toPosixPath } = coreUtils;
28
+ const { toPosixPath, normalizeLineEndings } = coreUtils;
32
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
33
30
  const planningWorkspace = require("./planning-workspace.cjs");
34
31
  const { planningDir } = planningWorkspace;
@@ -37,15 +34,48 @@ const frontmatter = require("./frontmatter.cjs");
37
34
  const { extractFrontmatter } = frontmatter;
38
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
36
  const phaseIdMod = require("./phase-id.cjs");
40
- const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
37
+ const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
41
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
39
  const phaseLocator = require("./phase-locator.cjs");
43
- const { getArchivedPhaseDirs } = phaseLocator;
40
+ const { listMilestonePhaseDirs, getAllArchivedPhaseDirs } = phaseLocator;
41
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
42
+ const auditMod = require("./audit.cjs");
43
+ const { isAuditItemAcknowledged, deriveUatGapSnapshotValue } = auditMod;
44
44
  const security_cjs_1 = require("./security.cjs");
45
45
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
46
46
  const configLoader = require("./config-loader.cjs");
47
47
  const { loadConfig } = configLoader;
48
48
  // ─── cmdAuditUat ─────────────────────────────────────────────────────────────
49
+ /**
50
+ * Select the UAT documents belonging to ONE phase directory.
51
+ *
52
+ * Extracted (#2790) so `cmdAuditUat` and the read-only `planning.inspect` query
53
+ * cannot drift on which files count as this phase's UAT. `scopeToPhase` has no
54
+ * unfiltered fallback on purpose: a phase whose own UAT file is genuinely absent
55
+ * scopes to empty and contributes nothing, rather than picking up a stray
56
+ * cross-phase file (#3511).
57
+ */
58
+ function selectPhaseUatFiles(files, phaseDirName) {
59
+ return scopeToPhase(files.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseDirName);
60
+ }
61
+ /**
62
+ * The ONE read boundary for every document `cmdAuditUat` scans off disk
63
+ * (#3707-CR follow-up MAJOR). Wraps `fs.readFileSync` +
64
+ * `normalizeLineEndings` in a single seam so a lone-CR-separated
65
+ * `*-UAT.md`, `*-VERIFICATION.md`, or `deferred-items.md` is normalized BY
66
+ * CONSTRUCTION before it reaches ANY downstream parser — current
67
+ * (`parseUatItemsWithStats`, `parseVerificationItems`, `parseDeferredItems`)
68
+ * or future. Fixing this per-parser was the original (#3707-CR) MEDIUM fix's
69
+ * mistake: two of the four ingresses in this function were normalized by
70
+ * editing their own parsers directly, and the other two (VERIFICATION,
71
+ * deferred-items.md) were missed precisely because nothing forced a new call
72
+ * site to remember the step. Routing every read through this function
73
+ * removes that failure mode: a parser added later needs no line-ending logic
74
+ * of its own, because the text it receives is already normalized.
75
+ */
76
+ function readNormalizedDocument(filePath) {
77
+ return normalizeLineEndings(node_fs_1.default.readFileSync(filePath, 'utf-8'));
78
+ }
49
79
  function cmdAuditUat(cwd, raw) {
50
80
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
51
81
  const hasActivePhases = node_fs_1.default.existsSync(phasesDir);
@@ -58,26 +88,29 @@ function cmdAuditUat(cwd, raw) {
58
88
  // mattering when a milestone closes: a deferred human-UAT scenario or a
59
89
  // `skipped` live-stack test is exactly what gets archived still-open.
60
90
  //
61
- // Reuses the canonical `getArchivedPhaseDirs` seam (phase-locator.cts), which
91
+ // Reuses the canonical `getAllArchivedPhaseDirs` seam (phase-locator.cts), which
62
92
  // `findPhaseInternal` already uses for this same fallback, so the archive
63
93
  // layout convention stays owned by one module.
64
- const archivedDirs = getArchivedPhaseDirs(cwd);
94
+ // #3804: the guard AND the scan use the cross-workstream enumeration —
95
+ // a project whose only phases live in workstream milestone trees is a
96
+ // fully-populated audit, not a broken install.
97
+ const archivedDirs = getAllArchivedPhaseDirs(cwd);
65
98
  if (!hasActivePhases && archivedDirs.length === 0) {
66
99
  error('No phases directory found in planning directory');
67
100
  }
68
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
69
101
  const results = [];
102
+ let acknowledgedFiles = 0;
70
103
  // Active dirs are milestone-filtered; archived dirs deliberately are NOT.
71
- // getMilestonePhaseFilter derives the CURRENT milestone's phase numbers from
72
- // ROADMAP.md, and archived phases belong to past milestones by definition — so
73
- // applying it to them discards every one and silently reinstates the bug.
104
+ // listMilestonePhaseDirs derives the CURRENT milestone's phase directories
105
+ // (window + sentinel filtered) from ROADMAP.md, and archived phases belong
106
+ // to past milestones by definition — so applying it to them discards every
107
+ // one and silently reinstates the bug.
74
108
  const scanTargets = [];
75
109
  if (hasActivePhases) {
76
- const dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
77
- .filter(e => e.isDirectory())
78
- .map(e => e.name)
79
- .filter(isDirInMilestone)
80
- .sort();
110
+ // #3185 (ADR-3180 Decision 1): routed through the canonical owner
111
+ // instead of a hand-rolled readdirSync + isDirInMilestone filter, which
112
+ // also never excluded sentinels, unlike the owner.
113
+ const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
81
114
  for (const dir of dirs) {
82
115
  scanTargets.push({ dir, phaseDir: node_path_1.default.join(phasesDir, dir) });
83
116
  }
@@ -93,30 +126,94 @@ function cmdAuditUat(cwd, raw) {
93
126
  const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
94
127
  const phaseNum = phaseMatch ? phaseMatch[1] : dir;
95
128
  const files = node_fs_1.default.readdirSync(phaseDir);
96
- // Process UAT files
97
- for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
129
+ // Process UAT files — scoped to THIS phase's own token (#3511) via
130
+ // scopeToPhase, so a stray, cross-phase, or ad-hoc file cannot be reported
131
+ // under this phase's audit-uat entry. A phase whose own UAT file is
132
+ // genuinely absent scopes to empty and contributes nothing — correct, and
133
+ // the reason scopeToPhase has no unfiltered fallback.
134
+ for (const file of selectPhaseUatFiles(files, dir)) {
98
135
  const uatFilePath = node_path_1.default.join(phaseDir, file);
99
- const content = node_fs_1.default.readFileSync(uatFilePath, 'utf-8');
100
- const items = parseUatItems(content);
101
- if (items.length > 0) {
102
- results.push({
136
+ const content = readNormalizedDocument(uatFilePath);
137
+ const { items, headingsSeen } = parseUatItemsWithStats(content);
138
+ const uatFm = extractFrontmatter(content, uatFilePath);
139
+ const status = (uatFm.status || 'unknown').toLowerCase();
140
+ // #3805: honour the audit_acknowledged marker with the SAME snapshot
141
+ // key audit.cts's scanUatGaps uses ('gap_snapshot', derived value
142
+ // composed by the shared derivation) — one acknowledgement means the
143
+ // same thing to both commands.
144
+ if (isAuditItemAcknowledged(uatFm, { snapshotKey: 'gap_snapshot', currentValue: deriveUatGapSnapshotValue(status, content) })) {
145
+ acknowledgedFiles++;
146
+ continue;
147
+ }
148
+ // `parse_gap` means the file contained `### N.` test blocks that
149
+ // yielded no items — NOT merely "zero items and not complete" (#3707
150
+ // MAJOR: that broader signal false-positived on an all-pass file and on
151
+ // a Gaps-only file with everything resolved). A file whose blocks all
152
+ // passed, or that has no test blocks at all, never sets `headingsSeen`,
153
+ // so it never sets the flag regardless of status.
154
+ //
155
+ // `status` deliberately does NOT gate this (#3078 security review). A
156
+ // terminal `status: complete` is an ASSERTION BY THE AUTHOR that the
157
+ // work is finished — and an assertion is exactly the thing that must
158
+ // not be allowed to switch off the detector that would contradict it.
159
+ // The earlier `status !== 'complete'` guard did precisely that: a file
160
+ // could declare itself complete and thereby suppress the report of the
161
+ // rows this tool could not read, which is a self-declared kill switch
162
+ // over the very detector this issue built. The distinction that
163
+ // actually matters is not "is it complete" but "is there anything the
164
+ // tool failed to parse":
165
+ // - complete + `headingsSeen === 0` — nothing unread, so nothing to
166
+ // contradict the claim. Still omitted entirely, exactly as before;
167
+ // that is the whole point of a terminal status and must not
168
+ // regress. (Same for a file whose blocks all parsed and passed.)
169
+ // - complete + `headingsSeen > 0` — the author's claim of
170
+ // completeness CANNOT BE VERIFIED against rows the parser could not
171
+ // read, so the file is surfaced with `parse_gap` and the
172
+ // `unparsed_blocks` count. The audit reports what it could not see
173
+ // rather than trusting the frontmatter over the file body.
174
+ //
175
+ // This check is deliberately UNCONDITIONAL on `items.length` (#3707
176
+ // follow-up BLOCKER): a MIXED file — some parseable rows plus some
177
+ // unparseable blocks — must report BOTH the real items AND the parse
178
+ // gap, quantified via `unparsed_blocks`. The previous `else if` only
179
+ // ever flagged a file with ZERO items, silently discarding
180
+ // `headingsSeen` (and every unparseable row it counted) the instant any
181
+ // single item existed anywhere in the file, including via the Gaps
182
+ // union.
183
+ if (items.length > 0 || headingsSeen > 0) {
184
+ const entry = {
103
185
  phase: phaseNum,
104
186
  phase_dir: dir,
105
187
  file,
106
188
  file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, file))),
107
189
  type: 'uat',
108
- status: (extractFrontmatter(content, uatFilePath).status || 'unknown'),
190
+ status,
109
191
  archived_milestone: milestone,
110
192
  items,
111
- });
193
+ };
194
+ if (headingsSeen > 0) {
195
+ entry.parse_gap = true;
196
+ entry.unparsed_blocks = headingsSeen;
197
+ }
198
+ results.push(entry);
112
199
  }
113
200
  }
114
- // Process VERIFICATION files
115
- for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
201
+ // Process VERIFICATION files — scoped to THIS phase's own token (#3511)
202
+ // for the same reason as the UAT loop above.
203
+ for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), dir)) {
116
204
  const verificationFilePath = node_path_1.default.join(phaseDir, file);
117
- const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
118
- const status = extractFrontmatter(content, verificationFilePath).status || 'unknown';
205
+ const content = readNormalizedDocument(verificationFilePath);
206
+ const verFm = extractFrontmatter(content, verificationFilePath);
207
+ const status = (verFm.status || 'unknown').toLowerCase();
208
+ // #3805: same marker, same 'status' snapshot key as scanVerificationGaps,
209
+ // and the same ORDERING — the open-status gate runs FIRST (a marker on
210
+ // a file that would never surface is not a suppressed item), then the
211
+ // acknowledgement suppresses what the gate surfaced.
119
212
  if (status === 'human_needed' || status === 'gaps_found') {
213
+ if (isAuditItemAcknowledged(verFm, { snapshotKey: 'status', currentValue: status })) {
214
+ acknowledgedFiles++;
215
+ continue;
216
+ }
120
217
  const items = parseVerificationItems(content, status, verificationFilePath);
121
218
  if (items.length > 0) {
122
219
  results.push({
@@ -141,7 +238,7 @@ function cmdAuditUat(cwd, raw) {
141
238
  // required.
142
239
  const deferredFile = 'deferred-items.md';
143
240
  if (files.includes(deferredFile)) {
144
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, deferredFile), 'utf-8');
241
+ const content = readNormalizedDocument(node_path_1.default.join(phaseDir, deferredFile));
145
242
  const items = parseDeferredItems(content);
146
243
  if (items.length > 0) {
147
244
  results.push({
@@ -161,10 +258,34 @@ function cmdAuditUat(cwd, raw) {
161
258
  const summary = {
162
259
  total_files: results.length,
163
260
  total_items: results.reduce((sum, r) => sum + r.items.length, 0),
261
+ // #3707 blocker 2: a distinct counter so a file whose test blocks
262
+ // yielded no items (structurally unparseable, not "all clear") stays
263
+ // visible even though it contributes zero to total_items. Consumers
264
+ // (audit-uat.md, progress.md) must gate their all-clear / debt checks on
265
+ // BOTH total_items === 0 AND parse_gap_files === 0.
266
+ //
267
+ // Counts EVERY entry with `parse_gap: true`, archived or not — same as
268
+ // `total_items`, which has no archived split. An outstanding item does
269
+ // not stop mattering because its phase was archived on milestone close
270
+ // (#2766): a deferred human-UAT scenario or a `skipped` live-stack test
271
+ // is exactly what gets archived still-open, so a parse gap on that same
272
+ // file is still an unread outstanding row, not closed history. Splitting
273
+ // this counter by `archived_milestone` (tried in this branch, reverted)
274
+ // demoted an in-progress phase filed under an archived dir out of the
275
+ // gate, and buried an archived outstanding row's parse failure relative
276
+ // to the identical row when it happened to parse — the exact bug class
277
+ // this issue exists to fix.
278
+ parse_gap_files: results.filter((r) => r.parse_gap).length,
164
279
  by_category: {},
165
280
  by_phase: {},
166
281
  };
167
282
  for (const r of results) {
283
+ // Deliberate (#3707 follow-up MINOR): this seeds a `by_phase` key at 0
284
+ // even for a parse-gap-only phase whose `items` is empty — do NOT "tidy"
285
+ // this away as dead code. The 0-valued key is itself the cue that this
286
+ // phase was scanned and produced no COUNTABLE items, distinguishing it
287
+ // from a phase absent from `by_phase` entirely (never scanned / no UAT
288
+ // file at all). A phase with a real outstanding item overwrites it below.
168
289
  if (!summary.by_phase[r.phase])
169
290
  summary.by_phase[r.phase] = 0;
170
291
  for (const item of r.items) {
@@ -173,7 +294,9 @@ function cmdAuditUat(cwd, raw) {
173
294
  summary.by_category[cat] = (summary.by_category[cat] || 0) + 1;
174
295
  }
175
296
  }
176
- output({ results, summary }, raw, undefined);
297
+ // #3805: acknowledged files surface as a COUNT (audit-open's honesty
298
+ // model: the marker fired, the items are suppressed, both facts visible).
299
+ output({ results, summary, acknowledged_files: acknowledgedFiles }, raw, undefined);
177
300
  }
178
301
  // ─── cmdRenderCheckpoint ──────────────────────────────────────────────────────
179
302
  function cmdRenderCheckpoint(cwd, options = {}, raw) {
@@ -202,6 +325,12 @@ function cmdRenderCheckpoint(cwd, options = {}, raw) {
202
325
  }
203
326
  // ─── parseCurrentTest ─────────────────────────────────────────────────────────
204
327
  function parseCurrentTest(content) {
328
+ // #3707-CR: this is the render-checkpoint path's own independent ingress
329
+ // into `tokenizeHeadings` (via the `parseFirstPendingTest` fallback below),
330
+ // separate from `parseUatItemsWithStats`'s. Normalize here too, ONCE, so a
331
+ // lone-CR document cannot hide its first pending row from this path either
332
+ // — see `normalizeLineEndings` for why.
333
+ content = normalizeLineEndings(content);
205
334
  // Use the seam to locate the ## Current Test section (ADR-1372 T5).
206
335
  // HTML-comment stripping within the section body is UAT-specific, so we keep
207
336
  // the comment removal caller-side after extracting the body.
@@ -262,24 +391,51 @@ function parseFirstPendingTest(content) {
262
391
  // tokenizeHeadings operates on the section body as a standalone document,
263
392
  // filtering to level-3 headings matching the UAT-specific "N. Name" pattern.
264
393
  // The UAT-specific item parsing (number extraction, result parsing) stays caller-side.
265
- const subHeadings = tokenizeHeadings(sectionBody).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
394
+ //
395
+ // #3078 blocker (same exposure as `parseUatItemsWithStats`): only a COLUMN-0
396
+ // heading is a test row — see `isColumnZeroHeading`. A `### N.` line indented
397
+ // <= 3 spaces INSIDE an `expected: |` value is value text, and must not
398
+ // register as a phantom heading and steal the real row's `result:` token.
399
+ //
400
+ // #3078 follow-up: tokenize a copy with the DELIMITER LINES of every
401
+ // wholly-INDENTED fenced block blanked out first (bodies untouched — column
402
+ // 0 is structure, indentation is content) — see
403
+ // `blankIndentedFenceDelimiters`. Without this, an
404
+ // indented ` ``` ` opener inside an `expected: |` value still reads as a
405
+ // real fence to `tokenizeHeadings` (CommonMark tolerates 1-3 leading
406
+ // spaces), which then hides every heading up to the next matching closer —
407
+ // including a later, genuinely column-0 `### N.` row.
408
+ //
409
+ // #3078 round-5 MAJOR: the row predicate is `isTestRowHeadingText`, the ONE
410
+ // shared helper `parseUatItemsWithStats` uses. It previously read
411
+ // `/^\d+\.\s+/` here while the audit path read `/^\d+\.(?!\d)/`, so
412
+ // `### 3.Foo` WAS a row on one path and was NOT on the other — two parse
413
+ // paths in one module disagreeing about the same grammar.
414
+ const subHeadings = tokenizeHeadings(blankIndentedFenceDelimiters(sectionBody)).filter((h) => h.level === 3 && isTestRowHeadingText(h.text) && isColumnZeroHeading(sectionBody, h));
266
415
  for (let i = 0; i < subHeadings.length; i += 1) {
267
416
  const current = subHeadings[i];
268
417
  const next = subHeadings[i + 1];
269
- // Slice the block for this sub-test from the section body text
418
+ // Slice the block for this sub-test from the RAW section body text
270
419
  const block = next
271
420
  ? sectionBody.slice(current.offset, next.offset)
272
421
  : sectionBody.slice(current.offset);
273
422
  if (!/^result:\s*\[?pending\]?\s*$/im.test(block)) {
274
423
  continue;
275
424
  }
276
- // Extract the UAT-specific number and name from the heading text
277
- const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
425
+ // Extract the UAT-specific number and name from the heading text via the
426
+ // SAME `parseTestRowHeadingText` seam the audit path uses (#3078 round-5
427
+ // MAJOR) — a name-mandatory `/^(\d+)\.\s+(.+)$/` here would have `continue`d
428
+ // past exactly the `### 3.` / `### 3.Foo` shapes the shared predicate just
429
+ // admitted, reintroducing the divergence one line below the fix.
430
+ const headingParts = parseTestRowHeadingText(current.text);
278
431
  if (!headingParts)
279
432
  continue;
280
- const testNumber = parseInt(headingParts[1], 10);
281
- const testName = headingParts[2].trim();
282
- const expected = parseExpectedFromTestBlock(block);
433
+ const testNumber = headingParts.number;
434
+ const testName = headingParts.name;
435
+ // #3078 blocker: clip the block at its first fence opener before handing
436
+ // it to `parseExpectedFromTestBlock`, so a raw read cannot reach into
437
+ // fence-hidden content — including a LATER row's own `expected:` line.
438
+ const expected = parseExpectedFromTestBlock(clipBlockAtFirstFence(block));
283
439
  if (!expected) {
284
440
  error(`Pending UAT test ${testNumber} is missing an expected field`);
285
441
  }
@@ -292,20 +448,122 @@ function parseFirstPendingTest(content) {
292
448
  }
293
449
  return null;
294
450
  }
295
- function parseExpectedFromTestBlock(block) {
296
- const expectedBlockMatch = block.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s)/m)
297
- || block.match(/^expected:\s*\|\n([\s\S]+)/m);
298
- if (expectedBlockMatch) {
299
- return expectedBlockMatch[1]
451
+ /**
452
+ * CRLF (#3078, found while hardening the scalar reader): the opener pattern
453
+ * demanded a BARE `\n` immediately after the `|`, so on a CRLF document
454
+ * `expected: |\r\n` never matched the block-scalar arm at all — control fell
455
+ * through to the INLINE arm, which happily captured the pipe character itself
456
+ * and published `expected: "|"`, discarding the entire multi-line value with no
457
+ * trace. `\r?` on the opener plus a per-line `\r` strip on the body fixes it.
458
+ * `(?:[1-9][+-]?|[+-][1-9]?)?` additionally admits the `|-` / `|+` chomping
459
+ * indicators AND the explicit indentation indicator (`|2`, `|2-`, `|-2`, ...,
460
+ * in either order per the YAML header grammar), keeping this reader in step
461
+ * with the column-0 heading rule (an indented heading inside a scalar body is
462
+ * otherwise a `expected: |-` or `expected: |2` value would be structurally
463
+ * masked but then read as the literal string `"|-"` / `"|2"` by the same
464
+ * fall-through.
465
+ *
466
+ * `[|>]` (#3078 follow-up): the `>` FOLDED-scalar family (`>`, `>-`, `>+`,
467
+ * `>2`, `>2+`, ...) hit the exact same fall-through as the CRLF/`|-`/`|+`
468
+ * bugs above — the opener only ever matched `|`, so `expected: >` fell to the
469
+ * inline arm and published the literal `">"` as the value, discarding the
470
+ * whole scalar. The opener character is now captured (group 1) so the caller
471
+ * can apply YAML's fold semantics for `>` while leaving `|` untouched.
472
+ *
473
+ * TRAILING COMMENT (#3078 round-6 MINOR 1): YAML permits a comment after a
474
+ * block-scalar header — `expected: | # sample`, `reason: >- # note` are both
475
+ * legal and open a scalar exactly as the bare forms do. The grammar was
476
+ * `$`-anchored immediately after the indicator, so those headers matched
477
+ * NEITHER `extractScalarField`'s opener (the value silently fell through to
478
+ * the inline arm and published the literal `"|"`) NOR
479
+ * `ANY_KEY_SCALAR_HEADER_LINE_RE` (so `countUnattributedIndentedRows` treated
480
+ * the scalar's own indented body heading as an unattributed lost row — a FALSE
481
+ * parse gap). `(?:#[^\r\n]*)?` closes both at the single shared source.
482
+ */
483
+ const SCALAR_HEADER_BODY = String.raw `[ \t]*([|>])(?:[1-9][+-]?|[+-][1-9]?)?[ \t]*(?:#[^\r\n]*)?`;
484
+ /**
485
+ * Build the block-scalar HEADER grammar for an arbitrary `key:` — the ONE
486
+ * source shared by `expected:`, `reason:` and `blocked_by:` (#3078 MINOR 2:
487
+ * `reason:`/`blocked_by:` previously had no block-scalar grammar of their own
488
+ * at all, and silently published the literal `"|"` / `">"` for a `|`/`>`
489
+ * value, discarding it). A key is always a hardcoded literal at each call
490
+ * site in this module (never untrusted input), so no escaping is needed.
491
+ */
492
+ function scalarHeaderFor(key) {
493
+ return String.raw `${key}:${SCALAR_HEADER_BODY}`;
494
+ }
495
+ /**
496
+ * ANY key's block-scalar HEADER line (#3078 MINOR 1), matched against ONE
497
+ * already-CR-stripped source line instead of against a multi-line block.
498
+ * Derived from the SAME `SCALAR_HEADER_BODY` source `scalarHeaderFor` uses
499
+ * so the opener grammars (`|`, `|-`, `|+`, `|2`, `|-2`, `>`, `>-`, `>+`, `>2+`,
500
+ * ...) cannot drift between them — the generative-divergence class this repo
501
+ * pins elsewhere.
502
+ *
503
+ * `countUnattributedIndentedRows` walks back from an indented `### N.`-shaped
504
+ * line to the nearest preceding column-0 line and asks whether THAT line
505
+ * opened a block scalar that still owns the indented line as its body.
506
+ * Testing only an `expected:`-ONLY grammar there meant an indented
507
+ * heading-shaped line inside ANY OTHER block scalar — `reported: |`
508
+ * (templates/UAT.md), `reason: |`, a verbatim user response containing
509
+ * ` ### 9. Section Nine` — was miscounted as a lost row even though nothing
510
+ * is missing. YAML's indentation rule (any column-0 line terminates a scalar)
511
+ * does not care WHICH key opened the scalar, only that a `[|>]`-family opener
512
+ * did, so the walk-back only needs to recognise the opener grammar, not the
513
+ * specific key.
514
+ */
515
+ const ANY_KEY_SCALAR_HEADER_LINE_RE = new RegExp(String.raw `^[A-Za-z_][\w-]*:${SCALAR_HEADER_BODY}$`);
516
+ /**
517
+ * Apply YAML FOLDED-scalar (`>`) line-joining to an already-dedented,
518
+ * CRLF-stripped block-scalar body: lines within a paragraph (no blank line
519
+ * between them) join with a single space; a blank line between paragraphs
520
+ * becomes a literal `\n` in the result. `|` (LITERAL) bodies are returned
521
+ * unchanged — folding is `>`-only.
522
+ */
523
+ function foldScalarBody(body) {
524
+ const lines = body.split('\n');
525
+ const paragraphs = [];
526
+ let current = [];
527
+ for (const line of lines) {
528
+ if (line === '') {
529
+ paragraphs.push(current.join(' '));
530
+ current = [];
531
+ }
532
+ else {
533
+ current.push(line);
534
+ }
535
+ }
536
+ paragraphs.push(current.join(' '));
537
+ return paragraphs.join('\n');
538
+ }
539
+ /**
540
+ * Extract a YAML-lite `key:` field's value from `block` — block-scalar
541
+ * (`|`/`>` family, dedented and, for `>`, YAML-folded) OR plain inline.
542
+ * Generalized from the `expected:`-only reader (#3078 MINOR 2) so `reason:`
543
+ * and `blocked_by:` — which previously had NO block-scalar grammar at all and
544
+ * silently published the literal `"|"` / `">"` for a multi-line value,
545
+ * discarding it — go through the exact same opener grammar and fold
546
+ * semantics instead of a third, hand-rolled dialect.
547
+ */
548
+ function extractScalarField(block, key) {
549
+ const opener = String.raw `^${scalarHeaderFor(key)}\r?\n`;
550
+ const blockMatch = block.match(new RegExp(`${opener}([\\s\\S]*?)(?=^\\w[\\w-]*:\\s)`, 'm'))
551
+ || block.match(new RegExp(`${opener}([\\s\\S]+)`, 'm'));
552
+ if (blockMatch) {
553
+ const openerChar = blockMatch[1];
554
+ const dedented = blockMatch[2]
300
555
  .split('\n')
301
- .map((line) => line.replace(/^ {2}/, ''))
556
+ .map((line) => line.replace(/\r$/, '').replace(/^ {2}/, ''))
302
557
  .join('\n')
303
558
  .trim();
559
+ return openerChar === '>' ? foldScalarBody(dedented) : dedented;
304
560
  }
305
- const expectedInlineMatch = block.match(/^expected:\s*(.+)\s*$/m);
306
- return expectedInlineMatch ? expectedInlineMatch[1].trim() : null;
561
+ const inlineMatch = block.match(new RegExp(String.raw `^${key}:\s*(.+)\s*$`, 'm'));
562
+ return inlineMatch ? inlineMatch[1].trim() : null;
563
+ }
564
+ function parseExpectedFromTestBlock(block) {
565
+ return extractScalarField(block, 'expected');
307
566
  }
308
- const CHECKPOINT_BOX_WIDTH = 64; // total column width of the ╔══...╗ border, borders stay byte-identical
309
567
  const CHECKPOINT_FRAMES = {
310
568
  english: {
311
569
  banner: 'CHECKPOINT: Verification Required',
@@ -408,52 +666,6 @@ function resolveCheckpointFrame(responseLanguage) {
408
666
  const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().normalize('NFC').toLowerCase()];
409
667
  return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english;
410
668
  }
411
- // Approximate terminal-cell width. East Asian Width W/F code points occupy two
412
- // cells, while Unicode combining marks occupy no additional cell beyond their
413
- // base character. Counting only W/F ranges is insufficient for scripts such as
414
- // Devanagari: Hindi vowel signs and viramas are combining marks, and treating
415
- // each as a full cell visibly shifts the checkpoint box's right border.
416
- function isWideCodePoint(codePoint) {
417
- return ((codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
418
- codePoint === 0x2329 || codePoint === 0x232a ||
419
- (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals .. CJK Symbols and Punctuation
420
- (codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana .. CJK Compatibility
421
- (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Ideographs Extension A
422
- (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified Ideographs
423
- (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi Syllables
424
- (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
425
- (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
426
- (codePoint >= 0xfe30 && codePoint <= 0xfe4f) || // CJK Compatibility Forms
427
- (codePoint >= 0xff00 && codePoint <= 0xff60) || // Fullwidth Forms
428
- (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
429
- (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Unified Ideographs Extension B+ / supplementary
430
- );
431
- }
432
- // Non-spacing/enclosing marks and format controls occupy zero terminal cells.
433
- // Spacing combining marks (General_Category=Mc), such as Devanagari vowel
434
- // signs, still advance the cursor and must contribute one column.
435
- const ZERO_WIDTH_MARK_RE = /\p{gc=Mn}|\p{gc=Me}|\p{gc=Cf}/u;
436
- // Iterates by Unicode code point (not UTF-16 code unit) so astral characters
437
- // are measured once, not as two surrogate units.
438
- function displayWidth(text) {
439
- let width = 0;
440
- for (const ch of text) {
441
- if (ZERO_WIDTH_MARK_RE.test(ch))
442
- continue;
443
- width += isWideCodePoint(ch.codePointAt(0)) ? 2 : 1;
444
- }
445
- return width;
446
- }
447
- // Pads `text` into a `║ text… ║` line matching CHECKPOINT_BOX_WIDTH. Content
448
- // that overflows the box (a longer translated string) is left unpadded rather
449
- // than truncated — a slightly ragged border beats losing text.
450
- function checkpointBoxLine(text) {
451
- const innerWidth = CHECKPOINT_BOX_WIDTH - 2;
452
- const content = ` ${text}`;
453
- const padLength = innerWidth - displayWidth(content);
454
- const padded = padLength > 0 ? content + ' '.repeat(padLength) : content;
455
- return `║${padded}║`;
456
- }
457
669
  const RTL_ISOLATE = '\u2067';
458
670
  const POP_DIRECTIONAL_ISOLATE = '\u2069';
459
671
  function isolateCheckpointFrameText(text, frame) {
@@ -466,51 +678,777 @@ function buildCheckpoint(currentTest, responseLanguage) {
466
678
  const banner = isolateCheckpointFrameText(frame.banner, frame);
467
679
  const instruction = isolateCheckpointFrameText(frame.instruction, frame);
468
680
  return [
469
- '╔══════════════════════════════════════════════════════════════╗',
470
- checkpointBoxLine(banner),
471
- '╚══════════════════════════════════════════════════════════════╝',
681
+ `### ${banner}`,
472
682
  '',
473
683
  `**Test ${currentTest.number}: ${currentTest.name}**`,
474
684
  '',
475
685
  currentTest.expected,
476
686
  '',
477
- '──────────────────────────────────────────────────────────────',
478
- instruction,
479
- '──────────────────────────────────────────────────────────────',
687
+ '---',
688
+ '',
689
+ `**${instruction}**`,
480
690
  ].join('\n');
481
691
  }
482
692
  // ─── parseUatItems ────────────────────────────────────────────────────────────
483
- function parseUatItems(content) {
693
+ /**
694
+ * Result tokens treated as PASSING (#3707 defect 1). Deliberately MINIMAL —
695
+ * that minimality is the point. Every token NOT in this set surfaces as an
696
+ * outstanding item, mirroring the fail-safe direction `parseGapsItems`
697
+ * already documents for this exact false-negative class (#2286): a project
698
+ * that invents a novel pass-word gets a visible, correctable false positive
699
+ * (an extra row an agent can dismiss) rather than today's invisible drop (a
700
+ * genuinely outstanding row silently vanishing with no trace). This was the
701
+ * issue's one open design question and was decided deliberately, here, in
702
+ * favor of the fail-safe direction over a larger "known synonyms" allowlist.
703
+ */
704
+ const UAT_PASS_RESULTS = new Set(['pass', 'passed']);
705
+ /**
706
+ * A fenced-code OPENER line at COLUMN 0 (``` or ~~~).
707
+ *
708
+ * Deliberately NOT the CommonMark `{0,3}`-space form (#3078 simplification):
709
+ * inside this module a fence only ever means "document structure the tokenizer
710
+ * hid from us", and every structural fence in a UAT file starts at column 0. An
711
+ * INDENTED fence run is, by construction, part of an `expected: |` block-scalar
712
+ * value — the ordinary way a UAT row reproduces a code sample verbatim — and
713
+ * must stay invisible to the clipper, or the very field it exists to protect
714
+ * gets truncated at its own sample. Column 0 is the whole rule for every
715
+ * fence-aware scan THIS MODULE writes directly against raw block text (this
716
+ * one, `dropTopLevelFencedRegions`'s `delimRe`). It does NOT extend to
717
+ * `tokenizeHeadings`, which is a third-party CommonMark scanner with its own
718
+ * {0,3}-space fence tolerance baked in — see `blankIndentedFenceDelimiters`
719
+ * for how an indented delimiter is kept from reaching that scanner at all.
720
+ */
721
+ const FENCE_OPENER_RE = /^(?:`{3,}|~{3,})/;
722
+ /**
723
+ * The CommonMark-tolerant (0-3 leading spaces) twin of `FENCE_OPENER_RE`,
724
+ * used ONLY by the inner delimiter-shape sweep in
725
+ * `blankIndentedFenceDelimiters` (#3078 round-7 MAJOR). That sweep runs
726
+ * strictly BETWEEN a neutralised block's own (already-blanked) delimiters,
727
+ * looking for a line `tokenizeHeadings` would itself read as a fence opener
728
+ * once those delimiters are gone — and `tokenizeHeadings` tolerates up to
729
+ * three leading spaces on an opener, so a column-0-anchored test here misses
730
+ * an INDENTED delimiter-shaped line and lets the mutation manufacture exactly
731
+ * the structure `scanFencedBlocks` never saw. `FENCE_OPENER_RE` itself stays
732
+ * column-0-anchored: every OTHER call site depends on that anchoring.
733
+ */
734
+ const INDENT_TOLERANT_DELIM_RE = /^ {0,3}(?:`{3,}|~{3,})/;
735
+ /**
736
+ * A raw source line whose shape is a UAT `### N.` test heading — the line-level
737
+ * twin of the `h.level === 3 && /^\d+\.(?!\d)/` token filter in
738
+ * `parseUatItemsWithStats`, and anchored at COLUMN 0 to match that filter's
739
+ * `isColumnZeroHeading` guard exactly. Used ONLY to count headings that
740
+ * `tokenizeHeadings` suppressed (a fence-straddled row), never to parse one:
741
+ * the two counts must be derived by the SAME rule or the shortfall they
742
+ * bracket over- or under-reports.
743
+ */
744
+ const TEST_HEADING_LINE_RE = /^#{3}(?!#)[ \t]+\d+\.(?!\d)/;
745
+ /**
746
+ * THE test-row grammar, in ONE place (#3078 round-5 MAJOR).
747
+ *
748
+ * `parseFirstPendingTest` (the render-checkpoint path) and
749
+ * `parseUatItemsWithStats` (the audit path) each filtered level-3 headings with
750
+ * their own literal — `/^\d+\.\s+/` vs `/^\d+\.(?!\d)/` — so the two paths in
751
+ * this one module DISAGREED about what a test row is: `### 3.Foo` (name squished
752
+ * against the dot) and `### 3.` (no name at all) were rows to the audit and were
753
+ * silently NOT rows to the checkpoint. That is the generative-divergence class
754
+ * this repo requires closed with a shared definition rather than two literals
755
+ * kept in sync by hand.
756
+ *
757
+ * The AUDIT rule wins, deliberately: `^\d+\.(?!\d)` admits `### 3.` and
758
+ * `### 3.Foo` (a heading missing or squishing its name still contributes to
759
+ * `headingsSeen`/items instead of vanishing from BOTH — the same silent-drop
760
+ * symptom the parse-gap flag exists to catch) while the `(?!\d)` lookahead keeps
761
+ * a DOTTED-SECTION heading like `### 1.2.3 Overview` out, since that is a
762
+ * document outline number, not test row 1. `TEST_HEADING_LINE_RE` /
763
+ * `INDENTED_TEST_HEADING_LINE_RE` are the raw-source-line twins of this same
764
+ * rule and carry the identical `\d+\.(?!\d)` core.
765
+ */
766
+ const TEST_ROW_HEADING_TEXT_RE = /^\d+\.(?!\d)/;
767
+ /** True when a level-3 heading's TEXT is a UAT test row. See `TEST_ROW_HEADING_TEXT_RE`. */
768
+ function isTestRowHeadingText(text) {
769
+ return TEST_ROW_HEADING_TEXT_RE.test(text);
770
+ }
771
+ /**
772
+ * Split a test-row heading's text into its number and display name — the
773
+ * extraction twin of `isTestRowHeadingText`, shared by both parse paths for the
774
+ * same anti-divergence reason. Returns `null` for text the predicate rejects.
775
+ *
776
+ * A bare `### 3.` (no trailing name) falls back to the heading's own trimmed
777
+ * text (`3.`) rather than yielding an empty name.
778
+ */
779
+ function parseTestRowHeadingText(text) {
780
+ if (!isTestRowHeadingText(text))
781
+ return null;
782
+ const parts = text.match(/^(\d+)\.\s*(.*)$/);
783
+ if (!parts)
784
+ return null;
785
+ return { number: parseInt(parts[1], 10), name: parts[2].trim() || text.trim() };
786
+ }
787
+ /**
788
+ * The INDENTED (1-3 leading spaces, CommonMark-legal) twin of
789
+ * `TEST_HEADING_LINE_RE` — used by the SHORTFALL SCAN ONLY, never by the parse
790
+ * gate.
791
+ *
792
+ * #3078 round-4 MAJOR 2: `isColumnZeroHeading` refusing to PARSE an indented
793
+ * `### N.` row is deliberate and stays (no `*UAT*.md` in the tree indents one).
794
+ * But the COUNTING side inherited that anchor through
795
+ * `TEST_HEADING_LINE_RE`, so a heading the parse gate rejected could never
796
+ * reach `headingsSeen` either: ` ### 1. Indented Row` with `result: pending`
797
+ * — which origin/next's unanchored `###\s*(\d+)\.` did surface — yielded no
798
+ * item, no gap, no count and no trace at all. Refusing to parse is defensible;
799
+ * vanishing silently is the exact defect class this issue exists to close, so
800
+ * the row now surfaces as a PARSE GAP instead.
801
+ */
802
+ const INDENTED_TEST_HEADING_LINE_RE = /^[ \t]+#{3}(?!#)[ \t]+\d+\.(?!\d)/;
803
+ /**
804
+ * True when `heading` starts at COLUMN 0 of its source line in `content`.
805
+ *
806
+ * The UAT test-row contract (#3078): a `### N.` row heading is structure ONLY
807
+ * at column 0. `tokenizeHeadings` implements CommonMark, which tolerates up to
808
+ * 3 leading spaces on an ATX heading — and that single over-permissive rule is
809
+ * what let a `### 3. Fake Row` line sitting INSIDE an `expected: |` value
810
+ * register as a phantom heading, open a block, and STEAL the real row's
811
+ * `result:` line, dropping a genuinely outstanding row from `items`. A scalar
812
+ * body is indented BY CONSTRUCTION (that is what makes it a body), so requiring
813
+ * column 0 makes every such line inert without the parser needing any notion of
814
+ * YAML block scalars at all. The shipped `templates/UAT.md` writes every `### N.`
815
+ * heading at column 0, and no UAT document in the tree indents one.
816
+ *
817
+ * `HeadingToken.offset` is the offset of the heading LINE's first character, so
818
+ * a column-0 heading is exactly one whose first character is the `#` itself.
819
+ */
820
+ function isColumnZeroHeading(content, heading) {
821
+ return content.charCodeAt(heading.offset) === 0x23 /* '#' */;
822
+ }
823
+ /**
824
+ * An INDENTED (1-3 leading spaces, never 0) fenced-code delimiter line.
825
+ * Column 0 is intentionally EXCLUDED — a column-0 fence is real document
826
+ * structure and `tokenizeHeadings` handling it is correct; only the
827
+ * CommonMark-legal 1-3-space tolerance is the problem this targets.
828
+ */
829
+ const INDENTED_FENCE_DELIM_RE = /^ {1,3}(?:`{3,}|~{3,})/;
830
+ /**
831
+ * Return `content` with the two DELIMITER LINES of every wholly-INDENTED
832
+ * fenced block overwritten by spaces, byte-length- and line-count-preserving,
833
+ * so every downstream offset and line index still lines up against the
834
+ * original document. The block's BODY is left verbatim — see "COLUMN 0 IS
835
+ * STRUCTURE, INDENTATION IS CONTENT" below for why that is the point, not an
836
+ * oversight.
837
+ *
838
+ * Why (#3078 follow-up, escalated design call, answered as option (b)):
839
+ * dropping `maskBlockScalarBodies` in favor of the column-0 heading filter
840
+ * (`isColumnZeroHeading`) fixed the phantom-heading theft, but it silently
841
+ * dropped a SECOND thing masking used to do — hide an INDENTED fence
842
+ * delimiter from `tokenizeHeadings` itself. `tokenizeHeadings` is a
843
+ * CommonMark scanner with its own {0,3}-space fence tolerance; a 1-3-space
844
+ * ` ``` ` inside an `expected: |` scalar body still opens a fence AS FAR AS
845
+ * THAT SCANNER IS CONCERNED, and every heading between it and its matching
846
+ * (or absent) closer — including a LATER, genuinely column-0 `### N.` row —
847
+ * is hidden from the token stream entirely, not merely mis-filtered. The
848
+ * column-0 heading filter cannot recover a heading the tokenizer never
849
+ * returned in the first place.
850
+ *
851
+ * This is deliberately the SAME "column 0 is structure, anything else is
852
+ * value text" rule already applied to headings (`isColumnZeroHeading`) and to
853
+ * this module's own raw-text fence scans (`FENCE_OPENER_RE`,
854
+ * `dropTopLevelFencedRegions`'s `delimRe`) — extended to the one place that
855
+ * rule cannot be expressed as a post-hoc filter, because the tokenizer
856
+ * consumes the fence delimiter before this module ever sees a token for it.
857
+ * It carries no YAML knowledge whatsoever (no notion of `expected:`, `|`,
858
+ * indentation width, or scalar bodies) — it blanks an indented delimiter LINE
859
+ * unconditionally, wherever it appears, the same context-free way the other
860
+ * column-0 rules do.
861
+ *
862
+ * PAIRED, NOT UNCONDITIONAL (#3078 round-4 MAJOR 1). Blanking every indented
863
+ * delimiter LINE on sight perturbs fence PAIRING in BOTH directions, because
864
+ * CommonMark lets a COLUMN-0 fence be closed by a delimiter indented up to
865
+ * three spaces:
866
+ * - a column-0 opener closed by an INDENTED closer had its closer blanked,
867
+ * so the fence never closed for `tokenizeHeadings` and every later row —
868
+ * including a genuinely column-0 `### N.` with an outstanding `result:` —
869
+ * was swallowed;
870
+ * - the mirror, an INDENTED opener closed by a COLUMN-0 closer, had its
871
+ * opener blanked, PROMOTING that closer into an opener and swallowing
872
+ * everything after it instead.
873
+ * Both documents are legal CommonMark that renders correctly, so neither may
874
+ * lose content. The decision is therefore made per FENCED BLOCK, not per line:
875
+ * a block is neutralised only when it is indented at BOTH ends (or is an
876
+ * indented opener that never closes at all) — i.e. when nothing about it is
877
+ * column-0 document structure. That is exactly the intended case, an indented
878
+ * fence pair living wholly inside an `expected: |` block-scalar value, which
879
+ * is why the helper exists; any block with a column-0 delimiter at either end
880
+ * is left completely alone so its pairing reaches the tokenizer unchanged.
881
+ *
882
+ * COLUMN 0 IS STRUCTURE, INDENTATION IS CONTENT — and that rule is applied in
883
+ * ONE direction only, to the DELIMITERS. Only the two delimiter lines of a
884
+ * neutralised block are blanked; its body is left exactly as written. A
885
+ * column-0 `### N.` sitting between two indented delimiters therefore becomes
886
+ * a real heading, and a `result:` line after it belongs to that heading. That
887
+ * is CORRECT under this rule, not theft: by the very rule that selected the
888
+ * block for neutralisation, an indented delimiter is not a fence at all, so
889
+ * there is no fence for the column-0 line to be "inside" of. The document is
890
+ * malformed; reading it this way is the consistent reading, and it is PINNED
891
+ * by test (see "#3078 round 5: column 0 is structure" in tests/uat.test.cjs).
892
+ * Blanking the whole block open-to-close was tried and REVERTED: it destroys
893
+ * content legitimately living between the delimiters, and — for the
894
+ * unterminated-opener case, where the "body" runs to EOF — silently deletes
895
+ * the entire remainder of the document, dropping every later row.
896
+ *
897
+ * NO SECOND FENCE DIALECT: the blocks come from `scanFencedBlocks`
898
+ * (markdown-sectionizer.cts), the SAME exported CommonMark state machine
899
+ * `stripFencedCode` — and therefore `tokenizeHeadings` — runs. Backtick AND
900
+ * tilde runs, run length >= 3, the <= 3-space indent tolerance, a closer of
901
+ * the same char with run length >= the opener and no trailing text, info
902
+ * strings (including the "a backtick fence's info string may not contain a
903
+ * backtick" rule), and the unterminated-at-EOF case are all classified by that
904
+ * engine, not re-derived here. This module contributes only the column-0
905
+ * question — which delimiter lines are structure — via
906
+ * `INDENTED_FENCE_DELIM_RE`.
907
+ *
908
+ * LINE-BASED by construction (`content.split('\n')` / `.join('\n')`), never
909
+ * character-array splicing — the exact bug class (`Array.from(content)`
910
+ * code-point indexing against UTF-16 offsets) that made the original
911
+ * `maskBlockScalarBodies` corrupt astral-character documents. A line's own
912
+ * `.length` and `' '.repeat(line.length)` are measured in the same (UTF-16)
913
+ * units as the string itself, so this cannot misalign regardless of
914
+ * code-point framing, and CRLF survives untouched: `split('\n')` leaves any
915
+ * `\r` attached to the end of its line, and blanking that line replaces the
916
+ * `\r` with a space exactly like every other character on it — `join('\n')`
917
+ * then reproduces the original line count and total length exactly.
918
+ */
919
+ function blankIndentedFenceDelimiters(content) {
920
+ const lines = content.split('\n');
921
+ const isIndentedDelimiter = (idx) => idx >= 0 && idx < lines.length && INDENTED_FENCE_DELIM_RE.test(lines[idx].replace(/\r$/, ''));
922
+ const blank = new Set();
923
+ for (const block of scanFencedBlocks(lines)) {
924
+ // A column-0 OPENER is real document structure: leave the whole block
925
+ // alone, closer included, so an indented closer still closes it.
926
+ if (!isIndentedDelimiter(block.openLineIdx))
927
+ continue;
928
+ // An indented opener paired with a COLUMN-0 closer is likewise real
929
+ // structure at its far end — blanking the opener would promote that closer
930
+ // into an opener and hide everything after it.
931
+ if (block.closeLineIdx !== -1 && !isIndentedDelimiter(block.closeLineIdx))
932
+ continue;
933
+ // DELIMITERS ONLY — never the body. THE RULE: column 0 is structure,
934
+ // indentation is content. An indented delimiter therefore neutralises
935
+ // ITSELF, but it never hides column-0 structure sitting between
936
+ // delimiters: a column-0 `### N.` there IS a heading, and a `result:`
937
+ // after it IS that heading's. Widening this to the whole block was tried
938
+ // (#3078 round 5) and reverted — it deletes content that legitimately
939
+ // lives between the delimiters, and on an UNTERMINATED indented opener it
940
+ // blanks to EOF, taking every later row with it. Pinned by test; do not
941
+ // "fix" it back.
942
+ blank.add(block.openLineIdx);
943
+ if (block.closeLineIdx !== -1)
944
+ blank.add(block.closeLineIdx);
945
+ // #3078 round-6 MAJOR: the two fence engines must not disagree about the
946
+ // text handed downstream. `scanFencedBlocks` classified the ORIGINAL
947
+ // lines, but `tokenizeHeadings` re-runs its own CommonMark state machine
948
+ // over this MUTATED copy. A COLUMN-0 delimiter-shaped line that was mere
949
+ // fence CONTENT in the original — e.g. a ```-run inside an indented
950
+ // ````-pair — is PROMOTED to a real opener the instant its enclosing
951
+ // delimiters are blanked, hiding every later heading to EOF. Blank those
952
+ // too, so the mutation cannot manufacture structure that the classifying
953
+ // engine never saw.
954
+ //
955
+ // DELIMITER-SHAPED LINES ONLY. A column-0 `### N.` heading between
956
+ // neutralised delimiters stays a heading (the pinned "column 0 is
957
+ // structure" behaviour), and the field lines of a row living between two
958
+ // rows' scalars survive untouched — both are pinned by test. This adds
959
+ // exactly one shape to the blank set: a line that would itself be read as
960
+ // a fence delimiter.
961
+ const inner = block.closeLineIdx === -1 ? lines.length : block.closeLineIdx;
962
+ for (let i = block.openLineIdx + 1; i < inner; i += 1) {
963
+ if (INDENT_TOLERANT_DELIM_RE.test(lines[i].replace(/\r$/, '')))
964
+ blank.add(i);
965
+ }
966
+ }
967
+ if (blank.size === 0)
968
+ return content;
969
+ return lines.map((line, i) => (blank.has(i) ? ' '.repeat(line.length) : line)).join('\n');
970
+ }
971
+ /**
972
+ * Truncate `block` at its first TOP-LEVEL fenced-code opener (#3078 blocker).
973
+ *
974
+ * `parseExpectedFromTestBlock` must read the RAW block (an `expected: |` scalar
975
+ * may legitimately reproduce fenced-looking text verbatim, so a fence-STRIPPED
976
+ * copy would corrupt the field). But a raw block slice can run straight into
977
+ * content that `tokenizeHeadings` correctly hid inside a fence — including a
978
+ * LATER test row's own `expected:` line, which the earlier row then published
979
+ * as its own. Clipping at the fence opener bounds the raw read to the part of
980
+ * the block the tokenizer also considered visible.
981
+ *
982
+ * Column-0 fences only (`FENCE_OPENER_RE`): a fenced sample nested inside a
983
+ * legitimate `expected: |` value is indented by construction, so it is invisible
984
+ * here and cannot clip the very field this exists to preserve.
985
+ */
986
+ function clipBlockAtFirstFence(block) {
987
+ const rawLines = block.split('\n');
988
+ let firstFenceLine = -1;
989
+ for (let i = 0; i < rawLines.length; i += 1) {
990
+ if (FENCE_OPENER_RE.test(rawLines[i])) {
991
+ firstFenceLine = i;
992
+ break;
993
+ }
994
+ }
995
+ if (firstFenceLine === -1)
996
+ return block;
997
+ const beforeFence = rawLines.slice(0, firstFenceLine).join('\n');
998
+ if (parseExpectedFromTestBlock(beforeFence))
999
+ return beforeFence;
1000
+ // #3078 follow-up MINOR 2: an `expected:` field appearing AFTER a fence has
1001
+ // CLOSED is not a theft risk — only content strictly INSIDE the fence must
1002
+ // stay hidden. The plain "clip at first opener" result above silently
1003
+ // discards a late `expected:` even when it sits outside every fence.
1004
+ // Reconstruct the block with every top-level FENCED REGION dropped, keeping
1005
+ // RAW text everywhere else. This exposes a late `expected:` living after a
1006
+ // fence closes, while an `expected:` living strictly inside the fence is
1007
+ // dropped along with it and stays unreachable — the "inside a fence" vs.
1008
+ // "after a closed fence" split falls straight out of whether the
1009
+ // fence-tracking state machine below is OPEN or CLOSED at that line, not out
1010
+ // of position relative to the FIRST fence opener alone.
1011
+ const visible = dropTopLevelFencedRegions(rawLines);
1012
+ if (parseExpectedFromTestBlock(visible))
1013
+ return visible;
1014
+ return beforeFence;
1015
+ }
1016
+ /**
1017
+ * Reconstruct `rawLines` with every TOP-LEVEL fenced region removed. Mirrors
1018
+ * `stripFencedCode`'s own delimiter algorithm — a fence run of the SAME
1019
+ * character and at least the SAME length, with no trailing content, is what
1020
+ * closes an open fence — so "inside a fence" here means the same thing it means
1021
+ * to the rest of this module's fence handling. An UNTERMINATED fence (open at
1022
+ * EOF) drops everything from its opener to the end, same as `stripFencedCode`.
1023
+ *
1024
+ * Delimiters are recognised at COLUMN 0 only, for the reason given on
1025
+ * `FENCE_OPENER_RE`: an INDENTED fence run belongs to an `expected: |` value,
1026
+ * not to document structure, and must not open a region here.
1027
+ */
1028
+ function dropTopLevelFencedRegions(rawLines) {
1029
+ const kept = [];
1030
+ let openFence = null;
1031
+ const delimRe = /^(`{3,}|~{3,})(.*)$/;
1032
+ for (let i = 0; i < rawLines.length; i += 1) {
1033
+ const line = rawLines[i].replace(/\r$/, '');
1034
+ const m = delimRe.exec(line);
1035
+ if (m) {
1036
+ const char = m[1][0];
1037
+ const len = m[1].length;
1038
+ const trailing = m[2];
1039
+ if (openFence === null) {
1040
+ if (char === '`' && trailing.includes('`')) {
1041
+ // Not a valid fence opener (CommonMark: backtick info string must
1042
+ // not contain a backtick) — ordinary content.
1043
+ kept.push(rawLines[i]);
1044
+ continue;
1045
+ }
1046
+ openFence = { char, len };
1047
+ }
1048
+ else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
1049
+ openFence = null;
1050
+ }
1051
+ continue; // all delimiter lines are dropped, opener or closer
1052
+ }
1053
+ if (openFence === null)
1054
+ kept.push(rawLines[i]);
1055
+ // Lines inside an open fence are silently dropped.
1056
+ }
1057
+ return kept.join('\n');
1058
+ }
1059
+ /**
1060
+ * Count the INDENTED (1-3 space) `### N.` heading-shaped lines in `surface`
1061
+ * that are NOT the value text of a preceding `expected:` block scalar.
1062
+ *
1063
+ * Why the exclusion (#3078 round-4 MAJOR 2): the parse gate refuses BOTH
1064
+ * shapes for the same reason (column 0 is structure), but only one of them is
1065
+ * a lost ROW. A `### 3. Fake Row` line sitting inside an `expected: |` value is
1066
+ * the row's own published `expected:` string — already surfaced, verbatim, on
1067
+ * the item — so counting it would flag a parse gap against a document with
1068
+ * nothing missing (the pinned scalar-body behaviour). A ` ### 1. Indented
1069
+ * Row` that no scalar owns is a row the parser declined to read, and must be
1070
+ * visible as an unparsed block instead of silently clean.
1071
+ *
1072
+ * Attribution is structural and cheap: walk BACK from the indented heading to
1073
+ * the first non-blank line at column 0 (a block-scalar body is indented by
1074
+ * construction, and blank lines are legal inside one). The heading is scalar
1075
+ * VALUE exactly when that line is ANY `key:` scalar header — not `expected:`
1076
+ * only (#3078 MINOR 1: testing the `expected:`-only grammar false-positived
1077
+ * on an indented heading-shaped line inside a DIFFERENT block scalar, e.g. a
1078
+ * template-sanctioned `reported: |` holding verbatim user prose, or a
1079
+ * `reason: |` body) — per `ANY_KEY_SCALAR_HEADER_LINE_RE`, derived from the
1080
+ * SAME `[|>]`-family opener grammar the reader itself uses. No second opener
1081
+ * dialect, and no attempt to model YAML indentation levels.
1082
+ */
1083
+ function countUnattributedIndentedRows(surface) {
1084
+ const lines = surface.split('\n');
1085
+ // LINEAR, not quadratic (#3078 round-6 MINOR 2). The walk-back above was
1086
+ // re-scanned per indented row, so a document of N rows and N lines cost
1087
+ // O(N^2) — measured 4x per 2x on real input (1000 rows 20ms → 16000 rows
1088
+ // 3.6s). The walk only ever asks ONE question of the prefix — "which is the
1089
+ // nearest preceding non-blank COLUMN-0 line?" — and that is a running value,
1090
+ // so a single forward pass computes it for every line at once. The
1091
+ // ATTRIBUTION RULE IS UNCHANGED: a blank line and an indented line are both
1092
+ // transparent (a block-scalar body is indented by construction and may
1093
+ // contain blank lines), and the first line that is neither terminates the
1094
+ // scalar; the heading is value text exactly when THAT line is any key's
1095
+ // block-scalar header.
1096
+ const stripped = lines.map((line) => line.replace(/\r$/, ''));
1097
+ const nearestColumnZero = new Array(lines.length);
1098
+ let last = -1;
1099
+ for (let i = 0; i < stripped.length; i += 1) {
1100
+ nearestColumnZero[i] = last;
1101
+ const line = stripped[i];
1102
+ if (line.trim() !== '' && !/^[ \t]/.test(line))
1103
+ last = i;
1104
+ }
1105
+ let count = 0;
1106
+ for (let i = 0; i < lines.length; i += 1) {
1107
+ if (!INDENTED_TEST_HEADING_LINE_RE.test(lines[i]))
1108
+ continue;
1109
+ const owner = nearestColumnZero[i];
1110
+ const ownedByScalar = owner !== -1 && ANY_KEY_SCALAR_HEADER_LINE_RE.test(stripped[owner]);
1111
+ if (!ownedByScalar)
1112
+ count += 1;
1113
+ }
1114
+ return count;
1115
+ }
1116
+ /**
1117
+ * `headingsSeen` is the TOTAL parse-gap tally (every heading-shaped thing this
1118
+ * parser could not turn into an item). `shortfallBlocks` is the SUBSET of it
1119
+ * contributed by the fence-suppression shortfall scan below — the one gap class
1120
+ * this module documents as carrying an ACCEPTED OVER-REPORT (a closed-fence
1121
+ * documentation sample written with literal digits is indistinguishable from a
1122
+ * genuinely fence-straddled row; see the long comment at the scan itself).
1123
+ * Reported separately so a consumer that must decide whether to WITHHOLD a
1124
+ * derived number — as opposed to merely REPORT the gap — can tell "a row I
1125
+ * definitely could not read" from "a row I possibly mis-counted".
1126
+ *
1127
+ * #3707-CR: `src/planning-inspect.cts`'s `buildUatRows` does NOT destructure
1128
+ * this field (verified — it and `cmdAuditUat` both consume only `items` and
1129
+ * `headingsSeen`), correcting an earlier stated instruction that it did.
1130
+ * `shortfallBlocks` currently has NO production consumer outside this
1131
+ * function's own computation. It is retained on the return value anyway,
1132
+ * deliberately, as part of this function's published stats contract — tests
1133
+ * assert on the full `{ items, headingsSeen, shortfallBlocks }` shape, and
1134
+ * dropping a returned field is a wider, unrelated change than a line-ending
1135
+ * fix warrants. A future consumer that needs to distinguish an
1136
+ * accepted-over-report shortfall from the rest of `headingsSeen` (the
1137
+ * original design intent above) can still do so.
1138
+ */
1139
+ function parseUatItemsWithStats(content) {
1140
+ content = normalizeLineEndings(content);
484
1141
  const items = [];
485
- // Match test blocks: ### N. Name\nexpected: ...\nresult: ...\n
486
- // Accept both bare (result: pending) and bracketed (result: [pending]) formats (#2273)
487
- const testPattern = /###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*\[?(\w+)\]?(?:\n(?:reported|reason|blocked_by):\s*[^\n]*)?/g;
488
- let match;
489
- while ((match = testPattern.exec(content)) !== null) {
490
- const [, num, name, expected, result] = match;
491
- if (result === 'pending' || result === 'skipped' || result === 'blocked') {
492
- // Extract optional fields — limit to current test block (up to next ### or EOF)
493
- const afterMatch = content.slice(match.index);
494
- const nextHeading = afterMatch.indexOf('\n###', 1);
495
- const blockText = nextHeading > 0 ? afterMatch.slice(0, nextHeading) : afterMatch;
496
- const reasonMatch = blockText.match(/reason:\s*(.+)/);
497
- const blockedByMatch = blockText.match(/blocked_by:\s*(.+)/);
498
- const item = {
499
- test: parseInt(num, 10),
500
- name: name.trim(),
501
- expected: expected.trim(),
502
- result,
503
- category: categorizeItem(result, reasonMatch?.[1], blockedByMatch?.[1]),
504
- };
505
- if (reasonMatch)
506
- item.reason = reasonMatch[1].trim();
507
- if (blockedByMatch)
508
- item.blocked_by = blockedByMatch[1].trim();
509
- items.push(item);
1142
+ let headingsSeen = 0;
1143
+ let shortfallBlocks = 0;
1144
+ // Locate every `### N. Name` test heading across the WHOLE document (not
1145
+ // adjacency-matched against `result:`, #3707 defect 2) and slice each one's
1146
+ // own block from its heading to the next heading OF ANY LEVEL (or EOF) —
1147
+ // a trailing `## Gaps` section or an interleaved `### Notes` heading must
1148
+ // not be absorbed into the preceding test's block, else its unanchored
1149
+ // `reason:`/`blocked_by:` scans below bleed a Gaps entry's fields onto the
1150
+ // last test row.
1151
+ // #3078 blocker: only a COLUMN-0 heading is document structure here (see
1152
+ // `isColumnZeroHeading`). The filter is applied to the WHOLE token stream,
1153
+ // not just to the `### N.` rows, because an indented heading must not act as
1154
+ // a block BOUNDARY either — a `### 3. Fake Row` line inside an `expected: |`
1155
+ // value would otherwise truncate its own row's block just before the real
1156
+ // `result:` line and drop a genuinely outstanding row from `items`.
1157
+ //
1158
+ // #3078 follow-up: tokenize a copy with every indented fence delimiter
1159
+ // blanked out (`blankIndentedFenceDelimiters`) BEFORE the column-0 filter
1160
+ // ever runs. Otherwise an indented ` ``` ` opener inside an `expected: |`
1161
+ // value still opens a real fence as far as `tokenizeHeadings` (a
1162
+ // CommonMark scanner, {0,3}-space fence tolerance) is concerned, hiding
1163
+ // every heading up to its closer from the token stream entirely — a LATER,
1164
+ // genuinely column-0 `### N.` row is never returned as a token at all, so
1165
+ // no post-hoc filter over the token stream could recover it.
1166
+ const allHeadings = tokenizeHeadings(blankIndentedFenceDelimiters(content)).filter((h) => isColumnZeroHeading(content, h));
1167
+ // #3707 follow-up MINOR: `^\d+\.` alone — a trailing name is OPTIONAL
1168
+ // (`### 3.` and `### 3.Foo`, without the space the old `\s+`-anchored
1169
+ // pattern required, both count) so a heading missing or squishing its name
1170
+ // still contributes to `headingsSeen`/items rather than being silently
1171
+ // excluded from BOTH — the same vanishing-row symptom the parse-gap flag
1172
+ // exists to catch, reachable here at the heading-filter layer instead.
1173
+ // #3078 round-5 MAJOR: that rule now lives in `isTestRowHeadingText` and is
1174
+ // shared verbatim with `parseFirstPendingTest`, which used to disagree.
1175
+ // Carry each match's own index into `allHeadings` from the filter pass
1176
+ // itself (security review finding 3) rather than re-deriving it via
1177
+ // `allHeadings.indexOf(current)` inside the loop below — the latter is an
1178
+ // O(n) scan per heading, making the whole loop O(n^2) in document size.
1179
+ const subHeadings = [];
1180
+ allHeadings.forEach((h, index) => {
1181
+ if (h.level === 3 && isTestRowHeadingText(h.text))
1182
+ subHeadings.push({ heading: h, index });
1183
+ });
1184
+ // #3078 blocker: `tokenizeHeadings` is fence-aware, so a BALANCED fence pair
1185
+ // that opens after one test row and closes after a later one makes every
1186
+ // `### N.` heading between them invisible — the rows are not merely
1187
+ // unparseable, they are absent from the token stream, so the loop below can
1188
+ // never count them and the file reports as CLEAN with an outstanding
1189
+ // `result: blocked` inside it. (origin/next's old whole-file regex did
1190
+ // surface those rows, making the silent drop a regression.) Comparing the
1191
+ // count of heading-SHAPED source lines against the headings the tokenizer
1192
+ // actually returned recovers the shortfall; each suppressed row counts
1193
+ // toward `headingsSeen`, so the file is flagged as a parse gap rather than
1194
+ // silently clean. The line scan is anchored at COLUMN 0 (`TEST_HEADING_LINE_RE`)
1195
+ // by the same rule the token filter uses, so a `### N.`-shaped line living
1196
+ // inside an `expected: |` value — which is value text, not a suppressed row —
1197
+ // cannot inflate the tally.
1198
+ //
1199
+ // #3078 round-7 HIGH — SYMMETRY IS THE INVARIANT. BOTH SIDES OF THIS
1200
+ // COMPARISON ARE WHOLE-DOCUMENT. DO NOT SCOPE EITHER ONE. Read this whole
1201
+ // comment before "optimising" the `## Notes` noise back out; three separate
1202
+ // HIGH-severity silent false-cleans have been produced by three separate
1203
+ // attempts to be clever about scope here, and every one of them was a
1204
+ // regression against origin/next's plain whole-file regex.
1205
+ //
1206
+ // History of the failures, so they are not re-derived:
1207
+ // - round-6 HIGH: the raw line scan was SECTION-SCOPED to the `## Tests`
1208
+ // body while `subHeadings` stayed whole-document, so a legal
1209
+ // `### 9. Old / result: pass` row in a preceding `## Prior` section
1210
+ // decremented the shortfall by one and SILENTLY DISABLED the
1211
+ // fence-straddle detector.
1212
+ // - round-7 HIGH: "equalising" that by ALSO scoping the token side to the
1213
+ // section's offset span made the two counters agree with each other but
1214
+ // left the PARSE side whole-document — so a `### N.` row living OUTSIDE
1215
+ // the first `## Tests` section was parsed and surfaced normally when
1216
+ // visible, yet vanished with NO item AND NO parse_gap the moment a fence
1217
+ // straddled it: neither side of the comparison covered it. Reproduced
1218
+ // three ways — a straddle inside a `## Regression Tests` section, a
1219
+ // straddle inside a SECOND `## Tests` section (`collectSection` takes the
1220
+ // FIRST match only), and, as control, the identical straddle in a file
1221
+ // with no `## Tests` heading at all, which alone reported correctly.
1222
+ //
1223
+ // THE RULE: the parse side reads rows wherever they live in the document, so
1224
+ // the counting side must too. Scan shaped `### N.` lines over the ENTIRE
1225
+ // document and compare against ALL tokenized row headings. Any narrowing of
1226
+ // one side that is not matched by the other manufactures a blind spot, and a
1227
+ // blind spot here is a SILENT FALSE CLEAN — a file with an outstanding
1228
+ // `result: blocked` in it that never even enters `results`.
1229
+ //
1230
+ // ACCEPTED CONSEQUENCE, DELIBERATELY TRADED (this replaces the #3078
1231
+ // follow-up MINOR 1 scoping): a `### N.`-shaped line inside a properly
1232
+ // CLOSED fence in a `## Notes` section — a documentation sample of the row
1233
+ // format. NOTE the shape needs LITERAL DIGITS — the scan requires `\d+`, so the
1234
+ // conventional placeholder `### N. Name` does NOT trigger it; only a sample written
1235
+ // with real numbers (`### 1. Example Row`) does. On FREQUENCY, claim only what is
1236
+ // measurable here: the SHAPE is uncommon (it takes a literal-digit row inside a
1237
+ // CLOSED fence), and that is a claim about the shape, NOT a measurement across real
1238
+ // projects. The in-tree sample size for it is ZERO PHASE FILES — the only `*UAT*.md`
1239
+ // anywhere in this repo is the shipped template (which `selectPhaseUatFiles` never
1240
+ // scans, and which itself scores headingsSeen=11, six of them literal-digit example
1241
+ // rows), so "no phase UAT file in-tree triggers it" is vacuously true and proves
1242
+ // nothing about rarity in the field. Do not restate it as evidence. If you test the
1243
+ // placeholder form, see no over-report, and conclude this pin is stale: it is not.
1244
+ // The ordinary way to explain the syntax inside a UAT file — is
1245
+ // counted as a suppressed row and raises a parse gap on a file with nothing
1246
+ // actually missing. That is an OVER-report: noisy, but VISIBLE and FAIL-SAFE
1247
+ // (an agent reads the file and dismisses it). Fence-closedness cannot
1248
+ // distinguish it from a genuinely hidden row, because the fence-straddle
1249
+ // case this scan exists to catch is ALSO a properly closed fence — so the
1250
+ // only lever left is scope, and scope is exactly what produced the two
1251
+ // silent false-cleans above. This entire issue exists to eliminate false
1252
+ // cleans, so the trade goes this way ON PURPOSE: an extra noisy row beats an
1253
+ // invisible missing one. The behaviour is pinned by test; do not "fix" it.
1254
+ let shapedHeadingLines = 0;
1255
+ for (const line of content.split('\n')) {
1256
+ if (TEST_HEADING_LINE_RE.test(line))
1257
+ shapedHeadingLines += 1;
1258
+ }
1259
+ if (shapedHeadingLines > subHeadings.length) {
1260
+ shortfallBlocks = shapedHeadingLines - subHeadings.length;
1261
+ headingsSeen += shortfallBlocks;
1262
+ }
1263
+ // #3078 round-4 MAJOR 2: an INDENTED `### N.` row is refused by the parse
1264
+ // gate (`isColumnZeroHeading`) — correct — but must not therefore vanish
1265
+ // without a trace. See `countUnattributedIndentedRows` for why an indented
1266
+ // heading that is the VALUE of a preceding `expected:` block scalar is
1267
+ // excluded from this tally (it is value text, not a row), keeping the
1268
+ // scalar-body pins intact while a genuinely indented ROW surfaces as a gap.
1269
+ //
1270
+ // WHOLE-DOCUMENT, for the same reason as the shortfall scan above: this
1271
+ // counter has no token-side twin to disagree with, but scoping it to a
1272
+ // `## Tests` body would silently drop an indented row living anywhere else
1273
+ // in the file — the identical vanishing-row class. Its own false-positive
1274
+ // guard is STRUCTURAL (scalar attribution via
1275
+ // `ANY_KEY_SCALAR_HEADER_LINE_RE`) — with ONE positional caveat: the walk stops at the
1276
+ // nearest COLUMN-0 line, so a block scalar nested inside a `## Gaps` bullet (a
1277
+ // `- truth:` entry carrying an indented `note: |`) is transparent to it and a
1278
+ // heading-shaped line inside that value is counted. That is another instance of the
1279
+ // accepted over-report above, not a separate defect, not positional, so it needs no scope.
1280
+ headingsSeen += countUnattributedIndentedRows(content);
1281
+ // #3078: an UNTERMINATED fence swallows the entire remainder of the
1282
+ // document — every later test row AND a trailing `## Gaps` section — so the
1283
+ // file yields nothing at all and never even enters `results`: a whole-file
1284
+ // false clean. Mirrors the per-file malformed-markdown guard
1285
+ // `evaluateUatPassed` already applies via `analyzeMarkdown`
1286
+ // (src/uat-predicate.cts:278), which likewise gates on
1287
+ // `stripFencedCode(raw).unterminatedFence`. Deliberately measured on the RAW
1288
+ // document: a fence opened inside an `expected:` scalar is still an
1289
+ // unterminated fence for every downstream markdown consumer, and the masked
1290
+ // copy would hide it.
1291
+ if (stripFencedCode(content).unterminatedFence) {
1292
+ headingsSeen += 1;
1293
+ }
1294
+ for (let i = 0; i < subHeadings.length; i += 1) {
1295
+ const { heading: current, index: currentIdx } = subHeadings[i];
1296
+ const next = allHeadings[currentIdx + 1];
1297
+ const block = next ? content.slice(current.offset, next.offset) : content.slice(current.offset);
1298
+ // Fence-stripped copy for the `result:`/`reason:`/`blocked_by:` field
1299
+ // scans below (#3707 follow-up MAJOR/regression): `block` is raw slice
1300
+ // text, and a fenced code sample inside a test block (a legitimate way to
1301
+ // document expected output) can contain a line that LOOKS like a field
1302
+ // declaration (e.g. an example ` ```\nresult: pending\n``` `). Scanning
1303
+ // raw text reads that sample's `result:` as the test's real outcome —
1304
+ // origin/next returned null here, so an unstripped scan is a regression,
1305
+ // not a pre-existing behavior to preserve. `parseExpectedFromTestBlock`
1306
+ // below still receives the RAW `block`, not this stripped copy: an
1307
+ // `expected: |` block-scalar value may legitimately reproduce
1308
+ // fenced-looking text verbatim, and stripping it would corrupt that field.
1309
+ // #3707 round-3 MINOR: an UNTERMINATED fence (EOF inside a fence, or —
1310
+ // here, scoped per test block — the closing delimiter living in a LATER
1311
+ // block, so from this block's own slice the fence never closes) makes
1312
+ // `stripFencedCode` drop everything from the opener to the end of the
1313
+ // block, including a real `result:`/`reason:`/`blocked_by:` line that
1314
+ // follows it. Falling back to the RAW (unstripped) block in that case
1315
+ // means a legitimate fenced-code false-positive (a `result:`-shaped line
1316
+ // INSIDE a properly-closed sample) is still guarded against in the common
1317
+ // case, while a malformed/unterminated fence no longer silently swallows
1318
+ // a real field line into a false parse_gap.
1319
+ const stripResult = stripFencedCode(block);
1320
+ const fenceStrippedBlock = stripResult.unterminatedFence ? block : stripResult.text;
1321
+ // A block with no `result:` line at all is not a test row (e.g. still
1322
+ // being drafted) — no item, no false positive. It IS, however, a heading
1323
+ // that failed to yield an item for a reason other than a PASS token, so
1324
+ // it counts toward `headingsSeen` (used to detect a genuine parse gap).
1325
+ // Deliberately NOT end-anchored (regression fix, #3707 blocker 1): a
1326
+ // trailing comment/clause after the token (`result: pending (blocked on
1327
+ // staging)`, `result: [skipped] # no device`, `result: blocked -
1328
+ // waiting`) must still match and surface the row instead of being
1329
+ // silently dropped. The trailing text itself is matched-and-ignored
1330
+ // (#3707 follow-up MINOR): it is NOT synthesized into `reason` — a real
1331
+ // `reason:` line is the only source for that field (see below) — because
1332
+ // doing so previously changed `categorizeItem`'s classification for
1333
+ // shapes origin/next categorized differently (an unpinned behavior
1334
+ // change, not something the blocker required).
1335
+ // #3078-CR defect A fix, split-then-match scan: the previous `.match()`
1336
+ // against `/^result:.../im` ran a MULTILINE regex anchor directly over
1337
+ // unsplit block text. ECMA-262's LineTerminator set for `^`/`$` under
1338
+ // `/m` includes U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR, but
1339
+ // `content.split('\n')` and this module's own heading tokenizer do NOT
1340
+ // treat either as a boundary. A `result:`-shaped line inside an
1341
+ // `expected: |` scalar body, sitting immediately after one of these
1342
+ // separators instead of an ordinary character, was therefore read as a
1343
+ // genuine line start by the regex engine even though it is not
1344
+ // `\n`-delimited from anything — it is exactly as much "one line" to
1345
+ // every other consumer as the ordinary-character control case.
1346
+ // Splitting on `\n` FIRST and testing each already-split line against a
1347
+ // single-line (`/im`-anchor-free) pattern fixes this: a line is never
1348
+ // split by U+2028/U+2029 (`String.prototype.split` matches only its
1349
+ // literal separator argument, never the wider ECMA-262 LineTerminator
1350
+ // set), so a `result:`-shaped line reachable only via one of those
1351
+ // separators can never register as its own split line — the split view
1352
+ // and the regex view are back in agreement, by construction, exactly the
1353
+ // way `splitLines` module is documented to be immune to the sibling `\r`
1354
+ // bug.
1355
+ //
1356
+ // FIRST MATCH WINS (byte-identical to origin/next otherwise): a block
1357
+ // with more than one column-0 `result:` line resolves to the FIRST one
1358
+ // encountered, same as the pre-existing `.match()` behaviour without
1359
+ // `/g` — this is deliberately NOT an ambiguity/parse-gap case (that
1360
+ // variant was tried and reverted: its boundary-truncation heuristic
1361
+ // mistook an indented `### N.` living inside a legitimate block scalar
1362
+ // for a heading boundary, corrupting every scalar/indent guard in this
1363
+ // module — see tests/uat.test.cjs's #3078 scalar guard family).
1364
+ // Trailing text is matched with `[^]*` rather than `.*` (final review
1365
+ // MINOR 1): `.` never matches U+2028/U+2029, so a column-0 `result:`
1366
+ // line whose trailing text contains one of those separators would
1367
+ // otherwise never reach `$`, and the whole line would fail to match —
1368
+ // an unpinned regression against origin/next, which parses it.
1369
+ const RESULT_LINE_RE = /^result:\s*\[?(\w+)\]?[^]*$/i;
1370
+ const resultLineMatch = fenceStrippedBlock
1371
+ .split('\n')
1372
+ .map((line) => line.match(RESULT_LINE_RE))
1373
+ .find((m) => m !== null);
1374
+ if (!resultLineMatch) {
1375
+ headingsSeen += 1;
1376
+ continue;
510
1377
  }
1378
+ // Security review finding 2: store the token lower-cased so the published
1379
+ // `result` field agrees with `category` (which categorizeItem already
1380
+ // lower-cases internally, below). No consumer needs the original casing —
1381
+ // `uat-predicate.cts` runs its own independent parser and already
1382
+ // lower-cases too — so the raw-cased form is kept nowhere.
1383
+ const result = resultLineMatch[1].toLowerCase();
1384
+ // #3707 defect 1: invert the old DROP-list filter to a PASS set — see
1385
+ // UAT_PASS_RESULTS's doc comment for why this direction was chosen.
1386
+ // A recognised PASS token is the ONLY reason a heading is excluded from
1387
+ // `headingsSeen` without producing an item — every other non-yielding
1388
+ // case (missing `result:` line, above) is a genuine parse gap.
1389
+ // `result` is already lower-cased at its extraction above, which is the
1390
+ // single point of normalization for this value — re-lowercasing here was
1391
+ // dead work and implied a second, independent normalization that does not
1392
+ // exist (#3078 round-5 MINOR).
1393
+ if (UAT_PASS_RESULTS.has(result))
1394
+ continue;
1395
+ // #3707 follow-up MINOR: the heading filter above now admits `### 3.`
1396
+ // (no name at all) and `### 3.Foo` (no space before the name), so this
1397
+ // extraction is loosened in lockstep — a bare number with no trailing
1398
+ // name falls back to the heading's own trimmed text (`3.`). #3078 round-5
1399
+ // MAJOR: shared with `parseFirstPendingTest` via `parseTestRowHeadingText`.
1400
+ const headingParts = parseTestRowHeadingText(current.text);
1401
+ const testNumber = headingParts.number;
1402
+ const testName = headingParts.name;
1403
+ // Reuse the existing block-scalar/inline `expected:` grammar rather than
1404
+ // re-deriving a second one (#3707 defect 2). #3078 blocker: the block is
1405
+ // CLIPPED at its first top-level fence opener first — still raw text (a
1406
+ // legitimate `expected: |` scalar must be read verbatim, fences and all),
1407
+ // but bounded to what the tokenizer also treated as visible, so this row
1408
+ // cannot reach past a fence into a LATER row's `expected:` line and
1409
+ // publish it as its own. See `clipBlockAtFirstFence`.
1410
+ const expected = parseExpectedFromTestBlock(clipBlockAtFirstFence(block));
1411
+ // #3078 MINOR 2: `reason:`/`blocked_by:` previously had no block-scalar
1412
+ // grammar at all (only a plain `/key:\s*(.+)/` single-line match), so a
1413
+ // `reason: |`/`reason: >`/`blocked_by: |` value silently published as the
1414
+ // literal string `"|"` / `">"`, discarding the real multi-line value the
1415
+ // author wrote — and `categorizeItem` below reads exactly this field, so a
1416
+ // discarded `reason` could silently change an item's category. Routed
1417
+ // through the SAME `extractScalarField` machinery `expected:` already
1418
+ // uses rather than adding a third hand-rolled opener dialect.
1419
+ const reason = extractScalarField(fenceStrippedBlock, 'reason') ?? undefined;
1420
+ const blockedBy = extractScalarField(fenceStrippedBlock, 'blocked_by') ?? undefined;
1421
+ const item = {
1422
+ test: testNumber,
1423
+ name: testName,
1424
+ result,
1425
+ category: categorizeItem(result, reason, blockedBy),
1426
+ };
1427
+ if (expected)
1428
+ item.expected = expected;
1429
+ if (reason)
1430
+ item.reason = reason;
1431
+ if (blockedBy)
1432
+ item.blocked_by = blockedBy;
1433
+ items.push(item);
511
1434
  }
512
1435
  items.push(...parseGapsItems(content));
513
- return items;
1436
+ return { items, headingsSeen, shortfallBlocks };
1437
+ }
1438
+ /**
1439
+ * ITEMS-ONLY convenience form over `parseUatItemsWithStats` — the same parse,
1440
+ * with the `headingsSeen` parse-gap counter dropped, for a caller that only
1441
+ * wants the rows.
1442
+ *
1443
+ * Deliberately RETAINED with no in-tree caller (#3078 round-5 MINOR): both
1444
+ * `cmdAuditUat` and `src/planning-inspect.cts` need the stats form, so this is
1445
+ * currently used only from outside. It is a public export of a shipped module,
1446
+ * and removing an exported symbol is a CONTRACT change, out of scope for a bug
1447
+ * fix — so it stays, as the documented thin wrapper it has always been, with a
1448
+ * direct test of its own rather than as untested dead weight.
1449
+ */
1450
+ function parseUatItems(content) {
1451
+ return parseUatItemsWithStats(content).items;
514
1452
  }
515
1453
  // ─── parseGapsItems ───────────────────────────────────────────────────────────
516
1454
  /**
@@ -766,31 +1704,584 @@ function parseGapsTableItems(sectionBody) {
766
1704
  * `.planning/todos/pending/*.md` entry required). Every other entry —
767
1705
  * including one with no `status:` field at all — is UNRESOLVED and is
768
1706
  * surfaced.
1707
+ *
1708
+ * #3457: when the section body contains headings, entries are delimited by
1709
+ * LEAF headings (see `splitDeferredHeadingEntries`) rather than by bullets —
1710
+ * the executor convention writes one deferred item as a heading followed by
1711
+ * sibling `- **Field:** …` bullets, which the bullet-only split mis-counted as
1712
+ * one item PER BULLET. A body with no headings keeps the original
1713
+ * one-bullet-per-item split unchanged.
769
1714
  */
770
- function parseDeferredItems(content) {
1715
+ /**
1716
+ * One `deferred-items.md` entry with its RAW (un-lowercased) `status:` field
1717
+ * value (`''` when the entry carries no parseable status). #3458 follow-up:
1718
+ * `parseDeferredItems` (below) is now DEFINED IN TERMS OF this — it filters
1719
+ * to `status !== 'resolved'` — and `audit.cts`'s `scanDeferredItems` also
1720
+ * consumes this directly so it can tell `resolved` (fixed for real, never
1721
+ * counted), the newer `acknowledged` (suppressed-but-tallied, #3458
1722
+ * follow-up), and everything else (open) apart WITHOUT a second,
1723
+ * independent entry-boundary/field-extraction pass that could drift from
1724
+ * this one.
1725
+ */
1726
+ function parseDeferredItemsWithStatus(content) {
771
1727
  const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
772
1728
  const sectionBody = deferredSection ? deferredSection.body : content;
773
1729
  const items = [];
774
- for (const entryLines of splitGapsEntries(sectionBody)) {
775
- const fields = extractGapEntryFields(entryLines);
776
- const rawStatus = fields.status;
777
- if (rawStatus && rawStatus.toLowerCase() === 'resolved')
778
- continue;
1730
+ // #3457: heading-delimited shape — an entry's fields live in sibling bullets
1731
+ // (`- **Status:** resolved`), so the bullet marker is stripped on EVERY line
1732
+ // before field extraction, not just line 0 (which `extractGapEntryFields`
1733
+ // does for the headless/Gaps shape, where a later `- ` line is a nested
1734
+ // sub-list, not a field).
1735
+ const headingEntries = splitDeferredHeadingEntries(sectionBody);
1736
+ const entries = headingEntries !== null
1737
+ ? headingEntries.map((entryLines) => ({
1738
+ lines: entryLines,
1739
+ fields: extractGapEntryFields(entryLines.map(stripLeadingBulletMarker)),
1740
+ }))
1741
+ : splitGapsEntries(sectionBody).map((entryLines) => ({
1742
+ lines: entryLines,
1743
+ fields: extractGapEntryFields(entryLines),
1744
+ }));
1745
+ for (const { lines: entryLines, fields } of entries) {
779
1746
  const text = rawGapEntryText(entryLines);
780
1747
  if (!text)
781
1748
  continue;
782
- items.push({
783
- name: text,
784
- result: 'unresolved',
785
- category: 'deferred',
786
- });
1749
+ items.push({ name: text, status: fields.status || '' });
787
1750
  }
788
1751
  // #2766: union with the table form — see parseDeferredTableItems. Executors
789
1752
  // write this file by hand with no mandated shape, and a GFM table is a natural
790
1753
  // choice for the common "test → failing seeds" case, which produced ZERO items.
791
- items.push(...parseDeferredTableItems(sectionBody));
1754
+ // Table rows carry no independently-parseable status column in general —
1755
+ // `parseDeferredTableItems` already excludes resolved/done/pass rows at its
1756
+ // own layer (any cell reading exactly one of those three) — so anything it
1757
+ // returns here is inherently open; `acknowledge` (#3458 follow-up) has no
1758
+ // representable field to write for a table row, so those are reported with
1759
+ // status `''` (never `resolved`/`acknowledged`) and remain permanently
1760
+ // un-acknowledgeable via the CLI writer — a known, deliberate limitation
1761
+ // (see `acknowledgeDeferredItem`'s doc comment).
1762
+ items.push(...parseDeferredTableItems(sectionBody).map((item) => ({ name: item.name, status: '' })));
792
1763
  return items;
793
1764
  }
1765
+ function parseDeferredItems(content) {
1766
+ return parseDeferredItemsWithStatus(content)
1767
+ .filter((entry) => !(entry.status && entry.status.toLowerCase() === 'resolved'))
1768
+ .map((entry) => ({
1769
+ name: entry.name,
1770
+ result: 'unresolved',
1771
+ category: 'deferred',
1772
+ }));
1773
+ }
1774
+ /**
1775
+ * CLI-writer half of the #3458 follow-up deferred_items suppression seam.
1776
+ * Sets the ONE deferred entry whose rendered text (`rawGapEntryText`, the
1777
+ * same value `parseDeferredItemsWithStatus`/the audit's JSON output surface
1778
+ * as `name`/`text`) exactly equals `targetText` to `status: acknowledged` —
1779
+ * a NEW terminal value, distinct from the existing `resolved` (which keeps
1780
+ * meaning "actually fixed"). This is the marker for this category: unlike
1781
+ * every other audit category (a sibling `audit_acknowledged` frontmatter map
1782
+ * that never touches the artifact's own `status:`), a deferred-items.md
1783
+ * entry's `status:` field carries no OTHER meaning, so the field itself
1784
+ * doubles as the marker — self-invalidating for free: edit the entry's
1785
+ * `status:` away from `acknowledged` (or delete the field) and it resurfaces
1786
+ * with no separate cleanup step, exactly like every other category's marker.
1787
+ *
1788
+ * #3781: the heading-delimited (#3457) entry shape is SUPPORTED, via
1789
+ * `splitDeferredHeadingEntriesWithSpans` — a span-carrying sibling of the
1790
+ * reader's walk that records each entry's (start, end) character span in the
1791
+ * SAME pass that groups its lines (the identical technique
1792
+ * `splitGapsEntriesWithSpans` uses for the headless shape). Leaf entries keep
1793
+ * their RAW heading line as `lines[0]` so `sectionBody.slice(start, end)` is
1794
+ * byte-verbatim; pending (preamble / container-direct) regions are contiguous
1795
+ * slices handed to `splitGapsEntriesWithSpans` with a baseOffset translation.
1796
+ * Two write rules differ from the headless path on this shape: the status
1797
+ * search runs over the READER-form lines (what the reader actually parses —
1798
+ * including the leaf line-0 corner where the heading text itself parses as a
1799
+ * status field, whose raw line is rewritten with its ATX prefix preserved),
1800
+ * and the insert branch inserts after the entry's LAST NON-BLANK line — a
1801
+ * heading entry's body is frequently a soft-wrapped sentence, and splicing
1802
+ * after line 0 would split it in half (#3781's sentence-split trap).
1803
+ * Entries whose span embeds a GFM table row are non-contiguous (table lines
1804
+ * are excluded from entries) and still refuse (`unsupported_heading_shape`)
1805
+ * rather than risk a wrong-entry write; the fully-headless shape below is
1806
+ * byte-for-byte the pre-#3781 path.
1807
+ *
1808
+ * Also refuses `ambiguous` (2+ entries share the exact same text — status must
1809
+ * be unique to identify one) and `not_found`, and is a no-op
1810
+ * (`already_resolved`) on an entry already carrying `status: resolved` — the
1811
+ * verdict-preserving direction: acknowledging a genuinely-fixed item would
1812
+ * silently downgrade its terminal state.
1813
+ *
1814
+ * SPAN-CARRIED, not re-searched (F1, #3458 follow-up review — see
1815
+ * `splitGapsEntriesWithSpans`'s doc comment): the target entry's location
1816
+ * within `sectionBody` is the (start, end) character span recorded by
1817
+ * `splitGapsEntriesWithSpans` in the SAME pass that produced `entryLines` /
1818
+ * `targetText` above — never re-derived afterwards by searching. The
1819
+ * previous implementation re-found the entry with a regex anchored on its
1820
+ * own (escaped) exact text; that regex necessarily matches the FIRST
1821
+ * occurrence of that text within `sectionBody`, which is not always the
1822
+ * entry that was actually selected (a continuation/quoted line inside an
1823
+ * EARLIER or LATER entry can carry byte-identical text) — and because the
1824
+ * mis-targeted span is byte-identical to `targetText`, no downstream check
1825
+ * on the WRITTEN text could ever distinguish a wrong-entry write from a
1826
+ * correct one. Carrying the span removes the re-derivation step entirely:
1827
+ * there is no second search to mis-target.
1828
+ *
1829
+ * Section-anchored (BLOCKER 1, #3458 follow-up review): the span is
1830
+ * `sectionBody`-relative — the SAME string `matches`/the `ambiguous` guard
1831
+ * were computed over — not `content`-relative, so an identical bullet living
1832
+ * outside `## Deferred Items` (e.g. in an unrelated `# Notes` or a
1833
+ * UAT/VERIFICATION body) can never steal the write. The span is translated
1834
+ * into `content`-relative offsets via `deferredSection.bodyStart` (the
1835
+ * section's own start offset, an invariant `collectSection` guarantees:
1836
+ * `content.slice(bodyStart, bodyEnd) === body`). Before writing, the
1837
+ * spanned text's own raw entry is re-derived and compared against
1838
+ * `targetText` one more time — this is now a GENUINE invariant check (the
1839
+ * span was computed by `splitGapsEntriesCore`'s independent offset
1840
+ * bookkeeping, a different code path than the `entryLines`/`targetText`
1841
+ * comparison above), not a no-op — if it does not match, the write is
1842
+ * refused with `match_verification_failed` rather than risk touching the
1843
+ * wrong span.
1844
+ */
1845
+ function acknowledgeDeferredItem(content, targetText) {
1846
+ const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
1847
+ const sectionBody = deferredSection ? deferredSection.body : content;
1848
+ // #3781: the heading-delimited shape carries its own span walk; the
1849
+ // headless path below is unchanged.
1850
+ const headingEntries = splitDeferredHeadingEntriesWithSpans(sectionBody);
1851
+ if (headingEntries !== null) {
1852
+ return acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText });
1853
+ }
1854
+ const entries = splitGapsEntriesWithSpans(sectionBody);
1855
+ const matches = entries
1856
+ .map((entry) => ({ entry, text: rawGapEntryText(entry.lines) }))
1857
+ .filter((e) => e.text === targetText);
1858
+ if (matches.length === 0)
1859
+ return { content, status: 'not_found' };
1860
+ if (matches.length > 1)
1861
+ return { content, status: 'ambiguous' };
1862
+ const { entry } = matches[0];
1863
+ const { lines: entryLines, start, end } = entry;
1864
+ const fields = extractGapEntryFields(entryLines);
1865
+ if (fields.status && fields.status.toLowerCase() === 'resolved') {
1866
+ return { content, status: 'already_resolved' };
1867
+ }
1868
+ // Anchor to the SAME section body `matches`/the `ambiguous` guard above
1869
+ // were computed over (BLOCKER 1) — never the whole `content`, which could
1870
+ // contain an identical bullet elsewhere. `start`/`end` are the entry's own
1871
+ // span, carried directly from `splitGapsEntriesWithSpans` — no re-search.
1872
+ const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
1873
+ const matchedLines = sectionBody.slice(start, end).split('\n');
1874
+ // Genuine invariant re-verification (see doc comment above): the span was
1875
+ // computed by a code path independent of the `entryLines`/`targetText`
1876
+ // comparison that selected this entry — this catches real drift between
1877
+ // the two rather than a regex trivially guaranteed to agree with itself.
1878
+ const strippedForVerify = matchedLines.map((l) => l.replace(/\r$/, ''));
1879
+ if (rawGapEntryText(strippedForVerify) !== targetText) {
1880
+ return { content, status: 'match_verification_failed' };
1881
+ }
1882
+ const matchIndexInContent = sectionOffset + start;
1883
+ // #3740: the search must mirror the reader exactly. extractGapEntryFields
1884
+ // strips a bullet marker on line 0 ALONE — a later `- ` line is a nested
1885
+ // sub-list, never a field line — so a marker-prefixed match on any
1886
+ // continuation line would rewrite a line no reader reads and report `ok`
1887
+ // while the entry stays outstanding. Line 0 KEEPS the marker-optional
1888
+ // form: the reader de-bullets it, so `- status: open` as the entry line is
1889
+ // a real field there (and first-wins means the insert branch could not
1890
+ // outrank it). Everything else falls through to the insert branch below,
1891
+ // which the marker-free and no-status controls already round-trip.
1892
+ //
1893
+ // #3775: the CASE axis of the same rule. The reader lowercases BOLDED
1894
+ // keys only; bare keys keep their literal case (#3457 design), so a bare
1895
+ // `Status:`/`STATUS:` line is stored under key `Status` and never read as
1896
+ // fields.status. The search therefore matches a bolded key in ANY case
1897
+ // and a bare key in LOWERCASE only — never a bare Title-case/UPPER line,
1898
+ // which must fall through to the insert branch whose lowercase output the
1899
+ // reader consumes (leaving any human `Status: resolved` untouched).
1900
+ const statusFieldBoldedRe = /^\s*\*+status:\*+/i;
1901
+ const statusFieldBareRe = /^\s*status:/;
1902
+ const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i;
1903
+ const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/;
1904
+ const statusLineIdx = matchedLines.findIndex((rawLine, idx) => {
1905
+ const line = rawLine.replace(/\r$/, '');
1906
+ return idx === 0
1907
+ ? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line))
1908
+ : (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line));
1909
+ });
1910
+ // No CRLF-preservation branch here (WARNING 1, #3458 follow-up review):
1911
+ // every write goes through `platformWriteSync` → `normalizeContent`, which
1912
+ // for a `.md` path unconditionally runs `_normalizeMd` — whole-file
1913
+ // `\r\n` → `\n`, plus blank-line normalization around headings/lists — on
1914
+ // EVERY write, not just this one. That is this codebase's single,
1915
+ // deliberate OS-facing I/O seam (`shell-command-projection.cts`), applied
1916
+ // uniformly to every `.md` writer; carving out one exception here would
1917
+ // fight it rather than follow it, for a guarantee (byte-identical CRLF on
1918
+ // disk) the seam already makes impossible. A marker write on a CRLF
1919
+ // `deferred-items.md` normalizes the WHOLE file to LF, same as any other
1920
+ // `.md` write in this codebase — expected, not a regression to guard
1921
+ // against. Where a source line still carries a trailing `\r` (read from an
1922
+ // on-disk CRLF document before normalization), `String.prototype.replace`
1923
+ // consumes it as part of `.*$` and the replacement text does not
1924
+ // reproduce it, so it is dropped here too — consistent with the eventual
1925
+ // whole-file normalization rather than duplicating it.
1926
+ let newMatchedLines;
1927
+ if (statusLineIdx === -1) {
1928
+ const bulletIndentMatch = matchedLines[0].match(/^(\s*)-\s+/);
1929
+ const continuationIndent = ' '.repeat((bulletIndentMatch ? bulletIndentMatch[1].length : 0) + 2);
1930
+ newMatchedLines = [
1931
+ matchedLines[0],
1932
+ `${continuationIndent}status: acknowledged`,
1933
+ ...matchedLines.slice(1),
1934
+ ];
1935
+ }
1936
+ else {
1937
+ const original = matchedLines[statusLineIdx];
1938
+ const replaced = original.replace(/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, (_m, indent, key, ws) => `${indent}${key}${ws}acknowledged`);
1939
+ newMatchedLines = matchedLines.slice();
1940
+ newMatchedLines[statusLineIdx] = replaced;
1941
+ }
1942
+ const newContent = content.slice(0, matchIndexInContent) + newMatchedLines.join('\n') + content.slice(matchIndexInContent + (end - start));
1943
+ return { content: newContent, status: 'ok' };
1944
+ }
1945
+ /**
1946
+ * #3781 — strip an ATX heading prefix, mirroring `tokenizeHeadings`' own ATX
1947
+ * regex (≤3 leading spaces, 1–6 `#`, space/tab separator, optional closing
1948
+ * `#` sequence) so the raw heading line reconciles byte-exactly with the
1949
+ * hash-stripped `text` the reader exposes. Returns null when the line is not
1950
+ * an ATX heading line.
1951
+ */
1952
+ function stripAtxPrefix(line) {
1953
+ const m = /^( {0,3})(#{1,6})([ \t]+.*|[ \t]*)?$/.exec(line.replace(/\r$/, ''));
1954
+ if (!m)
1955
+ return null;
1956
+ return m[3] === undefined
1957
+ ? ''
1958
+ : m[3].replace(/^[ \t]+/, '').replace(/[ \t]+#+[ \t]*$/, '').replace(/^#+[ \t]*$/, '').trim();
1959
+ }
1960
+ /**
1961
+ * #3781 — span-carrying sibling of `splitDeferredHeadingEntries`: ONE walk,
1962
+ * identical grouping rules (leaf = childless heading whose body carries a
1963
+ * bullet; container = next heading deeper; preamble/container-direct lines →
1964
+ * headless entries; table lines excluded), additionally recording each
1965
+ * entry's (start, end) character span within `sectionBody`. Returns null when
1966
+ * the body contains no heading at all — the caller then takes the unchanged
1967
+ * fully-headless path.
1968
+ */
1969
+ function splitDeferredHeadingEntriesWithSpans(sectionBody) {
1970
+ const headings = tokenizeHeadings(sectionBody);
1971
+ if (headings.length === 0)
1972
+ return null;
1973
+ const lines = sectionBody.split('\n');
1974
+ const lineStarts = [];
1975
+ const lineEnds = [];
1976
+ let cursor = 0;
1977
+ for (const rawLine of lines) {
1978
+ lineStarts.push(cursor);
1979
+ cursor += rawLine.length;
1980
+ lineEnds.push(cursor);
1981
+ cursor += 1;
1982
+ }
1983
+ const headingByLine = new Map();
1984
+ for (let i = 0; i < headings.length; i++) {
1985
+ const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level;
1986
+ headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
1987
+ }
1988
+ const isTableLine = (l) => /^\s*\|/.test(l.replace(/\r$/, ''));
1989
+ const isBulletLine = (l) => /^\s*-\s/.test(l.replace(/\r$/, ''));
1990
+ const entries = [];
1991
+ let current = null;
1992
+ let currentReaderLine0 = null;
1993
+ let currentStartLine = -1;
1994
+ let currentEndLine = -1;
1995
+ let currentHasBullet = false;
1996
+ let currentTable = false;
1997
+ let pendingStartLine = -1;
1998
+ let pendingEndLine = -1;
1999
+ const flushCurrent = () => {
2000
+ if (current !== null && currentHasBullet && currentReaderLine0 !== null) {
2001
+ const bodyReader = current.slice(1).map(stripLeadingBulletMarker);
2002
+ entries.push({
2003
+ kind: 'leaf',
2004
+ lines: current,
2005
+ readerLines: [currentReaderLine0, ...bodyReader],
2006
+ text: rawGapEntryText([currentReaderLine0, ...current.slice(1)]),
2007
+ fields: extractGapEntryFields([currentReaderLine0, ...bodyReader]),
2008
+ start: lineStarts[currentStartLine],
2009
+ end: lineEnds[currentEndLine],
2010
+ embeddedTable: currentTable,
2011
+ });
2012
+ }
2013
+ current = null;
2014
+ currentReaderLine0 = null;
2015
+ currentStartLine = -1;
2016
+ currentEndLine = -1;
2017
+ currentHasBullet = false;
2018
+ currentTable = false;
2019
+ };
2020
+ const flushPending = () => {
2021
+ if (pendingStartLine === -1)
2022
+ return;
2023
+ // The pending region is contiguous (a heading flushes it), but table lines
2024
+ // inside it were skipped by the walk: the reader's identity for this
2025
+ // region is computed over the table-FILTERED join, which may merge
2026
+ // entries across the gap, so spans cannot be translated faithfully —
2027
+ // mark the region's entries as refusing instead.
2028
+ let regionTable = false;
2029
+ for (let i = pendingStartLine; i <= pendingEndLine; i++) {
2030
+ if (isTableLine(lines[i]))
2031
+ regionTable = true;
2032
+ }
2033
+ const base = lineStarts[pendingStartLine];
2034
+ const regionText = sectionBody.slice(lineStarts[pendingStartLine], lineEnds[pendingEndLine]);
2035
+ for (const e of splitGapsEntriesWithSpans(regionText)) {
2036
+ entries.push({
2037
+ kind: 'pending',
2038
+ lines: e.lines,
2039
+ readerLines: e.lines,
2040
+ text: rawGapEntryText(e.lines),
2041
+ fields: extractGapEntryFields(e.lines),
2042
+ start: base + e.start,
2043
+ end: base + e.end,
2044
+ embeddedTable: regionTable,
2045
+ });
2046
+ }
2047
+ pendingStartLine = -1;
2048
+ pendingEndLine = -1;
2049
+ };
2050
+ for (let i = 0; i < lines.length; i++) {
2051
+ const heading = headingByLine.get(i + 1);
2052
+ if (heading !== undefined) {
2053
+ flushCurrent();
2054
+ flushPending();
2055
+ if (!heading.isContainer) {
2056
+ current = [lines[i]];
2057
+ currentReaderLine0 = heading.text;
2058
+ currentStartLine = i;
2059
+ currentEndLine = i;
2060
+ currentHasBullet = false;
2061
+ currentTable = false;
2062
+ }
2063
+ continue;
2064
+ }
2065
+ if (isTableLine(lines[i])) {
2066
+ if (current !== null)
2067
+ currentTable = true;
2068
+ continue;
2069
+ }
2070
+ if (current !== null) {
2071
+ current.push(lines[i]);
2072
+ currentEndLine = i;
2073
+ if (isBulletLine(lines[i]))
2074
+ currentHasBullet = true;
2075
+ }
2076
+ else {
2077
+ if (pendingStartLine === -1)
2078
+ pendingStartLine = i;
2079
+ pendingEndLine = i;
2080
+ }
2081
+ }
2082
+ flushCurrent();
2083
+ flushPending();
2084
+ return entries;
2085
+ }
2086
+ /**
2087
+ * #3781 — the heading-shaped half of `acknowledgeDeferredItem`, sharing the
2088
+ * headless path's guards (not_found / ambiguous / already_resolved /
2089
+ * match_verification_failed) and its rewrite/insert machinery, with the two
2090
+ * shape-specific rules documented on `acknowledgeDeferredItem` (reader-form
2091
+ * status search incl. the leaf line-0 ATX corner; insert after the entry's
2092
+ * last non-blank line). Extracted so the headless path stays byte-identical.
2093
+ */
2094
+ function acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText }) {
2095
+ const matches = headingEntries.filter((e) => e.text === targetText);
2096
+ if (matches.length === 0)
2097
+ return { content, status: 'not_found' };
2098
+ if (matches.length > 1)
2099
+ return { content, status: 'ambiguous' };
2100
+ const entry = matches[0];
2101
+ if (entry.embeddedTable)
2102
+ return { content, status: 'unsupported_heading_shape' };
2103
+ if (entry.fields.status && entry.fields.status.toLowerCase() === 'resolved') {
2104
+ return { content, status: 'already_resolved' };
2105
+ }
2106
+ const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
2107
+ const rawSlice = sectionBody.slice(entry.start, entry.end);
2108
+ const rawSliceLines = rawSlice.split('\n');
2109
+ // Genuine invariant re-verification: re-derive the entry's identity from
2110
+ // the span's own bytes and compare against the targetText that selected it
2111
+ // — the span was recorded by an offset bookkeeping independent of the
2112
+ // identity comparison above.
2113
+ const verifyText = entry.kind === 'leaf'
2114
+ ? (() => {
2115
+ const stripped = stripAtxPrefix(rawSliceLines[0]);
2116
+ return stripped === null ? null : rawGapEntryText([stripped, ...rawSliceLines.slice(1)]);
2117
+ })()
2118
+ : rawGapEntryText(rawSliceLines.map((l) => l.replace(/\r$/, '')));
2119
+ if (verifyText !== targetText) {
2120
+ return { content, status: 'match_verification_failed' };
2121
+ }
2122
+ // Status search over the READER-form lines — the exact set the reader
2123
+ // parses (bolded any case + bare lowercase, per #3775; line-0 forms per
2124
+ // #3740). Reader lines are index-aligned 1:1 with the raw lines.
2125
+ const statusFieldBoldedRe = /^\s*\*+status:\*+/i;
2126
+ const statusFieldBareRe = /^\s*status:/;
2127
+ const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i;
2128
+ const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/;
2129
+ const readerLines = entry.kind === 'leaf'
2130
+ ? [
2131
+ stripAtxPrefix(rawSliceLines[0]) ?? rawSliceLines[0].replace(/\r$/, ''),
2132
+ ...rawSliceLines.slice(1).map((l) => stripLeadingBulletMarker(l.replace(/\r$/, ''))),
2133
+ ]
2134
+ : rawSliceLines.map((l) => l.replace(/\r$/, ''));
2135
+ const statusLineIdx = readerLines.findIndex((line, idx) => idx === 0
2136
+ ? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line))
2137
+ : (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line)));
2138
+ let newRawLines;
2139
+ if (statusLineIdx === -1) {
2140
+ // Insert branch: after the entry's LAST NON-BLANK line — a heading
2141
+ // entry's body is frequently a soft-wrapped sentence, and splicing after
2142
+ // line 0 would split it in half (#3781's sentence trap). The headless
2143
+ // (no-heading-anywhere) path keeps its own splice-after-line-0 shape.
2144
+ let lastNonBlank = rawSliceLines.length - 1;
2145
+ while (lastNonBlank > 0 && rawSliceLines[lastNonBlank].replace(/\r$/, '').trim() === '') {
2146
+ lastNonBlank--;
2147
+ }
2148
+ const indent = entry.kind === 'pending'
2149
+ ? (() => {
2150
+ const bulletIndentMatch = rawSliceLines[0].match(/^(\s*)-\s+/);
2151
+ return ' '.repeat((bulletIndentMatch ? bulletIndentMatch[1].length : 0) + 2);
2152
+ })()
2153
+ : ' ';
2154
+ newRawLines = [
2155
+ ...rawSliceLines.slice(0, lastNonBlank + 1),
2156
+ `${indent}status: acknowledged`,
2157
+ ...rawSliceLines.slice(lastNonBlank + 1),
2158
+ ];
2159
+ }
2160
+ else {
2161
+ newRawLines = rawSliceLines.slice();
2162
+ if (entry.kind === 'leaf' && statusLineIdx === 0) {
2163
+ // Leaf line 0 is the RAW heading line — rewrite the heading-text portion
2164
+ // the reader treats as a field, with the ATX prefix preserved.
2165
+ const replacedReader = readerLines[statusLineIdx].replace(/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, (_m, indent, key, ws) => `${indent}${key}${ws}acknowledged`);
2166
+ const atxMatch = /^(\s*#+[ \t]*)(.*)$/.exec(rawSliceLines[0].replace(/\r$/, ''));
2167
+ newRawLines[0] = atxMatch ? atxMatch[1] + replacedReader : replacedReader;
2168
+ }
2169
+ else {
2170
+ // Every other status line is rewritten on its RAW line, so the bullet
2171
+ // marker and indent survive the write (the reader-form line has the
2172
+ // marker stripped — writing it back would mangle the markdown shape and
2173
+ // change the entry's identity text). Same replacement regex as the
2174
+ // headless path.
2175
+ newRawLines[statusLineIdx] = rawSliceLines[statusLineIdx].replace(/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, (_m, indent, key, ws) => `${indent}${key}${ws}acknowledged`);
2176
+ }
2177
+ }
2178
+ const matchIndexInContent = sectionOffset + entry.start;
2179
+ const newContent = content.slice(0, matchIndexInContent) + newRawLines.join('\n') + content.slice(matchIndexInContent + (entry.end - entry.start));
2180
+ return { content: newContent, status: 'ok' };
2181
+ }
2182
+ /**
2183
+ * Strip one leading `- ` bullet marker (#3457). Heading-delimited deferred
2184
+ * entries carry their fields as sibling bullets; `extractGapEntryFields` only
2185
+ * de-bullets line 0 (Gaps-protective — there, a later `- ` line is a nested
2186
+ * sub-list), so the deferred heading path de-bullets every line itself before
2187
+ * field extraction. Non-bullet lines pass through untouched.
2188
+ */
2189
+ function stripLeadingBulletMarker(line) {
2190
+ return line.replace(/^(\s*)-\s+/, '');
2191
+ }
2192
+ /**
2193
+ * Split a deferred-items section body into entries delimited by LEAF headings
2194
+ * (#3457). Returns `null` when the body contains no heading at all — the
2195
+ * caller then falls back to `splitGapsEntries`, keeping headless
2196
+ * one-bullet-per-item files byte-for-byte on the pre-#3457 path.
2197
+ *
2198
+ * A heading is a CONTAINER (group/provenance/title label, contributes no
2199
+ * entry) iff the NEXT heading is deeper — a deeper heading lives inside its
2200
+ * span. Otherwise it is a LEAF: an entry boundary. This handles all three
2201
+ * corpus shapes without hardcoding a depth: flat `#` title + `##` entries
2202
+ * (title's next heading is deeper → container; each `##` followed by a
2203
+ * same-or-shallower heading → leaf), a `##` container with `###` entries
2204
+ * (container's next heading is deeper), and mixed-depth files where a
2205
+ * childless `##` entry sits alongside a `##` group with `###` children — every
2206
+ * childless heading is a leaf at whatever depth it is written. The shallower
2207
+ * rules the issue reports as already tried (split on every heading; shallowest
2208
+ * level; deepest level) each mis-count one of these shapes.
2209
+ *
2210
+ * A leaf entry is [heading text, ...body lines up to the next heading] and is
2211
+ * kept only when its body (minus table lines) contains at least one `- `
2212
+ * bullet:
2213
+ * - a prose-only or bare heading contributes nothing — "prose is not an item"
2214
+ * is this parser's pre-existing contract (see the `# Notes` case);
2215
+ * - a table-only body is left entirely to `parseDeferredTableItems`, which
2216
+ * unions over the same section body, so the heading cannot double-count the
2217
+ * table's rows.
2218
+ *
2219
+ * Lines before the first heading, and lines directly under a container heading
2220
+ * (before its first child), are split one-bullet-per-item by the unchanged
2221
+ * `splitGapsEntries` — headless parity, so loose bullets before a later
2222
+ * heading group (the mixed shape) stay one item each.
2223
+ */
2224
+ function splitDeferredHeadingEntries(sectionBody) {
2225
+ const headings = tokenizeHeadings(sectionBody);
2226
+ if (headings.length === 0)
2227
+ return null;
2228
+ const lines = sectionBody.split('\n');
2229
+ const headingByLine = new Map();
2230
+ for (let i = 0; i < headings.length; i++) {
2231
+ // Container iff the next heading is deeper (see doc comment). An empty
2232
+ // heading text (`##` alone) does not itself mean container — the flag is
2233
+ // carried explicitly so a bare LEAF heading still opens an entry.
2234
+ const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level;
2235
+ headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
2236
+ }
2237
+ const entries = [];
2238
+ let current = null; // accumulating a leaf heading's entry
2239
+ let pending = []; // preamble / container-heading body lines
2240
+ let currentHasBullet = false;
2241
+ const flushCurrent = () => {
2242
+ // Keep the leaf entry only when its body carries a bullet; the heading
2243
+ // text line itself (element 0) never counts as one.
2244
+ if (current !== null && currentHasBullet)
2245
+ entries.push(current);
2246
+ current = null;
2247
+ currentHasBullet = false;
2248
+ };
2249
+ const flushPending = () => {
2250
+ entries.push(...splitGapsEntries(pending.join('\n')));
2251
+ pending = [];
2252
+ };
2253
+ for (let i = 0; i < lines.length; i++) {
2254
+ const lineNo = i + 1;
2255
+ const heading = headingByLine.get(lineNo);
2256
+ if (heading !== undefined) {
2257
+ flushCurrent();
2258
+ // Headless-shaped region (preamble / container-direct bullets) ends at
2259
+ // ANY heading; flushing here keeps entries in document order even when
2260
+ // a container's direct bullets precede its first child entry.
2261
+ flushPending();
2262
+ if (!heading.isContainer) {
2263
+ // Leaf heading: open an entry with the heading text as line 0.
2264
+ current = [heading.text];
2265
+ currentHasBullet = false;
2266
+ }
2267
+ continue;
2268
+ }
2269
+ // Table lines belong to parseDeferredTableItems, never to a heading entry.
2270
+ if (/^\s*\|/.test(lines[i].replace(/\r$/, '')))
2271
+ continue;
2272
+ if (current !== null) {
2273
+ current.push(lines[i]);
2274
+ if (/^\s*-\s/.test(lines[i].replace(/\r$/, '')))
2275
+ currentHasBullet = true;
2276
+ }
2277
+ else {
2278
+ pending.push(lines[i]);
2279
+ }
2280
+ }
2281
+ flushCurrent();
2282
+ flushPending();
2283
+ return entries;
2284
+ }
794
2285
  /**
795
2286
  * Extract deferred entries from GFM pipe tables in a deferred-items.md body
796
2287
  * (#2766) — a UNION with the bullet scan in `parseDeferredItems`.
@@ -831,49 +2322,123 @@ function parseDeferredTableItems(sectionBody) {
831
2322
  return items;
832
2323
  }
833
2324
  /**
834
- * Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
835
- * `- ` bullet openers.
836
- *
837
- * The indentation of the FIRST bullet line encountered establishes the
838
- * "top-level" indent for the whole section; any subsequent `- `-opening line
839
- * at that same indent (or shallower) starts a NEW entry, while everything
840
- * more deeply indented — field continuation lines (` status: ...`) AND
841
- * nested sub-lists (` - src/foo.ts` under ` artifacts:`) — is folded into
842
- * the CURRENT entry. This keeps a `artifacts:`/`missing:` sub-list's `- `
843
- * items from being mis-split into spurious standalone entries (#2286 review
844
- * LOW finding).
845
- *
846
- * Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
847
- * the template emits) are discarded. An empty/whitespace-only section body
848
- * (heading present, no bullets) returns `[]`.
2325
+ * Shared walk behind `splitGapsEntries` and `splitGapsEntriesWithSpans` — ONE
2326
+ * pass over `sectionBody` that both groups its lines into entries (see
2327
+ * `splitGapsEntries`'s doc comment for the grouping rule) AND records each
2328
+ * entry's (start, end) character offset within `sectionBody`. Extracted so
2329
+ * the two public shapes can never drift apart on what counts as an entry
2330
+ * boundary — a second, independently-written grouping pass is exactly how a
2331
+ * span-carrying sibling could disagree with the plain-lines version it is
2332
+ * supposed to be span-annotating.
849
2333
  */
850
- function splitGapsEntries(sectionBody) {
851
- const lines = sectionBody.split('\n');
2334
+ function splitGapsEntriesCore(sectionBody) {
2335
+ const rawLines = sectionBody.split('\n');
2336
+ const lineStarts = [];
2337
+ const lineEnds = [];
2338
+ let cursor = 0;
2339
+ for (const rawLine of rawLines) {
2340
+ lineStarts.push(cursor);
2341
+ cursor += rawLine.length;
2342
+ lineEnds.push(cursor);
2343
+ cursor += 1; // the '\n' separator — absent after the final line, but nothing reads past it
2344
+ }
852
2345
  const entries = [];
853
2346
  let current = null;
2347
+ let currentStartLine = -1;
2348
+ let currentEndLine = -1;
854
2349
  let baseIndent = null;
855
- for (const rawLine of lines) {
2350
+ const flush = () => {
2351
+ if (current !== null) {
2352
+ entries.push({ lines: current, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
2353
+ }
2354
+ };
2355
+ // #3898: a spaced-hyphen thematic break (`- - -`, `- -`, `- - -`, …) is a
2356
+ // SEPARATOR, not an entry. The opener regex below matches it (hyphen +
2357
+ // whitespace), which fabricated a gap named `- -` with result 'unknown' —
2358
+ // an item that cannot be cleared by editing any entry, because there is no
2359
+ // entry, only the separator the author wrote deliberately. A line whose
2360
+ // content after the opening marker consists solely of hyphens and spaces
2361
+ // (with at least one further hyphen) is skipped entirely: it neither opens
2362
+ // an entry nor is folded into the current one. This is deliberately NOT a
2363
+ // full thematic-break concept (option 2 in the issue): a break does not
2364
+ // close the Gaps list — entries after it keep parsing.
2365
+ const isSeparatorShaped = (line, bulletPrefixLen) => {
2366
+ const remainder = line.slice(bulletPrefixLen);
2367
+ return /^[-\s]*$/.test(remainder) && remainder.includes('-');
2368
+ };
2369
+ rawLines.forEach((rawLine, idx) => {
856
2370
  const line = rawLine.replace(/\r$/, '');
857
2371
  const bulletMatch = line.match(/^(\s*)-\s/);
2372
+ // Narrowed skip (review disposition a): a separator-shaped line is skipped
2373
+ // only when it sits BETWEEN entries (nothing open yet, or it would open a
2374
+ // top-level entry — where the phantom came from). One landing strictly
2375
+ // INSIDE a live entry (indent > baseIndent) folds back as a continuation
2376
+ // line, so the entry's GapsEntrySpan stays byte-contiguous — the span
2377
+ // invariant below and the ack writer's identity re-verification both hold.
2378
+ if (bulletMatch && isSeparatorShaped(line, bulletMatch[0].length) &&
2379
+ (current === null || bulletMatch[1].length <= (baseIndent ?? 0))) {
2380
+ return; // separator line between entries — neither an opener nor a continuation
2381
+ }
858
2382
  if (bulletMatch) {
859
2383
  const indent = bulletMatch[1].length;
860
2384
  if (baseIndent === null)
861
2385
  baseIndent = indent;
862
2386
  if (indent <= baseIndent) {
863
- if (current)
864
- entries.push(current);
2387
+ flush();
865
2388
  current = [line];
866
- continue;
2389
+ currentStartLine = idx;
2390
+ currentEndLine = idx;
2391
+ return;
867
2392
  }
868
2393
  }
869
- if (current)
2394
+ if (current !== null) {
870
2395
  current.push(line);
2396
+ currentEndLine = idx;
2397
+ }
871
2398
  // else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
872
- }
873
- if (current)
874
- entries.push(current);
2399
+ });
2400
+ flush();
875
2401
  return entries;
876
2402
  }
2403
+ /**
2404
+ * Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
2405
+ * `- ` bullet openers.
2406
+ *
2407
+ * The indentation of the FIRST bullet line encountered establishes the
2408
+ * "top-level" indent for the whole section; any subsequent `- `-opening line
2409
+ * at that same indent (or shallower) starts a NEW entry, while everything
2410
+ * more deeply indented — field continuation lines (` status: ...`) AND
2411
+ * nested sub-lists (` - src/foo.ts` under ` artifacts:`) — is folded into
2412
+ * the CURRENT entry. This keeps a `artifacts:`/`missing:` sub-list's `- `
2413
+ * items from being mis-split into spurious standalone entries (#2286 review
2414
+ * LOW finding).
2415
+ *
2416
+ * Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
2417
+ * the template emits) are discarded. An empty/whitespace-only section body
2418
+ * (heading present, no bullets) returns `[]`.
2419
+ */
2420
+ function splitGapsEntries(sectionBody) {
2421
+ return splitGapsEntriesCore(sectionBody).map((entry) => entry.lines);
2422
+ }
2423
+ /**
2424
+ * Sibling of `splitGapsEntries` (F1, #3458 follow-up review) that ADDITIVELY
2425
+ * carries each entry's character span — every existing `splitGapsEntries`
2426
+ * caller (`parseGapsItems`, `parseDeferredItemsWithStatus`,
2427
+ * `splitDeferredHeadingEntries`'s `flushPending`) is unaffected and keeps
2428
+ * using the plain `lines`-only shape. `acknowledgeDeferredItem` is the one
2429
+ * caller that needs a span: it used to select an entry via `splitGapsEntries`
2430
+ * and then RE-FIND that entry's location with a fresh regex search over
2431
+ * `sectionBody` — matching the FIRST occurrence of the entry's exact text,
2432
+ * not necessarily the entry actually selected (a continuation/quoted line
2433
+ * inside a DIFFERENT entry can carry byte-identical text). Because the
2434
+ * mis-targeted span is byte-identical to the target text, no check on the
2435
+ * WRITTEN result could ever tell a wrong-entry write apart from a correct
2436
+ * one. Carrying the span out of THIS same pass — the one that already knows
2437
+ * exactly where the entry lives — removes the re-derivation step entirely.
2438
+ */
2439
+ function splitGapsEntriesWithSpans(sectionBody) {
2440
+ return splitGapsEntriesCore(sectionBody);
2441
+ }
877
2442
  /**
878
2443
  * Extract `key: value` fields from one Gaps entry's lines, anchored to the
879
2444
  * START of each (bullet-marker-stripped, trimmed) line — never scanning the
@@ -888,10 +2453,22 @@ function splitGapsEntries(sectionBody) {
888
2453
  * any nested sub-list content in the template's field ordering); later
889
2454
  * `key:`-shaped nested-list content is captured, if it parses as one, but
890
2455
  * never overrides an already-seen top-level field.
2456
+ *
2457
+ * #3457: markdown emphasis around the KEY (`**Status:** resolved` — the
2458
+ * deferred-items convention bolds every field, and a bolded resolution marker
2459
+ * previously failed this regex outright and surfaced as its own bogus
2460
+ * unresolved entry) is unwrapped before the match, still anchored at the
2461
+ * start of the line. The unwrapped key is lower-cased, because the bolded
2462
+ * convention form is Title-cased (`**Status:**`) while the field vocabulary
2463
+ * this module reads is lowercase (`status`) — the same normalization
2464
+ * `mapGapsHeader` already applies to table header cells. Bare (unbolded) keys
2465
+ * keep their literal case, and mid-line emphasis is untouched, preserving the
2466
+ * start-anchored decoy invariant above.
891
2467
  */
892
2468
  function extractGapEntryFields(entryLines) {
893
2469
  const fields = {};
894
2470
  const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
2471
+ const boldedKeyRe = /^\*+([A-Za-z_][A-Za-z0-9_-]*):\*+/;
895
2472
  entryLines.forEach((rawLine, idx) => {
896
2473
  const line = rawLine.replace(/\r$/, '');
897
2474
  // Strip ONLY the entry-opening bullet marker (idx 0); a bullet marker on
@@ -899,7 +2476,8 @@ function extractGapEntryFields(entryLines) {
899
2476
  // `splitGapsEntries` already folding it in — it is not itself a field
900
2477
  // line unless it independently matches `key: value` after stripping.
901
2478
  const bulletStripped = line.match(/^(\s*)-\s+(.*)$/);
902
- const content = idx === 0 && bulletStripped ? bulletStripped[2] : line.trim();
2479
+ const content = (idx === 0 && bulletStripped ? bulletStripped[2] : line.trim())
2480
+ .replace(boldedKeyRe, (_m, key) => `${key.toLowerCase()}:`);
903
2481
  const m = fieldLineRe.exec(content);
904
2482
  if (!m)
905
2483
  return;
@@ -1093,7 +2671,13 @@ function normalizeHumanVerificationEntry(raw) {
1093
2671
  return s || raw.trim();
1094
2672
  }
1095
2673
  // ─── categorizeItem ───────────────────────────────────────────────────────────
1096
- function categorizeItem(result, reason, blockedBy) {
2674
+ function categorizeItem(rawResult, reason, blockedBy) {
2675
+ // Normalize once so this comparison agrees with the PASS-token check
2676
+ // (`UAT_PASS_RESULTS.has(result)`, over an already-lower-cased token):
2677
+ // `result: PENDING` and
2678
+ // `result: Blocked` must categorize the same as their lowercase forms,
2679
+ // not fall through to 'unknown'.
2680
+ const result = rawResult.toLowerCase();
1097
2681
  if (result === 'blocked' || blockedBy) {
1098
2682
  if (blockedBy) {
1099
2683
  if (/server/i.test(blockedBy))
@@ -1122,16 +2706,26 @@ function categorizeItem(result, reason, blockedBy) {
1122
2706
  return 'pending';
1123
2707
  if (result === 'human_needed')
1124
2708
  return 'human_uat';
2709
+ // #3707: the template-sanctioned `result: issue` token (templates/UAT.md)
2710
+ // has no UatCategory branch here, so a surfaced issue row previously fell
2711
+ // through to 'unknown' — placed AFTER the blocked/skipped/pending checks
2712
+ // above so it never shadows their more specific categorization.
2713
+ if (result === 'issue')
2714
+ return 'issue';
1125
2715
  return 'unknown';
1126
2716
  }
1127
2717
  module.exports = {
1128
2718
  cmdAuditUat,
1129
2719
  cmdRenderCheckpoint,
1130
2720
  parseCurrentTest,
2721
+ parseUatItems,
2722
+ parseUatItemsWithStats,
2723
+ selectPhaseUatFiles,
1131
2724
  buildCheckpoint,
1132
2725
  CHECKPOINT_FRAMES,
1133
2726
  CHECKPOINT_LANGUAGE_ALIASES,
1134
2727
  resolveCheckpointFrame,
1135
- checkpointBoxLine,
1136
2728
  parseDeferredItems,
2729
+ parseDeferredItemsWithStatus,
2730
+ acknowledgeDeferredItem,
1137
2731
  };