@opengsd/gsd-core 1.9.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (426) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +27 -3
  5. package/agents/gsd-debug-session-manager.md +11 -0
  6. package/agents/gsd-debugger.md +12 -246
  7. package/agents/gsd-doc-synthesizer.md +2 -4
  8. package/agents/gsd-executor.md +12 -10
  9. package/agents/gsd-integration-checker.md +3 -0
  10. package/agents/gsd-mempalace-curator.md +5 -2
  11. package/agents/gsd-phase-researcher.md +20 -1
  12. package/agents/gsd-plan-checker.md +46 -0
  13. package/agents/gsd-planner.md +49 -54
  14. package/agents/gsd-roadmapper.md +21 -3
  15. package/agents/gsd-user-profiler.md +3 -0
  16. package/agents/gsd-verifier.md +26 -73
  17. package/bin/install.js +1272 -1238
  18. package/bin/lib/ui-safety-gate.cjs +2 -0
  19. package/commands/gsd/code-review.md +1 -1
  20. package/commands/gsd/execute-phase.md +1 -1
  21. package/commands/gsd/map-codebase.md +1 -1
  22. package/commands/gsd/mempalace-capture.md +2 -2
  23. package/commands/gsd/mempalace-recall.md +1 -1
  24. package/commands/gsd/new-milestone.md +2 -2
  25. package/commands/gsd/plan-phase.md +1 -1
  26. package/commands/gsd/quick.md +1 -1
  27. package/commands/gsd/review-backlog.md +2 -1
  28. package/commands/gsd/verify-work.md +1 -1
  29. package/gsd-core/bin/gsd-tools.cjs +1009 -115
  30. package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
  31. package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
  32. package/gsd-core/bin/lib/api-coverage.cjs +123 -5
  33. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  35. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  36. package/gsd-core/bin/lib/audit.cjs +926 -202
  37. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  38. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  39. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  40. package/gsd-core/bin/lib/capability-registry.cjs +608 -148
  41. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  42. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  43. package/gsd-core/bin/lib/capability-validator.cjs +507 -24
  44. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  45. package/gsd-core/bin/lib/check-command-router.cjs +114 -38
  46. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  47. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  48. package/gsd-core/bin/lib/command-aliases.cjs +94 -0
  49. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  50. package/gsd-core/bin/lib/commands.cjs +665 -99
  51. package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
  52. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  53. package/gsd-core/bin/lib/config-loader.cjs +76 -0
  54. package/gsd-core/bin/lib/config.cjs +22 -2
  55. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  56. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  57. package/gsd-core/bin/lib/core-utils.cjs +217 -40
  58. package/gsd-core/bin/lib/decisions.cjs +23 -0
  59. package/gsd-core/bin/lib/docs.cjs +3 -2
  60. package/gsd-core/bin/lib/external-job.cjs +19 -4
  61. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  62. package/gsd-core/bin/lib/frontmatter.cjs +239 -32
  63. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  64. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  65. package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
  66. package/gsd-core/bin/lib/graphify.cjs +142 -27
  67. package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
  68. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  69. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  71. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  72. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  73. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  74. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  75. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  76. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  77. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  78. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  79. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  80. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  81. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  82. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  83. package/gsd-core/bin/lib/init.cjs +1325 -169
  84. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  85. package/gsd-core/bin/lib/install-engine.cjs +805 -264
  86. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  87. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  88. package/gsd-core/bin/lib/install-profiles.cjs +160 -57
  89. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  90. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  91. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  92. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  93. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  94. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  95. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  96. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  97. package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
  98. package/gsd-core/bin/lib/io.cjs +38 -3
  99. package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
  100. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  101. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  102. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  103. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  104. package/gsd-core/bin/lib/milestone.cjs +821 -109
  105. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  106. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  107. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  108. package/gsd-core/bin/lib/pattern.cjs +122 -0
  109. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  110. package/gsd-core/bin/lib/phase-id.cjs +507 -36
  111. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  112. package/gsd-core/bin/lib/phase-locator.cjs +258 -58
  113. package/gsd-core/bin/lib/phase.cjs +891 -156
  114. package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
  115. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  116. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  117. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  118. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  119. package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
  120. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  121. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  122. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  123. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  124. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
  125. package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
  126. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  127. package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
  128. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  129. package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
  130. package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
  131. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  132. package/gsd-core/bin/lib/roadmap.cjs +405 -84
  133. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
  134. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  135. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
  136. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  137. package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
  138. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
  139. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  140. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  141. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  142. package/gsd-core/bin/lib/security.cjs +104 -5
  143. package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
  144. package/gsd-core/bin/lib/smart-entry.cjs +154 -22
  145. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  146. package/gsd-core/bin/lib/state-document.cjs +152 -8
  147. package/gsd-core/bin/lib/state-transition.cjs +424 -105
  148. package/gsd-core/bin/lib/state.cjs +1927 -401
  149. package/gsd-core/bin/lib/surface.cjs +35 -10
  150. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  151. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  152. package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
  153. package/gsd-core/bin/lib/uat.cjs +706 -64
  154. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  155. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  156. package/gsd-core/bin/lib/unusable-input.cjs +33 -0
  157. package/gsd-core/bin/lib/update-context.cjs +8 -2
  158. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  159. package/gsd-core/bin/lib/validate.cjs +20 -6
  160. package/gsd-core/bin/lib/vendor/README.md +37 -0
  161. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  162. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  163. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  164. package/gsd-core/bin/lib/verification.cjs +287 -20
  165. package/gsd-core/bin/lib/verify.cjs +368 -880
  166. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  167. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
  168. package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
  169. package/gsd-core/bin/lib/workstream.cjs +8 -2
  170. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  171. package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
  172. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  173. package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
  174. package/gsd-core/references/agent-contracts.md +43 -26
  175. package/gsd-core/references/artifact-types.md +10 -3
  176. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  177. package/gsd-core/references/checkpoints.md +2 -2
  178. package/gsd-core/references/context-budget.md +1 -1
  179. package/gsd-core/references/debugger-techniques.md +255 -0
  180. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  181. package/gsd-core/references/doc-conflict-engine.md +1 -1
  182. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  183. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  184. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  185. package/gsd-core/references/execute-phase-response-language.md +1 -1
  186. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  187. package/gsd-core/references/gate-prompts.md +1 -1
  188. package/gsd-core/references/git-planning-commit.md +2 -1
  189. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  190. package/gsd-core/references/model-profiles.md +12 -4
  191. package/gsd-core/references/mvp-concepts.md +9 -9
  192. package/gsd-core/references/planner-guidance.md +3 -9
  193. package/gsd-core/references/planner-preconditions.md +1 -1
  194. package/gsd-core/references/planner-reviews.md +1 -1
  195. package/gsd-core/references/planning-config.md +8 -6
  196. package/gsd-core/references/research-documentation-lookup.md +5 -3
  197. package/gsd-core/references/revision-loop.md +1 -1
  198. package/gsd-core/references/specless-probe-fallback.md +8 -7
  199. package/gsd-core/references/universal-anti-patterns.md +3 -3
  200. package/gsd-core/references/verifier-phase-gates.md +192 -0
  201. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  202. package/gsd-core/references/verify-mvp-mode.md +1 -1
  203. package/gsd-core/references/workstream-flag.md +22 -6
  204. package/gsd-core/references/worktree-branch-check.md +2 -2
  205. package/gsd-core/templates/discussion-log.md +1 -1
  206. package/gsd-core/templates/phase-prompt.md +2 -4
  207. package/gsd-core/templates/state.md +4 -4
  208. package/gsd-core/templates/summary-complex.md +2 -0
  209. package/gsd-core/templates/summary-minimal.md +2 -0
  210. package/gsd-core/templates/summary-standard.md +2 -0
  211. package/gsd-core/templates/summary.md +2 -0
  212. package/gsd-core/templates/verification-report.md +9 -1
  213. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  214. package/gsd-core/workflows/audit-milestone.md +3 -0
  215. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  216. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  217. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  218. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  219. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  220. package/gsd-core/workflows/autonomous.md +33 -70
  221. package/gsd-core/workflows/cleanup.md +62 -3
  222. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  223. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
  224. package/gsd-core/workflows/code-review-fix.md +37 -10
  225. package/gsd-core/workflows/code-review.md +74 -166
  226. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  227. package/gsd-core/workflows/complete-milestone.md +160 -95
  228. package/gsd-core/workflows/debug.md +16 -17
  229. package/gsd-core/workflows/diagnose-issues.md +56 -8
  230. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  231. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  232. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  233. package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
  234. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  235. package/gsd-core/workflows/docs-update.md +8 -51
  236. package/gsd-core/workflows/edit-phase.md +26 -1
  237. package/gsd-core/workflows/eval-review.md +3 -5
  238. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
  239. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  240. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  241. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  242. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
  243. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  245. package/gsd-core/workflows/execute-phase.md +103 -187
  246. package/gsd-core/workflows/execute-plan.md +36 -4
  247. package/gsd-core/workflows/explore.md +131 -4
  248. package/gsd-core/workflows/fast.md +10 -2
  249. package/gsd-core/workflows/health.md +73 -4
  250. package/gsd-core/workflows/help/modes/full.md +6 -1
  251. package/gsd-core/workflows/import.md +4 -4
  252. package/gsd-core/workflows/ingest-docs.md +7 -6
  253. package/gsd-core/workflows/mvp-phase.md +6 -3
  254. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  255. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  256. package/gsd-core/workflows/new-milestone.md +35 -47
  257. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  258. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  259. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  260. package/gsd-core/workflows/new-project.md +27 -240
  261. package/gsd-core/workflows/next.md +12 -0
  262. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  263. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  264. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  265. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  266. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  267. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  268. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  269. package/gsd-core/workflows/plan-phase.md +89 -209
  270. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  271. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  272. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  273. package/gsd-core/workflows/progress.md +45 -159
  274. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  275. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  276. package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
  277. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  278. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  279. package/gsd-core/workflows/quick.md +55 -405
  280. package/gsd-core/workflows/resume-project.md +3 -0
  281. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  282. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  283. package/gsd-core/workflows/review.md +41 -13
  284. package/gsd-core/workflows/section-manifest.json +219 -0
  285. package/gsd-core/workflows/secure-phase.md +1 -1
  286. package/gsd-core/workflows/session-report.md +2 -1
  287. package/gsd-core/workflows/settings.md +66 -2
  288. package/gsd-core/workflows/ship.md +104 -44
  289. package/gsd-core/workflows/sketch.md +1 -1
  290. package/gsd-core/workflows/spec-phase.md +41 -20
  291. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  292. package/gsd-core/workflows/spike.md +50 -16
  293. package/gsd-core/workflows/sync-skills.md +106 -13
  294. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  295. package/gsd-core/workflows/transition.md +53 -31
  296. package/gsd-core/workflows/ui-phase.md +13 -12
  297. package/gsd-core/workflows/ui-review.md +2 -2
  298. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  299. package/gsd-core/workflows/update.md +19 -8
  300. package/gsd-core/workflows/validate-phase.md +1 -1
  301. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  302. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  303. package/gsd-core/workflows/verify-work.md +17 -65
  304. package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
  305. package/hooks/dist/gsd-check-update-worker.js +64 -12
  306. package/hooks/dist/gsd-check-update.js +19 -1
  307. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  308. package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
  309. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  310. package/hooks/dist/gsd-prompt-guard.js +21 -20
  311. package/hooks/dist/gsd-read-injection-scanner.js +45 -24
  312. package/hooks/dist/gsd-statusline.js +90 -6
  313. package/hooks/dist/gsd-update-banner.js +22 -1
  314. package/hooks/dist/gsd-workflow-guard.js +134 -36
  315. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  316. package/hooks/dist/gsd-write-guard.js +359 -0
  317. package/hooks/dist/lib/git-cmd.js +92 -59
  318. package/hooks/dist/lib/injection-patterns.js +45 -0
  319. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  320. package/hooks/dist/lib/isolation-sentinel.js +277 -0
  321. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  322. package/hooks/gsd-agent-isolation-guard.js +517 -0
  323. package/hooks/gsd-check-update-worker.js +64 -12
  324. package/hooks/gsd-check-update.js +19 -1
  325. package/hooks/gsd-cursor-pre-tool.js +0 -3
  326. package/hooks/gsd-cursor-subagent-start.js +607 -26
  327. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  328. package/hooks/gsd-prompt-guard.js +21 -20
  329. package/hooks/gsd-read-injection-scanner.js +45 -24
  330. package/hooks/gsd-statusline.js +90 -6
  331. package/hooks/gsd-update-banner.js +22 -1
  332. package/hooks/gsd-workflow-guard.js +134 -36
  333. package/hooks/gsd-worktree-path-guard.js +2 -1
  334. package/hooks/gsd-write-guard.js +359 -0
  335. package/hooks/hooks.json +12 -0
  336. package/hooks/lib/git-cmd.js +92 -59
  337. package/hooks/lib/injection-patterns.js +45 -0
  338. package/hooks/lib/isolation-deny-reason.js +39 -0
  339. package/hooks/lib/isolation-sentinel.js +277 -0
  340. package/hooks/managed-hooks-registry.cjs +2 -0
  341. package/package.json +31 -10
  342. package/pi/gsd.cjs +71 -12
  343. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  344. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  345. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  346. package/scripts/build-hooks.js +9 -0
  347. package/scripts/changeset/lint.cjs +68 -6
  348. package/scripts/changeset/serialize.cjs +5 -1
  349. package/scripts/check-alias-drift.cjs +7 -43
  350. package/scripts/check-contract-drift.cjs +297 -0
  351. package/scripts/ci-test-scope.cjs +19 -2
  352. package/scripts/command-contract-helpers.cjs +903 -1
  353. package/scripts/gen-adr-index.cjs +728 -38
  354. package/scripts/gen-capability-matrix.cjs +1 -1
  355. package/scripts/gen-capability-registry.cjs +3 -15
  356. package/scripts/gen-context-index.cjs +439 -0
  357. package/scripts/gen-health-docs.cjs +390 -0
  358. package/scripts/gen-inventory-manifest.cjs +150 -4
  359. package/scripts/gen-loop-host-contract.cjs +4 -24
  360. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  361. package/scripts/gen-registry.cjs +3 -14
  362. package/scripts/gen-section-manifest.cjs +638 -0
  363. package/scripts/generate-package-identity.cjs +4 -2
  364. package/scripts/lib/alias-drift-families.cjs +46 -0
  365. package/scripts/lib/drift-scan.cjs +278 -0
  366. package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
  367. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  368. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  369. package/scripts/lint-canary-version-leak.cjs +73 -0
  370. package/scripts/lint-command-contract.cjs +96 -13
  371. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  372. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  373. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  374. package/scripts/lint-default-flip-documentation.cjs +193 -0
  375. package/scripts/lint-docs-command-form.cjs +195 -0
  376. package/scripts/lint-docs-required.cjs +9 -1
  377. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  378. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  379. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  380. package/scripts/lint-example-parser-parity.cjs +395 -0
  381. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  382. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  383. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  384. package/scripts/lint-milestone-window-drift.cjs +468 -0
  385. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  386. package/scripts/lint-plan-count-drift.cjs +318 -0
  387. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  388. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  389. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  390. package/scripts/lint-regression-test-names.cjs +15 -13
  391. package/scripts/lint-removed-but-needed.cjs +320 -0
  392. package/scripts/lint-state-field-drift.cjs +805 -0
  393. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  394. package/scripts/lint-test-file-count.allowlist.json +40 -3
  395. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  396. package/scripts/lint-vendored-deps.cjs +124 -0
  397. package/scripts/mutation-matrix.cjs +13 -0
  398. package/scripts/pr-changed-files.cjs +63 -0
  399. package/scripts/pr-template-policy.cjs +14 -4
  400. package/scripts/prompt-injection-scan.sh +52 -6
  401. package/scripts/require-issue-link-policy.cjs +192 -0
  402. package/scripts/state-write-path-drift-baseline.json +19 -0
  403. package/scripts/sync-runtime-launcher.cjs +2 -4
  404. package/skills/gsd-autonomous/SKILL.md +0 -1
  405. package/skills/gsd-code-review/SKILL.md +1 -1
  406. package/skills/gsd-execute-phase/SKILL.md +1 -2
  407. package/skills/gsd-map-codebase/SKILL.md +1 -1
  408. package/skills/gsd-mempalace-capture/SKILL.md +2 -2
  409. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  410. package/skills/gsd-new-milestone/SKILL.md +2 -2
  411. package/skills/gsd-next/SKILL.md +0 -1
  412. package/skills/gsd-plan-phase/SKILL.md +1 -2
  413. package/skills/gsd-progress/SKILL.md +0 -1
  414. package/skills/gsd-quick/SKILL.md +1 -1
  415. package/skills/gsd-review-backlog/SKILL.md +2 -1
  416. package/skills/gsd-stats/SKILL.md +0 -1
  417. package/skills/gsd-verify-work/SKILL.md +1 -1
  418. package/vscode/package.json +1 -1
  419. package/gsd-core/workflows/discovery-phase.md +0 -298
  420. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  421. package/gsd-core/workflows/verify-phase.md +0 -577
  422. package/scripts/affected-tests-lib.cjs +0 -554
  423. package/scripts/gen-emitted-baseline.cjs +0 -145
  424. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  425. package/scripts/run-affected-tests.cjs +0 -7
  426. package/scripts/run-tests.cjs +0 -1050
@@ -22,10 +22,7 @@ const markdownSectionizer = require("./markdown-sectionizer.cjs");
22
22
  const { collectSection, tokenizeHeadings } = markdownSectionizer;
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const markdownTable = require("./markdown-table.cjs");
25
- const { splitTableRow } = markdownTable;
26
- // eslint-disable-next-line @typescript-eslint/no-require-imports
27
- const roadmapParser = require("./roadmap-parser.cjs");
28
- const { getMilestonePhaseFilter } = roadmapParser;
25
+ const { splitTableRow, isDelimiterRow } = markdownTable;
29
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
30
27
  const coreUtils = require("./core-utils.cjs");
31
28
  const { toPosixPath } = coreUtils;
@@ -37,7 +34,10 @@ 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;
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
39
+ const phaseLocator = require("./phase-locator.cjs");
40
+ const { getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocator;
41
41
  const security_cjs_1 = require("./security.cjs");
42
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
43
43
  const configLoader = require("./config-loader.cjs");
@@ -45,24 +45,56 @@ const { loadConfig } = configLoader;
45
45
  // ─── cmdAuditUat ─────────────────────────────────────────────────────────────
46
46
  function cmdAuditUat(cwd, raw) {
47
47
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
48
- if (!node_fs_1.default.existsSync(phasesDir)) {
48
+ const hasActivePhases = node_fs_1.default.existsSync(phasesDir);
49
+ // #2766: on milestone completion `milestone.cts` MOVES each phase dir into
50
+ // `.planning/milestones/<version>-phases/` (archive-by-default since #1871),
51
+ // leaving `.planning/phases/` empty or absent. Scanning only the active tree
52
+ // meant a partly-archived project silently omitted the archived phases, and a
53
+ // fully-archived one hard-errored with "No phases directory found" —
54
+ // indistinguishable from a broken install. Outstanding UAT items do not stop
55
+ // mattering when a milestone closes: a deferred human-UAT scenario or a
56
+ // `skipped` live-stack test is exactly what gets archived still-open.
57
+ //
58
+ // Reuses the canonical `getArchivedPhaseDirs` seam (phase-locator.cts), which
59
+ // `findPhaseInternal` already uses for this same fallback, so the archive
60
+ // layout convention stays owned by one module.
61
+ const archivedDirs = getArchivedPhaseDirs(cwd);
62
+ if (!hasActivePhases && archivedDirs.length === 0) {
49
63
  error('No phases directory found in planning directory');
50
64
  }
51
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
52
65
  const results = [];
53
- // Scan all phase directories
54
- const dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
55
- .filter(e => e.isDirectory())
56
- .map(e => e.name)
57
- .filter(isDirInMilestone)
58
- .sort();
59
- for (const dir of dirs) {
66
+ // Active dirs are milestone-filtered; archived dirs deliberately are NOT.
67
+ // listMilestonePhaseDirs derives the CURRENT milestone's phase directories
68
+ // (window + sentinel filtered) from ROADMAP.md, and archived phases belong
69
+ // to past milestones by definition — so applying it to them discards every
70
+ // one and silently reinstates the bug.
71
+ const scanTargets = [];
72
+ if (hasActivePhases) {
73
+ // #3185 (ADR-3180 Decision 1): routed through the canonical owner
74
+ // instead of a hand-rolled readdirSync + isDirInMilestone filter, which
75
+ // also never excluded sentinels, unlike the owner.
76
+ const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
77
+ for (const dir of dirs) {
78
+ scanTargets.push({ dir, phaseDir: node_path_1.default.join(phasesDir, dir) });
79
+ }
80
+ }
81
+ for (const archived of archivedDirs) {
82
+ scanTargets.push({
83
+ dir: archived.name,
84
+ phaseDir: archived.fullPath,
85
+ milestone: archived.milestone,
86
+ });
87
+ }
88
+ for (const { dir, phaseDir, milestone } of scanTargets) {
60
89
  const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
61
90
  const phaseNum = phaseMatch ? phaseMatch[1] : dir;
62
- const phaseDir = node_path_1.default.join(phasesDir, dir);
63
91
  const files = node_fs_1.default.readdirSync(phaseDir);
64
- // Process UAT files
65
- for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
92
+ // Process UAT files — scoped to THIS phase's own token (#3511) via
93
+ // scopeToPhase, so a stray, cross-phase, or ad-hoc file cannot be reported
94
+ // under this phase's audit-uat entry. A phase whose own UAT file is
95
+ // genuinely absent scopes to empty and contributes nothing — correct, and
96
+ // the reason scopeToPhase has no unfiltered fallback.
97
+ for (const file of scopeToPhase(files.filter(f => f.includes('-UAT') && f.endsWith('.md')), dir)) {
66
98
  const uatFilePath = node_path_1.default.join(phaseDir, file);
67
99
  const content = node_fs_1.default.readFileSync(uatFilePath, 'utf-8');
68
100
  const items = parseUatItems(content);
@@ -74,12 +106,14 @@ function cmdAuditUat(cwd, raw) {
74
106
  file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, file))),
75
107
  type: 'uat',
76
108
  status: (extractFrontmatter(content, uatFilePath).status || 'unknown'),
109
+ archived_milestone: milestone,
77
110
  items,
78
111
  });
79
112
  }
80
113
  }
81
- // Process VERIFICATION files
82
- for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
114
+ // Process VERIFICATION files — scoped to THIS phase's own token (#3511)
115
+ // for the same reason as the UAT loop above.
116
+ for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), dir)) {
83
117
  const verificationFilePath = node_path_1.default.join(phaseDir, file);
84
118
  const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
85
119
  const status = extractFrontmatter(content, verificationFilePath).status || 'unknown';
@@ -93,6 +127,7 @@ function cmdAuditUat(cwd, raw) {
93
127
  file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, file))),
94
128
  type: 'verification',
95
129
  status,
130
+ archived_milestone: milestone,
96
131
  items,
97
132
  });
98
133
  }
@@ -117,6 +152,7 @@ function cmdAuditUat(cwd, raw) {
117
152
  file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, deferredFile))),
118
153
  type: 'deferred',
119
154
  status: 'unresolved',
155
+ archived_milestone: milestone,
120
156
  items,
121
157
  });
122
158
  }
@@ -308,6 +344,43 @@ const CHECKPOINT_FRAMES = {
308
344
  banner: 'PUNTO DI CONTROLLO: Verifica richiesta',
309
345
  instruction: 'Digita `pass` o descrivi cosa non va.',
310
346
  },
347
+ dutch: {
348
+ banner: 'CONTROLEPUNT: Verificatie vereist',
349
+ instruction: 'Typ `pass` of beschrijf wat er mis is.',
350
+ },
351
+ polish: {
352
+ banner: 'PUNKT KONTROLNY: Wymagana weryfikacja',
353
+ instruction: 'Wpisz `pass` lub opisz, co jest nie tak.',
354
+ },
355
+ russian: {
356
+ banner: 'КОНТРОЛЬНАЯ ТОЧКА: требуется проверка',
357
+ instruction: 'Введите `pass` или опишите, что не так.',
358
+ },
359
+ ukrainian: {
360
+ banner: 'КОНТРОЛЬНА ТОЧКА: потрібна перевірка',
361
+ instruction: 'Введіть `pass` або опишіть, що не так.',
362
+ },
363
+ turkish: {
364
+ banner: 'KONTROL NOKTASI: Doğrulama gerekli',
365
+ instruction: '`pass` yazın veya sorunu açıklayın.',
366
+ },
367
+ hindi: {
368
+ banner: 'चेकपॉइंट: सत्यापन आवश्यक',
369
+ instruction: '`pass` लिखें या बताएं कि क्या गलत है।',
370
+ },
371
+ arabic: {
372
+ banner: 'نقطة تحقق: المراجعة مطلوبة',
373
+ instruction: 'اكتب `pass` أو صف المشكلة.',
374
+ direction: 'rtl',
375
+ },
376
+ vietnamese: {
377
+ banner: 'ĐIỂM KIỂM TRA: Cần xác minh',
378
+ instruction: 'Nhập `pass` hoặc mô tả vấn đề.',
379
+ },
380
+ indonesian: {
381
+ banner: 'TITIK PEMERIKSAAN: Verifikasi diperlukan',
382
+ instruction: 'Ketik `pass` atau jelaskan apa yang salah.',
383
+ },
311
384
  };
312
385
  // Free-form response_language aliases → canonical CHECKPOINT_FRAMES key.
313
386
  const CHECKPOINT_LANGUAGE_ALIASES = {
@@ -320,21 +393,27 @@ const CHECKPOINT_LANGUAGE_ALIASES = {
320
393
  chinese: 'chinese', zh: 'chinese', 'zh-cn': 'chinese', 'zh-tw': 'chinese', mandarin: 'chinese', 'simplified chinese': 'chinese', 'traditional chinese': 'chinese', '中文': 'chinese',
321
394
  korean: 'korean', ko: 'korean', '한국어': 'korean',
322
395
  italian: 'italian', it: 'italian', italiano: 'italian',
396
+ dutch: 'dutch', nl: 'dutch', nederlands: 'dutch', flemish: 'dutch', vlaams: 'dutch',
397
+ polish: 'polish', pl: 'polish', polski: 'polish',
398
+ russian: 'russian', ru: 'russian', 'ru-ru': 'russian', 'русский': 'russian',
399
+ ukrainian: 'ukrainian', uk: 'ukrainian', ua: 'ukrainian', 'українська': 'ukrainian',
400
+ turkish: 'turkish', tr: 'turkish', 'türkçe': 'turkish', turkce: 'turkish',
401
+ hindi: 'hindi', hi: 'hindi', 'हिन्दी': 'hindi', 'हिंदी': 'hindi',
402
+ arabic: 'arabic', ar: 'arabic', 'العربية': 'arabic',
403
+ vietnamese: 'vietnamese', vi: 'vietnamese', 'tiếng việt': 'vietnamese', 'tieng viet': 'vietnamese',
404
+ indonesian: 'indonesian', id: 'indonesian', 'bahasa indonesia': 'indonesian',
323
405
  };
324
406
  function resolveCheckpointFrame(responseLanguage) {
325
407
  if (!responseLanguage)
326
408
  return CHECKPOINT_FRAMES.english;
327
- const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().toLowerCase()];
409
+ const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().normalize('NFC').toLowerCase()];
328
410
  return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english;
329
411
  }
330
- // Approximate East Asian Width ranges (Unicode property values W and F) — the
331
- // CJK scripts CHECKPOINT_FRAMES ships (Japanese/Chinese/Korean) render each
332
- // matching code point at 2 terminal/display columns, not 1. Padding computed
333
- // from `.length` (UTF-16 code units) undercounts these by one column per
334
- // wide character, visually misaligning the box's right border (#2402 review
335
- // medium finding). Latin-script frames (English/Spanish/French/German/
336
- // Portuguese/Italian) contain no wide code points, so displayWidth === length
337
- // for them — no behavior change there.
412
+ // Approximate terminal-cell width. East Asian Width W/F code points occupy two
413
+ // cells, while Unicode combining marks occupy no additional cell beyond their
414
+ // base character. Counting only W/F ranges is insufficient for scripts such as
415
+ // Devanagari: Hindi vowel signs and viramas are combining marks, and treating
416
+ // each as a full cell visibly shifts the checkpoint box's right border.
338
417
  function isWideCodePoint(codePoint) {
339
418
  return ((codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
340
419
  codePoint === 0x2329 || codePoint === 0x232a ||
@@ -351,11 +430,17 @@ function isWideCodePoint(codePoint) {
351
430
  (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Unified Ideographs Extension B+ / supplementary
352
431
  );
353
432
  }
433
+ // Non-spacing/enclosing marks and format controls occupy zero terminal cells.
434
+ // Spacing combining marks (General_Category=Mc), such as Devanagari vowel
435
+ // signs, still advance the cursor and must contribute one column.
436
+ const ZERO_WIDTH_MARK_RE = /\p{gc=Mn}|\p{gc=Me}|\p{gc=Cf}/u;
354
437
  // Iterates by Unicode code point (not UTF-16 code unit) so astral characters
355
438
  // are measured once, not as two surrogate units.
356
439
  function displayWidth(text) {
357
440
  let width = 0;
358
441
  for (const ch of text) {
442
+ if (ZERO_WIDTH_MARK_RE.test(ch))
443
+ continue;
359
444
  width += isWideCodePoint(ch.codePointAt(0)) ? 2 : 1;
360
445
  }
361
446
  return width;
@@ -370,11 +455,20 @@ function checkpointBoxLine(text) {
370
455
  const padded = padLength > 0 ? content + ' '.repeat(padLength) : content;
371
456
  return `║${padded}║`;
372
457
  }
458
+ const RTL_ISOLATE = '\u2067';
459
+ const POP_DIRECTIONAL_ISOLATE = '\u2069';
460
+ function isolateCheckpointFrameText(text, frame) {
461
+ return frame.direction === 'rtl'
462
+ ? `${RTL_ISOLATE}${text}${POP_DIRECTIONAL_ISOLATE}`
463
+ : text;
464
+ }
373
465
  function buildCheckpoint(currentTest, responseLanguage) {
374
466
  const frame = resolveCheckpointFrame(responseLanguage);
467
+ const banner = isolateCheckpointFrameText(frame.banner, frame);
468
+ const instruction = isolateCheckpointFrameText(frame.instruction, frame);
375
469
  return [
376
470
  '╔══════════════════════════════════════════════════════════════╗',
377
- checkpointBoxLine(frame.banner),
471
+ checkpointBoxLine(banner),
378
472
  '╚══════════════════════════════════════════════════════════════╝',
379
473
  '',
380
474
  `**Test ${currentTest.number}: ${currentTest.name}**`,
@@ -382,7 +476,7 @@ function buildCheckpoint(currentTest, responseLanguage) {
382
476
  currentTest.expected,
383
477
  '',
384
478
  '──────────────────────────────────────────────────────────────',
385
- frame.instruction,
479
+ instruction,
386
480
  '──────────────────────────────────────────────────────────────',
387
481
  ].join('\n');
388
482
  }
@@ -486,6 +580,165 @@ function parseGapsItems(content) {
486
580
  item.reason = reason;
487
581
  items.push(item);
488
582
  }
583
+ // #2766: union with the table form. A `|`-leading line is never a `- ` bullet
584
+ // opener, so a section mixing bullet entries and a table surfaces both with no
585
+ // double-counting.
586
+ items.push(...parseGapsTableItems(gapsSection.body));
587
+ return items;
588
+ }
589
+ /**
590
+ * Split a section body into its GFM pipe tables, one entry per table (#2766).
591
+ *
592
+ * Shared by `parseGapsTableItems` and `parseDeferredTableItems` so the
593
+ * header/delimiter/table-boundary handling — the fiddly part — lives in exactly
594
+ * one place, and the two consumers only decide what a data row MEANS.
595
+ *
596
+ * Header detection is lookahead-free: the last data-shaped row is held in
597
+ * `pending` until the NEXT line decides its fate — a delimiter row
598
+ * (`|---|---|`) proves the held row was a header, anything else promotes it to a
599
+ * data row. So a conventional table drops exactly its header, a HEADERLESS table
600
+ * keeps every row (hand-authored planning tables often omit the delimiter), and
601
+ * a header with no data rows yields nothing. A prose or blank line ends the
602
+ * current table, so two tables separated by text are read independently and each
603
+ * drops its own header.
604
+ *
605
+ * Reuses the canonical `isDelimiterRow` shape check from markdown-table.cts
606
+ * rather than re-deriving it. Deliberately NOT routed through
607
+ * `parseMarkdownTable`, which reads only the FIRST table in a body and treats
608
+ * ragged/headerless shapes as errors (ADR-2143 §3) — correct for the mandated
609
+ * tables in STATE.md/ROADMAP.md, but the wrong contract here, where a malformed
610
+ * hand-written table must still surface its rows rather than be dropped.
611
+ */
612
+ function collectTableRows(sectionBody) {
613
+ const tables = [];
614
+ let current = null;
615
+ let pending = null;
616
+ const ensure = () => {
617
+ if (!current)
618
+ current = { header: null, rows: [] };
619
+ };
620
+ const flushPending = () => {
621
+ if (pending) {
622
+ ensure();
623
+ current.rows.push(pending);
624
+ pending = null;
625
+ }
626
+ };
627
+ const endTable = () => {
628
+ flushPending();
629
+ if (current) {
630
+ tables.push(current);
631
+ current = null;
632
+ }
633
+ };
634
+ for (const rawLine of sectionBody.split('\n')) {
635
+ const line = rawLine.replace(/\r$/, '').trim();
636
+ if (!line.startsWith('|')) {
637
+ endTable();
638
+ continue;
639
+ }
640
+ const cells = splitTableRow(line);
641
+ if (cells.length === 0)
642
+ continue;
643
+ if (isDelimiterRow(cells)) {
644
+ ensure();
645
+ current.header = pending; // may be null for a delimiter-first table
646
+ pending = null;
647
+ continue;
648
+ }
649
+ flushPending();
650
+ pending = cells;
651
+ }
652
+ endTable();
653
+ return tables;
654
+ }
655
+ /**
656
+ * Header-name → canonical Gaps field (#2766).
657
+ *
658
+ * Anchored on the `## Gaps` field vocabulary `templates/UAT.md` mandates for the
659
+ * YAML-lite bullet form (truth/status/reason/severity/test), plus the obvious
660
+ * synonyms a human writing the same information as a table reaches for instead.
661
+ */
662
+ const GAPS_COLUMN_ALIASES = {
663
+ truth: 'truth', gap: 'truth', finding: 'truth', item: 'truth',
664
+ description: 'truth', issue: 'truth', name: 'truth',
665
+ status: 'status', result: 'status', state: 'status',
666
+ reason: 'reason', note: 'reason', notes: 'reason',
667
+ detail: 'reason', details: 'reason', evidence: 'reason',
668
+ severity: 'severity',
669
+ test: 'test', '#': 'test', 'test #': 'test', 'test number': 'test',
670
+ };
671
+ function mapGapsHeader(header) {
672
+ if (!header)
673
+ return null;
674
+ const columns = {};
675
+ header.forEach((cell, idx) => {
676
+ const key = GAPS_COLUMN_ALIASES[cell.trim().toLowerCase().replace(/\*+/g, '')];
677
+ if (key && !(key in columns))
678
+ columns[key] = idx;
679
+ });
680
+ return Object.keys(columns).length > 0 ? columns : null;
681
+ }
682
+ /**
683
+ * Extract gap entries from GFM pipe tables in a `## Gaps` section (#2766) — a
684
+ * UNION with the YAML-lite bullet scan in `parseGapsItems`, for the same reason
685
+ * `parseDeferredTableItems` exists: `splitGapsEntries` keys entirely on `- `
686
+ * bullet openers, so a table-shaped `## Gaps` section yielded ZERO items and
687
+ * every finding in it was silently invisible.
688
+ *
689
+ * Neither `templates/UAT.md` nor `templates/verification-report.md` documents a
690
+ * table for this section (both mandate the bullet/numbered form), so a table
691
+ * here is off-template hand-authoring — which is precisely why it must not fail
692
+ * silently. Note `parseVerificationItems` in this same file already reads table
693
+ * rows AND numbered AND bullet items as a union because the live sections mix
694
+ * shapes; the Gaps and deferred parsers never got the same treatment.
695
+ *
696
+ * When a header row is present its columns are mapped by name against the
697
+ * template's own field vocabulary (see GAPS_COLUMN_ALIASES) so a tabled gap
698
+ * carries the same status/reason/test fields as its bullet equivalent and
699
+ * `categorizeItem` classifies it identically. With no recognizable header, the
700
+ * row degrades to a joined-cells name with status `unknown` — surfaced, not
701
+ * dropped, matching this module's established fail-safe stance.
702
+ *
703
+ * Resolution follows the bullet path exactly: an entry is skipped ONLY on an
704
+ * explicit resolved marker — the mapped `status` column reading `resolved`, or,
705
+ * absent a status column, any cell reading exactly `resolved`. A gap with no
706
+ * parseable status is NEVER treated as resolved.
707
+ */
708
+ function parseGapsTableItems(sectionBody) {
709
+ const items = [];
710
+ for (const { header, rows } of collectTableRows(sectionBody)) {
711
+ const columns = mapGapsHeader(header);
712
+ for (const cells of rows) {
713
+ const at = (key) => (columns && key in columns ? (cells[columns[key]] ?? '').trim() : '');
714
+ const rawStatus = at('status');
715
+ if (rawStatus && rawStatus.toLowerCase() === 'resolved')
716
+ continue;
717
+ // No status column: fall back to an explicit resolved marker in any cell
718
+ // (the headerless-table equivalent of `status: resolved`).
719
+ if (!columns || !('status' in columns)) {
720
+ if (cells.some(c => /^resolved$/i.test(c.trim())))
721
+ continue;
722
+ }
723
+ const truth = at('truth');
724
+ const reason = at('reason');
725
+ const testNum = at('test');
726
+ const name = truth || cells.filter(c => c !== '').join(' — ');
727
+ if (!name)
728
+ continue;
729
+ const status = rawStatus || 'unknown';
730
+ const item = {
731
+ name,
732
+ result: status,
733
+ category: categorizeItem(status, reason || undefined, undefined),
734
+ };
735
+ if (testNum && /^\d+$/.test(testNum))
736
+ item.test = parseInt(testNum, 10);
737
+ if (reason)
738
+ item.reason = reason;
739
+ items.push(item);
740
+ }
741
+ }
489
742
  return items;
490
743
  }
491
744
  // ─── parseDeferredItems ────────────────────────────────────────────────────────
@@ -514,50 +767,379 @@ function parseGapsItems(content) {
514
767
  * `.planning/todos/pending/*.md` entry required). Every other entry —
515
768
  * including one with no `status:` field at all — is UNRESOLVED and is
516
769
  * surfaced.
770
+ *
771
+ * #3457: when the section body contains headings, entries are delimited by
772
+ * LEAF headings (see `splitDeferredHeadingEntries`) rather than by bullets —
773
+ * the executor convention writes one deferred item as a heading followed by
774
+ * sibling `- **Field:** …` bullets, which the bullet-only split mis-counted as
775
+ * one item PER BULLET. A body with no headings keeps the original
776
+ * one-bullet-per-item split unchanged.
517
777
  */
518
- function parseDeferredItems(content) {
778
+ /**
779
+ * One `deferred-items.md` entry with its RAW (un-lowercased) `status:` field
780
+ * value (`''` when the entry carries no parseable status). #3458 follow-up:
781
+ * `parseDeferredItems` (below) is now DEFINED IN TERMS OF this — it filters
782
+ * to `status !== 'resolved'` — and `audit.cts`'s `scanDeferredItems` also
783
+ * consumes this directly so it can tell `resolved` (fixed for real, never
784
+ * counted), the newer `acknowledged` (suppressed-but-tallied, #3458
785
+ * follow-up), and everything else (open) apart WITHOUT a second,
786
+ * independent entry-boundary/field-extraction pass that could drift from
787
+ * this one.
788
+ */
789
+ function parseDeferredItemsWithStatus(content) {
519
790
  const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
520
791
  const sectionBody = deferredSection ? deferredSection.body : content;
521
792
  const items = [];
522
- for (const entryLines of splitGapsEntries(sectionBody)) {
523
- const fields = extractGapEntryFields(entryLines);
524
- const rawStatus = fields.status;
525
- if (rawStatus && rawStatus.toLowerCase() === 'resolved')
526
- continue;
793
+ // #3457: heading-delimited shape — an entry's fields live in sibling bullets
794
+ // (`- **Status:** resolved`), so the bullet marker is stripped on EVERY line
795
+ // before field extraction, not just line 0 (which `extractGapEntryFields`
796
+ // does for the headless/Gaps shape, where a later `- ` line is a nested
797
+ // sub-list, not a field).
798
+ const headingEntries = splitDeferredHeadingEntries(sectionBody);
799
+ const entries = headingEntries !== null
800
+ ? headingEntries.map((entryLines) => ({
801
+ lines: entryLines,
802
+ fields: extractGapEntryFields(entryLines.map(stripLeadingBulletMarker)),
803
+ }))
804
+ : splitGapsEntries(sectionBody).map((entryLines) => ({
805
+ lines: entryLines,
806
+ fields: extractGapEntryFields(entryLines),
807
+ }));
808
+ for (const { lines: entryLines, fields } of entries) {
527
809
  const text = rawGapEntryText(entryLines);
528
810
  if (!text)
529
811
  continue;
530
- items.push({
531
- name: text,
532
- result: 'unresolved',
533
- category: 'deferred',
534
- });
812
+ items.push({ name: text, status: fields.status || '' });
535
813
  }
814
+ // #2766: union with the table form — see parseDeferredTableItems. Executors
815
+ // write this file by hand with no mandated shape, and a GFM table is a natural
816
+ // choice for the common "test → failing seeds" case, which produced ZERO items.
817
+ // Table rows carry no independently-parseable status column in general —
818
+ // `parseDeferredTableItems` already excludes resolved/done/pass rows at its
819
+ // own layer (any cell reading exactly one of those three) — so anything it
820
+ // returns here is inherently open; `acknowledge` (#3458 follow-up) has no
821
+ // representable field to write for a table row, so those are reported with
822
+ // status `''` (never `resolved`/`acknowledged`) and remain permanently
823
+ // un-acknowledgeable via the CLI writer — a known, deliberate limitation
824
+ // (see `acknowledgeDeferredItem`'s doc comment).
825
+ items.push(...parseDeferredTableItems(sectionBody).map((item) => ({ name: item.name, status: '' })));
536
826
  return items;
537
827
  }
828
+ function parseDeferredItems(content) {
829
+ return parseDeferredItemsWithStatus(content)
830
+ .filter((entry) => !(entry.status && entry.status.toLowerCase() === 'resolved'))
831
+ .map((entry) => ({
832
+ name: entry.name,
833
+ result: 'unresolved',
834
+ category: 'deferred',
835
+ }));
836
+ }
538
837
  /**
539
- * Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
540
- * `- ` bullet openers.
838
+ * CLI-writer half of the #3458 follow-up deferred_items suppression seam.
839
+ * Sets the ONE deferred entry whose rendered text (`rawGapEntryText`, the
840
+ * same value `parseDeferredItemsWithStatus`/the audit's JSON output surface
841
+ * as `name`/`text`) exactly equals `targetText` to `status: acknowledged` —
842
+ * a NEW terminal value, distinct from the existing `resolved` (which keeps
843
+ * meaning "actually fixed"). This is the marker for this category: unlike
844
+ * every other audit category (a sibling `audit_acknowledged` frontmatter map
845
+ * that never touches the artifact's own `status:`), a deferred-items.md
846
+ * entry's `status:` field carries no OTHER meaning, so the field itself
847
+ * doubles as the marker — self-invalidating for free: edit the entry's
848
+ * `status:` away from `acknowledged` (or delete the field) and it resurfaces
849
+ * with no separate cleanup step, exactly like every other category's marker.
541
850
  *
542
- * The indentation of the FIRST bullet line encountered establishes the
543
- * "top-level" indent for the whole section; any subsequent `- `-opening line
544
- * at that same indent (or shallower) starts a NEW entry, while everything
545
- * more deeply indented — field continuation lines (` status: ...`) AND
546
- * nested sub-lists (` - src/foo.ts` under ` artifacts:`) — is folded into
547
- * the CURRENT entry. This keeps a `artifacts:`/`missing:` sub-list's `- `
548
- * items from being mis-split into spurious standalone entries (#2286 review
549
- * LOW finding).
851
+ * Deliberately refuses (`unsupported_heading_shape`) rather than guess when
852
+ * the section uses the heading-delimited (#3457) entry shape: reliably
853
+ * mapping a `splitDeferredHeadingEntries` entry back to its EXACT source line
854
+ * span is not safely derivable without re-deriving that function's
855
+ * leaf/container walk against a document that may also mix in headless
856
+ * (`splitGapsEntries`-derived) entries between headings — attempting it risks
857
+ * writing into the WRONG entry. The bullet-only (headless) shape below is the
858
+ * primary, documented SCOPE BOUNDARY convention and is handled precisely.
550
859
  *
551
- * Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
552
- * the template emits) are discarded. An empty/whitespace-only section body
553
- * (heading present, no bullets) returns `[]`.
860
+ * Also refuses `ambiguous` (2+ entries share the exact same text — status must
861
+ * be unique to identify one) and `not_found`, and is a no-op
862
+ * (`already_resolved`) on an entry already carrying `status: resolved` — the
863
+ * verdict-preserving direction: acknowledging a genuinely-fixed item would
864
+ * silently downgrade its terminal state.
865
+ *
866
+ * SPAN-CARRIED, not re-searched (F1, #3458 follow-up review — see
867
+ * `splitGapsEntriesWithSpans`'s doc comment): the target entry's location
868
+ * within `sectionBody` is the (start, end) character span recorded by
869
+ * `splitGapsEntriesWithSpans` in the SAME pass that produced `entryLines` /
870
+ * `targetText` above — never re-derived afterwards by searching. The
871
+ * previous implementation re-found the entry with a regex anchored on its
872
+ * own (escaped) exact text; that regex necessarily matches the FIRST
873
+ * occurrence of that text within `sectionBody`, which is not always the
874
+ * entry that was actually selected (a continuation/quoted line inside an
875
+ * EARLIER or LATER entry can carry byte-identical text) — and because the
876
+ * mis-targeted span is byte-identical to `targetText`, no downstream check
877
+ * on the WRITTEN text could ever distinguish a wrong-entry write from a
878
+ * correct one. Carrying the span removes the re-derivation step entirely:
879
+ * there is no second search to mis-target.
880
+ *
881
+ * Section-anchored (BLOCKER 1, #3458 follow-up review): the span is
882
+ * `sectionBody`-relative — the SAME string `matches`/the `ambiguous` guard
883
+ * were computed over — not `content`-relative, so an identical bullet living
884
+ * outside `## Deferred Items` (e.g. in an unrelated `# Notes` or a
885
+ * UAT/VERIFICATION body) can never steal the write. The span is translated
886
+ * into `content`-relative offsets via `deferredSection.bodyStart` (the
887
+ * section's own start offset, an invariant `collectSection` guarantees:
888
+ * `content.slice(bodyStart, bodyEnd) === body`). Before writing, the
889
+ * spanned text's own raw entry is re-derived and compared against
890
+ * `targetText` one more time — this is now a GENUINE invariant check (the
891
+ * span was computed by `splitGapsEntriesCore`'s independent offset
892
+ * bookkeeping, a different code path than the `entryLines`/`targetText`
893
+ * comparison above), not a no-op — if it does not match, the write is
894
+ * refused with `match_verification_failed` rather than risk touching the
895
+ * wrong span.
554
896
  */
555
- function splitGapsEntries(sectionBody) {
897
+ function acknowledgeDeferredItem(content, targetText) {
898
+ const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
899
+ const sectionBody = deferredSection ? deferredSection.body : content;
900
+ if (splitDeferredHeadingEntries(sectionBody) !== null) {
901
+ return { content, status: 'unsupported_heading_shape' };
902
+ }
903
+ const entries = splitGapsEntriesWithSpans(sectionBody);
904
+ const matches = entries
905
+ .map((entry) => ({ entry, text: rawGapEntryText(entry.lines) }))
906
+ .filter((e) => e.text === targetText);
907
+ if (matches.length === 0)
908
+ return { content, status: 'not_found' };
909
+ if (matches.length > 1)
910
+ return { content, status: 'ambiguous' };
911
+ const { entry } = matches[0];
912
+ const { lines: entryLines, start, end } = entry;
913
+ const fields = extractGapEntryFields(entryLines);
914
+ if (fields.status && fields.status.toLowerCase() === 'resolved') {
915
+ return { content, status: 'already_resolved' };
916
+ }
917
+ // Anchor to the SAME section body `matches`/the `ambiguous` guard above
918
+ // were computed over (BLOCKER 1) — never the whole `content`, which could
919
+ // contain an identical bullet elsewhere. `start`/`end` are the entry's own
920
+ // span, carried directly from `splitGapsEntriesWithSpans` — no re-search.
921
+ const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
922
+ const matchedLines = sectionBody.slice(start, end).split('\n');
923
+ // Genuine invariant re-verification (see doc comment above): the span was
924
+ // computed by a code path independent of the `entryLines`/`targetText`
925
+ // comparison that selected this entry — this catches real drift between
926
+ // the two rather than a regex trivially guaranteed to agree with itself.
927
+ const strippedForVerify = matchedLines.map((l) => l.replace(/\r$/, ''));
928
+ if (rawGapEntryText(strippedForVerify) !== targetText) {
929
+ return { content, status: 'match_verification_failed' };
930
+ }
931
+ const matchIndexInContent = sectionOffset + start;
932
+ const statusFieldRe = /^\s*(?:-\s+)?(\*+status:\*+|status:)/i;
933
+ const statusLineIdx = matchedLines.findIndex((rawLine) => statusFieldRe.test(rawLine.replace(/\r$/, '')));
934
+ // No CRLF-preservation branch here (WARNING 1, #3458 follow-up review):
935
+ // every write goes through `platformWriteSync` → `normalizeContent`, which
936
+ // for a `.md` path unconditionally runs `_normalizeMd` — whole-file
937
+ // `\r\n` → `\n`, plus blank-line normalization around headings/lists — on
938
+ // EVERY write, not just this one. That is this codebase's single,
939
+ // deliberate OS-facing I/O seam (`shell-command-projection.cts`), applied
940
+ // uniformly to every `.md` writer; carving out one exception here would
941
+ // fight it rather than follow it, for a guarantee (byte-identical CRLF on
942
+ // disk) the seam already makes impossible. A marker write on a CRLF
943
+ // `deferred-items.md` normalizes the WHOLE file to LF, same as any other
944
+ // `.md` write in this codebase — expected, not a regression to guard
945
+ // against. Where a source line still carries a trailing `\r` (read from an
946
+ // on-disk CRLF document before normalization), `String.prototype.replace`
947
+ // consumes it as part of `.*$` and the replacement text does not
948
+ // reproduce it, so it is dropped here too — consistent with the eventual
949
+ // whole-file normalization rather than duplicating it.
950
+ let newMatchedLines;
951
+ if (statusLineIdx === -1) {
952
+ const bulletIndentMatch = matchedLines[0].match(/^(\s*)-\s+/);
953
+ const continuationIndent = ' '.repeat((bulletIndentMatch ? bulletIndentMatch[1].length : 0) + 2);
954
+ newMatchedLines = [
955
+ matchedLines[0],
956
+ `${continuationIndent}status: acknowledged`,
957
+ ...matchedLines.slice(1),
958
+ ];
959
+ }
960
+ else {
961
+ const original = matchedLines[statusLineIdx];
962
+ const replaced = original.replace(/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, (_m, indent, key, ws) => `${indent}${key}${ws}acknowledged`);
963
+ newMatchedLines = matchedLines.slice();
964
+ newMatchedLines[statusLineIdx] = replaced;
965
+ }
966
+ const newContent = content.slice(0, matchIndexInContent) + newMatchedLines.join('\n') + content.slice(matchIndexInContent + (end - start));
967
+ return { content: newContent, status: 'ok' };
968
+ }
969
+ /**
970
+ * Strip one leading `- ` bullet marker (#3457). Heading-delimited deferred
971
+ * entries carry their fields as sibling bullets; `extractGapEntryFields` only
972
+ * de-bullets line 0 (Gaps-protective — there, a later `- ` line is a nested
973
+ * sub-list), so the deferred heading path de-bullets every line itself before
974
+ * field extraction. Non-bullet lines pass through untouched.
975
+ */
976
+ function stripLeadingBulletMarker(line) {
977
+ return line.replace(/^(\s*)-\s+/, '');
978
+ }
979
+ /**
980
+ * Split a deferred-items section body into entries delimited by LEAF headings
981
+ * (#3457). Returns `null` when the body contains no heading at all — the
982
+ * caller then falls back to `splitGapsEntries`, keeping headless
983
+ * one-bullet-per-item files byte-for-byte on the pre-#3457 path.
984
+ *
985
+ * A heading is a CONTAINER (group/provenance/title label, contributes no
986
+ * entry) iff the NEXT heading is deeper — a deeper heading lives inside its
987
+ * span. Otherwise it is a LEAF: an entry boundary. This handles all three
988
+ * corpus shapes without hardcoding a depth: flat `#` title + `##` entries
989
+ * (title's next heading is deeper → container; each `##` followed by a
990
+ * same-or-shallower heading → leaf), a `##` container with `###` entries
991
+ * (container's next heading is deeper), and mixed-depth files where a
992
+ * childless `##` entry sits alongside a `##` group with `###` children — every
993
+ * childless heading is a leaf at whatever depth it is written. The shallower
994
+ * rules the issue reports as already tried (split on every heading; shallowest
995
+ * level; deepest level) each mis-count one of these shapes.
996
+ *
997
+ * A leaf entry is [heading text, ...body lines up to the next heading] and is
998
+ * kept only when its body (minus table lines) contains at least one `- `
999
+ * bullet:
1000
+ * - a prose-only or bare heading contributes nothing — "prose is not an item"
1001
+ * is this parser's pre-existing contract (see the `# Notes` case);
1002
+ * - a table-only body is left entirely to `parseDeferredTableItems`, which
1003
+ * unions over the same section body, so the heading cannot double-count the
1004
+ * table's rows.
1005
+ *
1006
+ * Lines before the first heading, and lines directly under a container heading
1007
+ * (before its first child), are split one-bullet-per-item by the unchanged
1008
+ * `splitGapsEntries` — headless parity, so loose bullets before a later
1009
+ * heading group (the mixed shape) stay one item each.
1010
+ */
1011
+ function splitDeferredHeadingEntries(sectionBody) {
1012
+ const headings = tokenizeHeadings(sectionBody);
1013
+ if (headings.length === 0)
1014
+ return null;
556
1015
  const lines = sectionBody.split('\n');
1016
+ const headingByLine = new Map();
1017
+ for (let i = 0; i < headings.length; i++) {
1018
+ // Container iff the next heading is deeper (see doc comment). An empty
1019
+ // heading text (`##` alone) does not itself mean container — the flag is
1020
+ // carried explicitly so a bare LEAF heading still opens an entry.
1021
+ const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level;
1022
+ headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
1023
+ }
1024
+ const entries = [];
1025
+ let current = null; // accumulating a leaf heading's entry
1026
+ let pending = []; // preamble / container-heading body lines
1027
+ let currentHasBullet = false;
1028
+ const flushCurrent = () => {
1029
+ // Keep the leaf entry only when its body carries a bullet; the heading
1030
+ // text line itself (element 0) never counts as one.
1031
+ if (current !== null && currentHasBullet)
1032
+ entries.push(current);
1033
+ current = null;
1034
+ currentHasBullet = false;
1035
+ };
1036
+ const flushPending = () => {
1037
+ entries.push(...splitGapsEntries(pending.join('\n')));
1038
+ pending = [];
1039
+ };
1040
+ for (let i = 0; i < lines.length; i++) {
1041
+ const lineNo = i + 1;
1042
+ const heading = headingByLine.get(lineNo);
1043
+ if (heading !== undefined) {
1044
+ flushCurrent();
1045
+ // Headless-shaped region (preamble / container-direct bullets) ends at
1046
+ // ANY heading; flushing here keeps entries in document order even when
1047
+ // a container's direct bullets precede its first child entry.
1048
+ flushPending();
1049
+ if (!heading.isContainer) {
1050
+ // Leaf heading: open an entry with the heading text as line 0.
1051
+ current = [heading.text];
1052
+ currentHasBullet = false;
1053
+ }
1054
+ continue;
1055
+ }
1056
+ // Table lines belong to parseDeferredTableItems, never to a heading entry.
1057
+ if (/^\s*\|/.test(lines[i].replace(/\r$/, '')))
1058
+ continue;
1059
+ if (current !== null) {
1060
+ current.push(lines[i]);
1061
+ if (/^\s*-\s/.test(lines[i].replace(/\r$/, '')))
1062
+ currentHasBullet = true;
1063
+ }
1064
+ else {
1065
+ pending.push(lines[i]);
1066
+ }
1067
+ }
1068
+ flushCurrent();
1069
+ flushPending();
1070
+ return entries;
1071
+ }
1072
+ /**
1073
+ * Extract deferred entries from GFM pipe tables in a deferred-items.md body
1074
+ * (#2766) — a UNION with the bullet scan in `parseDeferredItems`.
1075
+ *
1076
+ * Cells are joined with ` — ` rather than taking only the first: these tables
1077
+ * carry the useful detail in the later columns (the failing seeds, the reason,
1078
+ * the owner), and dropping them would surface a name with no context.
1079
+ *
1080
+ * A row is skipped when any cell reads exactly `resolved`/`done`/`pass`
1081
+ * (case-insensitive), mirroring the "explicit resolution only" convention
1082
+ * `parseGapsItems` uses for `status: resolved` and `parseVerificationItems` uses
1083
+ * for its `hasPassResult` cell scan — so a human can close a tabled deferred
1084
+ * item in place and keep deferred-items.md the single source of truth.
1085
+ *
1086
+ * Deliberately permissive: an unrelated table in a deferred-items.md (say a
1087
+ * table of environment notes) will surface as deferred entries. That is the
1088
+ * correct fail-safe direction for a false-NEGATIVE bug — the whole file exists to
1089
+ * record outstanding work, and this module's established stance (see
1090
+ * parseGapsItems' 'unknown'-status fallback) is to surface a questionable entry
1091
+ * rather than silently drop a real one.
1092
+ */
1093
+ function parseDeferredTableItems(sectionBody) {
1094
+ const items = [];
1095
+ for (const { rows } of collectTableRows(sectionBody)) {
1096
+ for (const cells of rows) {
1097
+ if (cells.some(c => /^(resolved|done|pass)$/i.test(c)))
1098
+ continue;
1099
+ const name = cells.filter(c => c !== '').join(' — ');
1100
+ if (!name)
1101
+ continue;
1102
+ items.push({
1103
+ name,
1104
+ result: 'unresolved',
1105
+ category: 'deferred',
1106
+ });
1107
+ }
1108
+ }
1109
+ return items;
1110
+ }
1111
+ /**
1112
+ * Shared walk behind `splitGapsEntries` and `splitGapsEntriesWithSpans` — ONE
1113
+ * pass over `sectionBody` that both groups its lines into entries (see
1114
+ * `splitGapsEntries`'s doc comment for the grouping rule) AND records each
1115
+ * entry's (start, end) character offset within `sectionBody`. Extracted so
1116
+ * the two public shapes can never drift apart on what counts as an entry
1117
+ * boundary — a second, independently-written grouping pass is exactly how a
1118
+ * span-carrying sibling could disagree with the plain-lines version it is
1119
+ * supposed to be span-annotating.
1120
+ */
1121
+ function splitGapsEntriesCore(sectionBody) {
1122
+ const rawLines = sectionBody.split('\n');
1123
+ const lineStarts = [];
1124
+ const lineEnds = [];
1125
+ let cursor = 0;
1126
+ for (const rawLine of rawLines) {
1127
+ lineStarts.push(cursor);
1128
+ cursor += rawLine.length;
1129
+ lineEnds.push(cursor);
1130
+ cursor += 1; // the '\n' separator — absent after the final line, but nothing reads past it
1131
+ }
557
1132
  const entries = [];
558
1133
  let current = null;
1134
+ let currentStartLine = -1;
1135
+ let currentEndLine = -1;
559
1136
  let baseIndent = null;
560
- for (const rawLine of lines) {
1137
+ const flush = () => {
1138
+ if (current !== null) {
1139
+ entries.push({ lines: current, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
1140
+ }
1141
+ };
1142
+ rawLines.forEach((rawLine, idx) => {
561
1143
  const line = rawLine.replace(/\r$/, '');
562
1144
  const bulletMatch = line.match(/^(\s*)-\s/);
563
1145
  if (bulletMatch) {
@@ -565,20 +1147,61 @@ function splitGapsEntries(sectionBody) {
565
1147
  if (baseIndent === null)
566
1148
  baseIndent = indent;
567
1149
  if (indent <= baseIndent) {
568
- if (current)
569
- entries.push(current);
1150
+ flush();
570
1151
  current = [line];
571
- continue;
1152
+ currentStartLine = idx;
1153
+ currentEndLine = idx;
1154
+ return;
572
1155
  }
573
1156
  }
574
- if (current)
1157
+ if (current !== null) {
575
1158
  current.push(line);
1159
+ currentEndLine = idx;
1160
+ }
576
1161
  // else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
577
- }
578
- if (current)
579
- entries.push(current);
1162
+ });
1163
+ flush();
580
1164
  return entries;
581
1165
  }
1166
+ /**
1167
+ * Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
1168
+ * `- ` bullet openers.
1169
+ *
1170
+ * The indentation of the FIRST bullet line encountered establishes the
1171
+ * "top-level" indent for the whole section; any subsequent `- `-opening line
1172
+ * at that same indent (or shallower) starts a NEW entry, while everything
1173
+ * more deeply indented — field continuation lines (` status: ...`) AND
1174
+ * nested sub-lists (` - src/foo.ts` under ` artifacts:`) — is folded into
1175
+ * the CURRENT entry. This keeps a `artifacts:`/`missing:` sub-list's `- `
1176
+ * items from being mis-split into spurious standalone entries (#2286 review
1177
+ * LOW finding).
1178
+ *
1179
+ * Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
1180
+ * the template emits) are discarded. An empty/whitespace-only section body
1181
+ * (heading present, no bullets) returns `[]`.
1182
+ */
1183
+ function splitGapsEntries(sectionBody) {
1184
+ return splitGapsEntriesCore(sectionBody).map((entry) => entry.lines);
1185
+ }
1186
+ /**
1187
+ * Sibling of `splitGapsEntries` (F1, #3458 follow-up review) that ADDITIVELY
1188
+ * carries each entry's character span — every existing `splitGapsEntries`
1189
+ * caller (`parseGapsItems`, `parseDeferredItemsWithStatus`,
1190
+ * `splitDeferredHeadingEntries`'s `flushPending`) is unaffected and keeps
1191
+ * using the plain `lines`-only shape. `acknowledgeDeferredItem` is the one
1192
+ * caller that needs a span: it used to select an entry via `splitGapsEntries`
1193
+ * and then RE-FIND that entry's location with a fresh regex search over
1194
+ * `sectionBody` — matching the FIRST occurrence of the entry's exact text,
1195
+ * not necessarily the entry actually selected (a continuation/quoted line
1196
+ * inside a DIFFERENT entry can carry byte-identical text). Because the
1197
+ * mis-targeted span is byte-identical to the target text, no check on the
1198
+ * WRITTEN result could ever tell a wrong-entry write apart from a correct
1199
+ * one. Carrying the span out of THIS same pass — the one that already knows
1200
+ * exactly where the entry lives — removes the re-derivation step entirely.
1201
+ */
1202
+ function splitGapsEntriesWithSpans(sectionBody) {
1203
+ return splitGapsEntriesCore(sectionBody);
1204
+ }
582
1205
  /**
583
1206
  * Extract `key: value` fields from one Gaps entry's lines, anchored to the
584
1207
  * START of each (bullet-marker-stripped, trimmed) line — never scanning the
@@ -593,10 +1216,22 @@ function splitGapsEntries(sectionBody) {
593
1216
  * any nested sub-list content in the template's field ordering); later
594
1217
  * `key:`-shaped nested-list content is captured, if it parses as one, but
595
1218
  * never overrides an already-seen top-level field.
1219
+ *
1220
+ * #3457: markdown emphasis around the KEY (`**Status:** resolved` — the
1221
+ * deferred-items convention bolds every field, and a bolded resolution marker
1222
+ * previously failed this regex outright and surfaced as its own bogus
1223
+ * unresolved entry) is unwrapped before the match, still anchored at the
1224
+ * start of the line. The unwrapped key is lower-cased, because the bolded
1225
+ * convention form is Title-cased (`**Status:**`) while the field vocabulary
1226
+ * this module reads is lowercase (`status`) — the same normalization
1227
+ * `mapGapsHeader` already applies to table header cells. Bare (unbolded) keys
1228
+ * keep their literal case, and mid-line emphasis is untouched, preserving the
1229
+ * start-anchored decoy invariant above.
596
1230
  */
597
1231
  function extractGapEntryFields(entryLines) {
598
1232
  const fields = {};
599
1233
  const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
1234
+ const boldedKeyRe = /^\*+([A-Za-z_][A-Za-z0-9_-]*):\*+/;
600
1235
  entryLines.forEach((rawLine, idx) => {
601
1236
  const line = rawLine.replace(/\r$/, '');
602
1237
  // Strip ONLY the entry-opening bullet marker (idx 0); a bullet marker on
@@ -604,7 +1239,8 @@ function extractGapEntryFields(entryLines) {
604
1239
  // `splitGapsEntries` already folding it in — it is not itself a field
605
1240
  // line unless it independently matches `key: value` after stripping.
606
1241
  const bulletStripped = line.match(/^(\s*)-\s+(.*)$/);
607
- const content = idx === 0 && bulletStripped ? bulletStripped[2] : line.trim();
1242
+ const content = (idx === 0 && bulletStripped ? bulletStripped[2] : line.trim())
1243
+ .replace(boldedKeyRe, (_m, key) => `${key.toLowerCase()}:`);
608
1244
  const m = fieldLineRe.exec(content);
609
1245
  if (!m)
610
1246
  return;
@@ -834,5 +1470,11 @@ module.exports = {
834
1470
  cmdRenderCheckpoint,
835
1471
  parseCurrentTest,
836
1472
  buildCheckpoint,
1473
+ CHECKPOINT_FRAMES,
1474
+ CHECKPOINT_LANGUAGE_ALIASES,
1475
+ resolveCheckpointFrame,
1476
+ checkpointBoxLine,
837
1477
  parseDeferredItems,
1478
+ parseDeferredItemsWithStatus,
1479
+ acknowledgeDeferredItem,
838
1480
  };