@opengsd/gsd-core 1.14.0 → 1.15.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 (283) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ja-JP.md +3 -3
  4. package/README.ko-KR.md +3 -3
  5. package/README.pt-BR.md +3 -3
  6. package/README.zh-CN.md +3 -3
  7. package/agents/gsd-code-fixer.compact.md +7 -6
  8. package/agents/gsd-code-fixer.md +9 -8
  9. package/agents/gsd-debug-session-manager.compact.md +17 -2
  10. package/agents/gsd-debug-session-manager.md +17 -2
  11. package/agents/gsd-debugger.md +2 -2
  12. package/agents/gsd-eval-auditor.compact.md +1 -1
  13. package/agents/gsd-eval-auditor.md +1 -1
  14. package/agents/gsd-executor.md +13 -8
  15. package/agents/gsd-intel-updater.compact.md +1 -1
  16. package/agents/gsd-intel-updater.md +1 -1
  17. package/agents/gsd-phase-researcher.md +19 -11
  18. package/agents/gsd-plan-checker.md +8 -7
  19. package/agents/gsd-planner.md +12 -8
  20. package/agents/gsd-project-researcher.compact.md +1 -1
  21. package/agents/gsd-project-researcher.md +1 -1
  22. package/agents/gsd-research-synthesizer.compact.md +1 -1
  23. package/agents/gsd-research-synthesizer.md +1 -1
  24. package/agents/gsd-ui-auditor.md +155 -17
  25. package/agents/gsd-ui-researcher.compact.md +1 -1
  26. package/agents/gsd-ui-researcher.md +1 -1
  27. package/agents/gsd-verifier.md +10 -9
  28. package/bin/install.js +642 -95
  29. package/commands/gsd/autonomous.md +2 -2
  30. package/commands/gsd/capture.md +1 -1
  31. package/commands/gsd/mempalace-capture.md +7 -3
  32. package/commands/gsd/plan-review-convergence.md +6 -6
  33. package/commands/gsd/progress.md +1 -1
  34. package/commands/gsd/quick-batch.md +1 -1
  35. package/commands/gsd/review.md +2 -3
  36. package/gsd-core/bin/gsd-tools.cjs +335 -22
  37. package/gsd-core/bin/lib/adr-parser.cjs +3 -1
  38. package/gsd-core/bin/lib/audit.cjs +81 -13
  39. package/gsd-core/bin/lib/capability-registry.cjs +82 -187
  40. package/gsd-core/bin/lib/capability-validator.cjs +0 -1
  41. package/gsd-core/bin/lib/check-command-router.cjs +101 -14
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +21 -25
  43. package/gsd-core/bin/lib/commands.cjs +175 -42
  44. package/gsd-core/bin/lib/config-loader.cjs +65 -4
  45. package/gsd-core/bin/lib/config.cjs +33 -7
  46. package/gsd-core/bin/lib/decisions.cjs +30 -14
  47. package/gsd-core/bin/lib/frontmatter.cjs +13 -0
  48. package/gsd-core/bin/lib/graphify.cjs +10 -2
  49. package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
  50. package/gsd-core/bin/lib/init.cjs +207 -41
  51. package/gsd-core/bin/lib/install-engine.cjs +13 -0
  52. package/gsd-core/bin/lib/installer-migrations.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone.cjs +18 -5
  54. package/gsd-core/bin/lib/model-resolver.cjs +159 -50
  55. package/gsd-core/bin/lib/phase-command-router.cjs +9 -1
  56. package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
  57. package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
  58. package/gsd-core/bin/lib/phase-id.cjs +109 -7
  59. package/gsd-core/bin/lib/phase-locator.cjs +29 -10
  60. package/gsd-core/bin/lib/phase.cjs +227 -26
  61. package/gsd-core/bin/lib/plan-document.cjs +49 -1
  62. package/gsd-core/bin/lib/planning-document.cjs +459 -0
  63. package/gsd-core/bin/lib/planning-inspect.cjs +18 -1
  64. package/gsd-core/bin/lib/planning-workspace.cjs +8 -3
  65. package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
  66. package/gsd-core/bin/lib/probe-core.cjs +7 -1
  67. package/gsd-core/bin/lib/project-root.cjs +41 -2
  68. package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
  69. package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
  70. package/gsd-core/bin/lib/roadmap-command-router.cjs +12 -4
  71. package/gsd-core/bin/lib/roadmap-parser.cjs +163 -3
  72. package/gsd-core/bin/lib/roadmap-upgrade.cjs +1539 -13
  73. package/gsd-core/bin/lib/roadmap.cjs +251 -31
  74. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +283 -31
  75. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -1
  76. package/gsd-core/bin/lib/runtime-homes.cjs +4 -0
  77. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -33
  78. package/gsd-core/bin/lib/runtime-name-policy.cjs +111 -1
  79. package/gsd-core/bin/lib/shell-command-projection.cjs +10 -6
  80. package/gsd-core/bin/lib/state-transition.cjs +39 -2
  81. package/gsd-core/bin/lib/state.cjs +42 -0
  82. package/gsd-core/bin/lib/surface.cjs +17 -1
  83. package/gsd-core/bin/lib/tdd-red-evidence.cjs +78 -5
  84. package/gsd-core/bin/lib/uat-predicate.cjs +47 -4
  85. package/gsd-core/bin/lib/uat.cjs +8 -0
  86. package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
  87. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +100 -9
  88. package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
  89. package/gsd-core/bin/lib/verification.cjs +268 -15
  90. package/gsd-core/bin/lib/verify-command-grounding.cjs +46 -2
  91. package/gsd-core/bin/lib/verify.cjs +132 -25
  92. package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
  93. package/gsd-core/bin/lib/worktree-safety.cjs +784 -51
  94. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  95. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  96. package/gsd-core/references/checkpoints.md +5 -3
  97. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
  98. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
  99. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
  100. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
  101. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
  102. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
  103. package/gsd-core/references/edge-probe.md +195 -21
  104. package/gsd-core/references/execute-phase-between-wave-reset.md +7 -6
  105. package/gsd-core/references/execute-phase-wave-guard.md +22 -11
  106. package/gsd-core/references/gsd-run-resolver.md +1 -1
  107. package/gsd-core/references/model-profiles.md +1 -1
  108. package/gsd-core/references/phase-argument-parsing.md +9 -7
  109. package/gsd-core/references/phase-id-convention.md +28 -0
  110. package/gsd-core/references/planner-gap-closure.md +2 -0
  111. package/gsd-core/references/planner-load-graph-context.md +24 -13
  112. package/gsd-core/references/planner-verify-command-grounding.md +14 -0
  113. package/gsd-core/references/planning-config.md +11 -2
  114. package/gsd-core/references/tdd.md +27 -4
  115. package/gsd-core/references/ui-consideration-probe.md +10 -5
  116. package/gsd-core/references/verify-command-path-resolvability.md +10 -2
  117. package/gsd-core/references/worktree-path-safety.md +321 -0
  118. package/gsd-core/templates/verification-report.md +1 -1
  119. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  120. package/gsd-core/workflows/add-backlog.md +1 -1
  121. package/gsd-core/workflows/add-phase.md +1 -1
  122. package/gsd-core/workflows/add-tests.md +2 -2
  123. package/gsd-core/workflows/add-todo.md +3 -3
  124. package/gsd-core/workflows/ai-integration-phase.md +11 -3
  125. package/gsd-core/workflows/audit-fix.md +1 -1
  126. package/gsd-core/workflows/audit-milestone.md +1 -1
  127. package/gsd-core/workflows/audit-uat.md +1 -1
  128. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
  129. package/gsd-core/workflows/autonomous.md +16 -6
  130. package/gsd-core/workflows/check-todos.md +2 -2
  131. package/gsd-core/workflows/cleanup.md +2 -2
  132. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
  133. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  134. package/gsd-core/workflows/code-review-fix.md +108 -22
  135. package/gsd-core/workflows/code-review.md +63 -46
  136. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +1 -1
  137. package/gsd-core/workflows/complete-milestone.md +2 -2
  138. package/gsd-core/workflows/debug.md +3 -3
  139. package/gsd-core/workflows/diagnose-issues.md +1 -1
  140. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  141. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  142. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  143. package/gsd-core/workflows/discuss-phase.md +1 -1
  144. package/gsd-core/workflows/do.md +2 -2
  145. package/gsd-core/workflows/docs-update.md +3 -3
  146. package/gsd-core/workflows/edit-phase.md +1 -1
  147. package/gsd-core/workflows/eval-review.md +10 -3
  148. package/gsd-core/workflows/execute-phase/detail/elaboration.md +2 -2
  149. package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1017 -0
  150. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  151. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +3 -3
  152. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +37 -3
  153. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  154. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  155. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  156. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
  157. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
  158. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
  159. package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
  160. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
  161. package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +24 -0
  162. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
  163. package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
  164. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +1 -1
  165. package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
  166. package/gsd-core/workflows/execute-phase.md +36 -26
  167. package/gsd-core/workflows/execute-plan.md +5 -4
  168. package/gsd-core/workflows/explore.md +3 -3
  169. package/gsd-core/workflows/extract-learnings.md +2 -1
  170. package/gsd-core/workflows/fast.md +1 -1
  171. package/gsd-core/workflows/forensics.md +1 -1
  172. package/gsd-core/workflows/graduation.md +1 -1
  173. package/gsd-core/workflows/health.md +2 -2
  174. package/gsd-core/workflows/help/modes/full.compact.md +3 -3
  175. package/gsd-core/workflows/help/modes/full.md +5 -5
  176. package/gsd-core/workflows/help/modes/topic.md +15 -5
  177. package/gsd-core/workflows/import.md +2 -2
  178. package/gsd-core/workflows/inbox.md +2 -2
  179. package/gsd-core/workflows/ingest-docs.md +3 -3
  180. package/gsd-core/workflows/insert-phase.md +1 -1
  181. package/gsd-core/workflows/list-seeds.md +1 -1
  182. package/gsd-core/workflows/list-workspaces.md +1 -1
  183. package/gsd-core/workflows/manager.md +2 -2
  184. package/gsd-core/workflows/map-codebase.md +2 -2
  185. package/gsd-core/workflows/milestone-summary.md +1 -1
  186. package/gsd-core/workflows/mvp-phase.md +1 -1
  187. package/gsd-core/workflows/new-milestone.md +2 -2
  188. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
  189. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
  190. package/gsd-core/workflows/new-project.md +7 -7
  191. package/gsd-core/workflows/new-workspace.md +2 -2
  192. package/gsd-core/workflows/next.md +1 -1
  193. package/gsd-core/workflows/note.md +1 -1
  194. package/gsd-core/workflows/onboard.md +1 -1
  195. package/gsd-core/workflows/pause-work.md +1 -1
  196. package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
  197. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
  198. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
  199. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
  200. package/gsd-core/workflows/plan-phase.md +24 -7
  201. package/gsd-core/workflows/plan-review-convergence.md +21 -5
  202. package/gsd-core/workflows/plant-seed.md +62 -20
  203. package/gsd-core/workflows/pr-branch.md +113 -13
  204. package/gsd-core/workflows/profile-user.md +2 -2
  205. package/gsd-core/workflows/progress.md +1 -1
  206. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +25 -0
  207. package/gsd-core/workflows/quick/steps/quick-verification.md +1 -1
  208. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +1 -1
  209. package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
  210. package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
  211. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
  212. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
  213. package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
  214. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
  215. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +1 -1
  216. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
  217. package/gsd-core/workflows/quick-batch.md +1 -1
  218. package/gsd-core/workflows/quick.md +21 -9
  219. package/gsd-core/workflows/reapply-patches.md +9 -3
  220. package/gsd-core/workflows/remove-phase.md +1 -1
  221. package/gsd-core/workflows/remove-workspace.md +2 -2
  222. package/gsd-core/workflows/resume-project.md +1 -1
  223. package/gsd-core/workflows/review.md +31 -16
  224. package/gsd-core/workflows/scan.md +1 -1
  225. package/gsd-core/workflows/secure-phase.md +3 -2
  226. package/gsd-core/workflows/settings-advanced.md +30 -10
  227. package/gsd-core/workflows/settings-integrations.md +2 -3
  228. package/gsd-core/workflows/settings.md +4 -4
  229. package/gsd-core/workflows/ship.md +3 -2
  230. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  231. package/gsd-core/workflows/sketch.md +1 -1
  232. package/gsd-core/workflows/smart-entry.md +2 -2
  233. package/gsd-core/workflows/spec-phase.md +15 -5
  234. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  235. package/gsd-core/workflows/spike.md +1 -1
  236. package/gsd-core/workflows/stats.md +1 -1
  237. package/gsd-core/workflows/sync-skills.md +5 -5
  238. package/gsd-core/workflows/thread.md +1 -1
  239. package/gsd-core/workflows/transition.md +1 -1
  240. package/gsd-core/workflows/ui-phase.md +44 -8
  241. package/gsd-core/workflows/ui-review.md +18 -4
  242. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  243. package/gsd-core/workflows/undo.md +339 -20
  244. package/gsd-core/workflows/update.md +7 -7
  245. package/gsd-core/workflows/validate-phase.md +3 -2
  246. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
  247. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  248. package/gsd-core/workflows/verify-work.md +81 -16
  249. package/hooks/dist/gsd-agent-isolation-guard.js +24 -0
  250. package/hooks/dist/gsd-secret-read-guard.js +27 -1
  251. package/hooks/dist/gsd-statusline.js +70 -13
  252. package/hooks/dist/gsd-validate-commit.sh +63 -4
  253. package/hooks/gsd-agent-isolation-guard.js +24 -0
  254. package/hooks/gsd-secret-read-guard.js +27 -1
  255. package/hooks/gsd-statusline.js +70 -13
  256. package/hooks/gsd-validate-commit.sh +63 -4
  257. package/package.json +3 -2
  258. package/scripts/build-hooks.js +15 -6
  259. package/scripts/check-contract-drift.cjs +127 -11
  260. package/scripts/command-contract-helpers.cjs +3 -0
  261. package/scripts/docs-guard-registry.cjs +28 -0
  262. package/scripts/gen-loop-host-contract.cjs +69 -0
  263. package/scripts/lib/macos-conformance-tier.generated.cjs +14 -0
  264. package/scripts/lib/ndjson-reporter.cjs +3 -2
  265. package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
  266. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +28 -1
  267. package/scripts/lint-phase-arg-assignment.cjs +257 -0
  268. package/scripts/lint-phase-id-drift.cjs +290 -5
  269. package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
  270. package/scripts/lint-retired-runtime-name.cjs +619 -0
  271. package/scripts/lint-state-write-path-drift.cjs +93 -0
  272. package/scripts/lint-test-file-count.allowlist.json +28 -9
  273. package/scripts/lint-workflow-shellcheck-baseline.json +15 -0
  274. package/scripts/prompt-injection-scan.sh +4 -0
  275. package/scripts/release-tarball-smoke.cjs +194 -1
  276. package/skills/gsd-autonomous/SKILL.md +2 -2
  277. package/skills/gsd-capture/SKILL.md +1 -1
  278. package/skills/gsd-mempalace-capture/SKILL.md +7 -3
  279. package/skills/gsd-plan-review-convergence/SKILL.md +5 -5
  280. package/skills/gsd-progress/SKILL.md +1 -1
  281. package/skills/gsd-quick-batch/SKILL.md +1 -1
  282. package/skills/gsd-review/SKILL.md +2 -3
  283. package/vscode/package.json +1 -1
@@ -269,5 +269,53 @@ function parsePlanDocument(content, planPath = '') {
269
269
  taskCount: tasks.length,
270
270
  };
271
271
  }
272
- const planDocument = { TASK_KIND, parsePlanDocument, planIdFromFile };
272
+ const planDocument = { TASK_KIND, parsePlanDocument, planIdFromFile, extractThreatRegisterIds };
273
+ /**
274
+ * #4683 — first-cell IDs of the STRIDE register rows inside every
275
+ * `<threat_model>` block. The register is a markdown table (the
276
+ * `<threat_model>` template in agents/gsd-planner.md): one row per threat,
277
+ * first cell `T-{phase}-NN` — decimal phases included — or the reserved
278
+ * `T-{phase}-SC` supply-chain row. Only digit-suffixed IDs match: `-SC` is
279
+ * deliberately shared by EVERY plan in a phase (planner rule "Keep
280
+ * `T-{phase}-SC` in `<threat_model>`"), so it can never be a uniqueness
281
+ * violation. IDs in prose or non-threat tables never count; only register
282
+ * rows inside a threat_model block do. One entry per matched row, in document
283
+ * order — deciding that the same ID in two plans is a collision is the
284
+ * aggregator's question (init.cts), not the per-document parser's.
285
+ *
286
+ * Knowingly unmatched residual classes (#4683 review, accepted): lowercase
287
+ * `t-47-01`, letter suffixes (`T-47-05A`), annotated first cells
288
+ * (`| T-47-06 (revised) |`), IDs in non-first cells, and an unterminated
289
+ * `<threat_model>` block all yield no claim. All are off-template shapes — the
290
+ * planner template fixes the row grammar — so the residual risk is silent
291
+ * under-detection, never a false hard-stop.
292
+ */
293
+ const THREAT_MODEL_BLOCK_RE = /<threat_model>([\s\S]*?)<\/threat_model>/gi;
294
+ const THREAT_REGISTER_ROW_RE = /^[^\S\n]*\|[^\S\n]*(T-\d+(?:\.\d+)?-\d+)[^\S\n]*\|/;
295
+ function extractThreatRegisterIds(content) {
296
+ // Fenced code blocks are prose, not registers (#4683 review MAJOR): a plan
297
+ // that QUOTES an existing register — exactly what the gap-closure flow tells
298
+ // the planner to read — must not have its quoted IDs counted as claims, or
299
+ // the execute-phase gate would hard-stop a correct phase. Same line-toggling
300
+ // idiom as the deferred-scope scan in phase.cts.
301
+ const lines = [];
302
+ let inFence = false;
303
+ for (const line of content.split(/\r?\n/)) {
304
+ if (/^\s*(?:```|~~~)/.test(line)) {
305
+ inFence = !inFence;
306
+ lines.push('');
307
+ continue;
308
+ }
309
+ lines.push(inFence ? '' : line);
310
+ }
311
+ const ids = [];
312
+ for (const blockMatch of lines.join('\n').matchAll(THREAT_MODEL_BLOCK_RE)) {
313
+ for (const line of blockMatch[1].split('\n')) {
314
+ const row = line.match(THREAT_REGISTER_ROW_RE);
315
+ if (row)
316
+ ids.push(row[1]);
317
+ }
318
+ }
319
+ return ids;
320
+ }
273
321
  module.exports = planDocument;
@@ -0,0 +1,459 @@
1
+ "use strict";
2
+ /**
3
+ * Planning Document — the parse -> mutate -> serialize seam for a `.planning/`
4
+ * root artifact BODY (ADR-4910, epic #4906 Phase 1, #4917).
5
+ *
6
+ * Composes the existing structural seams — never reimplements them:
7
+ * - `markdown-sectionizer.cjs` (`tokenizeHeadings`, `collectSections`,
8
+ * `scanFencedBlocks`, `scanInlineCodeSpans`) for headings/sections and
9
+ * fence/inline-code awareness.
10
+ * - `markdown-table.cjs` (`splitTableRow`, `isDelimiterRow`,
11
+ * `parseMarkdownTable`) for GFM table detection and validation.
12
+ * - `artifacts.cjs` (`isCanonicalPlanningFile`) for the artifact-kind gate.
13
+ *
14
+ * This phase migrates NO call site — it is purely additive (ADR-4910 §7).
15
+ * Only `boldField` nodes are writable; `table`/`checklist` nodes parse and
16
+ * read only. Phase 3 (#4958) checked its own evidence (#4736, #4793) and
17
+ * found neither needed a table/checklist writer here — see ADR-4910's
18
+ * 2026-09-24 amendment. A writer for either kind is unclaimed until a real
19
+ * call site names it.
20
+ *
21
+ * Hyrum's Law commitment (row 3 of the design's behaviour table): `serialize`
22
+ * with zero staged edits returns `doc.source` BYTE-IDENTICAL — never a
23
+ * re-render (#4499's root cause). Every byte outside an edited `valueSpan` is
24
+ * the ORIGINAL source, spliced, never regenerated.
25
+ *
26
+ * ADR-457 build-at-publish: source in src/planning-document.cts, compiled to
27
+ * gsd-core/bin/lib/planning-document.cjs (gitignored).
28
+ */
29
+ var __importDefault = (this && this.__importDefault) || function (mod) {
30
+ return (mod && mod.__esModule) ? mod : { "default": mod };
31
+ };
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.PLANNING_ARTIFACTS = void 0;
34
+ exports.parsePlanningDoc = parsePlanningDoc;
35
+ exports.findField = findField;
36
+ exports.readNode = readNode;
37
+ exports.setFieldValue = setFieldValue;
38
+ exports.hasUnreadableNodes = hasUnreadableNodes;
39
+ exports.serialize = serialize;
40
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
41
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
42
+ const artifacts_cjs_1 = require("./artifacts.cjs");
43
+ // `frontmatter.cts` uses `export =` (CJS-style single export object), so it
44
+ // is imported as a default import (esModuleInterop), not a named import.
45
+ const frontmatter_cjs_1 = __importDefault(require("./frontmatter.cjs"));
46
+ const { frontmatterRegion } = frontmatter_cjs_1.default;
47
+ /**
48
+ * Canonical `.planning/` root artifact basenames this seam recognises,
49
+ * derived from the SAME registry `isCanonicalPlanningFile` consults
50
+ * (`artifacts.cts`'s `CANONICAL_EXACT`) — never a second, independently
51
+ * maintained list.
52
+ *
53
+ * Filtered to `.md` names only: `CANONICAL_EXACT` also carries non-markdown
54
+ * artifacts (`config.json`, `state.json`, `milestone.lock`, …) that this
55
+ * parser has no grammar for. Handing that JSON/lock content to the markdown
56
+ * parser below returns a successful EMPTY document (`nodes: []`), which reads
57
+ * as "this document records nothing" when the truth is "wrong kind entirely"
58
+ * — the empty-vs-error confusion #4917 / ADR-4910 §5 exists to eliminate. Do
59
+ * NOT remove this filter to "restore" the full registry.
60
+ */
61
+ exports.PLANNING_ARTIFACTS = Object.freeze(Array.from(artifacts_cjs_1.CANONICAL_EXACT).filter((name) => name.endsWith('.md')));
62
+ // ─── Internal helpers ───────────────────────────────────────────────────────
63
+ let nodeCounter = 0;
64
+ function mintId(kind) {
65
+ nodeCounter += 1;
66
+ return `${kind}-${nodeCounter}-${Math.random().toString(36).slice(2, 8)}`;
67
+ }
68
+ function splitLinesInfo(source) {
69
+ const out = [];
70
+ let offset = 0;
71
+ const rawLines = source.split('\n');
72
+ for (let i = 0; i < rawLines.length; i++) {
73
+ const raw = rawLines[i];
74
+ const hasCR = raw.endsWith('\r');
75
+ const text = hasCR ? raw.slice(0, -1) : raw;
76
+ out.push({ text, start: offset, end: offset + text.length });
77
+ offset += raw.length + 1; // +1 for the '\n' split on ('\r' already counted in raw.length)
78
+ }
79
+ return out;
80
+ }
81
+ /**
82
+ * Locate the frontmatter block, if any, by COMPOSING `frontmatter.cts`'s
83
+ * `frontmatterRegion` — the fence-detection grammar (byte-0 rule, BOM strip,
84
+ * `\n---` search, CR handling) lives there, once, and this seam never
85
+ * re-derives it (ADR-4910 Decision 1).
86
+ *
87
+ * `frontmatterRegion` reports the YAML body's own bounds (`region`,
88
+ * `terminated`, and the possibly BOM-stripped `content`), not this seam's
89
+ * `Span` shape (an absolute byte range into the UNSTRIPPED `source`,
90
+ * inclusive of both fences). This adapter translates one into the other by
91
+ * reading ONLY the two boundary characters `frontmatterRegion` already
92
+ * anchored (whether the YAML end / closing fence sit on a CRLF line) — it
93
+ * does not re-scan for the fences themselves.
94
+ */
95
+ function findFrontmatterSpan(source) {
96
+ const found = frontmatterRegion(source);
97
+ if (!found)
98
+ return null;
99
+ // `found.content` may be `source` with a single leading BOM stripped;
100
+ // every offset below is relative to `found.content`, so translate back to
101
+ // `source` coordinates by the same delta.
102
+ const bomDelta = source.length - found.content.length;
103
+ const content = found.content;
104
+ if (!found.terminated) {
105
+ return { span: { start: bomDelta, end: bomDelta + content.length }, terminated: false };
106
+ }
107
+ // `frontmatterRegion` already did fence DETECTION — `found` being non-null
108
+ // and `terminated` IS that result. It reports only the YAML body's bounds
109
+ // (`region`), not an absolute span, so recover the closing fence's end
110
+ // from `region`'s length. The one thing still read directly here is the
111
+ // opening fence's fixed-width line ending (`\n` vs `\r\n`), needed to
112
+ // translate `region`'s length into a `content` offset — not a re-scan for
113
+ // the fence itself.
114
+ const headerEnd = content.startsWith('---\r\n') ? 5 : 4;
115
+ const yamlEnd = headerEnd + found.region.length;
116
+ const closingLineStart = content[yamlEnd] === '\r' ? yamlEnd + 1 : yamlEnd;
117
+ const fenceLineStart = closingLineStart + 1;
118
+ let fenceEnd = fenceLineStart + 3;
119
+ if (content[fenceEnd] === '\r')
120
+ fenceEnd += 1;
121
+ return { span: { start: bomDelta, end: bomDelta + fenceEnd }, terminated: true };
122
+ }
123
+ /** Build the set of 0-based line indices that fall inside a fenced code
124
+ * block (opening/closing delimiter lines included), so `**Label:**`/table/
125
+ * checklist scanning never treats fenced content as a node (rows 9/14). */
126
+ function fencedLineIndices(lines) {
127
+ const raw = lines.map((l) => l.text);
128
+ const blocks = (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(raw);
129
+ const set = new Set();
130
+ for (const b of blocks) {
131
+ const end = b.closeLineIdx === -1 ? raw.length - 1 : b.closeLineIdx;
132
+ for (let i = b.openLineIdx; i <= end; i++)
133
+ set.add(i);
134
+ }
135
+ return set;
136
+ }
137
+ /** Matches both shipped bold-field spellings: colon-inside (`**Label:**`,
138
+ * the original grammar) and colon-outside (`**Label**:`, the canonical form
139
+ * used throughout `templates/roadmap.md`). Each alternative's trailing
140
+ * marker is exactly 3 characters (`:**` or `**:`), so `token.slice(2, -3)`
141
+ * in `parseBoldFieldLine` strips the leading `**` and the spelling-specific
142
+ * trailing marker identically for both, yielding the same `label` either
143
+ * way. Deliberately excludes a bare unbolded `Label:` form — see Phase 1's
144
+ * prose-vs-field disambiguation design. */
145
+ const BOLD_FIELD_RE = /^(\s*)(\*\*[^*\r\n]+(?::\*\*|\*\*:))([ \t]*)([^\r\n]*)$/;
146
+ /** Boundary marking a hand-written trailing annotation on a field line —
147
+ * the token owner must never destroy prose past this separator. */
148
+ const TRAILING_SEPARATOR_RE = / — /;
149
+ function parseBoldFieldLine(line) {
150
+ const m = BOLD_FIELD_RE.exec(line.text);
151
+ if (!m)
152
+ return null;
153
+ const [, leading, token, spacing, rest] = m;
154
+ const labelStart = line.start + leading.length;
155
+ const labelSpan = { start: labelStart, end: labelStart + token.length };
156
+ const label = token.slice(2, -3);
157
+ const restStart = labelSpan.end + spacing.length;
158
+ const sepMatch = TRAILING_SEPARATOR_RE.exec(rest);
159
+ const valueRaw = sepMatch ? rest.slice(0, sepMatch.index) : rest;
160
+ const trimmedValue = valueRaw.replace(/\s+$/, '');
161
+ const valueSpan = { start: restStart, end: restStart + trimmedValue.length };
162
+ const trailingSpan = { start: valueSpan.end, end: line.end };
163
+ return {
164
+ kind: 'boldField',
165
+ id: mintId('boldField'),
166
+ span: { start: labelSpan.start, end: line.end },
167
+ error: null,
168
+ label,
169
+ labelSpan,
170
+ valueSpan,
171
+ trailingSpan,
172
+ value: trimmedValue,
173
+ };
174
+ }
175
+ /** A checklist line is one whose SOLE bullet, per `iterateBullets` (the same
176
+ * grammar the repo's other bullet consumers use), is a checkbox marker, OR
177
+ * whose bullet TEXT begins with a task-list marker.
178
+ *
179
+ * `iterateBullets` owns bullet *structure* — is this a bullet, where does its
180
+ * text start — and continues to own that here unchanged. It only classifies
181
+ * `-`-prefixed bullets as `checkbox-checked`/`checkbox-unchecked`; GFM also
182
+ * permits `*` and `+` as bullet markers, and `* [ ] x` / `+ [x] y` are valid
183
+ * GFM task-list items that `iterateBullets` reports as plain `dash`-family
184
+ * bullets with the `[ ]`/`[x]` left in the bullet's own text. Widening
185
+ * `iterateBullets` itself is forbidden by ADR-2143 §2's extend-never-mutate
186
+ * lock (inherited by this epic), so the task-list-marker interpretation is
187
+ * layered on here, over the bullet's already-extracted text — never by
188
+ * re-scanning the raw line with a new hand-rolled regex.
189
+ *
190
+ * Known limit inherited from `iterateBullets`, not introduced here:
191
+ * `-\t[ ] text` (a tab between the marker and the text) is not recognised as
192
+ * a bullet at all, so it can never become a checklist line. That is a
193
+ * pre-existing `markdown-sectionizer` boundary affecting every consumer of
194
+ * `iterateBullets`, and fixing it would mean altering the locked seam. */
195
+ function isChecklistLine(text) {
196
+ const items = (0, markdown_sectionizer_cjs_1.iterateBullets)(text);
197
+ if (items.length !== 1)
198
+ return false;
199
+ const item = items[0];
200
+ if (item.marker === 'checkbox-checked' || item.marker === 'checkbox-unchecked')
201
+ return true;
202
+ return /^\[[ xX]\] /.test(item.text);
203
+ }
204
+ /**
205
+ * Scan the document body (everything outside the frontmatter block and
206
+ * outside fenced code) for `boldField`, `table`, and `checklist` nodes, in
207
+ * document order.
208
+ */
209
+ function scanBodyNodes(source, lines, frontmatterEnd) {
210
+ const fenced = fencedLineIndices(lines);
211
+ const nodes = [];
212
+ let i = 0;
213
+ while (i < lines.length) {
214
+ const line = lines[i];
215
+ if (fenced.has(i) || line.start < frontmatterEnd) {
216
+ i += 1;
217
+ continue;
218
+ }
219
+ const trimmed = line.text.trim();
220
+ // Table: a pipe-shaped header line followed by a valid delimiter row.
221
+ if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1 && i + 1 < lines.length) {
222
+ const delimiterLine = lines[i + 1];
223
+ const delimiterCells = (0, markdown_table_cjs_1.splitTableRow)(delimiterLine.text);
224
+ const headerCells = (0, markdown_table_cjs_1.splitTableRow)(line.text);
225
+ if (delimiterLine.text.trim().startsWith('|')
226
+ && (0, markdown_table_cjs_1.isDelimiterRow)(delimiterCells)
227
+ && delimiterCells.length === headerCells.length
228
+ && !fenced.has(i + 1)) {
229
+ let last = i + 1;
230
+ while (last + 1 < lines.length && lines[last + 1].text.trim().startsWith('|') && !fenced.has(last + 1)) {
231
+ last += 1;
232
+ }
233
+ const span = { start: line.start, end: lines[last].end };
234
+ const tableText = source.slice(span.start, span.end);
235
+ const parsed = (0, markdown_table_cjs_1.parseMarkdownTable)(tableText);
236
+ nodes.push(parsed.ok
237
+ ? {
238
+ kind: 'table',
239
+ id: mintId('table'),
240
+ span,
241
+ error: null,
242
+ columns: parsed.value.columns,
243
+ }
244
+ : {
245
+ kind: 'table',
246
+ id: mintId('table'),
247
+ span,
248
+ error: { reason: parsed.reason, span },
249
+ columns: null,
250
+ });
251
+ i = last + 1;
252
+ continue;
253
+ }
254
+ }
255
+ // Checklist: a contiguous run of checkbox-bullet lines.
256
+ if (isChecklistLine(line.text)) {
257
+ let last = i;
258
+ let count = 0;
259
+ while (last < lines.length && !fenced.has(last) && isChecklistLine(lines[last].text)) {
260
+ count += 1;
261
+ last += 1;
262
+ }
263
+ last -= 1;
264
+ const span = { start: line.start, end: lines[last].end };
265
+ nodes.push({ kind: 'checklist', id: mintId('checklist'), span, error: null, items: count });
266
+ i = last + 1;
267
+ continue;
268
+ }
269
+ // Bold field.
270
+ const field = parseBoldFieldLine(line);
271
+ if (field) {
272
+ nodes.push(field);
273
+ i += 1;
274
+ continue;
275
+ }
276
+ i += 1;
277
+ }
278
+ return nodes;
279
+ }
280
+ // ─── Public API ─────────────────────────────────────────────────────────────
281
+ /**
282
+ * Parse `source` (the raw text of a `.planning/` root artifact) into a
283
+ * `PlanningDoc`. Document-level `Result` failure is reserved for: `artifact`
284
+ * not a recognised planning artifact kind, `source` not a readable string, or
285
+ * an opened-but-never-closed frontmatter fence (ADR-4910 §5's reservation).
286
+ * A malformed SUB-structure (a ragged table, say) never fails the whole
287
+ * document — it is recorded as that one node's `error`, and every sibling
288
+ * node stays readable (row 7). `nodes: []` on a genuinely empty document is
289
+ * success, not an error (row 15).
290
+ */
291
+ function parsePlanningDoc(source, artifact) {
292
+ if (typeof source !== 'string') {
293
+ return { ok: false, reason: 'unreadable: source is not a string' };
294
+ }
295
+ if (typeof artifact !== 'string' ||
296
+ !(0, artifacts_cjs_1.isCanonicalPlanningFile)(artifact) ||
297
+ !exports.PLANNING_ARTIFACTS.includes(artifact)) {
298
+ return {
299
+ ok: false,
300
+ reason: `not a markdown planning document (artifact: ${String(artifact)})`,
301
+ };
302
+ }
303
+ const nodes = [];
304
+ let frontmatterEnd = 0;
305
+ const fm = findFrontmatterSpan(source);
306
+ if (fm) {
307
+ if (!fm.terminated) {
308
+ return { ok: false, reason: 'no frontmatter terminator' };
309
+ }
310
+ nodes.push({ kind: 'frontmatter', id: mintId('frontmatter'), span: fm.span, error: null });
311
+ frontmatterEnd = fm.span.end;
312
+ }
313
+ if (source.length === 0) {
314
+ return { ok: true, value: { source, artifact, nodes: [], staged: new Map() } };
315
+ }
316
+ const lines = splitLinesInfo(source);
317
+ // Sections: one per heading, in document order — every heading is its own
318
+ // boundary (`collectSections(source, () => true)`), so a nested `####`
319
+ // still gets its own SectionNode rather than being folded into its parent.
320
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(source);
321
+ if (headings.length > 0) {
322
+ const sections = (0, markdown_sectionizer_cjs_1.collectSections)(source, () => true);
323
+ for (const s of sections) {
324
+ nodes.push({
325
+ kind: 'section',
326
+ id: mintId('section'),
327
+ span: { start: s.heading.offset, end: s.bodyEnd },
328
+ error: null,
329
+ heading: s.heading.text,
330
+ level: s.heading.level,
331
+ });
332
+ }
333
+ }
334
+ nodes.push(...scanBodyNodes(source, lines, frontmatterEnd));
335
+ nodes.sort((a, b) => a.span.start - b.span.start);
336
+ return { ok: true, value: { source, artifact, nodes, staged: new Map() } };
337
+ }
338
+ /** Find the id of the (first, document-order) `boldField` node whose label
339
+ * exactly matches `label`, or `null` when none does. */
340
+ function findField(doc, label) {
341
+ for (const n of doc.nodes) {
342
+ if (n.kind === 'boldField' && n.label === label)
343
+ return n.id;
344
+ }
345
+ return null;
346
+ }
347
+ /** Read a node by id. Node-scoped failure only — an unknown id or a node
348
+ * that failed to parse never throws. */
349
+ function readNode(doc, id) {
350
+ const node = doc.nodes.find((n) => n.id === id);
351
+ if (!node) {
352
+ return { ok: false, reason: 'unknown node id', span: { start: 0, end: 0 } };
353
+ }
354
+ if (node.error) {
355
+ return { ok: false, reason: node.error.reason, span: node.error.span };
356
+ }
357
+ if (node.kind === 'boldField') {
358
+ return { ok: true, value: doc.staged.get(id) ?? node.value };
359
+ }
360
+ return { ok: true, value: doc.source.slice(node.span.start, node.span.end) };
361
+ }
362
+ /**
363
+ * Stage a new value for a `boldField` node, returning a NEW `PlanningDoc`
364
+ * (immutable — `doc` itself is never mutated). Refuses an id this doc did
365
+ * not mint, and refuses any node kind other than `boldField` — only the
366
+ * `valueSpan` is ever writable this phase (ADR-4910 §1).
367
+ */
368
+ function setFieldValue(doc, id, value) {
369
+ const node = doc.nodes.find((n) => n.id === id);
370
+ if (!node) {
371
+ return { ok: false, reason: 'unknown node id' };
372
+ }
373
+ if (node.kind !== 'boldField') {
374
+ return { ok: false, reason: `node kind '${node.kind}' is not writable this phase` };
375
+ }
376
+ // #4917 / ADR-4910 Decision 2 & 4: a boldField's token boundary is a LINE
377
+ // boundary, not just an offset range — a value containing \n or \r escapes
378
+ // the field's own span and reparses as sibling structure (a forged field)
379
+ // once spliced back into the source. Decision 4 licenses refusal for any
380
+ // value the grammar cannot represent; Phase 3 may widen this to escaping,
381
+ // but Phase 1 refuses outright. Do not remove this as an over-restriction.
382
+ if (/[\r\n]/.test(value)) {
383
+ return { ok: false, reason: 'field value must not contain a line break (\\r or \\n)' };
384
+ }
385
+ // #4917 / ADR-4910 Decision 4: "a value that cannot be represented in the
386
+ // grammar is refused by the writer, with a report." This is a GENERAL
387
+ // round-trip representability check, not a blacklist of forbidden
388
+ // substrings — the `\r`/`\n` guard above is a narrower special case kept
389
+ // for its clearer message, but THIS check is the backstop. It rebuilds the
390
+ // line exactly as it would be written (existing leading/label/spacing +
391
+ // the new value + the existing trailing text) and re-parses that line
392
+ // through the SAME `parseBoldFieldLine` grammar the reader uses. If the
393
+ // value the grammar reads back is not byte-identical to what the caller
394
+ // staged, the grammar cannot represent this value (e.g. it contains the
395
+ // ` — ` trailing-separator token, which would silently reclassify the
396
+ // rest of the value as trailing prose) and the write is refused. Do NOT
397
+ // replace this with a list of forbidden characters/substrings — the next
398
+ // separator the grammar grows would silently slip past a blacklist.
399
+ const leadingText = doc.source.slice(node.span.start, node.labelSpan.start);
400
+ const tokenText = doc.source.slice(node.labelSpan.start, node.labelSpan.end);
401
+ const spacingText = doc.source.slice(node.labelSpan.end, node.valueSpan.start);
402
+ const trailingText = doc.source.slice(node.trailingSpan.start, node.trailingSpan.end);
403
+ const candidateLine = `${leadingText}${tokenText}${spacingText}${value}${trailingText}`;
404
+ const candidateInfo = { text: candidateLine, start: 0, end: candidateLine.length };
405
+ const reparsed = parseBoldFieldLine(candidateInfo);
406
+ if (!reparsed || reparsed.value !== value) {
407
+ return {
408
+ ok: false,
409
+ reason: 'field value is not representable in the boldField grammar (would not round-trip)',
410
+ };
411
+ }
412
+ const staged = new Map(doc.staged);
413
+ staged.set(id, value);
414
+ return { ok: true, value: { source: doc.source, artifact: doc.artifact, nodes: doc.nodes, staged } };
415
+ }
416
+ /** True when any node in `doc` failed to parse. */
417
+ function hasUnreadableNodes(doc) {
418
+ return doc.nodes.some((n) => n.error !== null);
419
+ }
420
+ /**
421
+ * Splice every staged edit into `doc.source` and return the resulting text.
422
+ * With zero staged edits, returns `doc.source` BYTE-IDENTICAL — never a
423
+ * re-render (row 3). Refuses outright — even with zero staged edits — when
424
+ * `hasUnreadableNodes(doc)` is true (the ADR-4910 amendment): `serialize`
425
+ * re-emits the WHOLE document, so the refusal is document-scoped, not
426
+ * mutation-scoped.
427
+ */
428
+ function serialize(doc) {
429
+ if (hasUnreadableNodes(doc)) {
430
+ return {
431
+ ok: false,
432
+ reason: 'unreadable-nodes',
433
+ nodes: doc.nodes
434
+ .filter((n) => n.error !== null)
435
+ .map((n) => ({ id: n.id, kind: n.kind, span: n.error.span, reason: n.error.reason })),
436
+ };
437
+ }
438
+ if (doc.staged.size === 0) {
439
+ return { ok: true, value: doc.source };
440
+ }
441
+ const edits = [];
442
+ for (const [id, value] of doc.staged) {
443
+ const node = doc.nodes.find((n) => n.id === id);
444
+ if (!node || node.kind !== 'boldField')
445
+ continue; // unreachable: setFieldValue already gated this
446
+ edits.push({ start: node.valueSpan.start, end: node.valueSpan.end, value });
447
+ }
448
+ edits.sort((a, b) => a.start - b.start);
449
+ let out = '';
450
+ let cursor = 0;
451
+ for (const e of edits) {
452
+ out += doc.source.slice(cursor, e.start) + e.value;
453
+ cursor = e.end;
454
+ }
455
+ out += doc.source.slice(cursor);
456
+ return { ok: true, value: out };
457
+ }
458
+ // Consumers: require('../gsd-core/bin/lib/planning-document.cjs')
459
+ // Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish).
@@ -917,7 +917,24 @@ function extractDependencyTokens(sectionBody) {
917
917
  const m = DEPENDS_ON_LINE_RE.exec(sectionBody);
918
918
  if (!m)
919
919
  return [];
920
- return sortedUnique([...m[1].matchAll(/\d+(?:\.\d+)*/g)].map((t) => t[0]));
920
+ // #4764: phase REFERENCES, not digit runs — the same prose-anchored grammar
921
+ // init.manager's dep_phases extraction uses (owner: phase-id.cts's
922
+ // PHASE_DEP_REF_SOURCE). The whole-field token scrape this replaces pulled
923
+ // calendar dates, git shas and ledger ids in as dependencies. The grammar's
924
+ // capture group 1 already excludes the "Phase(s)" anchor word, so no
925
+ // prefix-strip literal is needed here. Self-exclusion (init.manager drops
926
+ // the row's own number) is deliberately NOT applied: this reader has no row
927
+ // context at the extraction site and reports informationally, it does not
928
+ // gate.
929
+ const refRe = new RegExp(phaseIdMod.PHASE_DEP_REF_SOURCE, 'gi');
930
+ const tokenRe = new RegExp(phaseIdMod.PHASE_NUMBER_TOKEN_SOURCE, 'g');
931
+ const tokens = [];
932
+ let refMatch;
933
+ while ((refMatch = refRe.exec(m[1])) !== null) {
934
+ for (const t of refMatch[1].matchAll(tokenRe))
935
+ tokens.push(t[0]);
936
+ }
937
+ return sortedUnique(tokens);
921
938
  }
922
939
  /**
923
940
  * This phase's own ROADMAP.md section body — milestone-scoped via the SAME
@@ -114,13 +114,18 @@ const PLANNING_LOCK_RETRY_ERRNOS = new Set([
114
114
  * two-readers-two-bases lesson).
115
115
  */
116
116
  function resolveEnvWorkstream() {
117
- return process.env['GSD_WORKSTREAM'] ?? null;
117
+ const value = process.env['GSD_WORKSTREAM']?.trim();
118
+ return value || null;
118
119
  }
119
120
  function planningDir(cwd, ws, project) {
120
121
  if (project === undefined)
121
- project = process.env['GSD_PROJECT'] ?? null;
122
+ project = process.env['GSD_PROJECT']?.trim() || null;
123
+ else if (typeof project === 'string')
124
+ project = project.trim() || null;
122
125
  if (ws === undefined)
123
126
  ws = resolveEnvWorkstream();
127
+ else if (typeof ws === 'string')
128
+ ws = ws.trim() || null;
124
129
  // Reject path separators and traversal components in project/workstream names
125
130
  const BAD_SEGMENT = /[/\\]|\.\./;
126
131
  if (project && BAD_SEGMENT.test(project)) {
@@ -187,7 +192,7 @@ function worktreesOptedOutUnguarded(cwd) {
187
192
  const scoped = ownKey(readCfg(node_path_1.default.join(planningDir(cwd), 'config.json')));
188
193
  if (scoped.present)
189
194
  return scoped.value === false;
190
- if (process.env['GSD_WORKSTREAM']) {
195
+ if (resolveEnvWorkstream() !== null) {
191
196
  const root = ownKey(readCfg(node_path_1.default.join(planningRoot(cwd), 'config.json')));
192
197
  if (root.present)
193
198
  return root.value === false;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ /**
3
+ * PR-branch `.planning/` path classification patterns — single source of
4
+ * truth (ADR-4910 §8, epic #4906 Phase 5; issues #4605, #4606).
5
+ *
6
+ * `gsd-core/workflows/pr-branch.md` cannot `require()` this module at
7
+ * runtime — a workflow markdown file has no module system, and its bash
8
+ * steps are copy-pasted text executed by whatever shell interprets the
9
+ * workflow. So the three exported constants below are mirrored VERBATIM
10
+ * into that file's `TRANSIENT_DIRS=`, `STRUCTURAL_RE=`, and
11
+ * `MILESTONE_PHASES_RE=` bash variable assignments, and
12
+ * `scripts/lint-pr-branch-pattern-drift.cjs` asserts the mirror never
13
+ * drifts from this file (compiled to `gsd-core/bin/lib/pr-branch-patterns.cjs`)
14
+ * as part of `npm run lint:ci`. Change a value here, then update
15
+ * `gsd-core/workflows/pr-branch.md` to match, or the drift guard fails.
16
+ *
17
+ * This module does not read the filesystem and has no dependencies (a leaf
18
+ * module, mirroring `src/secrets.cts` / `src/planning-scope.cts`).
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.MILESTONE_PHASES_RE_SRC = exports.STRUCTURAL_RE_SRC = exports.TRANSIENT_DIRS_SRC = void 0;
22
+ /**
23
+ * Space-separated list of `.planning/` subdirectory names that are reviewer
24
+ * noise (PLAN.md, SUMMARY.md, CONTEXT.md, RESEARCH.md, and friends) —
25
+ * filtered out of a PR branch in BOTH default and strict mode.
26
+ *
27
+ * Mirrored verbatim into `gsd-core/workflows/pr-branch.md`'s
28
+ * `TRANSIENT_DIRS="..."` assignment.
29
+ */
30
+ exports.TRANSIENT_DIRS_SRC = 'phases quick research threads todos debug seeds codebase ui-reviews';
31
+ /**
32
+ * Structural planning files — repository planning state, preserved in
33
+ * default mode and filtered out in strict mode. Anchored on both
34
+ * alternatives so `.planning/STATEX.md` and `.planning/STATE.md.bak` are
35
+ * NOT treated as structural. The `milestones` alternative matches only
36
+ * FILES directly under `.planning/milestones/` (e.g. `v1.0-ROADMAP.md`) —
37
+ * not a `<milestone>-phases/` subdirectory nested there. That subdirectory
38
+ * is reviewer noise, not structural state (#4605); it falls through to
39
+ * `MILESTONE_PHASES_RE_SRC` below instead.
40
+ *
41
+ * Mirrored verbatim into `gsd-core/workflows/pr-branch.md`'s
42
+ * `STRUCTURAL_RE="..."` assignment.
43
+ */
44
+ exports.STRUCTURAL_RE_SRC = '^\\.planning/(STATE|ROADMAP|MILESTONES|PROJECT|REQUIREMENTS)\\.md$|^\\.planning/milestones/[^/]+\\.md$';
45
+ /**
46
+ * Milestone-scoped phase-plan directories — the same reviewer noise as
47
+ * `TRANSIENT_DIRS_SRC`'s `phases` entry, but nested per-milestone once a
48
+ * project has passed at least one milestone:
49
+ * `.planning/milestones/<milestone>-phases/`. The milestone slug (`v1.0`,
50
+ * `m2`, ...) varies per project, so this is declared as a shape, not a
51
+ * literal path — a single path segment standing in for the slug, anchored
52
+ * the same way `STRUCTURAL_RE_SRC`'s alternatives are (#4605).
53
+ *
54
+ * Mirrored verbatim into `gsd-core/workflows/pr-branch.md`'s
55
+ * `MILESTONE_PHASES_RE="..."` assignment.
56
+ */
57
+ exports.MILESTONE_PHASES_RE_SRC = '^\\.planning/milestones/[^/]+-phases/';
@@ -234,6 +234,12 @@ function analyzeCoverage(items, resolutions = [], validators) {
234
234
  const unresolved = merged.filter((i) => i.status === 'unresolved').length;
235
235
  const applicable = merged.length;
236
236
  const resolved = applicable - unresolved; // closed set: resolved-status + dismissed
237
+ // #4656: the unclassified soft-signal rows count toward `applicable` (the
238
+ // rollup is count-preserving and `resolved = applicable - unresolved` is a
239
+ // documented identity), so the count is exposed as a SIBLING field — the
240
+ // zero-applicable guards can then also fire when EVERY requirement is
241
+ // unclassified, the case the spec-phase/ui-phase docs promise to catch.
242
+ const unclassified = merged.filter((i) => i.category === 'unclassified').length;
237
243
  const byVerification = {};
238
244
  for (const tier of validators.verification)
239
245
  byVerification[tier] = 0;
@@ -242,7 +248,7 @@ function analyzeCoverage(items, resolutions = [], validators) {
242
248
  byVerification[i.verification] = (byVerification[i.verification] ?? 0) + 1;
243
249
  }
244
250
  }
245
- return { items: merged, coverage: { applicable, resolved, unresolved, byVerification } };
251
+ return { items: merged, coverage: { applicable, resolved, unresolved, unclassified, byVerification } };
246
252
  }
247
253
  /**
248
254
  * The prohibition adapter's injected runtime validators (ADR-550 #5). There is no closed