@opengsd/gsd-core 1.12.0 → 1.13.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 (286) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-executor.md +63 -35
  5. package/agents/gsd-plan-checker.md +76 -57
  6. package/agents/gsd-planner.md +14 -0
  7. package/agents/gsd-ui-checker.md +19 -3
  8. package/agents/gsd-ui-researcher.md +29 -0
  9. package/agents/gsd-verifier.md +23 -1
  10. package/bin/install.js +239 -67
  11. package/commands/gsd/execute-phase.md +1 -1
  12. package/commands/gsd/ns-workflow.md +2 -1
  13. package/commands/gsd/phase.md +1 -1
  14. package/commands/gsd/quick-batch.md +105 -0
  15. package/commands/gsd/surface.md +18 -8
  16. package/gsd-core/bin/gsd-tools.cjs +195 -50
  17. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +514 -114
  19. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  20. package/gsd-core/bin/lib/capability-validator.cjs +120 -4
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  22. package/gsd-core/bin/lib/check-command-router.cjs +85 -2
  23. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  24. package/gsd-core/bin/lib/clusters.cjs +1 -0
  25. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  26. package/gsd-core/bin/lib/commands.cjs +337 -13
  27. package/gsd-core/bin/lib/config-loader.cjs +3 -0
  28. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  29. package/gsd-core/bin/lib/decisions.cjs +213 -1
  30. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  31. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  32. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  33. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  34. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  35. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  36. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  37. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  38. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  39. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  40. package/gsd-core/bin/lib/init.cjs +132 -15
  41. package/gsd-core/bin/lib/install-engine.cjs +184 -12
  42. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  43. package/gsd-core/bin/lib/install-profiles.cjs +22 -14
  44. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  45. package/gsd-core/bin/lib/io.cjs +35 -0
  46. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  47. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  48. package/gsd-core/bin/lib/milestone.cjs +22 -2
  49. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  50. package/gsd-core/bin/lib/phase-id.cjs +251 -9
  51. package/gsd-core/bin/lib/phase.cjs +774 -35
  52. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  53. package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
  54. package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
  55. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  56. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  57. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  58. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  59. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  60. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  61. package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
  62. package/gsd-core/bin/lib/roadmap.cjs +187 -58
  63. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
  64. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  65. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
  66. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
  67. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  68. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  69. package/gsd-core/bin/lib/state-document.cjs +30 -5
  70. package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
  71. package/gsd-core/bin/lib/state-transition.cjs +333 -44
  72. package/gsd-core/bin/lib/state.cjs +684 -125
  73. package/gsd-core/bin/lib/surface.cjs +23 -8
  74. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  75. package/gsd-core/bin/lib/uat.cjs +1419 -515
  76. package/gsd-core/bin/lib/update-context.cjs +6 -2
  77. package/gsd-core/bin/lib/validate.cjs +230 -12
  78. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  79. package/gsd-core/bin/lib/verification.cjs +273 -12
  80. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  81. package/gsd-core/bin/lib/verify.cjs +346 -16
  82. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
  84. package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
  85. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  86. package/gsd-core/references/agent-contracts.md +3 -3
  87. package/gsd-core/references/edge-probe.md +17 -13
  88. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  89. package/gsd-core/references/execute-phase-response-language.md +6 -0
  90. package/gsd-core/references/executor-examples.md +42 -0
  91. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  92. package/gsd-core/references/mvp-concepts.md +2 -2
  93. package/gsd-core/references/plan-checker-examples.md +41 -0
  94. package/gsd-core/references/planner-antipatterns.md +25 -0
  95. package/gsd-core/references/planner-chunked.md +5 -1
  96. package/gsd-core/references/planner-coupling.md +42 -0
  97. package/gsd-core/references/planner-quick-batch.md +71 -0
  98. package/gsd-core/references/planner-reviews.md +47 -0
  99. package/gsd-core/references/planner-revision.md +75 -2
  100. package/gsd-core/references/planning-config.md +2 -1
  101. package/gsd-core/references/response-language-directive.md +9 -0
  102. package/gsd-core/references/revision-loop.md +118 -11
  103. package/gsd-core/references/tdd.md +14 -9
  104. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  105. package/gsd-core/templates/phase-prompt.md +4 -0
  106. package/gsd-core/templates/verification-report.md +5 -0
  107. package/gsd-core/workflows/add-backlog.md +2 -0
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +1 -1
  110. package/gsd-core/workflows/add-todo.md +1 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  112. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  113. package/gsd-core/workflows/audit-fix.md +2 -0
  114. package/gsd-core/workflows/audit-milestone.md +2 -0
  115. package/gsd-core/workflows/audit-uat.md +2 -0
  116. package/gsd-core/workflows/autonomous.md +2 -0
  117. package/gsd-core/workflows/check-todos.md +1 -1
  118. package/gsd-core/workflows/cleanup.md +1 -1
  119. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
  120. package/gsd-core/workflows/code-review-fix.md +2 -0
  121. package/gsd-core/workflows/code-review.md +73 -31
  122. package/gsd-core/workflows/complete-milestone.md +13 -4
  123. package/gsd-core/workflows/debug.md +1 -1
  124. package/gsd-core/workflows/diagnose-issues.md +5 -1
  125. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  126. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  127. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  128. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  129. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  130. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  131. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  132. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  133. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  134. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  135. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  136. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  137. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  138. package/gsd-core/workflows/discuss-phase.md +1 -1
  139. package/gsd-core/workflows/do.md +43 -13
  140. package/gsd-core/workflows/docs-update.md +1 -1
  141. package/gsd-core/workflows/edit-phase.md +2 -0
  142. package/gsd-core/workflows/eval-review.md +1 -1
  143. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
  144. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
  145. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  146. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  147. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  148. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  149. package/gsd-core/workflows/execute-phase.md +32 -14
  150. package/gsd-core/workflows/execute-plan.md +8 -8
  151. package/gsd-core/workflows/explore.md +2 -0
  152. package/gsd-core/workflows/extract-learnings.md +2 -0
  153. package/gsd-core/workflows/fast.md +6 -0
  154. package/gsd-core/workflows/forensics.md +2 -0
  155. package/gsd-core/workflows/graduation.md +1 -1
  156. package/gsd-core/workflows/health.md +1 -1
  157. package/gsd-core/workflows/help/modes/brief.md +2 -0
  158. package/gsd-core/workflows/help/modes/default.md +2 -0
  159. package/gsd-core/workflows/help/modes/full.md +12 -0
  160. package/gsd-core/workflows/help/modes/topic.md +2 -0
  161. package/gsd-core/workflows/help.md +2 -0
  162. package/gsd-core/workflows/import.md +3 -3
  163. package/gsd-core/workflows/inbox.md +1 -1
  164. package/gsd-core/workflows/ingest-docs.md +1 -1
  165. package/gsd-core/workflows/insert-phase.md +2 -0
  166. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  167. package/gsd-core/workflows/list-seeds.md +2 -0
  168. package/gsd-core/workflows/list-workspaces.md +2 -0
  169. package/gsd-core/workflows/manager.md +3 -3
  170. package/gsd-core/workflows/map-codebase.md +2 -0
  171. package/gsd-core/workflows/milestone-summary.md +2 -0
  172. package/gsd-core/workflows/mvp-phase.md +1 -1
  173. package/gsd-core/workflows/new-milestone.md +1 -1
  174. package/gsd-core/workflows/new-project.md +5 -3
  175. package/gsd-core/workflows/new-workspace.md +1 -1
  176. package/gsd-core/workflows/next.md +2 -0
  177. package/gsd-core/workflows/node-repair.md +2 -0
  178. package/gsd-core/workflows/note.md +2 -0
  179. package/gsd-core/workflows/onboard.md +1 -1
  180. package/gsd-core/workflows/pause-work.md +19 -4
  181. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  182. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  183. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  184. package/gsd-core/workflows/plan-phase.md +130 -12
  185. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  186. package/gsd-core/workflows/plant-seed.md +1 -1
  187. package/gsd-core/workflows/pr-branch.md +11 -3
  188. package/gsd-core/workflows/profile-user.md +1 -1
  189. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  190. package/gsd-core/workflows/progress.md +25 -3
  191. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  192. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  193. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  194. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  195. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  196. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  197. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  198. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  199. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  200. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  201. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  202. package/gsd-core/workflows/quick-batch.md +203 -0
  203. package/gsd-core/workflows/quick.md +13 -3
  204. package/gsd-core/workflows/reapply-patches.md +2 -0
  205. package/gsd-core/workflows/remove-phase.md +2 -0
  206. package/gsd-core/workflows/remove-workspace.md +1 -1
  207. package/gsd-core/workflows/resume-project.md +6 -2
  208. package/gsd-core/workflows/review.md +215 -10
  209. package/gsd-core/workflows/scan.md +2 -0
  210. package/gsd-core/workflows/section-manifest.json +12 -0
  211. package/gsd-core/workflows/secure-phase.md +1 -1
  212. package/gsd-core/workflows/session-report.md +2 -0
  213. package/gsd-core/workflows/settings-advanced.md +2 -0
  214. package/gsd-core/workflows/settings-integrations.md +9 -8
  215. package/gsd-core/workflows/settings.md +1 -1
  216. package/gsd-core/workflows/ship.md +10 -10
  217. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  218. package/gsd-core/workflows/sketch.md +1 -1
  219. package/gsd-core/workflows/smart-entry.md +1 -1
  220. package/gsd-core/workflows/spec-phase.md +24 -19
  221. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  222. package/gsd-core/workflows/spike.md +1 -1
  223. package/gsd-core/workflows/stats.md +2 -0
  224. package/gsd-core/workflows/sync-skills.md +12 -4
  225. package/gsd-core/workflows/thread.md +2 -0
  226. package/gsd-core/workflows/transition.md +2 -0
  227. package/gsd-core/workflows/ui-phase.md +26 -5
  228. package/gsd-core/workflows/ui-review.md +1 -1
  229. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  230. package/gsd-core/workflows/undo.md +1 -1
  231. package/gsd-core/workflows/update.md +41 -38
  232. package/gsd-core/workflows/validate-phase.md +1 -1
  233. package/gsd-core/workflows/verify-work.md +49 -3
  234. package/hooks/dist/gsd-check-update-worker.js +19 -2
  235. package/hooks/dist/gsd-context-monitor.js +283 -12
  236. package/hooks/dist/gsd-node-runner.sh +1 -0
  237. package/hooks/dist/gsd-prompt-guard.js +30 -5
  238. package/hooks/dist/gsd-read-guard.js +2 -0
  239. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  240. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  241. package/hooks/dist/gsd-statusline.js +7 -3
  242. package/hooks/dist/gsd-validate-commit.sh +444 -7
  243. package/hooks/dist/gsd-workflow-guard.js +2 -1
  244. package/hooks/dist/lib/git-cmd.js +210 -1
  245. package/hooks/dist/lib/injection-patterns.js +36 -6
  246. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  247. package/hooks/gsd-check-update-worker.js +19 -2
  248. package/hooks/gsd-context-monitor.js +283 -12
  249. package/hooks/gsd-node-runner.sh +1 -0
  250. package/hooks/gsd-prompt-guard.js +30 -5
  251. package/hooks/gsd-read-guard.js +2 -0
  252. package/hooks/gsd-read-injection-scanner.js +5 -5
  253. package/hooks/gsd-secret-read-guard.js +1079 -0
  254. package/hooks/gsd-statusline.js +7 -3
  255. package/hooks/gsd-validate-commit.sh +444 -7
  256. package/hooks/gsd-workflow-guard.js +2 -1
  257. package/hooks/hooks.json +6 -0
  258. package/hooks/lib/git-cmd.js +210 -1
  259. package/hooks/lib/injection-patterns.js +36 -6
  260. package/hooks/managed-hooks-registry.cjs +1 -0
  261. package/package.json +5 -5
  262. package/scripts/build-hooks.js +11 -4
  263. package/scripts/ci-test-scope.cjs +7 -0
  264. package/scripts/docs-guard-registry.cjs +10 -0
  265. package/scripts/gen-loop-host-contract.cjs +67 -15
  266. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  267. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  268. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  269. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  270. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
  271. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  272. package/scripts/lint-phase-id-drift.cjs +133 -8
  273. package/scripts/lint-portable-grep.cjs +176 -0
  274. package/scripts/lint-response-language-coverage.cjs +524 -0
  275. package/scripts/lint-test-file-count.allowlist.json +3 -1
  276. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  277. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  278. package/scripts/npm-audit-baseline.cjs +376 -0
  279. package/scripts/prompt-injection-scan.sh +8 -0
  280. package/scripts/require-issue-link-policy.cjs +16 -1
  281. package/skills/gsd-execute-phase/SKILL.md +1 -1
  282. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  283. package/skills/gsd-phase/SKILL.md +1 -1
  284. package/skills/gsd-quick-batch/SKILL.md +105 -0
  285. package/skills/gsd-surface/SKILL.md +18 -8
  286. package/vscode/package.json +1 -1
@@ -75,6 +75,215 @@ const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([
75
75
  * shape without hardcoding the `D`.
76
76
  */
77
77
  const boldLeadInBulletRe = /^\s*-\s+\*\*[A-Z]+[0-9]*-[A-Za-z0-9]/m;
78
+ /**
79
+ * #3939: a decision bullet's DECLARATION line — the `- **D-NN … **` bold lead-in
80
+ * the three grammars above anchor on — may wrap across a line break. Physical
81
+ * line breaks inside a bullet are markdown-insignificant, and GSD's own
82
+ * discuss-phase writer emits the wrapped shape whenever a decision title runs
83
+ * past the wrap column. All three grammars require the closing `**` in the same
84
+ * string as the `- **D-` anchor, so a wrapped declaration matched none of them
85
+ * and fell to the #1365 parse-miss guard, forcing `could-not-parse` (which
86
+ * hard-blocks `check.decision-coverage-plan`) on a well-formed CONTEXT.md.
87
+ *
88
+ * The repair is confined to how the LOGICAL bullet is assembled — the grammars
89
+ * themselves are untouched, so every single-line form parses exactly as before.
90
+ */
91
+ const decisionBulletStartRe = /^\s*-\s+\*\*D-/;
92
+ /**
93
+ * A line that opens a new BLOCK-LEVEL construct, and therefore terminates the
94
+ * bullet above it: a list marker of any family (`-`, `*`, `+`, `1.`, `1)`), an
95
+ * ATX heading, a blockquote, or a table row. Joining never reaches across one of
96
+ * these (nor across a blank/whitespace-only line, checked separately), so a
97
+ * declaration whose bold run genuinely never closes cannot absorb the block
98
+ * below it and get "closed" by an unrelated inline `**` — it stays a parse-miss
99
+ * and still fails loud, which #1365's contract requires.
100
+ *
101
+ * The four MARKER families demand trailing whitespace so that a continuation
102
+ * line opening with emphasis (`*in* the header.** …`) is text, not a bullet.
103
+ * The table-row alternative deliberately does not: CommonMark tables may open
104
+ * flush (`|Col1|Col2|`), and a leading `|` is never ordinary decision prose.
105
+ * A `- ` line at ANY indent stops the join: a deeper one is #3169 nested
106
+ * elaboration, which the main loop folds into the open decision itself.
107
+ *
108
+ * DELIBERATE DIVERGENCE from the sectionizer seam (ADR-1372): `iterateBullets`
109
+ * recognises only the `N. ` ordered-list form (`numberedRe`,
110
+ * src/markdown-sectionizer.cts), while this set also stops at the `N) ` form.
111
+ * That is intentional and one-directional — this regex answers "may the join
112
+ * cross this line?", where recognising MORE block openers is the conservative
113
+ * answer (a missed terminator can manufacture a decision; a spare one can only
114
+ * make a malformed bullet fail loud, which #1365 already wants). `N)` is a
115
+ * CommonMark ordered-list marker, so a join must not reach across it whether or
116
+ * not the seam's own bullet iterator yields it. Both forms are pinned by tests,
117
+ * and a drift test asserts the seam still does NOT treat `N)` as a bullet, so
118
+ * this divergence stays visible if either side moves.
119
+ *
120
+ * Accepted over-termination: continuation prose that happens to open with
121
+ * digits-then-`.`/`)` ("10. really keeps going") or a literal `|` stops the join
122
+ * early, so such a bullet fails loud rather than parsing. That is the same
123
+ * markdown ambiguity every line-oriented reader carries, and this direction of
124
+ * the trade is the one #1365 asks for — fail loud, never guess.
125
+ */
126
+ const blockConstructRe = /^(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|>\s|\|)/;
127
+ /**
128
+ * The id-adjacent `[tags]` REGION of a logical bullet, as far as it has been
129
+ * assembled. Matching means the region is still unsettled, in one of two ways:
130
+ * capture group 1 is present when the bracket is open (group 1 is the content
131
+ * seen so far), and absent when the id has been read but no `[` has followed
132
+ * yet — so a bracket may still open on the next absorbed line.
133
+ *
134
+ * A NON-match means the region is settled for good: the bracket closed, or
135
+ * something other than `[` followed the id. Either way the join no longer has
136
+ * to watch for a splice.
137
+ *
138
+ * The id character class is deliberately looser than the grammars' (it admits
139
+ * an empty id, so a bare `- **D-` still counts as unsettled). This regex only
140
+ * answers "may an id-adjacent bracket still open here?", where recognising MORE
141
+ * shapes is the conservative direction: an over-broad match can only make a
142
+ * malformed bullet fail loud, while a missed one silently re-classifies.
143
+ *
144
+ * Only the ID-ADJACENT bracket matters: that is the one the three grammars turn
145
+ * into `tags` (and therefore into `trackable`). A `[` further along the title is
146
+ * ordinary text and does not restrict the join.
147
+ */
148
+ const tagRegionRe = /^\s*-\s+\*\*D-[A-Za-z0-9_-]*\s*(?:\[([^\]]*))?$/;
149
+ /**
150
+ * #3939 (review): would folding `next` onto a lead-in whose `[tags]` bracket is
151
+ * still open splice the inserted space INTO a tag token?
152
+ *
153
+ * Tags are comma-split and trimmed, so a space landing next to a delimiter
154
+ * (`[`, `,`, `]`) changes nothing — `[informational,` + `deferred]` is still
155
+ * exactly `[informational, deferred]`. A space landing anywhere else splits one
156
+ * token into two (`[defer` + `red]` → `defer red`), which would not fail; it
157
+ * would parse to a DIFFERENT tag, silently flipping `trackable` on a gate that
158
+ * decides whether a decision must be covered. Refusing to join there leaves the
159
+ * bullet unchanged, so it reaches the #1365 parse-miss guard and fails loud —
160
+ * a wrong answer about coverage is worse than a blocked gate.
161
+ *
162
+ * `tail` is the bracket content accumulated so far, `next` the trimmed
163
+ * continuation line.
164
+ */
165
+ function wouldSpliceTagToken(tail, next) {
166
+ const before = tail.trimEnd();
167
+ if (before === '' || before.endsWith(',') || before.endsWith('['))
168
+ return false;
169
+ return !(next.startsWith(',') || next.startsWith(']'));
170
+ }
171
+ /**
172
+ * True when the bullet's own bold lead-in — the FIRST bold run on the line —
173
+ * is still open at end-of-line. Deliberately asks only about that first run
174
+ * (not `**`-parity over the whole string), because that is the run the three
175
+ * grammars anchor on: a balanced inline `**bold**` later in the body must not
176
+ * make a terminated lead-in look open.
177
+ */
178
+ function boldLeadInIsUnterminated(text) {
179
+ const open = text.indexOf('**');
180
+ if (open === -1)
181
+ return false;
182
+ return text.indexOf('**', open + 2) === -1;
183
+ }
184
+ /**
185
+ * Fold a decision bullet whose bold lead-in wraps into ONE logical line, so the
186
+ * declaration grammars see the whole lead-in (#3939).
187
+ *
188
+ * Bounded and fail-loud-preserving: a wrapped declaration absorbs following
189
+ * lines only until its lead-in closes, and a blank/whitespace-only line, a new
190
+ * block-level construct (`blockConstructRe`), or the end of the block stops it.
191
+ * If the lead-in never closes, the original line is emitted UNCHANGED — a
192
+ * genuinely malformed bullet (e.g. an unterminated bold run) still reaches the
193
+ * parse-miss guard and still fails loud, exactly as #1365 requires. Non-decision
194
+ * lines pass through untouched, so continuation lines (#1372 FIX) and nested
195
+ * cross-reference bullets (#3169) are handled by the main loop as before.
196
+ *
197
+ * The joined line keeps the FIRST physical line's leading whitespace, so the
198
+ * `indentWidth` signal #3169 depends on is unchanged. Absorbed lines are
199
+ * trimmed and re-joined with a single space, which is what a soft line break
200
+ * means in markdown — so the join reproduces the rendered one-line text rather
201
+ * than concatenating the raw bytes.
202
+ *
203
+ * One place that equivalence does not hold is inside the id-adjacent `[tags]`
204
+ * bracket, where an inserted space can split a tag token and silently flip
205
+ * `trackable`. The join stops there instead (`wouldSpliceTagToken`), leaving the
206
+ * bullet to fail loud.
207
+ *
208
+ * Absorption stops at the first `**` on a continuation line, so an inline
209
+ * `**bold**` INSIDE a wrapped title closes the run early. That is deliberate:
210
+ * the result is byte-identical to what the same bullet written on one physical
211
+ * line parses to (the text past the early close re-attaches through the main
212
+ * loop's continuation folding), which is the whole contract here — wrapping is
213
+ * markdown-insignificant, never a second grammar.
214
+ */
215
+ function joinWrappedBoldLeadIns(lines) {
216
+ const joined = [];
217
+ for (let i = 0; i < lines.length; i += 1) {
218
+ const line = lines[i];
219
+ if (!decisionBulletStartRe.test(line) || !boldLeadInIsUnterminated(line)) {
220
+ joined.push(line);
221
+ continue;
222
+ }
223
+ // Absorbed lines accumulate as SEGMENTS joined by a single space, and each
224
+ // new segment is searched on its own: the lead-in is known to be open at the
225
+ // end of the declaration line, and the inserted space means a closing `**`
226
+ // can never straddle a segment boundary, so the first `**` in any later
227
+ // segment is the close. Scanning per segment (rather than re-searching the
228
+ // accumulated string, which forces a rope flatten every iteration) keeps a
229
+ // long unterminated run linear on the plan gate's hot path.
230
+ const segments = [line];
231
+ // Bracket content accumulated while the id-adjacent `[tags]` bracket is
232
+ // still open; null when it is not open. O(1) per segment.
233
+ let tagTail = null;
234
+ // The logical text assembled so far, kept ONLY while the id-adjacent
235
+ // bracket has yet to open, so a bracket that opens on ANY absorbed line
236
+ // arms the splice guard — not just one that opens on the declaration line.
237
+ // Null once the region settles (the bracket opened and `tagTail` took over,
238
+ // or the id was followed by something else), so this never re-walks a long
239
+ // absorption: a non-empty segment that is not a bracket-open settles the
240
+ // region immediately, which bounds the string to a single extra join.
241
+ let tagRegion = null;
242
+ const declRegion = tagRegionRe.exec(line);
243
+ if (declRegion !== null) {
244
+ if (declRegion[1] === undefined)
245
+ tagRegion = line;
246
+ else
247
+ tagTail = declRegion[1];
248
+ }
249
+ let scan = i + 1;
250
+ let closed = false;
251
+ while (scan < lines.length) {
252
+ const trimmed = lines[scan].trim();
253
+ if (trimmed === '' || blockConstructRe.test(trimmed))
254
+ break;
255
+ if (tagTail !== null && wouldSpliceTagToken(tagTail, trimmed))
256
+ break;
257
+ segments.push(trimmed);
258
+ scan += 1;
259
+ if (tagTail !== null) {
260
+ tagTail = trimmed.indexOf(']') === -1 ? trimmed : null;
261
+ }
262
+ else if (tagRegion !== null) {
263
+ tagRegion = `${tagRegion} ${trimmed}`;
264
+ const opened = tagRegionRe.exec(tagRegion);
265
+ if (opened === null)
266
+ tagRegion = null;
267
+ else if (opened[1] !== undefined) {
268
+ tagTail = opened[1];
269
+ tagRegion = null;
270
+ }
271
+ }
272
+ if (trimmed.indexOf('**') !== -1) {
273
+ closed = true;
274
+ break;
275
+ }
276
+ }
277
+ if (closed) {
278
+ joined.push(segments.join(' '));
279
+ i = scan - 1;
280
+ }
281
+ else {
282
+ joined.push(line);
283
+ }
284
+ }
285
+ return joined;
286
+ }
78
287
  /**
79
288
  * Parse decision lines from a block of text (the inner text of a <decisions>
80
289
  * or markdown-header section body). Returns the extracted decisions and a count
@@ -83,9 +292,12 @@ const boldLeadInBulletRe = /^\s*-\s+\*\*[A-Z]+[0-9]*-[A-Za-z0-9]/m;
83
292
  * FIX B (#1365): parseMisses > 0 means the caller must treat the result as
84
293
  * could-not-parse even when some decisions were extracted — a silent drop is
85
294
  * worse than a fail-loud signal.
295
+ *
296
+ * #3939: physical lines are folded into logical bullets first, so a declaration
297
+ * whose bold lead-in wraps is matched as the one bullet it is.
86
298
  */
87
299
  function parseDecisionLines(block) {
88
- const lines = block.split(/\r?\n/);
300
+ const lines = joinWrappedBoldLeadIns(block.split(/\r?\n/));
89
301
  const out = [];
90
302
  let category = '';
91
303
  let inDiscretion = false;
@@ -110,6 +110,15 @@ function validateRequirement(requirement) {
110
110
  if (r.shapes == null && !(typeof r.text === 'string' && r.text.trim())) {
111
111
  throw new Error(`requirement ${requirement.id} text must be a non-empty string when no shapes override is provided`);
112
112
  }
113
+ // text_en (#3717) is optional, but when present it must be a non-empty string. An empty
114
+ // string is NOT caught by `??` (only null/undefined are), so an unvalidated `text_en: ''`
115
+ // would silently win `text_en ?? text` and classify against '' — the same fail-open shape
116
+ // #1110/#2773 already exist to eliminate, just moved one field over. Validated
117
+ // unconditionally (not gated on whether `shapes` will make it unused) so bad data fails
118
+ // closed even when it happens to be dead for this particular call.
119
+ if (r.text_en != null && !(typeof r.text_en === 'string' && r.text_en.trim())) {
120
+ throw new Error(`requirement ${requirement.id} text_en must be a non-empty string when present`);
121
+ }
113
122
  }
114
123
  /** Validate an edge resolution against the edge verification vocabulary. */
115
124
  function validateResolution(resolution) {
@@ -135,7 +144,11 @@ function proposeEdges(requirement) {
135
144
  shapes = requirement.shapes;
136
145
  }
137
146
  else {
138
- shapes = classifyShape(requirement.text);
147
+ // #3717: prefer the English translation when present — SHAPE_CUES are English-only
148
+ // word-boundary patterns, so a non-English `text` (e.g. response_language projects)
149
+ // would otherwise classify to zero shapes. validateRequirement (called above) has
150
+ // already guaranteed text_en, if present, is a non-empty string.
151
+ shapes = classifyShape(requirement.text_en ?? requirement.text);
139
152
  if (shapes.length === 0) {
140
153
  // Prose present but no shape cue matched. Do NOT silently drop it (#1110): an
141
154
  // edge-relevant requirement whose phrasing missed every cue would otherwise vanish from
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ /**
3
+ * File-overlap partitioner — shared greedy first-fit stage assignment (#3674).
4
+ *
5
+ * Extracted from `claude-orchestration.cts`'s `partitionStages` (#1143), which
6
+ * emits sequential Workflow `parallel()` stage barriers so that no two plans
7
+ * sharing a `files_modified` entry ever cohabit a stage. This module is the
8
+ * SAME algorithm, generalized: it depends on no `Plan`/`Wave` interface from
9
+ * `claude-orchestration.cts` (or any other caller-specific shape), so a future
10
+ * consumer (quick-batch, #3675 / ADR-1239 "Quick-batch binding") can partition
11
+ * its own planned-path items without pulling in orchestration internals.
12
+ *
13
+ * This is a pure, behavior-preserving extraction (#3674's acceptance bar is
14
+ * byte-identical output for `claude-orchestration.cts`'s existing callers —
15
+ * not an improvement). Explicitly OUT of scope, per the #3674 design lock:
16
+ * - dependency-DAG ordering — this module only ever sees a flat item list
17
+ * and file-overlap; a caller resolves dependency order before calling in
18
+ * (same contract `partitionStages` already had);
19
+ * - path normalization — file entries are compared by exact string equality
20
+ * only. `Foo.ts` vs `foo.ts`, or `src/a.ts` vs `src\a.ts`, are treated as
21
+ * DISTINCT files. This is deliberate, not a gap to "fix" during extraction;
22
+ * - filesystem access — this module never reads a path off disk.
23
+ *
24
+ * Zero external dependencies. Pure function. Never throws on well-typed input.
25
+ */
26
+ /**
27
+ * Partition `items` into a near-minimal number of sequential stages (via
28
+ * greedy first-fit — not guaranteed optimal for arbitrary overlap graphs, but
29
+ * correct: no two items sharing a file ever cohabit a stage) such that no two
30
+ * items in the same stage share a file. Each item goes into the earliest
31
+ * stage where it does not overlap any item already there, in input order.
32
+ *
33
+ * An item with an EMPTY `files` array declares no files; it overlaps nothing
34
+ * and coalesces into stage 0 (mirrors `partitionStages`' original behavior —
35
+ * this module cannot guard against undeclared concurrent writes; a caller
36
+ * must declare `files` accurately).
37
+ *
38
+ * File comparison is EXACT STRING EQUALITY — no path normalization, no
39
+ * case-folding, no separator canonicalization. Duplicate `id`s in the input
40
+ * are NOT deduplicated; each item is placed independently, in input order.
41
+ *
42
+ * Deterministic: identical input (including input order) always yields an
43
+ * identical partition.
44
+ */
45
+ function partitionByFileOverlap(items) {
46
+ const stages = [];
47
+ for (const item of items) {
48
+ const fileSet = new Set(item.files);
49
+ let placed = false;
50
+ for (const stage of stages) {
51
+ let overlap = false;
52
+ for (const f of fileSet) {
53
+ if (stage.files.has(f)) {
54
+ overlap = true;
55
+ break;
56
+ }
57
+ }
58
+ if (!overlap) {
59
+ stage.items.push(item);
60
+ for (const f of fileSet)
61
+ stage.files.add(f);
62
+ placed = true;
63
+ break;
64
+ }
65
+ }
66
+ if (!placed) {
67
+ stages.push({ items: [item], files: new Set(fileSet) });
68
+ }
69
+ }
70
+ return stages.map((s) => s.items.map((i) => i.id));
71
+ }
72
+ module.exports = {
73
+ partitionByFileOverlap,
74
+ };
@@ -652,46 +652,137 @@ function countTopLevelKeyShapedLines(region) {
652
652
  * key its deduplication. Optional because this function has 50-odd call sites and several
653
653
  * hold only an in-memory string; those dedup on a content digest instead.
654
654
  */
655
- function extractFrontmatter(content, sourcePath) {
655
+ /**
656
+ * The frontmatter REGION of a document: the text between the opening `---`
657
+ * fence at byte 0 and its closing fence.
658
+ *
659
+ * One fence parser, not two (#3850 review round 2, B1). `extractFrontmatter`
660
+ * and `frontmatterListEntries` need the identical answer to "where does the
661
+ * frontmatter start and stop" — same BOM tolerance (#2977), same byte-0-only
662
+ * fence rule, same CR handling before the closing fence — and differ only in
663
+ * what they do with the region text afterwards. A second copy of this logic is
664
+ * the `DEFECT.GENERATIVE-FIX` shape: it silently stops agreeing the first time
665
+ * either is taught something.
666
+ *
667
+ * `terminated: false` is the fence-opened-but-never-closed case. It is not an
668
+ * error here because the two callers disagree about it: `extractFrontmatter`
669
+ * runs the #1882 truncation probe over the region and may warn, while
670
+ * `frontmatterListEntries` has no array to return and gives up. So the shape
671
+ * is reported and the decision is left to them.
672
+ *
673
+ * `content` is returned alongside because it is the BOM-STRIPPED text, and a
674
+ * caller reporting on the document (the truncation diagnostic) must describe
675
+ * the same bytes the offsets were computed against.
676
+ */
677
+ function frontmatterRegion(content) {
656
678
  // #2977: tolerate a single leading UTF-8 BOM (U+FEFF), which Windows tooling
657
- // (PowerShell `>`/`Out-File` on PS 5.1, several editors) writes by default. Without this
658
- // strip, the byte-0 `startsWith('---')` fence check below fails on the BOM and the whole
659
- // parse collapses to {} — every frontmatter field silently disappears, and the engine
660
- // proceeds as though the file had no frontmatter at all. The BOM is a single codepoint;
661
- // stripping it here restores byte-0 alignment so the rest of the function is unchanged.
662
- // Scope: BOM only. Arbitrary non-BOM content before the fence (leading whitespace/blank
663
- // line/comment) is a separate product-intent decision (tolerate vs diagnose) left to a
664
- // future change — this fix does not broaden the byte-0 fence rule beyond the BOM.
665
- if (content.charCodeAt(0) === 0xFEFF) {
679
+ // (PowerShell `>`/`Out-File` on PS 5.1, several editors) writes by default.
680
+ // Without this strip, the byte-0 `startsWith('---')` fence check below fails
681
+ // on the BOM and the whole parse collapses — every frontmatter field silently
682
+ // disappears, and the engine proceeds as though the file had no frontmatter
683
+ // at all. The BOM is a single codepoint; stripping it here restores byte-0
684
+ // alignment. Scope: BOM only. Arbitrary non-BOM content before the fence
685
+ // (leading whitespace/blank line/comment) is a separate product-intent
686
+ // decision (tolerate vs diagnose) left to a future change.
687
+ if (content.charCodeAt(0) === 0xFEFF)
666
688
  content = content.slice(1);
667
- }
668
- // Match frontmatter only at byte 0 — a `---` block later in the document
669
- // body (YAML examples, horizontal rules) must never be treated as frontmatter.
689
+ // Match frontmatter only at byte 0 — a `---` block later in the document body
690
+ // (YAML examples, horizontal rules) must never be treated as frontmatter.
670
691
  const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
671
692
  if (headerEnd === -1)
672
- return {};
693
+ return null;
673
694
  const closingLineStart = content.indexOf('\n---', headerEnd);
674
695
  if (closingLineStart === -1) {
675
- const region = content.slice(headerEnd);
676
- const keyCount = countKeysBeforeTruncation(region);
677
- if (keyCount >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(region)) {
696
+ return { region: content.slice(headerEnd), terminated: false, content };
697
+ }
698
+ const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
699
+ return { region: content.slice(headerEnd, yamlEnd), terminated: true, content };
700
+ }
701
+ function extractFrontmatter(content, sourcePath) {
702
+ // Fence location (BOM strip, byte-0 rule, CR handling) lives in
703
+ // `frontmatterRegion` so this and `frontmatterListEntries` cannot drift
704
+ // apart on where the frontmatter is.
705
+ const found = frontmatterRegion(content);
706
+ if (!found)
707
+ return {};
708
+ if (!found.terminated) {
709
+ const keyCount = countKeysBeforeTruncation(found.region);
710
+ if (keyCount >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(found.region)) {
678
711
  warnUnusableInput({
679
712
  reason: UNUSABLE_REASON.FRONTMATTER_UNTERMINATED,
680
713
  source: sourcePath,
681
- content,
714
+ content: found.content,
682
715
  });
683
716
  }
684
717
  return {};
685
718
  }
686
- const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
687
- const region = content.slice(headerEnd, yamlEnd);
688
719
  try {
689
- return parseGuardedYamlRegion(region);
720
+ return parseGuardedYamlRegion(found.region);
690
721
  }
691
722
  catch {
692
723
  return unparseableResult();
693
724
  }
694
725
  }
726
+ /**
727
+ * The entries of a top-level frontmatter ARRAY key, VERBATIM — as the values
728
+ * YAML actually describes rather than the display-flattened strings
729
+ * `extractFrontmatter` returns (#3850).
730
+ *
731
+ * `extractFrontmatter` renders each object entry for HUMANS —
732
+ * `normalizeParsedValue` maps `{test, resolution}` to `"test: …, resolution: …"`
733
+ * via `flattenObjectListItem`. That is the right contract for a reader printing
734
+ * a list, and the wrong one for a reader that needs to branch on a specific
735
+ * field: `status`, `resolution` and `reason` are recoverable from that string
736
+ * only by re-parsing prose, which cannot distinguish a real `resolution:` field
737
+ * from the same text inside a quoted `truth:`.
738
+ *
739
+ * This returns the same entries BEFORE that display step, off the same
740
+ * `extractFrontmatter` parse path — same BOM strip (#2977), same byte-0 fence
741
+ * rule (shared via `frontmatterRegion`), same anchor/alias and sentinel guards,
742
+ * same ambiguous-colon repair. It is deliberately NOT a second parser: a
743
+ * hand-rolled fence regex or entry slicer re-loses whatever the real one
744
+ * learned, which is the `DEFECT.GENERATIVE-FIX` shape.
745
+ *
746
+ * EVERY element is returned, at its own index, whatever its type (#3850 review
747
+ * round 3, Blocker). An earlier revision filtered to objects, which compacted
748
+ * the array: a caller numbering entries by array position then numbered the
749
+ * SURVIVORS, so a list mixing object and non-object entries lost rows outright
750
+ * and mis-numbered the rest — the same silently-vanishing-row defect this whole
751
+ * issue exists to close, triggered by entry shape instead of file status.
752
+ * Deciding what a non-object entry MEANS is a caller's judgement (the two
753
+ * readers in `uat.cts` answer it differently and both are right for their own
754
+ * vocabulary); dropping it is nobody's.
755
+ *
756
+ * Returns `null` when the document has no frontmatter, the frontmatter is
757
+ * unterminated or unparseable, the key is absent, or the key is not an array —
758
+ * "nothing to iterate" cases the caller should not have to tell apart.
759
+ */
760
+ function frontmatterListEntries(content, key) {
761
+ const found = frontmatterRegion(content);
762
+ if (!found || !found.terminated)
763
+ return null;
764
+ let raw;
765
+ try {
766
+ refuseAnchorsAndAliases(found.region);
767
+ refuseIfSentinelPresent(found.region);
768
+ raw = restoreNullBytesDeep(loadWithAmbiguousColonRepair(escapeNullBytesForParse(found.region)));
769
+ }
770
+ catch {
771
+ // Same posture as `extractFrontmatter`: an unparseable region is "no
772
+ // frontmatter", never a throw into a caller that was only reading a field.
773
+ return null;
774
+ }
775
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
776
+ return null;
777
+ const value = raw[key];
778
+ // `Array.isArray` narrows an `unknown` to `any[]`, and returning that
779
+ // unchecked is how `any` escapes a guarded parser into every caller. The
780
+ // element type genuinely IS unknown here — that is the point of this
781
+ // function — so say so.
782
+ if (!Array.isArray(value))
783
+ return null;
784
+ return value;
785
+ }
695
786
  /**
696
787
  * Escape a string for emission inside a YAML double-quoted scalar (#1779). ADR-3473 §8.1
697
788
  * (#3881): routed through the vendored js-yaml's `dump()` (forced double-quoted style) rather
@@ -816,6 +907,22 @@ function agentScalarNeedsDoubleQuoting(s) {
816
907
  return true;
817
908
  return false;
818
909
  }
910
+ /**
911
+ * #4053 — Quote a numeric-looking scalar that is not all-digit (`22.10`,
912
+ * `1.0`, `1e3`, `0x1F`) so a spec YAML reader keeps it a string: bare `22.10`
913
+ * reloads as the float 22.1 and collides with `22.1`, a different phase.
914
+ *
915
+ * All-digit strings stay bare as a deliberate, scoped trade-off — not a
916
+ * safety guarantee. A leading-zero value (`02`, `017`) also mis-parses under
917
+ * a spec reader (to 2, 17). It is left unquoted because zero-padded ids
918
+ * (`plan: 01`, `phase: 02`) are the pervasive GSD convention, quoting them
919
+ * all is the blanket quoting #4053 asked to avoid, and the loss is padding
920
+ * rather than identity: `02` and `2` normalize to the same phase; `22.1` and
921
+ * `22.10` do not.
922
+ */
923
+ function generalScalarNeedsNumericQuoting(s) {
924
+ return YAML_NUMERIC_RE.test(s) && !/^\d+$/.test(s);
925
+ }
819
926
  function reconstructFrontmatter(obj) {
820
927
  const lines = [];
821
928
  // #3257: read the full-line-comment channel (set by parseGuardedYamlRegion when comments
@@ -899,13 +1006,13 @@ function reconstructFrontmatter(obj) {
899
1006
  else {
900
1007
  // eslint-disable-next-line @typescript-eslint/no-base-to-string
901
1008
  const sv = String(subval);
902
- lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) ? `"${escapeDoubleQuotedScalar(sv)}"` : sv}`);
1009
+ lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) || generalScalarNeedsNumericQuoting(sv) ? `"${escapeDoubleQuotedScalar(sv)}"` : sv}`);
903
1010
  }
904
1011
  }
905
1012
  }
906
1013
  else {
907
1014
  const sv = String(value);
908
- if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{') || scalarNeedsDoubleQuoting(sv)) {
1015
+ if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{') || scalarNeedsDoubleQuoting(sv) || generalScalarNeedsNumericQuoting(sv)) {
909
1016
  lines.push(`${key}: "${escapeDoubleQuotedScalar(sv)}"`);
910
1017
  }
911
1018
  else {
@@ -1529,6 +1636,13 @@ module.exports = {
1529
1636
  stripFrontmatter,
1530
1637
  noOpObjectListSetError,
1531
1638
  parseMustHavesBlock,
1639
+ // #3850: an array key's entries as parsed OBJECTS, for a caller that must
1640
+ // branch on an entry's `status:`/`resolution:` rather than print it. Off the
1641
+ // same parse path as `extractFrontmatter`, minus only the display flattening.
1642
+ frontmatterListEntries,
1643
+ // #3850: the display rendering itself, so a caller deriving a name from those
1644
+ // objects produces the byte-identical string `extractFrontmatter` would have.
1645
+ flattenObjectListItem,
1532
1646
  FRONTMATTER_SCHEMAS,
1533
1647
  cmdFrontmatterGet,
1534
1648
  cmdFrontmatterSet,
@@ -36,6 +36,9 @@ const { scanPhasePlans } = planScanMod;
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
37
  const phaseIdMod = require("./phase-id.cjs");
38
38
  const { scopeToPhase } = phaseIdMod;
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ const planningScopeMod = require("./planning-scope.cjs");
41
+ const { SCOPE } = planningScopeMod;
39
42
  /**
40
43
  * Parse REQ-IDs from REQUIREMENTS.md content.
41
44
  *
@@ -303,6 +306,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
303
306
  summary: 'workflow.post_planning_gaps disabled — skipping post-planning gap analysis',
304
307
  counts: { total: 0, covered: 0, uncovered: 0 },
305
308
  phase_dir_read_error: null,
309
+ phase_dir_scope: SCOPE.COMPLETE,
306
310
  };
307
311
  }
308
312
  const absPhaseDir = node_path_1.default.isAbsolute(phaseDir) ? phaseDir : node_path_1.default.join(cwd, phaseDir);
@@ -325,19 +329,20 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
325
329
  }
326
330
  // Read the phase directory once; reuse the listing for both context detection
327
331
  // and plan-file enumeration (avoids redundant readdirSync calls).
328
- let phaseDirFiles = [];
329
- // #3885 (ADR-3473 §8.5): the existsSync guard above already means a catch
330
- // here is NEVER "genuinely absent" (ENOENT) — this directory exists, so any
331
- // failure to list it is a real read error (EACCES/EIO/...) and must be
332
- // named, not folded into the same `[]` an absent directory produces.
333
- let phaseDirReadError = null;
334
- try {
335
- if (node_fs_1.default.existsSync(absPhaseDir))
336
- phaseDirFiles = node_fs_1.default.readdirSync(absPhaseDir);
337
- }
338
- catch (err) {
339
- phaseDirReadError = `Could not read phase directory ${formatDiagnosticToken(absPhaseDir)}: ${formatDiagnosticToken(err?.message ?? String(err))}`;
340
- }
332
+ //
333
+ // #4014 (epic #3473 B4): findContextMdIn's directory-string form now owns
334
+ // the listing + ENOENT-vs-other discrimination, retiring the local
335
+ // existsSync-guarded readdirSync try/catch — ENOENT (genuinely absent)
336
+ // and a successful-but-empty read both resolve to SCOPE.COMPLETE with an
337
+ // empty listing, exactly like the existsSync guard's short-circuit did.
338
+ const { files: phaseDirFiles, scope: phaseDirScope } = findContextMdIn(absPhaseDir);
339
+ // #3885 (ADR-3473 §8.5): `phaseDirReadError` stays additive for the
340
+ // shipped `phase_dir_read_error` JSON field — derived from `phaseDirScope`
341
+ // rather than from its own caught error, since findContextMdIn's
342
+ // directory-string form reports SCOPE, not the raw errno message.
343
+ const phaseDirReadError = phaseDirScope === SCOPE.UNREADABLE
344
+ ? `Could not read phase directory ${formatDiagnosticToken(absPhaseDir)}`
345
+ : null;
341
346
  // #3511-class: scope the raw listing to this phase dir before the
342
347
  // phase-numbered -CONTEXT.md predicate. `phaseDirFiles` itself stays raw —
343
348
  // it is also reused below only as a `.length > 0` guard ahead of
@@ -405,6 +410,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
405
410
  summary: coverageSummary + '; extracted 0 of N — possible format mismatch',
406
411
  counts: { total: rows.length, covered, uncovered },
407
412
  phase_dir_read_error: phaseDirReadError,
413
+ phase_dir_scope: phaseDirScope,
408
414
  };
409
415
  }
410
416
  return {
@@ -414,6 +420,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
414
420
  summary: 'extracted 0 of N — possible format mismatch',
415
421
  counts: { total: 0, covered: 0, uncovered: 0 },
416
422
  phase_dir_read_error: phaseDirReadError,
423
+ phase_dir_scope: phaseDirScope,
417
424
  };
418
425
  }
419
426
  // #1365: if no items at all, surface a clean no-check message.
@@ -431,6 +438,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
431
438
  summary: 'no requirements or decisions to check',
432
439
  counts: { total: 0, covered: 0, uncovered: 0 },
433
440
  phase_dir_read_error: phaseDirReadError,
441
+ phase_dir_scope: phaseDirScope,
434
442
  };
435
443
  }
436
444
  const rows = sortRows([
@@ -449,6 +457,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
449
457
  summary,
450
458
  counts: { total: rows.length, covered, uncovered },
451
459
  phase_dir_read_error: phaseDirReadError,
460
+ phase_dir_scope: phaseDirScope,
452
461
  };
453
462
  }
454
463
  function cmdGapAnalysis(cwd, args, raw) {