@opengsd/gsd-core 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (395) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +1 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-dom-verifier.md +169 -0
  7. package/agents/gsd-eval-auditor.md +1 -1
  8. package/agents/gsd-executor.md +17 -9
  9. package/agents/gsd-framework-selector.md +1 -3
  10. package/agents/gsd-intel-updater.md +1 -1
  11. package/agents/gsd-mempalace-curator.md +0 -1
  12. package/agents/gsd-pattern-mapper.md +11 -0
  13. package/agents/gsd-phase-researcher.md +3 -1
  14. package/agents/gsd-plan-checker.md +15 -55
  15. package/agents/gsd-planner.md +6 -4
  16. package/agents/gsd-project-researcher.md +1 -1
  17. package/agents/gsd-research-synthesizer.md +2 -2
  18. package/agents/gsd-roadmapper.md +15 -11
  19. package/agents/gsd-ui-checker.md +63 -4
  20. package/agents/gsd-ui-researcher.md +41 -3
  21. package/agents/gsd-verifier.md +1 -1
  22. package/bin/install.js +609 -134
  23. package/commands/gsd/discuss-phase.md +1 -1
  24. package/commands/gsd/import.md +1 -1
  25. package/commands/gsd/quick.md +8 -4
  26. package/gsd-core/bin/gsd-tools.cjs +567 -51
  27. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  28. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  29. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  30. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  31. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  32. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  33. package/gsd-core/bin/lib/audit.cjs +163 -41
  34. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  35. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  36. package/gsd-core/bin/lib/capability-registry.cjs +336 -95
  37. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  38. package/gsd-core/bin/lib/capability-validator.cjs +205 -18
  39. package/gsd-core/bin/lib/check-command-router.cjs +145 -5
  40. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  41. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  43. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  44. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  45. package/gsd-core/bin/lib/commands.cjs +543 -44
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  47. package/gsd-core/bin/lib/config-loader.cjs +118 -29
  48. package/gsd-core/bin/lib/config.cjs +92 -2
  49. package/gsd-core/bin/lib/configuration.cjs +129 -37
  50. package/gsd-core/bin/lib/core-utils.cjs +84 -7
  51. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  52. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  53. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  54. package/gsd-core/bin/lib/frontmatter.cjs +840 -305
  55. package/gsd-core/bin/lib/gap-checker.cjs +27 -3
  56. package/gsd-core/bin/lib/git-base-branch.cjs +174 -39
  57. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  58. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +6 -3
  59. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  60. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  61. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  62. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  63. package/gsd-core/bin/lib/init.cjs +120 -41
  64. package/gsd-core/bin/lib/install-engine.cjs +68 -3
  65. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -1
  66. package/gsd-core/bin/lib/install-profiles.cjs +78 -4
  67. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  68. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  69. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  70. package/gsd-core/bin/lib/intel.cjs +101 -26
  71. package/gsd-core/bin/lib/io.cjs +160 -15
  72. package/gsd-core/bin/lib/learnings.cjs +85 -14
  73. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  74. package/gsd-core/bin/lib/markdown-table.cjs +52 -4
  75. package/gsd-core/bin/lib/milestone.cjs +90 -5
  76. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  77. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  78. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  79. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  80. package/gsd-core/bin/lib/phase-id.cjs +70 -4
  81. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  82. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  83. package/gsd-core/bin/lib/phase.cjs +405 -84
  84. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  85. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  86. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  87. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  88. package/gsd-core/bin/lib/planning-snapshot.cjs +18 -14
  89. package/gsd-core/bin/lib/planning-workspace.cjs +56 -0
  90. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  91. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  92. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  93. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  94. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  95. package/gsd-core/bin/lib/review-lane-descriptor.cjs +9 -9
  96. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  97. package/gsd-core/bin/lib/roadmap-parser.cjs +79 -16
  98. package/gsd-core/bin/lib/roadmap.cjs +74 -19
  99. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +96 -8
  100. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -1
  101. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +287 -55
  102. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  103. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  104. package/gsd-core/bin/lib/shell-command-projection.cjs +71 -8
  105. package/gsd-core/bin/lib/smart-entry.cjs +12 -22
  106. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  107. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  108. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  109. package/gsd-core/bin/lib/state-document.cjs +186 -0
  110. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  111. package/gsd-core/bin/lib/state-transition.cjs +517 -101
  112. package/gsd-core/bin/lib/state.cjs +946 -163
  113. package/gsd-core/bin/lib/surface.cjs +10 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  115. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  116. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  117. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  118. package/gsd-core/bin/lib/uat.cjs +1376 -125
  119. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  120. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  121. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  122. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  123. package/gsd-core/bin/lib/vendor/README.md +43 -5
  124. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  125. package/gsd-core/bin/lib/verification.cjs +14 -1
  126. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  127. package/gsd-core/bin/lib/verify.cjs +95 -40
  128. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  129. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  130. package/gsd-core/bin/lib/worktree-safety.cjs +177 -21
  131. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  132. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  133. package/gsd-core/bin/shared/exit-codes.json +8 -0
  134. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  135. package/gsd-core/bin/shared/model-catalog.json +8 -1
  136. package/gsd-core/references/agent-contracts.md +3 -2
  137. package/gsd-core/references/api-coverage.md +24 -2
  138. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  139. package/gsd-core/references/checkpoints.md +37 -19
  140. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  141. package/gsd-core/references/edge-probe.md +8 -0
  142. package/gsd-core/references/execute-mvp-tdd.md +1 -3
  143. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  144. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  145. package/gsd-core/references/failing-direction.md +78 -0
  146. package/gsd-core/references/gate-prompts.md +1 -1
  147. package/gsd-core/references/git-integration.md +5 -5
  148. package/gsd-core/references/git-planning-commit.md +3 -3
  149. package/gsd-core/references/gsd-run-resolver.md +1 -1
  150. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  151. package/gsd-core/references/model-profiles.md +1 -1
  152. package/gsd-core/references/nyquist-compliance.md +74 -0
  153. package/gsd-core/references/offer-next.md +3 -5
  154. package/gsd-core/references/phase-argument-parsing.md +3 -3
  155. package/gsd-core/references/planner-failing-direction.md +53 -0
  156. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  157. package/gsd-core/references/planner-revision.md +1 -1
  158. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  159. package/gsd-core/references/planning-config.md +37 -8
  160. package/gsd-core/references/reviewer-instances.md +31 -0
  161. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  162. package/gsd-core/references/tdd.md +1 -3
  163. package/gsd-core/references/ui-brand.md +65 -21
  164. package/gsd-core/references/ui-consideration-probe.md +1 -1
  165. package/gsd-core/references/universal-anti-patterns.md +2 -2
  166. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  167. package/gsd-core/references/verify-mvp-mode.md +1 -1
  168. package/gsd-core/references/workstream-flag.md +11 -11
  169. package/gsd-core/templates/README.md +1 -1
  170. package/gsd-core/templates/SECURITY.md +3 -3
  171. package/gsd-core/templates/UI-SPEC.md +25 -3
  172. package/gsd-core/templates/VALIDATION.md +3 -3
  173. package/gsd-core/templates/phase-prompt.md +3 -0
  174. package/gsd-core/templates/state.md +7 -0
  175. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  176. package/gsd-core/workflows/add-backlog.md +1 -1
  177. package/gsd-core/workflows/add-phase.md +3 -3
  178. package/gsd-core/workflows/add-tests.md +3 -8
  179. package/gsd-core/workflows/add-todo.md +1 -1
  180. package/gsd-core/workflows/ai-integration-phase.md +4 -9
  181. package/gsd-core/workflows/audit-fix.md +12 -3
  182. package/gsd-core/workflows/audit-milestone.md +9 -9
  183. package/gsd-core/workflows/audit-uat.md +17 -2
  184. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  185. package/gsd-core/workflows/autonomous.md +10 -26
  186. package/gsd-core/workflows/check-todos.md +1 -1
  187. package/gsd-core/workflows/cleanup.md +2 -2
  188. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  189. package/gsd-core/workflows/code-review-fix.md +1 -1
  190. package/gsd-core/workflows/code-review.md +121 -40
  191. package/gsd-core/workflows/complete-milestone.md +15 -10
  192. package/gsd-core/workflows/debug.md +5 -3
  193. package/gsd-core/workflows/diagnose-issues.md +12 -6
  194. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  195. package/gsd-core/workflows/discuss-phase/modes/chain.md +3 -7
  196. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  197. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  198. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -2
  199. package/gsd-core/workflows/discuss-phase.md +1 -1
  200. package/gsd-core/workflows/do.md +3 -6
  201. package/gsd-core/workflows/docs-update.md +5 -4
  202. package/gsd-core/workflows/edit-phase.md +1 -1
  203. package/gsd-core/workflows/eval-review.md +4 -9
  204. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  205. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +113 -11
  206. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  207. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  208. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  209. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +22 -4
  210. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  211. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  212. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  213. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  214. package/gsd-core/workflows/execute-phase.md +38 -54
  215. package/gsd-core/workflows/execute-plan.md +17 -12
  216. package/gsd-core/workflows/explore.md +1 -1
  217. package/gsd-core/workflows/extract-learnings.md +1 -1
  218. package/gsd-core/workflows/fast.md +2 -2
  219. package/gsd-core/workflows/forensics.md +1 -1
  220. package/gsd-core/workflows/graduation.md +5 -5
  221. package/gsd-core/workflows/health.md +3 -6
  222. package/gsd-core/workflows/import.md +14 -11
  223. package/gsd-core/workflows/inbox.md +4 -5
  224. package/gsd-core/workflows/ingest-docs.md +44 -11
  225. package/gsd-core/workflows/insert-phase.md +5 -5
  226. package/gsd-core/workflows/list-seeds.md +5 -3
  227. package/gsd-core/workflows/list-workspaces.md +1 -1
  228. package/gsd-core/workflows/manager.md +12 -23
  229. package/gsd-core/workflows/map-codebase.md +1 -1
  230. package/gsd-core/workflows/milestone-summary.md +1 -1
  231. package/gsd-core/workflows/mvp-phase.md +2 -2
  232. package/gsd-core/workflows/new-milestone.md +9 -21
  233. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  234. package/gsd-core/workflows/new-project.md +12 -26
  235. package/gsd-core/workflows/new-workspace.md +1 -1
  236. package/gsd-core/workflows/next.md +2 -2
  237. package/gsd-core/workflows/pause-work.md +1 -1
  238. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  239. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  240. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  241. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  242. package/gsd-core/workflows/plan-phase.md +121 -42
  243. package/gsd-core/workflows/plan-review-convergence.md +46 -9
  244. package/gsd-core/workflows/plant-seed.md +2 -2
  245. package/gsd-core/workflows/pr-branch.md +187 -51
  246. package/gsd-core/workflows/profile-user.md +16 -14
  247. package/gsd-core/workflows/progress.md +27 -12
  248. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  249. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -3
  250. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  251. package/gsd-core/workflows/quick/steps/research-phase.md +2 -4
  252. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  253. package/gsd-core/workflows/quick.md +20 -29
  254. package/gsd-core/workflows/remove-phase.md +4 -4
  255. package/gsd-core/workflows/remove-workspace.md +2 -2
  256. package/gsd-core/workflows/resume-project.md +8 -12
  257. package/gsd-core/workflows/review.md +193 -15
  258. package/gsd-core/workflows/scan.md +1 -1
  259. package/gsd-core/workflows/secure-phase.md +2 -2
  260. package/gsd-core/workflows/settings-advanced.md +7 -9
  261. package/gsd-core/workflows/settings-integrations.md +64 -31
  262. package/gsd-core/workflows/settings.md +3 -5
  263. package/gsd-core/workflows/ship.md +12 -6
  264. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  265. package/gsd-core/workflows/sketch.md +12 -18
  266. package/gsd-core/workflows/smart-entry.md +3 -5
  267. package/gsd-core/workflows/spec-phase.md +23 -1
  268. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  269. package/gsd-core/workflows/spike.md +20 -31
  270. package/gsd-core/workflows/stats.md +2 -2
  271. package/gsd-core/workflows/sync-skills.md +1 -1
  272. package/gsd-core/workflows/thread.md +11 -7
  273. package/gsd-core/workflows/transition.md +5 -5
  274. package/gsd-core/workflows/ui-phase.md +10 -16
  275. package/gsd-core/workflows/ui-review.md +6 -10
  276. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  277. package/gsd-core/workflows/undo.md +8 -16
  278. package/gsd-core/workflows/update.md +6 -10
  279. package/gsd-core/workflows/validate-phase.md +2 -2
  280. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  281. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  282. package/gsd-core/workflows/verify-work.md +57 -18
  283. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  284. package/hooks/dist/gsd-config-reload.js +18 -12
  285. package/hooks/dist/gsd-context-monitor.js +19 -10
  286. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  287. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  288. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  289. package/hooks/dist/gsd-cursor-stop.js +2 -1
  290. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  291. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  292. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  293. package/hooks/dist/gsd-graphify-update.sh +22 -18
  294. package/hooks/dist/gsd-node-runner.sh +76 -0
  295. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  296. package/hooks/dist/gsd-prompt-guard.js +16 -7
  297. package/hooks/dist/gsd-read-guard.js +16 -7
  298. package/hooks/dist/gsd-read-injection-scanner.js +17 -8
  299. package/hooks/dist/gsd-session-state.sh +1 -0
  300. package/hooks/dist/gsd-statusline.js +215 -26
  301. package/hooks/dist/gsd-validate-commit.sh +80 -6
  302. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  303. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  304. package/hooks/dist/gsd-workflow-guard.js +34 -16
  305. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  306. package/hooks/dist/gsd-write-guard.js +35 -25
  307. package/hooks/dist/lib/cli-exit.js +560 -0
  308. package/hooks/dist/lib/exit-code-registry.js +98 -0
  309. package/hooks/dist/lib/git-probe.js +84 -0
  310. package/hooks/dist/lib/hook-exit.js +81 -0
  311. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  312. package/hooks/gsd-agent-isolation-guard.js +77 -38
  313. package/hooks/gsd-config-reload.js +18 -12
  314. package/hooks/gsd-context-monitor.js +19 -10
  315. package/hooks/gsd-cursor-post-tool.js +3 -1
  316. package/hooks/gsd-cursor-pre-tool.js +3 -1
  317. package/hooks/gsd-cursor-session-start.js +2 -1
  318. package/hooks/gsd-cursor-stop.js +2 -1
  319. package/hooks/gsd-cursor-subagent-start.js +28 -23
  320. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  321. package/hooks/gsd-ensure-canonical-path.js +2 -1
  322. package/hooks/gsd-graphify-update.sh +22 -18
  323. package/hooks/gsd-node-runner.sh +76 -0
  324. package/hooks/gsd-phase-boundary.sh +1 -0
  325. package/hooks/gsd-prompt-guard.js +16 -7
  326. package/hooks/gsd-read-guard.js +16 -7
  327. package/hooks/gsd-read-injection-scanner.js +17 -8
  328. package/hooks/gsd-session-state.sh +1 -0
  329. package/hooks/gsd-statusline.js +215 -26
  330. package/hooks/gsd-validate-commit.sh +80 -6
  331. package/hooks/gsd-windsurf-pre-command.js +16 -11
  332. package/hooks/gsd-windsurf-pre-write.js +22 -13
  333. package/hooks/gsd-workflow-guard.js +34 -16
  334. package/hooks/gsd-worktree-path-guard.js +36 -21
  335. package/hooks/gsd-write-guard.js +35 -25
  336. package/hooks/lib/cli-exit.js +560 -0
  337. package/hooks/lib/exit-code-registry.js +98 -0
  338. package/hooks/lib/git-probe.js +84 -0
  339. package/hooks/lib/hook-exit.js +81 -0
  340. package/hooks/managed-hooks-registry.cjs +3 -0
  341. package/package.json +12 -7
  342. package/scripts/base64-scan.sh +74 -12
  343. package/scripts/build-hooks.js +5 -0
  344. package/scripts/check-glossary-refs.cjs +77 -15
  345. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  346. package/scripts/ci-check-job-near-cap.cjs +49 -0
  347. package/scripts/ci-pr-mergeability.cjs +262 -0
  348. package/scripts/ci-test-scope.cjs +45 -12
  349. package/scripts/ci-timeout-report.cjs +230 -0
  350. package/scripts/docs-guard-registry.cjs +396 -0
  351. package/scripts/gen-capability-registry.cjs +8 -6
  352. package/scripts/gen-exit-code-docs.cjs +318 -0
  353. package/scripts/gen-exit-code-registry.cjs +891 -0
  354. package/scripts/gen-features.cjs +836 -0
  355. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  356. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  357. package/scripts/gen-loop-host-contract.cjs +134 -1
  358. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  359. package/scripts/gen-state-md-docs.cjs +727 -0
  360. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  361. package/scripts/lib/ci-job-timing.cjs +72 -0
  362. package/scripts/lib/cli-exit.cjs +546 -44
  363. package/scripts/lib/drift-scan.cjs +32 -2
  364. package/scripts/lib/exit-code-registry.cjs +98 -0
  365. package/scripts/lib/ndjson-reporter.cjs +119 -0
  366. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  367. package/scripts/lint-docs-guard-registration.cjs +495 -0
  368. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  369. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  370. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  371. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  372. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  373. package/scripts/lint-phase-enumeration-drift.cjs +21 -8
  374. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  375. package/scripts/lint-removed-but-needed.cjs +184 -16
  376. package/scripts/lint-seam-enforcement.cjs +182 -0
  377. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  378. package/scripts/lint-source-test-name-collision.cjs +241 -0
  379. package/scripts/lint-state-write-path-drift.cjs +337 -432
  380. package/scripts/lint-test-file-count.allowlist.json +122 -4
  381. package/scripts/lint-test-file-count.cjs +25 -3
  382. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  383. package/scripts/lint-vendored-deps.cjs +208 -35
  384. package/scripts/mutation-matrix.cjs +599 -50
  385. package/scripts/prompt-injection-scan.sh +75 -14
  386. package/scripts/secret-scan.sh +75 -13
  387. package/scripts/select-docs-guards.cjs +56 -0
  388. package/scripts/sync-runtime-launcher.cjs +22 -3
  389. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  390. package/skills/gsd-import/SKILL.md +1 -1
  391. package/skills/gsd-quick/SKILL.md +8 -4
  392. package/vscode/package.json +1 -1
  393. package/bin/lib/ui-safety-gate.cjs +0 -109
  394. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  395. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -5,6 +5,16 @@
5
5
  * ADR-457 build-at-publish: the hand-written bin/lib/frontmatter.cjs collapsed
6
6
  * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
7
7
  * from the prior hand-written .cjs; only strict types are added.
8
+ *
9
+ * ADR-3473 §8.1 (#3881): the read path is no longer a hand-rolled line
10
+ * scanner. `parseGuardedYamlRegion` now parses through the vendored `js-yaml`
11
+ * (`./vendor/js-yaml.cjs`, verbatim `node_modules/js-yaml/dist/js-yaml.js`)
12
+ * under `FAILSAFE_SCHEMA` + `json: true` — every scalar comes back a string
13
+ * (today's contract, no adapter needed) and duplicate keys overwrite
14
+ * (last-wins, the documented invariant). What js-yaml does NOT do —
15
+ * anchors/alias refusal, the #3257 comment channel, the #1882 truncation
16
+ * probe, null-byte preservation and object-list flattening for the existing
17
+ * string-shaped value contract — is layered on top, in one place, below.
8
18
  */
9
19
  var __importDefault = (this && this.__importDefault) || function (mod) {
10
20
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -20,43 +30,17 @@ const text_lines_cjs_1 = require("./text-lines.cjs");
20
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
31
  const unusableInputMod = require("./unusable-input.cjs");
22
32
  const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
33
+ const js_yaml_cjs_1 = require("./vendor/js-yaml.cjs");
23
34
  // ─── Parsing engine ───────────────────────────────────────────────────────────
24
35
  /**
25
- * Split a YAML inline array body on commas, respecting quoted strings.
26
- * e.g. '"a, b", c' → ['a, b', 'c']
36
+ * Base js-yaml options for every parse in this module (ADR-3473 §8.1 §3.1/§3.3).
37
+ * `FAILSAFE_SCHEMA` resolves only `!!str`/`!!seq`/`!!map` — every scalar comes
38
+ * back a string, which is today's contract and needs no coercion layer.
39
+ * `json: true` makes duplicate keys overwrite (last-wins) instead of throwing,
40
+ * which is the documented behavior `tests/fixtures/adversarial/frontmatter/
41
+ * duplicate-keys.md` pins.
27
42
  */
28
- function splitInlineArray(body) {
29
- const items = [];
30
- let current = '';
31
- let inQuote = null;
32
- for (let i = 0; i < body.length; i++) {
33
- const ch = body[i];
34
- if (inQuote) {
35
- if (ch === inQuote) {
36
- inQuote = null;
37
- }
38
- else {
39
- current += ch;
40
- }
41
- }
42
- else if (ch === '"' || ch === "'") {
43
- inQuote = ch;
44
- }
45
- else if (ch === ',') {
46
- const trimmed = current.trim();
47
- if (trimmed)
48
- items.push(trimmed);
49
- current = '';
50
- }
51
- else {
52
- current += ch;
53
- }
54
- }
55
- const trimmed = current.trim();
56
- if (trimmed)
57
- items.push(trimmed);
58
- return items;
59
- }
43
+ const YAML_LOAD_OPTS = { schema: js_yaml_cjs_1.FAILSAFE_SCHEMA, json: true };
60
44
  /**
61
45
  * How many parsed keys an unterminated region must yield before it is reported as a
62
46
  * truncated frontmatter rather than left alone as ordinary Markdown. See the rationale on
@@ -81,7 +65,9 @@ const UNTERMINATED_KEY_THRESHOLD = 2;
81
65
  * a frontmatter block ends mid-block, so *every* line in the region is still frontmatter-shaped,
82
66
  * whereas a document merely opening with a rule goes on to prose. So the region must be
83
67
  * uniformly frontmatter-shaped AND carry enough keys to be worth reporting; either test alone
84
- * has a false-positive class the other closes.
68
+ * has a false-positive class the other closes. This heuristic is deliberately a raw-text scan,
69
+ * independent of whichever parser counts the keys (see `countKeysBeforeTruncation`) — it is the
70
+ * guard that keeps a stricter parser from turning "opens with a rule" into a false positive.
85
71
  */
86
72
  function isFrontmatterShaped(region) {
87
73
  const lines = (0, text_lines_cjs_1.splitLines)(region).filter((line) => line.trim() !== '');
@@ -105,161 +91,529 @@ function isFrontmatterShaped(region) {
105
91
  */
106
92
  const FULL_LINE_COMMENTS = Symbol('fullLineComments');
107
93
  /**
108
- * Unescape the interior of a YAML double-quoted scalar — the exact inverse of
109
- * `escapeDoubleQuoted` (#3497). The writer has escaped `\`/`"`/`\n`/`\t`/`\r`/
110
- * `\xHH` since #1779, but the reader only stripped the delimiters, so
111
- * parse(serialize(x)) ≠ x for any quoted scalar carrying a `"` or `\`: each
112
- * read-modify-write round-trip doubled the backslashes (b → 2b+1), growing a
113
- * repeatedly-synced field — and its document — without bound until tooling
114
- * OOMed. Recognized escapes decode per YAML double-quoted semantics; an
115
- * unrecognized `\c` is kept literally (backslash + char), matching the
116
- * strip-only behavior hand-authored files had before this fix.
94
+ * ADR-3473 §8.1 §0.3 (#3881, consequence 2): a Symbol-keyed marker carried on the `{}`
95
+ * `extractFrontmatter` returns when the region failed to parse (malformed YAML, or a refused
96
+ * anchor/alias/merge key — consequence 6). Mirrors `FULL_LINE_COMMENTS` exactly: invisible to
97
+ * `Object.keys`/`Object.entries`/`JSON.stringify`/`for-in`, so the 70 call sites that never
98
+ * inspect it are unaffected, while the 8 `hasFrontmatter = Object.keys(...).length > 0` sites
99
+ * consult it to tell "genuinely empty" apart from "unparseable" and avoid reassembling the
100
+ * document without its (unparsed but still present) frontmatter block. Those 8 call sites (7 in
101
+ * `state-transition.cts` behind `isUnparseableFrontmatter`/`rawFrontmatterPrefix`, plus 1 more in
102
+ * `state.cts`'s `cmdStateCompletePhase`) are wired on this branch — this module sets and exports
103
+ * the marker; the callers consume it.
117
104
  */
118
- function unescapeDoubleQuoted(s) {
119
- let out = '';
120
- for (let i = 0; i < s.length; i++) {
121
- const ch = s[i];
122
- if (ch !== '\\' || i === s.length - 1) {
123
- out += ch;
124
- continue;
125
- }
126
- const next = s[++i];
127
- if (next === '\\' || next === '"') {
128
- out += next;
129
- }
130
- else if (next === 'n') {
131
- out += '\n';
132
- }
133
- else if (next === 't') {
134
- out += '\t';
105
+ const FRONTMATTER_UNPARSEABLE = Symbol('frontmatterUnparseable');
106
+ function unparseableResult() {
107
+ // Plain-prototype (post-remote-runner-fix, #3881): the PUBLIC parse surface must keep
108
+ // handing callers ordinary `{}`-shaped objects — `assert.deepStrictEqual` compares
109
+ // prototypes, and 50+ existing call sites/tests compare against object literals. The
110
+ // Symbol marker is still attached via `Object.defineProperty` rather than bracket
111
+ // assignment, which is what actually matters for prototype-pollution safety: a data
112
+ // property named e.g. `__proto__` set through `defineProperty` never invokes the
113
+ // inherited `Object.prototype.__proto__` accessor setter the way `fm[k] = v` would.
114
+ const fm = {};
115
+ Object.defineProperty(fm, FRONTMATTER_UNPARSEABLE, {
116
+ value: true, writable: true, enumerable: true, configurable: true,
117
+ });
118
+ return fm;
119
+ }
120
+ /**
121
+ * Convert an internal, possibly null-prototype, YAML-derived value tree into an ordinary
122
+ * plain-prototype tree for the public parse surface (post-remote-runner-fix, #3881).
123
+ *
124
+ * The internal construction (`normalizeParsedValue`, `restoreNullBytesDeep`,
125
+ * `extractCommentChannel`) deliberately builds with `Object.create(null)` so a hostile key
126
+ * like `__proto__`/`constructor`/`toString` is always a genuine own data property and never
127
+ * resolves to (or overwrites) an inherited `Object.prototype` member WHILE THE TREE IS BEING
128
+ * BUILT. That safety property has nothing to do with what prototype the FINAL object callers
129
+ * receive — `assert.deepStrictEqual` compares prototypes, so handing back a null-prototype
130
+ * object silently broke every caller comparing against `{}` object literals (57+ tests). This
131
+ * walks the tree exactly once at the return boundary and re-homes every string/number-keyed
132
+ * own property onto an ordinary `{}` via `Object.defineProperty` (never `out[k] = v`), which
133
+ * is what keeps the copy itself safe: `defineProperty` always creates a real own data
134
+ * property, even for a key literally named `__proto__`, and never triggers the inherited
135
+ * setter the way bracket assignment would.
136
+ *
137
+ * The `FULL_LINE_COMMENTS` Symbol channel is copied across by reference, NOT recursed into —
138
+ * it stays null-prototype. It is purely internal plumbing (only `reconstructFrontmatter` /
139
+ * `propagateCommentChannel`, both in this module, ever read `channel.leading[key]` with an
140
+ * arbitrary user-authored key), invisible to every external reader (`Object.keys` /
141
+ * `Object.entries` / `JSON.stringify` / `for-in` all skip symbols), and re-plaining it would
142
+ * reopen the exact `leading[key]` inherited-member bug the null prototype exists to close.
143
+ */
144
+ function toPlainValueTree(value) {
145
+ if (Array.isArray(value))
146
+ return value.map(toPlainValueTree);
147
+ if (value !== null && typeof value === 'object') {
148
+ const out = {};
149
+ for (const k of Object.keys(value)) {
150
+ Object.defineProperty(out, k, {
151
+ value: toPlainValueTree(value[k]),
152
+ writable: true, enumerable: true, configurable: true,
153
+ });
135
154
  }
136
- else if (next === 'r') {
137
- out += '\r';
155
+ for (const s of Object.getOwnPropertySymbols(value)) {
156
+ Object.defineProperty(out, s, {
157
+ value: value[s], // internal channel: copied raw, not recursed
158
+ writable: true, enumerable: true, configurable: true,
159
+ });
138
160
  }
139
- else if (next === 'x') {
140
- const hex = s.slice(i + 1, i + 3);
141
- if (/^[0-9a-fA-F]{2}$/.test(hex)) {
142
- out += String.fromCharCode(parseInt(hex, 16));
143
- i += 2;
144
- }
145
- else {
146
- out += '\\x'; // not \xHH — keep literally
147
- }
161
+ return out;
162
+ }
163
+ return value;
164
+ }
165
+ /**
166
+ * ADR-3473 §8.1 (consequence 6, corrected post-#3881-review): `FAILSAFE_SCHEMA` still resolves
167
+ * anchors, aliases and merge keys — that is core YAML mechanics, not tag resolution, so no
168
+ * schema choice disables it. A hostile 7-line frontmatter (`&a [...]` fanned out through nested
169
+ * aliases) expands to tens of megabytes in a few milliseconds, and `.planning/` documents are
170
+ * user-authored, untrusted input. Corpus occurrences of anchors/aliases/merge keys today: zero,
171
+ * so refusing them costs nothing.
172
+ *
173
+ * This was originally a raw-text line regex, and it was bypassable: a quoted key (`"a": &x 1`),
174
+ * a flow mapping (`{b: &x 1, c: *x}`) or a flow sequence (`[&x "q", *x]`) all define/use an
175
+ * anchor while never matching the "bareword key, then `&`/`*`" line shape the regex checked —
176
+ * so the exact expansion this guard exists to stop went straight through unrefused. Detecting a
177
+ * YAML anchor with a regex is re-implementing a YAML parser in order to guard a YAML parser; the
178
+ * fix is to let the real parser report it instead of re-deriving anchor syntax by hand. js-yaml's
179
+ * `load` accepts a `listener` invoked once per parse event with the parser's internal `State`;
180
+ * `state.anchor` is non-null on every event belonging to an anchored node, in every spelling
181
+ * above (verified by execution against all four), so throwing the instant it is set aborts the
182
+ * parse before any alias expansion happens — the 303-byte quoted-key bomb refuses in ~1ms rather
183
+ * than expanding to ~35MB. A `<<: *base` merge key is refused too, because it can only ever
184
+ * reference a previously anchored node — the alias itself trips `state.anchor`. A merge key with
185
+ * NO alias (`<<: {b: 1}`) carries no anchor and is not separately refused: under
186
+ * `FAILSAFE_SCHEMA` (no `!!merge` type resolution) it never actually merges — it parses as an
187
+ * ordinary literal `"<<"` string key with a normal, non-expanding nested map — so it carries none
188
+ * of the resource-exhaustion risk this guard exists for.
189
+ */
190
+ /** Thrown from inside the `listener` callback below; never surfaced past `refuseAnchorsAndAliases`. */
191
+ class AnchorDetectedSignal extends Error {
192
+ }
193
+ function refuseAnchorsAndAliases(yaml) {
194
+ try {
195
+ (0, js_yaml_cjs_1.load)(yaml, {
196
+ ...YAML_LOAD_OPTS,
197
+ listener: (_event, state) => {
198
+ // Thrown FROM INSIDE the listener, not merely recorded and checked after `load`
199
+ // returns: js-yaml keeps parsing (and, for an alias, keeps EXPANDING) past a listener
200
+ // that only sets a flag, which reintroduces the exact resource-exhaustion window this
201
+ // guard exists to close. Throwing here aborts the parse immediately, before any
202
+ // expansion — the billion-laughs fixture refuses in ~1-2ms rather than building the
203
+ // ~35MB tree first and discarding it.
204
+ if (state.anchor !== null && state.anchor !== undefined)
205
+ throw new AnchorDetectedSignal();
206
+ },
207
+ });
208
+ }
209
+ catch (e) {
210
+ if (e instanceof AnchorDetectedSignal) {
211
+ throw new js_yaml_cjs_1.YAMLException('frontmatter: anchors, aliases and merge keys are refused (ADR-3473 §8.1)');
148
212
  }
149
- else {
150
- out += '\\' + next; // unrecognized escape — keep literally
213
+ // Any other failure (malformed YAML unrelated to anchors) is reported by the real parse
214
+ // in parseGuardedYamlRegion; this pre-pass only exists to refuse anchors/aliases early.
215
+ }
216
+ }
217
+ /**
218
+ * ADR-3473 §8.1 (consequence 7): js-yaml rejects a literal U+0000 unconditionally, under every
219
+ * schema. The fixture invariant (`null-byte-value.md`) is "preserve or normalize; never truncate
220
+ * silently", so the byte is swapped for a private-use sentinel before the parse and restored in
221
+ * every resulting string afterward — preserving the exact byte rather than normalizing it away.
222
+ *
223
+ * CORRECTED (post-#3881-review, finding 3): the round-trip was non-injective. `restoreNullBytesDeep`
224
+ * rewrites EVERY U+E000 in the parsed tree back to U+0000 — including one the document author
225
+ * legitimately wrote — so a document containing a literal U+E000 (with or without an actual NUL
226
+ * elsewhere) came back corrupted: its own U+E000 silently became a NUL. Rather than pick a
227
+ * "provably absent" sentinel (unprovable in general — any fixed codepoint can itself appear in
228
+ * user-authored input), `refuseIfSentinelPresent` makes the substitution provably reversible by
229
+ * refusing outright whenever the RAW region already contains U+E000, before any substitution
230
+ * happens — consistent with this module's existing refusal path (anchors/aliases/merge keys) for
231
+ * "cannot faithfully round-trip this input." Once refused, the sentinel is guaranteed absent from
232
+ * the input the escape/restore pair actually operates on, and the substitution is injective by
233
+ * construction.
234
+ */
235
+ const NULL_BYTE_SENTINEL = String.fromCharCode(0xE000);
236
+ function refuseIfSentinelPresent(yaml) {
237
+ if (yaml.includes(NULL_BYTE_SENTINEL)) {
238
+ throw new js_yaml_cjs_1.YAMLException('frontmatter: contains the reserved null-byte-escape sentinel U+E000 — refused rather than ' +
239
+ 'silently corrupted on restore (ADR-3473 §8.1)');
240
+ }
241
+ }
242
+ function escapeNullBytesForParse(yaml) {
243
+ return yaml.indexOf('\u0000') === -1 ? yaml : yaml.split('\u0000').join(NULL_BYTE_SENTINEL);
244
+ }
245
+ function restoreNullBytesDeep(value) {
246
+ if (typeof value === 'string') {
247
+ return value.includes(NULL_BYTE_SENTINEL) ? value.split(NULL_BYTE_SENTINEL).join('\u0000') : value;
248
+ }
249
+ if (Array.isArray(value))
250
+ return value.map(restoreNullBytesDeep);
251
+ if (value && typeof value === 'object') {
252
+ // Null-prototype (post-#3881-review, finding 3): an ordinary {} here silently DROPS a
253
+ // top-level key literally named __proto__ -- out['__proto__'] = v on a normal object
254
+ // invokes the inherited Object.prototype.__proto__ SETTER (reassigning the object's own
255
+ // prototype) instead of creating a data property, so key: __proto__ in a document
256
+ // vanishes from the parsed result with no error. Confirmed by execution: ---\n__proto__:
257
+ // hello\nz: 1\n---\n parsed to {z: "1"}, silently dropping the __proto__ key entirely.
258
+ // Object.create(null) has no such setter, so the assignment below is always a genuine
259
+ // own data property, for every key including __proto__ itself.
260
+ const out = Object.create(null);
261
+ for (const [k, v] of Object.entries(value)) {
262
+ const restoredKey = k.includes(NULL_BYTE_SENTINEL) ? k.split(NULL_BYTE_SENTINEL).join(String.fromCharCode(0)) : k;
263
+ out[restoredKey] = restoreNullBytesDeep(v);
151
264
  }
265
+ return out;
152
266
  }
153
- return out;
267
+ return value;
154
268
  }
155
269
  /**
156
- * Strip the quote delimiters off a parsed YAML scalar, un-escaping the interior
157
- * when the scalar is double-quoted (#3497 — the parse-side complement of
158
- * `escapeDoubleQuoted`). Single-quoted scalars keep the historical strip-only
159
- * behavior (the writer never emits them; `''` → `'` folding is out of scope).
160
- * A scalar wrapped in double quotes un-escapes; anything else keeps the exact
161
- * prior delimiter-strip behavior, including a stray unpaired boundary quote.
270
+ * ADR-3473 §8.1 (consequence 3): js-yaml resolves `- test: a b` (and the three other spellings
271
+ * of the same value — `"a b"`, `'a b'`, `{test: a b}`) to ONE tree shape, `[{test: "a b"}]` —
272
+ * unlike the legacy scanner, whose output was a function of the raw source line and therefore
273
+ * produced four different strings for those four spellings (ADR-3473 40-design.md §0.1). No
274
+ * adapter over a tree can recover a distinction the tree does not carry, so this renders a single
275
+ * canonical string per object-list item instead, keeping the existing value SHAPE (an array of
276
+ * strings) that `sliceTopLevelFrontmatterSegments`, the `[object Object]` guard and
277
+ * `noOpObjectListSetError` all depend on. Choosing structured (non-string) values is fork (b) —
278
+ * out of scope for this phase.
162
279
  */
163
- function parseQuotedScalar(value) {
164
- if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
165
- return unescapeDoubleQuoted(value.slice(1, -1));
280
+ function flattenScalarForDisplay(value) {
281
+ if (value === null || value === undefined)
282
+ return '';
283
+ if (Array.isArray(value))
284
+ return `[${value.map(flattenScalarForDisplay).join(', ')}]`;
285
+ if (typeof value === 'object')
286
+ return flattenObjectListItem(value);
287
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
288
+ return String(value);
289
+ }
290
+ function flattenObjectListItem(item) {
291
+ return Object.entries(item)
292
+ .map(([k, v]) => `${k}: ${flattenScalarForDisplay(v)}`)
293
+ .join(', ');
294
+ }
295
+ /**
296
+ * Recursively normalize a parsed js-yaml tree to this module's historical contract:
297
+ * - `null`/`undefined` in an object-value slot becomes `{}` (consequence 1 — matches the
298
+ * legacy scanner's empty-value handling exactly, so `reconstructFrontmatter` — which omits
299
+ * null-valued keys — still round-trips a bare `key:` line instead of deleting it);
300
+ * - `null`/`undefined` inside an array becomes `''` (arrays are always string[] in this
301
+ * module's contract);
302
+ * - a map or nested array found as an array ITEM is flattened to a canonical string
303
+ * (consequence 3);
304
+ * - every other scalar is already a string under `FAILSAFE_SCHEMA` and passes through.
305
+ */
306
+ function normalizeParsedValue(value, inArray) {
307
+ if (value === null || value === undefined)
308
+ return inArray ? '' : {};
309
+ if (Array.isArray(value)) {
310
+ return value.map((item) => {
311
+ if (item !== null && typeof item === 'object') {
312
+ return Array.isArray(item) ? flattenScalarForDisplay(item) : flattenObjectListItem(item);
313
+ }
314
+ return normalizeParsedValue(item, true);
315
+ });
316
+ }
317
+ if (typeof value === 'object') {
318
+ // Null-prototype (post-#3881-review, finding 3): a top-level YAML key named `constructor`,
319
+ // `__proto__`, `toString`, `valueOf` or `hasOwnProperty` is ordinary user-authored input
320
+ // (`.planning/` frontmatter), not an attack — but on an ordinary `{}` it resolves to the
321
+ // inherited Object.prototype member instead of `undefined`, which crashes downstream
322
+ // bracket reads (`commentChannel?.leading[key]`) and silently mis-answers `fm[field]`
323
+ // lookups in `cmdFrontmatterGet`. `Object.create(null)` severs the prototype chain so every
324
+ // reader of a parsed Frontmatter object gets a real bracket-read contract: present or
325
+ // `undefined`, never an inherited function.
326
+ const out = Object.create(null);
327
+ for (const [k, v] of Object.entries(value)) {
328
+ out[k] = normalizeParsedValue(v, false);
329
+ }
330
+ return out;
166
331
  }
167
- return value.replace(/^["']|["']$/g, '');
332
+ return value;
168
333
  }
169
334
  /**
170
- * Parse one already-delimited YAML region into a Frontmatter object.
171
- *
172
- * Extracted from `extractFrontmatter` (#1882) so the truncation probe below and the real
173
- * parse run the *same* parser. A second, simpler "does this look like YAML?" matcher would
174
- * be a parallel surface that drifts — exactly the generative-fix-divergence class.
335
+ * ADR-3473 §8.1 (consequence 5): the #3257 comment scan used to key off the legacy parser's own
336
+ * `[a-zA-Z0-9_-]+:` key regex — a Unicode key (e.g. `相:`) never matched it, so a comment above
337
+ * one silently attached to the WRONG key once js-yaml owns the real (Unicode-inclusive) key set.
338
+ * This attributes each pending column-0 comment block against js-yaml's own parsed top-level key
339
+ * list, in document order, by matching the literal key text at column 0 rather than re-deriving a
340
+ * key shape independently — so it can never disagree with what was actually parsed.
175
341
  */
176
- function parseYamlRegion(yaml) {
177
- const frontmatter = {};
342
+ function extractCommentChannel(yaml, orderedKeys) {
178
343
  const lines = (0, text_lines_cjs_1.splitLines)(yaml);
179
- // #3257: pending column-0 full-line comments, attached to the next top-level key.
180
- let pendingComments = [];
181
- let commentChannel;
182
- const stack = [{ obj: frontmatter, key: null, indent: -1 }];
344
+ // #3742: pending full-line comments carry their indentation so an INDENTED
345
+ // comment (` # note` above a nested key) can attach to the nested key that
346
+ // follows it — recorded under a dotted path key (`progress.total_phases`)
347
+ // that reconstructFrontmatter re-emits at the same nesting depth. Column-0
348
+ // comments keep the exact pre-#3742 behavior (top-level key attachment).
349
+ let pending = [];
350
+ let channel;
351
+ let keyIdx = 0;
352
+ // Stack of enclosing mapping keys with their indentation, for dotted-path
353
+ // construction on nested key lines. Only indented keys push here.
354
+ const pathStack = [];
355
+ const attach = (pathKey, comments) => {
356
+ if (!channel)
357
+ channel = { leading: Object.create(null), trailing: [] };
358
+ // Null-prototype `leading` (post-#3881-review, finding 3): the path key is
359
+ // derived from arbitrary user-authored YAML keys — `constructor`,
360
+ // `__proto__`, `toString`, `valueOf`, `hasOwnProperty` all round-trip
361
+ // through here. On an ordinary `{}` those resolve to inherited
362
+ // Object.prototype members; the null prototype makes every lookup an
363
+ // own-property-or-undefined read.
364
+ channel.leading[pathKey] = comments.map((c) => c.line);
365
+ };
183
366
  for (const line of lines) {
184
- // Skip empty lines
185
367
  if (line.trim() === '')
186
368
  continue;
187
- // #3257: capture column-0 full-line comments; attach them to the next top-level key.
188
- if (/^#/.test(line)) {
189
- pendingComments.push(line);
369
+ const commentMatch = /^(\s*)#/.exec(line);
370
+ if (commentMatch) {
371
+ pending.push({ indent: commentMatch[1].length, line });
190
372
  continue;
191
373
  }
192
- // Calculate indentation (number of leading spaces)
193
- const indentMatch = line.match(/^(\s*)/);
194
- const indent = indentMatch ? indentMatch[1].length : 0;
195
- // Pop stack back to appropriate level
196
- while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
197
- stack.pop();
198
- }
199
- const current = stack[stack.length - 1];
200
- // Check for key: value pattern
201
- const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
202
- if (keyMatch) {
203
- const key = keyMatch[2];
204
- // #3257: attach any pending comments to this (top-level) key.
205
- if (pendingComments.length) {
206
- if (!commentChannel)
207
- commentChannel = { leading: {}, trailing: [] };
208
- commentChannel.leading[key] = pendingComments;
209
- pendingComments = [];
210
- }
211
- const value = keyMatch[3].trim();
212
- if (value === '' || value === '[') {
213
- // Key with no value or opening bracket — could be nested object or array
214
- const newObj = value === '[' ? [] : {};
215
- current.obj[key] = newObj;
216
- current.key = null;
217
- // Push new context for potential nested content
218
- stack.push({ obj: newObj, key: null, indent });
219
- }
220
- else if (value.startsWith('[') && value.endsWith(']')) {
221
- // Inline array: key: [a, b, c] — quote-aware split (REG-04 fix)
222
- current.obj[key] = splitInlineArray(value.slice(1, -1));
223
- current.key = null;
374
+ // A list item (`- foo: bar`) is not a mapping key: its `- ` prefix would
375
+ // otherwise register as a key named `- foo` and corrupt the path stack
376
+ // (#3742 review). List items fall through to the pending-drop below.
377
+ const isListItem = /^\s*-\s/.test(line);
378
+ const keyLineMatch = isListItem
379
+ ? null
380
+ : /^(\s*)(?:"([^"]+)"|'([^']+)'|([^:\s][^:]*)):(?:\s|$)/.exec(line);
381
+ if (keyLineMatch) {
382
+ const indent = keyLineMatch[1].length;
383
+ const key = keyLineMatch[2] ?? keyLineMatch[3] ?? keyLineMatch[4];
384
+ if (indent === 0) {
385
+ // Top-level: keep the pre-#3742 orderedKeys walk — the comment
386
+ // attaches only to the next EXPECTED top-level key.
387
+ if (keyIdx < orderedKeys.length && key === orderedKeys[keyIdx]) {
388
+ const col0 = pending.filter((c) => c.indent === 0);
389
+ if (col0.length)
390
+ attach(key, col0);
391
+ keyIdx++;
392
+ // A top-level mapping key opens a nesting context for the indented
393
+ // keys that follow it (#3742 dotted-path attachment).
394
+ pathStack.length = 0;
395
+ pathStack.push({ indent: 0, key });
396
+ pending = [];
397
+ continue;
398
+ }
224
399
  }
225
400
  else {
226
- // Simple key: value
227
- current.obj[key] = parseQuotedScalar(value);
228
- current.key = null;
229
- }
230
- }
231
- else if (line.trim().startsWith('- ')) {
232
- // Array item
233
- const itemValue = parseQuotedScalar(line.trim().slice(2));
234
- // If current context is an empty object, convert to array
235
- if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) {
236
- // Find the key in parent that points to this object and convert it
237
- const parent = stack.length > 1 ? stack[stack.length - 2] : null;
238
- if (parent) {
239
- for (const k of Object.keys(parent.obj)) {
240
- if (parent.obj[k] === current.obj) {
241
- parent.obj[k] = [itemValue];
242
- current.obj = parent.obj[k];
243
- break;
244
- }
245
- }
401
+ // Nested key line: a pending comment at the SAME indentation attaches
402
+ // to this key under its dotted path. Deeper/misaligned pending
403
+ // comments were not leading this key — drop them, matching the
404
+ // top-level rule's "attach only when a key follows" discipline.
405
+ while (pathStack.length > 0 && pathStack[pathStack.length - 1].indent >= indent)
406
+ pathStack.pop();
407
+ const sameIndent = pending.filter((c) => c.indent === indent);
408
+ if (sameIndent.length && key.length > 0) {
409
+ attach([...pathStack.map((e) => e.key), key].join('.'), sameIndent);
246
410
  }
247
- }
248
- else if (Array.isArray(current.obj)) {
249
- current.obj.push(itemValue);
411
+ pathStack.push({ indent, key });
412
+ pending = [];
413
+ continue;
250
414
  }
251
415
  }
416
+ // A non-comment line that is not the next expected top-level key start: any comments
417
+ // pending before it were not actually leading a key (malformed/unusual input) — drop
418
+ // rather than misattach, matching the prior scan's "attach only when a key follows" shape.
419
+ pending = [];
420
+ }
421
+ const col0Trailing = pending.filter((c) => c.indent === 0);
422
+ if (col0Trailing.length) {
423
+ if (!channel)
424
+ channel = { leading: Object.create(null), trailing: [] };
425
+ channel.trailing = col0Trailing.map((c) => c.line);
426
+ }
427
+ return channel;
428
+ }
429
+ /**
430
+ * Parse one already-delimited YAML region into a Frontmatter object, via the vendored js-yaml
431
+ * (ADR-3473 §8.1). Throws (a `YAMLException`, or a plain `Error` from `refuseAnchorsAndAliases`)
432
+ * on anything js-yaml itself cannot parse or that this module refuses outright; callers decide
433
+ * whether to surface that as `unparseableResult()` or use it as a truncation signal.
434
+ *
435
+ * Renamed from `parseYamlRegion` (post-#3881-review, finding 2): §8.1 says `parseYamlRegion` is
436
+ * "deleted, not patched" — the hand-rolled line scanner that name identified IS gone, but the
437
+ * name itself survived on a new function with two callers (`extractFrontmatter` and
438
+ * `countKeysBeforeTruncation`) that could not be inlined without duplicating the
439
+ * refusal/null-byte/comment-channel glue below. Renaming closes that gap literally: nothing in
440
+ * this module still answers to the old hand-rolled scanner's name.
441
+ *
442
+ * RESTORED (fix #3881/#3881-followup-2, closes the #3705-shaped regression reported against
443
+ * `tests/smart-entry.unit.test.cjs:867`/`tests/smart-entry.property.test.cjs`): a prior revision
444
+ * of this function fell back, on a throw, to TWO hand-rolled re-implementations of YAML dialect —
445
+ * `repairAmbiguousColonValues` (below) for `key: value: extra`-shaped ambiguous colons, and
446
+ * `repairMalformedInlineArrays`/`splitLegacyInlineArrayItems` for a malformed/unclosed `[...]`.
447
+ * Both were deleted in 810e5e508 after a sweep of every tracked `*.md` file in this repo (910
448
+ * files) showed disabling each repair independently changed the parse result for zero documents.
449
+ *
450
+ * THAT SWEEP MEASURED THE WRONG POPULATION. `repairAmbiguousColonValues`'s one real dependent is
451
+ * not a document committed anywhere in this repo — it is user hand-edited STATE.md content that
452
+ * exists only on end users' machines and is pinned here by `tests/smart-entry.unit.test.cjs` (see
453
+ * its own in-file comment: "silently re-opened #2571/#2570 for hand-edited STATE.md that omits
454
+ * the template em dash"). The exact shape: `last_activity: 2026-06-08: reviewed the PR queue` — a
455
+ * colon-separated date+description a user typed by hand instead of the template's ` — ` (em dash)
456
+ * separator. js-yaml correctly refuses this as genuinely ambiguous YAML (a colon+space inside an
457
+ * unquoted scalar opens a nested mapping key); the old hand-rolled scanner tolerated it by taking
458
+ * everything after the first `key:` verbatim. A future sweep of tracked `.md` files will AGAIN
459
+ * show zero dependents for this exact reason — the dependent never lives in this repo's tree, it
460
+ * lives in a user's own `.planning/STATE.md`. Do not delete this again on that evidence alone;
461
+ * `tests/smart-entry.unit.test.cjs` and the frontmatter-level row in
462
+ * `tests/feat-3881-yaml-parser-consequences.test.cjs` are the actual proof the dependent exists.
463
+ *
464
+ * `repairMalformedInlineArrays`/`splitLegacyInlineArrayItems` stay deleted: their zero-dependents
465
+ * finding was reverified directly (frontmatter/smart-entry/verify/roadmap suites all pass without
466
+ * them) and, unlike the colon repair, nothing in the test suite or #2570/#2571 documents a
467
+ * hand-edited-STATE.md shape that depends on inline-array leniency.
468
+ */
469
+ function loadWithAmbiguousColonRepair(yaml) {
470
+ try {
471
+ return (0, js_yaml_cjs_1.load)(yaml, YAML_LOAD_OPTS);
252
472
  }
253
- // #3257: trailing comments (after the last key) + attach the channel if any comment was seen.
254
- if (pendingComments.length) {
255
- if (!commentChannel)
256
- commentChannel = { leading: {}, trailing: [] };
257
- commentChannel.trailing = pendingComments;
473
+ catch (e) {
474
+ const repaired = repairAmbiguousColonValues(yaml);
475
+ if (repaired === yaml)
476
+ throw e; // nothing to repair — surface the original error
477
+ try {
478
+ return (0, js_yaml_cjs_1.load)(repaired, YAML_LOAD_OPTS);
479
+ }
480
+ catch {
481
+ throw e; // repair didn't help (still invalid, possibly for another reason) — surface the original
482
+ }
258
483
  }
484
+ }
485
+ /**
486
+ * Double-quote (and escape) any column-0 `key: value` line whose (single-line) value contains an
487
+ * unquoted colon+whitespace or a trailing bare colon — the exact shape that reads as an ambiguous
488
+ * nested mapping key to a real YAML parser (`key: value: extra`, `key: value:`). Lines that are
489
+ * already safely quoted or open a flow/block collection (`"`, `'`, `[`, `{`) are left untouched
490
+ * (js-yaml already handles those); an empty value (`key:` alone, opening a nested block) is left
491
+ * untouched too, since repairing it would change a legitimate nested-map opener into a scalar.
492
+ * Only column-0 lines are considered — an indented line is either already-valid nested content or
493
+ * a genuinely different malformation this repair does not claim to fix.
494
+ *
495
+ * Restored (fix #3881/#3881-followup-2) — see `loadWithAmbiguousColonRepair`'s docblock for why a
496
+ * tracked-document sweep cannot see this function's one real dependent (hand-edited STATE.md,
497
+ * #2571/#2570, pinned by `tests/smart-entry.unit.test.cjs`).
498
+ */
499
+ function repairAmbiguousColonValues(yaml) {
500
+ return (0, text_lines_cjs_1.splitLines)(yaml)
501
+ .map((line) => {
502
+ const m = /^([A-Za-z0-9_][A-Za-z0-9_-]*):[ \t](.+)$/.exec(line);
503
+ if (!m)
504
+ return line;
505
+ const [, key, value] = m;
506
+ if (/^["'[{]/.test(value))
507
+ return line; // already safely quoted/collection-opened
508
+ if (!/:(?:[ \t]|$)/.test(value))
509
+ return line; // no ambiguous colon in the value
510
+ const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
511
+ return `${key}: "${escaped}"`;
512
+ })
513
+ .join('\n');
514
+ }
515
+ function parseGuardedYamlRegion(yaml) {
516
+ refuseAnchorsAndAliases(yaml);
517
+ refuseIfSentinelPresent(yaml);
518
+ const escaped = escapeNullBytesForParse(yaml);
519
+ const raw = loadWithAmbiguousColonRepair(escaped);
520
+ const normalized = normalizeParsedValue(raw, false);
521
+ const root = normalized && typeof normalized === 'object' && !Array.isArray(normalized)
522
+ ? normalized
523
+ : Object.create(null);
524
+ const restored = restoreNullBytesDeep(root);
525
+ const commentChannel = extractCommentChannel(yaml, Object.keys(restored));
259
526
  if (commentChannel) {
260
- frontmatter[FULL_LINE_COMMENTS] = commentChannel;
527
+ restored[FULL_LINE_COMMENTS] = commentChannel;
528
+ }
529
+ // Plain-prototype at the public-surface boundary (post-remote-runner-fix, #3881): see
530
+ // `toPlainValueTree`'s docblock. Everything above this line stays null-prototype internally.
531
+ return toPlainValueTree(restored);
532
+ }
533
+ /**
534
+ * ADR-3473 §8.1 (consequence 4): the #1882 truncation probe used to run the SAME parser
535
+ * (`parseGuardedYamlRegion`) over an unterminated region and count its keys — deliberately, so a second
536
+ * "does this look like YAML?" matcher could never drift from the real parser. js-yaml is stricter
537
+ * than the old scanner, though: the dominant real truncation shape (fence opened, well-formed
538
+ * keys, then the document body follows with no closing fence) is *invalid* YAML — a plain-text
539
+ * paragraph at column 0 right after a block mapping raises `bad indentation of a mapping entry`
540
+ * — so a naive "parse the whole region, count keys on success" port yields 0 keys and goes
541
+ * silent on exactly the case #1882 exists for.
542
+ *
543
+ * CORRECTED (post-#3881-review, finding 5): the original recovery — re-parse ONLY the exact
544
+ * prefix named by `e.mark.line` — regressed on every realistic truncation shape actually
545
+ * checked by execution: an unquoted-colon value (`title: a: b`) and a mis-indented sibling
546
+ * key (` plan: 2`) both raise "bad indentation of a mapping entry" ON the offending line
547
+ * itself, so `mark.line` names that SAME line and slicing BEFORE it drops the offending
548
+ * line's own key entirely — undercounting by exactly the key the probe most needs to see. An
549
+ * open flow collection (`list: [a, b`) raises its error on the (nonexistent) line AFTER the
550
+ * region's end, so the "marked prefix" still contains the same unterminated `[` and the retry
551
+ * parse fails too, falling through to a hard `0`. And a refused anchor/alias/merge key throws
552
+ * a mark-LESS `YAMLException` (`refuseAnchorsAndAliases`, thrown from inside the parse
553
+ * `listener` before js-yaml attaches position info) — the `e.mark` guard was never entered at
554
+ * all, hard `0` again, even though every other key in the region is perfectly valid.
555
+ *
556
+ * A first fix attempt tried shrinking the region line-by-line and re-parsing through the SAME
557
+ * real parser only — still no second matcher. It did NOT recover any of the three shapes above:
558
+ * the offending line in the first two IS the malformed token, at every possible prefix boundary
559
+ * that includes it, so no amount of shrinking ever makes it parse; the only prefix that ever
560
+ * succeeds is the one line BEFORE it, i.e. exactly the original bug's undercount. A parser-only
561
+ * strategy cannot report a key whose own line is genuinely invalid YAML — the same limitation
562
+ * that made the mark-based recovery fail in the first place. This means "the one real parser,
563
+ * never a hand-rolled matcher" is unreachable for the truncation-probe's actual job (a lower-
564
+ * bound COUNT of what looks like a key line, not a validity judgment) — this file already
565
+ * accepts an independent raw-text matcher for the adjacent question of "is this shaped like
566
+ * frontmatter" (`isFrontmatterShaped`, used by this probe's only caller), so `countKeysBeforeTruncation`
567
+ * takes the MAX of two lower bounds: how many keys the real parser can recover from the longest
568
+ * parseable line-prefix (still the primary signal — correct on the dominant fence-then-prose
569
+ * shape, and on any prefix boundary that genuinely IS the truncation point), and how many
570
+ * column-0 `key:`-shaped lines the raw text contains (recovers the three regressed shapes,
571
+ * whose offending key line the parser can never count). Neither alone is sufficient; together
572
+ * they never under-report a key that either signal can see.
573
+ */
574
+ function countKeysBeforeTruncation(region) {
575
+ const parsed = parsedKeyCount(region);
576
+ const textual = countTopLevelKeyShapedLines(region);
577
+ return Math.max(parsed, textual);
578
+ }
579
+ /** How many keys the real parser recovers from the longest line-prefix of `region` that parses
580
+ * cleanly (the whole region itself, when it parses outright). Bounded to at most `region`'s own
581
+ * line count re-parses — no worse than the whole-region parse already paid for on the caller's
582
+ * unterminated-region path, which is itself bounded by ordinary `.planning/` document sizes (the
583
+ * huge-bounded fixture parses successfully on the FIRST try and never reaches the shrink loop).
584
+ */
585
+ function parsedKeyCount(region) {
586
+ try {
587
+ return Object.keys(parseGuardedYamlRegion(region)).length;
588
+ }
589
+ catch {
590
+ const lines = (0, text_lines_cjs_1.splitLines)(region);
591
+ for (let n = lines.length - 1; n >= 1; n--) {
592
+ const prefix = lines.slice(0, n).join('\n');
593
+ if (prefix.trim() === '')
594
+ continue;
595
+ try {
596
+ return Object.keys(parseGuardedYamlRegion(prefix)).length;
597
+ }
598
+ catch {
599
+ continue;
600
+ }
601
+ }
602
+ return 0;
261
603
  }
262
- return frontmatter;
604
+ }
605
+ /** How many `key:`-shaped lines `region` textually contains — the raw-text lower bound that
606
+ * recovers a key whose OWN line is malformed YAML (an unquoted colon in the value, or a
607
+ * mis-indented sibling that reads as an "indented continuation" to the real parser), which no
608
+ * re-parse of any prefix can ever count (see `countKeysBeforeTruncation`'s docblock). Matches
609
+ * ANY indentation, not only column 0 — the mis-indented-sibling shape is, by construction, a key
610
+ * the author intended as top-level but indented by mistake; requiring column 0 here would just
611
+ * relocate the exact undercount finding 5 reports. Deliberately the SAME key-shape pattern this
612
+ * file already uses for the sibling shape check (`isFrontmatterShaped`'s first branch) — ASCII-
613
+ * only is an accepted, precedented scope limit for this raw-text heuristic, not a new one.
614
+ */
615
+ function countTopLevelKeyShapedLines(region) {
616
+ return (0, text_lines_cjs_1.splitLines)(region).filter((line) => /^\s*[A-Za-z0-9_-]+:/.test(line)).length;
263
617
  }
264
618
  /**
265
619
  * Extract frontmatter from a document.
@@ -273,8 +627,9 @@ function parseYamlRegion(yaml) {
273
627
  * The discriminator is the reason this is not simply "opened but never closed". A Markdown
274
628
  * document whose first line is a thematic break (`---`) takes that exact branch, so flagging
275
629
  * on the missing fence alone reports corruption on perfectly good Markdown. Instead the
276
- * unterminated region is run through this module's own parser and reported only when it
277
- * yields **two or more** keys.
630
+ * unterminated region's key count (see `countKeysBeforeTruncation`) is reported only when it
631
+ * yields **two or more** keys AND the region is uniformly frontmatter-shaped raw text
632
+ * (`isFrontmatterShaped`).
278
633
  *
279
634
  * Two, not one, and the extra key is doing real work. A single `key: value` line is genuinely
280
635
  * ambiguous: `---` followed by `Note: this is a paragraph.` — or `Author:`, `TODO:`, `See:` —
@@ -288,13 +643,17 @@ function parseYamlRegion(yaml) {
288
643
  * (STATE.md, PLAN.md, ROADMAP.md, SUMMARY.md, agent/command docs) carries two or more
289
644
  * frontmatter keys, so the realistic interruption window stays covered.
290
645
  *
646
+ * A closed region that js-yaml itself cannot parse (malformed YAML, or a refused
647
+ * anchor/alias/merge key — ADR-3473 §8.1 consequence 6) returns `{}` carrying the
648
+ * `FRONTMATTER_UNPARSEABLE` Symbol (consequence 2) rather than a bare, indistinguishable `{}`.
649
+ *
291
650
  * @param content Raw document text.
292
651
  * @param sourcePath Optional resolved path, used to name the file in the diagnostic and to
293
652
  * key its deduplication. Optional because this function has 50-odd call sites and several
294
653
  * hold only an in-memory string; those dedup on a content digest instead.
295
654
  */
296
655
  function extractFrontmatter(content, sourcePath) {
297
- // #2977: tolerate a single leading UTF-8 BOM (\uFEFF), which Windows tooling
656
+ // #2977: tolerate a single leading UTF-8 BOM (U+FEFF), which Windows tooling
298
657
  // (PowerShell `>`/`Out-File` on PS 5.1, several editors) writes by default. Without this
299
658
  // strip, the byte-0 `startsWith('---')` fence check below fails on the BOM and the whole
300
659
  // parse collapses to {} — every frontmatter field silently disappears, and the engine
@@ -314,8 +673,8 @@ function extractFrontmatter(content, sourcePath) {
314
673
  const closingLineStart = content.indexOf('\n---', headerEnd);
315
674
  if (closingLineStart === -1) {
316
675
  const region = content.slice(headerEnd);
317
- const probe = parseYamlRegion(region);
318
- if (Object.keys(probe).length >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(region)) {
676
+ const keyCount = countKeysBeforeTruncation(region);
677
+ if (keyCount >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(region)) {
319
678
  warnUnusableInput({
320
679
  reason: UNUSABLE_REASON.FRONTMATTER_UNTERMINATED,
321
680
  source: sourcePath,
@@ -325,26 +684,47 @@ function extractFrontmatter(content, sourcePath) {
325
684
  return {};
326
685
  }
327
686
  const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
328
- return parseYamlRegion(content.slice(headerEnd, yamlEnd));
687
+ const region = content.slice(headerEnd, yamlEnd);
688
+ try {
689
+ return parseGuardedYamlRegion(region);
690
+ }
691
+ catch {
692
+ return unparseableResult();
693
+ }
329
694
  }
330
695
  /**
331
- * Escape a string for emission inside a YAML double-quoted scalar (#1779).
332
- * Backslash must be escaped first so the backslashes added for embedded quotes
333
- * (and control chars) are not themselves doubled. Without this, a value
334
- * carrying an indicator (`:`/`#`) that also contains a literal `"` serializes
335
- * to invalid YAML, e.g. `upstream: "https://x (Tom; "Git. Ship. Done")"`. A
336
- * literal newline/tab/control char inside the quotes likewise breaks (or
337
- * silently alters) the scalar, so those are escaped to their YAML forms too.
696
+ * Escape a string for emission inside a YAML double-quoted scalar (#1779). ADR-3473 §8.1
697
+ * (#3881): routed through the vendored js-yaml's `dump()` (forced double-quoted style) rather
698
+ * than a hand-rolled character-class replace chain, so the writer shares the same escaping
699
+ * engine the reader now uses. js-yaml emits control-char escapes as uppercase hex (`\x1F`);
700
+ * this repo has emitted lowercase (`\x1f`) since #1779, so the hex digits are lowercased after
701
+ * dump to keep serialized output byte-stable across the migration FOR THE CASES the old
702
+ * hand-rolled chain actually covered (backslash/quote/newline/tab/CR, and every C0 control
703
+ * plus DEL via \xHH). It is NOT byte-stable end-to-end (post-#3881-review, finding 4,
704
+ * verified by execution): the old chain left BEL/NUL unescaped-as-hex (`\x07`/`\x00`) and
705
+ * left NEL/NBSP/LINE SEPARATOR/PARAGRAPH SEPARATOR/BOM as raw literal bytes entirely (they
706
+ * fall outside its `\u0000-\u001f\u007f` class); js-yaml's dump instead emits the YAML-named
707
+ * escapes `\a`/`\0`/`\N`/`\_`/`\L`/`\P` for those six, and a `\uXXXX` escape for the BOM and
708
+ * any lone UTF-16 surrogate. The serialized TEXT differs from pre-migration output for these
709
+ * codepoints, but the round-trip is equivalence-preserving, not merely byte-preserving: every
710
+ * one of `\a`/`\0`/`\N`/`\_`/`\L`/`\P`/`\uXXXX` is a YAML double-quoted-scalar escape that
711
+ * resolves back to the EXACT source codepoint on re-parse (confirmed by execution — see
712
+ * `tests/frontmatter.unit.test.cjs`'s pinned cases). scalarNeedsDoubleQuoting was extended in
713
+ * the same review round to also route lone surrogates through this quoted+escaped path,
714
+ * because they were previously emitted bare and produced genuinely UNPARSEABLE YAML.
715
+ *
716
+ * Renamed from `escapeDoubleQuoted` (post-#3881-review, finding 2): §8.1 says this function is
717
+ * "deleted, not patched" — like `parseGuardedYamlRegion`, the hand-rolled character-class chain
718
+ * is gone, but unlike that function this one HAS no other caller inside this module to hide the
719
+ * old name's survival behind, so the rename is a straight mechanical propagation to its three
720
+ * call sites (`reconstructFrontmatter` here, plus `commands.cts` and
721
+ * `runtime-artifact-conversion.cts`, both updated in this change — no ADR amendment needed).
338
722
  */
339
- function escapeDoubleQuoted(s) {
340
- return s
341
- .replace(/\\/g, '\\\\')
342
- .replace(/"/g, '\\"')
343
- .replace(/\n/g, '\\n')
344
- .replace(/\t/g, '\\t')
345
- .replace(/\r/g, '\\r')
346
- // Remaining C0 controls + DEL → \xHH (a valid YAML double-quoted escape).
347
- .replace(/[\u0000-\u001f\u007f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
723
+ function escapeDoubleQuotedScalar(s) {
724
+ const dumped = (0, js_yaml_cjs_1.dump)(s, { schema: js_yaml_cjs_1.FAILSAFE_SCHEMA, forceQuotes: true, quotingType: '"', lineWidth: -1 });
725
+ const withoutTrailingNewline = dumped.endsWith('\n') ? dumped.slice(0, -1) : dumped;
726
+ const interior = withoutTrailingNewline.slice(1, -1); // strip the outer double-quote pair
727
+ return interior.replace(/\\x([0-9A-Fa-f]{2})/g, (_m, hex) => `\\x${hex.toLowerCase()}`);
348
728
  }
349
729
  /**
350
730
  * A plain (unquoted) scalar that would mis-parse or round-trip lossily when
@@ -353,7 +733,7 @@ function escapeDoubleQuoted(s) {
353
733
  * or control char, a leading YAML indicator (quote, `&`/`*`/`!` anchor/alias/
354
734
  * tag, `|`/`>` block scalar, flow `[]{},`, `#`, reserved `%`/`@`/backtick, or
355
735
  * `-`/`?`/`:` before a space), or leading/trailing whitespace. This helper is
356
- * the correctness complement of `escapeDoubleQuoted`: it broadens the *trigger*
736
+ * the correctness complement of `escapeDoubleQuotedScalar`: it broadens the *trigger*
357
737
  * for quoting without broadening the lossy object-list handling deferred to
358
738
  * #1572/#1660.
359
739
  */
@@ -368,11 +748,77 @@ function scalarNeedsDoubleQuoting(s) {
368
748
  // `-` `?` `:` only start a plain scalar safely when NOT followed by a space.
369
749
  if (/^[-?:](\s|$)/.test(s))
370
750
  return true;
751
+ // Post-#3881-review, finding 4 (found while verifying escapeDoubleQuotedScalar's byte-
752
+ // stability claim): an unpaired UTF-16 surrogate (U+D800-U+DFFF) is outside YAML's
753
+ // printable-character set, so js-yaml's loader refuses it ("the stream contains
754
+ // non-printable characters") the instant it is emitted bare. No other trigger above
755
+ // catches it -- not whitespace, not a C0/C1 control, not a leading indicator -- so a bare
756
+ // emission was genuinely invalid YAML: reconstructFrontmatter produced text
757
+ // extractFrontmatter could not re-parse, silently collapsing to {} via
758
+ // unparseableResult(). Confirmed by execution: reconstructFrontmatter({weird: '\uD800'})
759
+ // round-tripped to undefined before this fix. Routing it through the quoted +
760
+ // escapeDoubleQuotedScalar path (which already emits the \uD800 escape) fixes the
761
+ // round-trip.
762
+ if (/[\uD800-\uDFFF]/.test(s))
763
+ return true;
764
+ return false;
765
+ }
766
+ /**
767
+ * #3706 — Does this value need double-quoting when written as an AGENT
768
+ * frontmatter scalar (`model:`, `variant:`)?
769
+ *
770
+ * Deliberately a superset of `scalarNeedsDoubleQuoting` rather than a second,
771
+ * competing predicate: that one answers "can this open a plain scalar safely",
772
+ * which is necessary but not sufficient for a value that must ROUND-TRIP as the
773
+ * exact string it went in as. Keeping both here is the point — they are two
774
+ * answers to one question and drift the moment they live apart.
775
+ *
776
+ * The extra clauses, each an observed mis-parse rather than a precaution:
777
+ *
778
+ * - a non-alphanumeric first character. `scalarNeedsDoubleQuoting` rejects the
779
+ * indicators that cannot OPEN a scalar, but YAML still resolves plenty of
780
+ * values that open legally: `~` and `.inf`/`.nan` become null and floats,
781
+ * and a leading sign or dot (`+1`, `-0`, `.5`) becomes a number. Requiring
782
+ * alphanumeric-first covers that whole family at once, and costs nothing:
783
+ * every real model ID and effort level starts alphanumeric.
784
+ * - a trailing `:` — `model: foo:` is read as a nested mapping key and fails
785
+ * the whole frontmatter with "bad indentation of a mapping entry".
786
+ * - a boolean/null word — YAML 1.1 readers resolve `no`/`y`/`off`/`null` to
787
+ * non-strings, so a variant named `no` arrives as `false`.
788
+ * - a numeric-looking value, including the YAML 1.1 sexagesimal form: `12:30`
789
+ * resolves to the integer 750, and `:` is legal mid-identifier here.
790
+ * - a date. `2026-08-25` starts alphanumeric and survives every clause above,
791
+ * yet YAML resolves it to a Date object rather than a string.
792
+ */
793
+ const YAML_WORD_SCALAR_RE = /^(?:y|n|yes|no|true|false|on|off|null)$/i;
794
+ const YAML_NUMERIC_RE = /^(?:\d[\d_]*(?:\.[\d_]*)?(?:[eE][-+]?\d+)?|0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?::[0-5]?\d)+(?:\.[\d_]*)?)$/;
795
+ // YAML 1.1 timestamp: a bare ymd, optionally followed by a time part.
796
+ const YAML_TIMESTAMP_RE = /^\d{4}-\d{1,2}-\d{1,2}(?:[Tt ].*)?$/;
797
+ function agentScalarNeedsDoubleQuoting(s) {
798
+ if (scalarNeedsDoubleQuoting(s))
799
+ return true;
800
+ if (!/^[A-Za-z0-9]/.test(s))
801
+ return true;
802
+ // A plain scalar ENDS at `: ` or ` #` wherever they appear — the base
803
+ // predicate only inspects the first character, because its question is
804
+ // whether the scalar can legally open. `a: b` makes the line a nested
805
+ // mapping (a parse error at this indent) and `a #b` silently truncates to
806
+ // `a`. Both were found by the round-trip property, not by inspection.
807
+ if (/:\s/.test(s) || /\s#/.test(s))
808
+ return true;
809
+ if (s.endsWith(':'))
810
+ return true;
811
+ if (YAML_WORD_SCALAR_RE.test(s))
812
+ return true;
813
+ if (YAML_NUMERIC_RE.test(s))
814
+ return true;
815
+ if (YAML_TIMESTAMP_RE.test(s))
816
+ return true;
371
817
  return false;
372
818
  }
373
819
  function reconstructFrontmatter(obj) {
374
820
  const lines = [];
375
- // #3257: read the full-line-comment channel (set by parseYamlRegion when comments
821
+ // #3257: read the full-line-comment channel (set by parseGuardedYamlRegion when comments
376
822
  // were present). Object.entries skips the Symbol key, so the data loop is unchanged.
377
823
  const commentChannel = obj[FULL_LINE_COMMENTS];
378
824
  for (const [key, value] of Object.entries(obj)) {
@@ -393,7 +839,7 @@ function reconstructFrontmatter(obj) {
393
839
  else {
394
840
  lines.push(`${key}:`);
395
841
  for (const item of value) {
396
- lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuoted(item)}"` : item}`);
842
+ lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuotedScalar(item)}"` : item}`);
397
843
  }
398
844
  }
399
845
  }
@@ -402,6 +848,12 @@ function reconstructFrontmatter(obj) {
402
848
  for (const [subkey, subval] of Object.entries(value)) {
403
849
  if (subval === null || subval === undefined)
404
850
  continue;
851
+ // #3742: re-emit a nested key's leading full-line comments (channel
852
+ // path key `parent.subkey`) at the subkey's own indentation.
853
+ const nestedLeading = commentChannel?.leading[`${key}.${subkey}`];
854
+ if (nestedLeading)
855
+ for (const c of nestedLeading)
856
+ lines.push(` ${c.trimStart()}`);
405
857
  if (Array.isArray(subval)) {
406
858
  if (subval.length === 0) {
407
859
  lines.push(` ${subkey}: []`);
@@ -412,7 +864,7 @@ function reconstructFrontmatter(obj) {
412
864
  else {
413
865
  lines.push(` ${subkey}:`);
414
866
  for (const item of subval) {
415
- lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuoted(item)}"` : item}`);
867
+ lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuotedScalar(item)}"` : item}`);
416
868
  }
417
869
  }
418
870
  }
@@ -421,6 +873,12 @@ function reconstructFrontmatter(obj) {
421
873
  for (const [subsubkey, subsubval] of Object.entries(subval)) {
422
874
  if (subsubval === null || subsubval === undefined)
423
875
  continue;
876
+ // #3742: same nested-comment re-emission one level deeper
877
+ // (`parent.sub.subsub`).
878
+ const deepLeading = commentChannel?.leading[`${key}.${subkey}.${subsubkey}`];
879
+ if (deepLeading)
880
+ for (const c of deepLeading)
881
+ lines.push(` ${c.trimStart()}`);
424
882
  if (Array.isArray(subsubval)) {
425
883
  if (subsubval.length === 0) {
426
884
  lines.push(` ${subsubkey}: []`);
@@ -441,14 +899,14 @@ function reconstructFrontmatter(obj) {
441
899
  else {
442
900
  // eslint-disable-next-line @typescript-eslint/no-base-to-string
443
901
  const sv = String(subval);
444
- lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) ? `"${escapeDoubleQuoted(sv)}"` : sv}`);
902
+ lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) ? `"${escapeDoubleQuotedScalar(sv)}"` : sv}`);
445
903
  }
446
904
  }
447
905
  }
448
906
  else {
449
907
  const sv = String(value);
450
908
  if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{') || scalarNeedsDoubleQuoting(sv)) {
451
- lines.push(`${key}: "${escapeDoubleQuoted(sv)}"`);
909
+ lines.push(`${key}: "${escapeDoubleQuotedScalar(sv)}"`);
452
910
  }
453
911
  else {
454
912
  lines.push(`${key}: ${sv}`);
@@ -468,20 +926,57 @@ function reconstructFrontmatter(obj) {
468
926
  * it — AC5). No-op when `source` carries no channel. Consumers that rebuild their
469
927
  * target object fresh (syncStateFrontmatter builds derivedFm via buildStateFrontmatter
470
928
  * and copies keys with Object.keys, which skips the Symbol) MUST call this before
471
- * reconstructFrontmatter, or the channel parseYamlRegion attached to the extracted
929
+ * reconstructFrontmatter, or the channel parseGuardedYamlRegion attached to the extracted
472
930
  * source is lost.
473
931
  */
474
932
  function propagateCommentChannel(source, target) {
475
933
  const channel = source[FULL_LINE_COMMENTS];
476
934
  if (!channel)
477
935
  return;
478
- const filtered = { leading: {}, trailing: channel.trailing };
936
+ // #3742: two changes, both about a target that is a PARTIAL rebuild.
937
+ //
938
+ // (a) Root-segment membership: a comment keyed by a dotted path
939
+ // (`progress.total_plans`) survives while its root section survives —
940
+ // requiring the full path to resolve inside `target` would drop every
941
+ // nested comment the moment the rebuild reconstructed the section
942
+ // object (a fresh object with the same leaf keys still matches at
943
+ // EMIT time; membership is about the section existing at all).
944
+ // (b) Merge, not clobber: `target` may already carry its own channel
945
+ // (extracted from content that kept some comments). Target entries win
946
+ // for the same key; source entries fill the gaps; trailing lists
947
+ // concatenate (source first, mirroring document order when the source
948
+ // is the earlier snapshot).
949
+ const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
950
+ const rootAlive = (k) => {
951
+ const root = k.split('.')[0];
952
+ return hasOwn(target, root);
953
+ };
954
+ // Null-prototype `leading` (post-#3881-review, finding 3) — same rationale as
955
+ // `extractCommentChannel`. `target` may be a plain `{}` built by a caller outside this
956
+ // module (e.g. `buildStateFrontmatter`), so membership is checked via
957
+ // `hasOwnProperty`, not the `in` operator: `in` walks target's OWN prototype chain too,
958
+ // and a target key named `constructor`/`toString`/etc. would otherwise read as "present"
959
+ // even when it was never actually set.
960
+ const existingChannel = target[FULL_LINE_COMMENTS];
961
+ // Trailing: when the target already carries a channel, its trailing list is
962
+ // the SAME comments re-parsed from content this lineage already emitted —
963
+ // concatenating would duplicate them on every write (unbounded growth,
964
+ // #3742 review). Take the target's list; only a channel-less target
965
+ // (a fresh rebuild, e.g. buildStateFrontmatter output) inherits the
966
+ // source's trailing comments.
967
+ const merged = {
968
+ leading: Object.create(null),
969
+ trailing: existingChannel ? existingChannel.trailing : channel.trailing,
970
+ };
971
+ for (const [key, comments] of Object.entries(existingChannel?.leading ?? {})) {
972
+ merged.leading[key] = comments;
973
+ }
479
974
  for (const [key, comments] of Object.entries(channel.leading)) {
480
- if (key in target)
481
- filtered.leading[key] = comments;
975
+ if (!hasOwn(merged.leading, key) && rootAlive(key))
976
+ merged.leading[key] = comments;
482
977
  }
483
- if (filtered.trailing.length || Object.keys(filtered.leading).length) {
484
- target[FULL_LINE_COMMENTS] = filtered;
978
+ if (merged.trailing.length || Object.keys(merged.leading).length) {
979
+ target[FULL_LINE_COMMENTS] = merged;
485
980
  }
486
981
  }
487
982
  /**
@@ -631,126 +1126,87 @@ function frontmatterDeepEqual(a, b) {
631
1126
  }
632
1127
  return false;
633
1128
  }
1129
+ /**
1130
+ * ADR-3473 §8.1 (#3881): the legacy `- key: value` same-line-with-dash capture never trimmed
1131
+ * or number-coerced its value (`current[kvMatch[1]] = kvMatch[2]` verbatim), while every
1132
+ * CONTINUATION line (a further-indented sibling key under the same list item) both trimmed
1133
+ * (`kvMatch[2].trim()` — #1905/#1154, a quoted `"backstop "` must not silently stop matching
1134
+ * the literal `backstop` marker) and number-coerced (`/^\d+$/.test(val) ? parseInt(val, 10) :
1135
+ * val`). That distinction was purely a byproduct of the hand-rolled line scanner's own
1136
+ * position tracking — real YAML has no such notion; `path: x` on the dash's own line and
1137
+ * `count: 1` one line below it are the same kind of mapping entry. `tests/frontmatter.test.cjs`
1138
+ * ("trims a continuation-KV value…") pins the trimming behavior, so it is reproduced here by
1139
+ * treating an object item's FIRST own key (source order, matching the dash line) as untouched
1140
+ * and every subsequent key as "continuation": trimmed, and coerced to a number when (after
1141
+ * trimming) it is all-digits — the exact `/^\d+$/` shape the legacy scanner recognized, never a
1142
+ * broader YAML-native numeric resolution (which would also promote floats/octal/booleans the
1143
+ * legacy scanner left as strings).
1144
+ */
1145
+ function coerceMustHavesValue(value, isContinuation) {
1146
+ if (typeof value !== 'string')
1147
+ return value; // arrays/nested maps pass through untouched
1148
+ if (!isContinuation)
1149
+ return value;
1150
+ const trimmed = value.trim();
1151
+ return /^\d+$/.test(trimmed) ? parseInt(trimmed, 10) : trimmed;
1152
+ }
1153
+ /** Normalize one must_haves list item to the legacy contract: a plain scalar item stays a
1154
+ * string; an object item gets `coerceMustHavesValue`'s same-line/continuation treatment
1155
+ * (see that function's docblock).
1156
+ */
1157
+ function normalizeMustHavesItem(item) {
1158
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
1159
+ return item;
1160
+ const out = {};
1161
+ Object.entries(item).forEach(([k, v], idx) => {
1162
+ out[k] = coerceMustHavesValue(v, idx > 0);
1163
+ });
1164
+ return out;
1165
+ }
1166
+ /**
1167
+ * Extract a specific block from `must_haves` in frontmatter YAML (e.g. `must_haves.truths`,
1168
+ * `must_haves.artifacts`, `must_haves.key_links`) — via the same vendored js-yaml parser the
1169
+ * rest of this module uses (ADR-3473 §8.1 / #3881), rather than the hand-rolled indentation
1170
+ * scanner this replaces.
1171
+ *
1172
+ * Deliberately NOT routed through `parseGuardedYamlRegion`: that function flattens an
1173
+ * object-shaped list ITEM to a single canonical string (consequence 3), which is the correct
1174
+ * contract for the top-level Frontmatter value shape but would collapse `must_haves.artifacts`'s
1175
+ * `{path, provides, ...}` items into unusable strings. This parses the region independently,
1176
+ * under the same `FAILSAFE_SCHEMA` + `json: true` options (every scalar a string, duplicate
1177
+ * keys last-wins) and the same anchor/alias/merge-key refusal (`refuseAnchorsAndAliases`) —
1178
+ * `.planning/` must_haves blocks are untrusted input exactly like the rest of frontmatter.
1179
+ */
634
1180
  function parseMustHavesBlock(content, blockName) {
635
- // Extract a specific block from must_haves in raw frontmatter YAML
636
- // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
637
1181
  const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
638
1182
  if (!fmMatch)
639
1183
  return [];
640
1184
  const yaml = fmMatch[1];
641
- const yamlLines = (0, text_lines_cjs_1.splitLines)(yaml);
642
- // Find must_haves: first to detect its indentation level. Split-then-scan
643
- // (rather than a whole-string /m match) so a CRLF or blank-line boundary
644
- // can never be absorbed into the indent capture (#3360) — see
645
- // .gsd/phase/chore-3413-text-lines-seam/40-design.md.
646
- const mustHavesLinePattern = /^(\s*)must_haves:\s*$/;
647
- const mustHavesLineIndex = yamlLines.findIndex((line) => mustHavesLinePattern.test(line));
648
- if (mustHavesLineIndex === -1)
1185
+ let parsed;
1186
+ try {
1187
+ refuseAnchorsAndAliases(yaml);
1188
+ parsed = (0, js_yaml_cjs_1.load)(yaml, YAML_LOAD_OPTS);
1189
+ }
1190
+ catch {
649
1191
  return [];
650
- const mustHavesIndent = yamlLines[mustHavesLineIndex].match(/^(\s*)/)[1].length;
651
- // Find the block (e.g., "truths:", "artifacts:", "key_links:") under must_haves
652
- // It must be indented more than must_haves but we detect the actual indent dynamically
653
- const blockLinePattern = new RegExp(`^(\\s+)${blockName}:\\s*$`);
654
- const blockLineIndex = yamlLines.findIndex((line) => blockLinePattern.test(line));
655
- if (blockLineIndex === -1)
1192
+ }
1193
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
656
1194
  return [];
657
- const blockIndent = yamlLines[blockLineIndex].match(/^(\s*)/)[1].length;
658
- // The block must be nested under must_haves (more indented)
659
- if (blockIndent <= mustHavesIndent)
1195
+ const mustHaves = parsed.must_haves;
1196
+ if (!mustHaves || typeof mustHaves !== 'object' || Array.isArray(mustHaves))
660
1197
  return [];
661
- const blockLines = yamlLines.slice(blockLineIndex + 1); // skip the header line
662
- // List items are indented one level deeper than blockIndent
663
- // Continuation KVs are indented one level deeper than list items
664
- const items = [];
665
- let current = null;
666
- let listItemIndent = -1; // detected from first "- " line
667
- for (const line of blockLines) {
668
- // Skip empty lines
669
- if (line.trim() === '')
670
- continue;
671
- const indentMatch = line.match(/^(\s*)/);
672
- const indent = indentMatch ? indentMatch[1].length : 0;
673
- // Stop at same or lower indent level than the block header
674
- if (indent <= blockIndent && line.trim() !== '')
675
- break;
676
- const trimmed = line.trim();
677
- if (trimmed.startsWith('- ')) {
678
- // Detect list item indent from the first occurrence
679
- if (listItemIndent === -1)
680
- listItemIndent = indent;
681
- // Only treat as a top-level list item if at the expected indent
682
- if (indent === listItemIndent) {
683
- if (current)
684
- items.push(current);
685
- const afterDash = trimmed.slice(2);
686
- const trimmedAfterDash = afterDash.trim();
687
- // Check if it's a fully-quoted string (may contain ':' inside the quotes)
688
- if ((trimmedAfterDash.startsWith('"') && trimmedAfterDash.endsWith('"')) ||
689
- (trimmedAfterDash.startsWith("'") && trimmedAfterDash.endsWith("'"))) {
690
- current = trimmedAfterDash.slice(1, -1);
691
- // Check if it's a simple string item (no colon means not a key-value)
692
- }
693
- else if (!afterDash.includes(':')) {
694
- current = afterDash.replace(/^["']|["']$/g, '');
695
- }
696
- else {
697
- // Key-value on same line as dash: "- path: value"
698
- // YAML KV always has at least one space after the colon: "key: value"
699
- // Requiring \s+ rejects "Class::Method" and "db:seed" (no space after colon)
700
- const kvMatch = afterDash.match(/^(\w+):\s+"?([^"]*)"?\s*$/);
701
- if (kvMatch) {
702
- current = {};
703
- (current)[kvMatch[1]] = kvMatch[2];
704
- }
705
- else {
706
- // Looks like KV but doesn't match — treat as plain string (#2757)
707
- current = afterDash.replace(/^["']|["']$/g, '');
708
- }
709
- }
710
- continue;
711
- }
712
- }
713
- if (current && typeof current === 'object' && indent > listItemIndent) {
714
- // Continuation key-value or nested array item
715
- if (trimmed.startsWith('- ')) {
716
- // Array item under a key
717
- const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, '');
718
- const keys = Object.keys(current);
719
- const lastKey = keys[keys.length - 1];
720
- if (lastKey && !Array.isArray((current)[lastKey])) {
721
- const existing = (current)[lastKey];
722
- (current)[lastKey] = existing ? [existing] : [];
723
- }
724
- if (lastKey)
725
- (current)[lastKey].push(arrVal);
726
- }
727
- else {
728
- const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/);
729
- if (kvMatch) {
730
- // Trim: a quoted value like `"backstop "` captures the inner trailing space in group 2.
731
- // Left untrimmed, a hand-authored `must_haves` marker degrades (a `backstop` truth silently
732
- // grades green instead of abstaining — #1905, the #1154 false-pass; also the sibling
733
- // check_target/violationFixture path). Whitespace is never semantic in a scalar KV value.
734
- const val = kvMatch[2].trim();
735
- // Try to parse as number
736
- (current)[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
737
- }
738
- }
739
- }
740
- }
741
- if (current)
742
- items.push(current);
743
- // Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue.
744
- // This is a critical diagnostic: empty must_haves causes verification to silently degrade
745
- // to Option C (LLM-derived truths) instead of checking documented contracts.
746
- if (items.length === 0 && blockLines.length > 0) {
747
- const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length;
748
- if (nonEmptyLines > 0) {
749
- process.stderr.write(`[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` +
1198
+ const block = mustHaves[blockName];
1199
+ if (!Array.isArray(block)) {
1200
+ // Warn when the block exists but isn't a usable list — likely a YAML formatting issue.
1201
+ // This is a critical diagnostic: empty must_haves causes verification to silently degrade
1202
+ // to Option C (LLM-derived truths) instead of checking documented contracts.
1203
+ if (block !== undefined && block !== null) {
1204
+ process.stderr.write(`[gsd-tools] WARNING: must_haves.${blockName} block has content but parsed 0 items. ` +
750
1205
  `Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n`);
751
1206
  }
1207
+ return [];
752
1208
  }
753
- return items;
1209
+ return block.map(normalizeMustHavesItem);
754
1210
  }
755
1211
  // ─── Frontmatter CRUD commands ────────────────────────────────────────────────
756
1212
  // Shared base for 'plan' and 'plan-gap-closure' below — a plain array reference (not
@@ -867,6 +1323,14 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
867
1323
  catch {
868
1324
  parsedValue = value;
869
1325
  }
1326
+ // #1660 (broadened): a lossy object-list field being genuinely CHANGED must fail closed
1327
+ // before it is regenerated, not just when the regenerated result happens to be byte-identical
1328
+ // to the original (see objectListFieldWouldLoseData's docblock).
1329
+ const lossyErr = objectListFieldWouldLoseData(content, field, parsedValue);
1330
+ if (lossyErr) {
1331
+ output({ error: lossyErr, field }, raw, undefined);
1332
+ return;
1333
+ }
870
1334
  fm[field] = parsedValue;
871
1335
  const newContent = spliceFrontmatter(content, fm);
872
1336
  // #1660: a no-op set (newContent unchanged) with a dict-valued field means the lossy
@@ -898,6 +1362,69 @@ function noOpObjectListSetError(originalContent, newContent, parsedValue) {
898
1362
  return null;
899
1363
  return 'frontmatter set had no effect — the supplied value is equivalent to the existing field under the frontmatter parser, which cannot faithfully round-trip object-list fields like must_haves. Edit the file directly.';
900
1364
  }
1365
+ /**
1366
+ * #1660 (broadened, ADR-3473 §8.1 / #3881): `noOpObjectListSetError` only catches the
1367
+ * BYTE-IDENTICAL no-op case. Under the js-yaml migration, `flattenObjectListItem` correctly
1368
+ * joins EVERY sub-key of an object-list item (`path: X, provides: Y`) instead of the legacy
1369
+ * hand-rolled scanner's accidental behavior of silently discarding every field but the one on
1370
+ * the dash line itself. That fixes a real data-loss bug on READ, but it also means a `set` that
1371
+ * replaces such a field with a plainly-flattened string (e.g. `{artifacts: ["path: X"]}`,
1372
+ * omitting `provides`) is no longer byte-identical to the original — so it no longer trips the
1373
+ * no-op guard, sails through `regenerateFrontmatterKey` (which only refuses when the NEW value
1374
+ * itself contains a live JS object), and silently writes a version with `provides` gone.
1375
+ *
1376
+ * This is the general form of the same "cannot faithfully round-trip" contract: a field is
1377
+ * lossy exactly when regenerating its OWN already-parsed value fails to reproduce its own raw
1378
+ * source text byte-for-byte (proof, not a guess, that this key's original shape does not
1379
+ * survive parse → reconstruct). When that is true AND the caller is genuinely changing the
1380
+ * field (not merely re-supplying an equal value, which `frontmatterDeepEqual` already lets
1381
+ * through), the set is refused — matching `regenerateFrontmatterKey`'s own fail-closed
1382
+ * philosophy for the mirror-image case (new value carries a nested object outright).
1383
+ */
1384
+ function objectListFieldWouldLoseData(content, field, newValue) {
1385
+ // A NEW value that itself carries a live nested object (rather than an already-flattened
1386
+ // string) is the mirror-image case `regenerateFrontmatterKey` already refuses on its own
1387
+ // (the "[object Object]" guard, via spliceFrontmatter) — leave that path's existing throw
1388
+ // behavior alone rather than intercepting it here with a different (non-throwing) contract.
1389
+ try {
1390
+ regenerateFrontmatterKey(field, newValue);
1391
+ }
1392
+ catch {
1393
+ return null;
1394
+ }
1395
+ let originalParsed;
1396
+ try {
1397
+ originalParsed = extractFrontmatter(content);
1398
+ }
1399
+ catch {
1400
+ return null;
1401
+ }
1402
+ if (!Object.prototype.hasOwnProperty.call(originalParsed, field))
1403
+ return null;
1404
+ const originalValue = originalParsed[field];
1405
+ if (frontmatterDeepEqual(newValue, originalValue))
1406
+ return null;
1407
+ const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
1408
+ if (!fmMatch)
1409
+ return null;
1410
+ const original = sliceTopLevelFrontmatterSegments(fmMatch[1]).find((s) => s.key === field);
1411
+ if (!original)
1412
+ return null;
1413
+ let regeneratedOriginal;
1414
+ try {
1415
+ regeneratedOriginal = regenerateFrontmatterKey(field, originalValue);
1416
+ }
1417
+ catch {
1418
+ return `frontmatter set refused — the existing "${field}" field contains a nested object-list ` +
1419
+ `(e.g. must_haves.artifacts) the frontmatter writer cannot faithfully represent, and this change ` +
1420
+ `would silently discard data. Edit the file directly instead of using frontmatter set/merge.`;
1421
+ }
1422
+ if (regeneratedOriginal.trim() === original.raw.trim())
1423
+ return null;
1424
+ return `frontmatter set refused — the existing "${field}" field cannot be faithfully round-tripped by ` +
1425
+ `the frontmatter writer (its structure would be flattened and data, such as a nested object-list ` +
1426
+ `field, silently dropped). Edit the file directly instead of using frontmatter set/merge.`;
1427
+ }
901
1428
  function cmdFrontmatterMerge(cwd, filePath, data, raw) {
902
1429
  if (!filePath || !data) {
903
1430
  error('file and data required');
@@ -982,8 +1509,16 @@ function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
982
1509
  output({ valid: missing.length === 0, missing, present, invalidValue, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
983
1510
  }
984
1511
  module.exports = {
1512
+ // #3706: shared with the agent-frontmatter writers so a config-supplied
1513
+ // `model:`/`variant:` value cannot break out of its scalar. Previously private
1514
+ // here while those writers interpolated raw — one escaper, three call sites.
1515
+ escapeDoubleQuotedScalar,
1516
+ agentScalarNeedsDoubleQuoting,
985
1517
  extractFrontmatter,
986
1518
  UNTERMINATED_KEY_THRESHOLD,
1519
+ // ADR-3473 §8.1 (#3881, consequence 2): the unparseable-vs-empty marker Symbol. Exported so
1520
+ // the 8 `hasFrontmatter` call sites named in the design can consult it in a follow-up change.
1521
+ FRONTMATTER_UNPARSEABLE,
987
1522
  // Additive alias (#644 prohibition-probe schema contract): the probe round-trip seam reads a
988
1523
  // frontmatter object via `parseFrontmatter` (the name the contract test pins). It is the SAME
989
1524
  // function as `extractFrontmatter` — a bare-object parse with no behavior change — exposed under