@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
@@ -37,6 +37,22 @@
37
37
  * over markdown, not `.cts` source, so its sanction is an HTML comment on the
38
38
  * nearest preceding non-blank line: `<!-- phase-id-owner: <reason> -->`.
39
39
  *
40
+ * - phase-heading-scan literal drift (#4906 Phase 5 / #4984): `findPhaseHeadingScanLiteralDrift`
41
+ * detects a hand-rolled `#{2,4}\s*Phase\s+` heading-SCAN literal, which bypasses
42
+ * `buildPhaseHeadingScanRegex` / `phaseHeadingPrefixSrcFor` (both in
43
+ * `src/phase-id.cts`) and so silently fails to recognize bracket-convention
44
+ * headings. Sanctioned the same way as the token/bracket/name-validity rules
45
+ * (`// phase-id-owner:` on the nearest preceding non-blank line), with a
46
+ * line-level escape for a line that already names either owner function.
47
+ * Wired into `scanRepo` (ADR-4910 §8): every pre-existing site this phase
48
+ * could not migrate (src/phase.cts x11, src/roadmap-parser.cts x3,
49
+ * src/planning-snapshot.cts x1) is grandfathered with a dedicated
50
+ * `// phase-id-owner:` sanction comment rather than a separate allowlist —
51
+ * the guard is ACTIVE from this phase forward: a fresh unsanctioned copy
52
+ * fails CI immediately, and each grandfathered comment is deleted as its
53
+ * site migrates in a later phase (ADR-4910 §8's census/grandfather/delete
54
+ * sequence, never renewed).
55
+ *
40
56
  * - branch-slug fallback drift: a `.replace('{slug}', ... || 'phase')` call
41
57
  * silently substitutes the literal string `'phase'` when a phase's slug
42
58
  * can't be derived, producing a non-identifying branch name like
@@ -234,6 +250,39 @@ function findBranchSlugFallbackDrift(text) {
234
250
  return out;
235
251
  }
236
252
 
253
+ // #4906 Phase 5 (#4984): a hand-rolled heading-SCAN literal `#{2,4}\s*Phase\s+`
254
+ // (in either regex-literal `\s` or template `\\s` escaping, mirroring
255
+ // TOKEN_DRIFT_RE's tolerance) bypasses `buildPhaseHeadingScanRegex` /
256
+ // `phaseHeadingPrefixSrcFor` (both in `src/phase-id.cts`) entirely, so it
257
+ // silently fails to recognize bracket-convention headings (`[GSD.03] Phase
258
+ // 03:`) — the exact defect this phase fixed at five call sites in init.cts
259
+ // and milestone.cts. A line naming either owner function counts as "built
260
+ // from the owner" and is not flagged, mirroring the token rule's
261
+ // `line.includes(CANON_REF)` escape.
262
+ const PHASE_HEADING_SCAN_LITERAL_DRIFT_RE = /#\{2,4\}\\{1,2}s\*Phase\\{1,2}s\+/;
263
+ const PHASE_HEADING_SCAN_LITERAL_CANON_REFS = ['buildPhaseHeadingScanRegex', 'phaseHeadingPrefixSrcFor'];
264
+
265
+ /**
266
+ * Pure: find every unsanctioned hand-rolled `#{2,4}\s*Phase\s+` heading-scan
267
+ * literal in `text`. Sanctioned when the line already names either owner
268
+ * function (`buildPhaseHeadingScanRegex(` / `phaseHeadingPrefixSrcFor(`), or
269
+ * by a `// phase-id-owner:` comment on the nearest preceding non-blank line
270
+ * (same mechanism as every other rule in this file). Returns [{ line, found }].
271
+ */
272
+ function findPhaseHeadingScanLiteralDrift(text) {
273
+ const out = [];
274
+ const lines = text.split('\n');
275
+ for (let i = 0; i < lines.length; i++) {
276
+ const line = lines[i];
277
+ const m = PHASE_HEADING_SCAN_LITERAL_DRIFT_RE.exec(line);
278
+ if (!m) continue;
279
+ if (PHASE_HEADING_SCAN_LITERAL_CANON_REFS.some((ref) => line.includes(ref))) continue;
280
+ if (isSanctionedByPrecedingComment(lines, i, OWNER_RE)) continue;
281
+ out.push({ line: i + 1, found: m[0] });
282
+ }
283
+ return out;
284
+ }
285
+
237
286
  // #4634: ban base-10-forced shell arithmetic (`$((10#...))`) on a variable
238
287
  // that still carries a possibly-decimal/multi-segment phase id — this
239
288
  // construct is exactly the pattern that breaks on a value like `08.5`. The
@@ -246,11 +295,14 @@ function findBranchSlugFallbackDrift(text) {
246
295
  // 1. Prose mentioning the literal pattern in a full-line `#`-comment
247
296
  // (filtered by the caller, not this regex — see below).
248
297
  // 2. `$((10#$PHASE_INT))` / `$((10#$SPOT_PHASE_INT))` — arithmetic on the
249
- // NOW-safe variable the #4619 fix produces via `PHASE_INT=${PHASE_NUMBER%%.*}`;
250
- // a `%%.*`-stripped value can never contain a dot, so base-10 arithmetic
251
- // on it can never hit the #4619 syntax-error class. Any name ending in
252
- // `_INT` (case-insensitive) is that established "already reduced to a
253
- // safe integer" convention.
298
+ // NOW-safe variable the #4619 fix produces via a leading-digit-run split
299
+ // (`PHASE_INT=${PHASE_NUMBER%%[!0-9]*}` since #4748; `%%.*` before it);
300
+ // a digit-run value can never contain a dot OR a letter, so base-10
301
+ // arithmetic on it can never hit the #4619 / #4748 error classes. Any
302
+ // name ending in `_INT` (case-insensitive) is that established "already
303
+ // reduced to a safe integer" convention. That convention is a NAME, not
304
+ // a proof — `findDotOnlyIntegerSplitDrift` below polices that the split
305
+ // producing it actually stops at the first non-digit.
254
306
  // 3. `$((10#{plan_padded}))` / `$((10#${PLAN_ID}))` — plan ids are plain
255
307
  // integers and were never in scope; this rule only polices variables
256
308
  // that carry a *phase* id.
@@ -389,6 +441,204 @@ function scanMarkdownSingleSegmentPhaseRegex(root) {
389
441
  return violations;
390
442
  }
391
443
 
444
+ // #4660 (epic #4634): the six shell/markdown mirrors #4568 widened on the
445
+ // segment-count axis stayed digit-only on the LETTER axis — the canonical
446
+ // grammar (`src/phase-id.cts`) is `\d+[A-Z]?(?:\.\d+)*`, with an optional
447
+ // single uppercase letter after the leading digits (`12A`, `3A`, `23A.1.2`,
448
+ // documented in docs/CONFIGURATION.md and relied on by `renameIntegerPhases`).
449
+ // A digit-only mirror `[0-9]+(\.[0-9]+)*` hard-rejects (validating sites) or
450
+ // silently truncates (extracting sites) a letter-suffixed id. This rule is the
451
+ // ratchet for that axis, the twin of the single-segment rule above: it flags
452
+ // the unbounded-segment shape whose digit run is NOT followed by the letter
453
+ // class. `[A-Z]` is the canonical spelling; the case-flexible `[A-Za-z]`
454
+ // directory-scanning variant is a deliberately separate axis and is tolerated
455
+ // here so this rule cannot force it to narrow.
456
+ const LETTERLESS_PHASE_MIRROR_DRIFT_RE =
457
+ /(?:\\{1,2}d|\[0-9\])\+(?!\[A-Z(?:a-z)?\]\?)\(\\{1,2}\.(?:\\{1,2}d|\[0-9\])\+\)\*/;
458
+
459
+ /**
460
+ * Pure: find every unsanctioned digit-only (letter-less) unbounded-segment
461
+ * phase regex in `text`, restricted to lines that plausibly carry a
462
+ * phase-number variable — the same `PHASE_CARRYING_LINE_RE` filter and the
463
+ * same `<!-- phase-id-owner: ... -->` sanction as the single-segment rule.
464
+ * Returns [{ line, found }].
465
+ */
466
+ function findLetterlessPhaseMirrorDrift(text) {
467
+ const out = [];
468
+ const lines = text.split('\n');
469
+ for (let i = 0; i < lines.length; i++) {
470
+ const line = lines[i];
471
+ const m = LETTERLESS_PHASE_MIRROR_DRIFT_RE.exec(line);
472
+ if (!m) continue;
473
+ if (!PHASE_CARRYING_LINE_RE.test(line)) continue;
474
+ if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
475
+ out.push({ line: i + 1, found: m[0] });
476
+ }
477
+ return out;
478
+ }
479
+
480
+ /**
481
+ * Scan the same three markdown roots as the single-segment rule for
482
+ * unsanctioned letter-less phase-regex mirrors. Returns [{ file, line, found }]
483
+ * with repo-relative paths.
484
+ */
485
+ function scanMarkdownLetterlessPhaseMirror(root) {
486
+ const violations = [];
487
+ for (const dir of SINGLE_SEGMENT_SCAN_DIRS) {
488
+ for (const file of walkMd(path.join(root, dir), [])) {
489
+ const rel = path.relative(root, file);
490
+ let text;
491
+ try {
492
+ text = fs.readFileSync(file, 'utf8');
493
+ } catch {
494
+ continue;
495
+ }
496
+ for (const d of findLetterlessPhaseMirrorDrift(text)) {
497
+ violations.push({ file: rel, kind: 'letterless-phase-mirror', ...d });
498
+ }
499
+ }
500
+ }
501
+ return violations;
502
+ }
503
+
504
+ // #4748 (epic #4634): three shell shapes OUTSIDE the grammar-mirror family the
505
+ // rules above police — consumers of a phase id rather than regexes for one —
506
+ // each of which the letter axis broke while every rule above reported clean:
507
+ //
508
+ // a. `PHASE_INT=${PHASE_NUMBER%%.*}` — the post-#4619 dot-only split. The
509
+ // `_INT` name it produces satisfies the shell-arithmetic rule's escape,
510
+ // but on `03A` the "integer" is `03A` and `$((10#03A))` aborts. The safe
511
+ // split stops at the first NON-digit: `${PHASE_NUMBER%%[!0-9]*}`.
512
+ // b. `[0-9]+\.?[0-9]*` (and `\d+\.?\d*`) — a digit-then-optional-dot
513
+ // extraction that is neither the bounded `(\.[0-9]+)?` shape the
514
+ // single-segment rule bans nor the unbounded `(\.[0-9]+)*` shape the
515
+ // letterless rule inspects, so both were blind to it. It captures `12`
516
+ // from `12A` and `23.1` from `23.1.2`, silently.
517
+ // c. `printf "%02d" "$PHASE_NUMBER"` — re-padding the whole id in shell.
518
+ // Rejects a letter id (prints `03`, exit 1) and misreads an already
519
+ // padded `08` as octal (prints `00`). Padding belongs to the canonical
520
+ // normalizer (`padded_phase` from init, or `normalizePhaseName`); the one
521
+ // legitimate shell pad is of an `_INT` value via `$((10#…))`.
522
+ //
523
+ // Same sanction as the other markdown rules: `<!-- phase-id-owner: … -->` on
524
+ // the nearest preceding non-blank line. Same documented limit: a per-line
525
+ // textual scan for the common accidental shape, not an obfuscated one.
526
+
527
+ // a. `<name>_INT=${<phase-carrying>%%.*}` — the `_INT` destination is the
528
+ // discriminator, deliberately: it is the name the shell-arithmetic rule
529
+ // trusts as "already a safe integer", so a dot-only split INTO it is the
530
+ // exact promise this rule exists to check. A dot-only split into any other
531
+ // name is a different, legitimate operation — `PARENT_PHASE="${PHASE_NUMBER%%.*}"`
532
+ // (gap-closure-artifacts.md) wants everything before the first dot, letter
533
+ // included, and is correct. Widening to any destination was tried and flagged
534
+ // that site. The optional quote after `=` is the one spelling that site uses.
535
+ const DOT_ONLY_INT_SPLIT_DRIFT_RE = /[A-Za-z0-9_]*_INT="?\$\{([A-Za-z0-9_]+)%%\\?\.\*\}/i;
536
+
537
+ /**
538
+ * Pure: find every unsanctioned dot-only integer split of a phase-carrying
539
+ * variable in `text`. Skips full-line `#` comments (prose). Returns [{ line, found }].
540
+ */
541
+ function findDotOnlyIntegerSplitDrift(text) {
542
+ const out = [];
543
+ const lines = text.split('\n');
544
+ for (let i = 0; i < lines.length; i++) {
545
+ const line = lines[i];
546
+ if (/^\s*#/.test(line)) continue;
547
+ const m = DOT_ONLY_INT_SPLIT_DRIFT_RE.exec(line);
548
+ if (!m) continue;
549
+ if (!/phase/i.test(m[1])) continue;
550
+ if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
551
+ out.push({ line: i + 1, found: m[0] });
552
+ }
553
+ return out;
554
+ }
555
+
556
+ // b. The digit-then-optional-dot shape, in both `[0-9]` and `\d` spellings.
557
+ // Anchored on the trailing `*` of the second digit class so a bare `[0-9]+`
558
+ // probe or a `[0-9]+\.[0-9]+` (mandatory-dot) shape is not matched.
559
+ const LOOSE_DOTTED_PHASE_DRIFT_RE = /(?:\\{1,2}d|\[0-9\])\+\\{1,2}\.\?(?:\\{1,2}d|\[0-9\])\*/;
560
+
561
+ /**
562
+ * Pure: find every unsanctioned `[0-9]+\.?[0-9]*`-shaped phase extraction in
563
+ * `text`, restricted to phase-carrying lines like its two sibling regex rules.
564
+ * Returns [{ line, found }].
565
+ */
566
+ function findLooseDottedPhaseRegexDrift(text) {
567
+ const out = [];
568
+ const lines = text.split('\n');
569
+ for (let i = 0; i < lines.length; i++) {
570
+ const line = lines[i];
571
+ const m = LOOSE_DOTTED_PHASE_DRIFT_RE.exec(line);
572
+ if (!m) continue;
573
+ if (!PHASE_CARRYING_LINE_RE.test(line)) continue;
574
+ if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
575
+ out.push({ line: i + 1, found: m[0] });
576
+ }
577
+ return out;
578
+ }
579
+
580
+ // c. `printf "%Nd" …` / `printf '%0Nd' …` — any integer conversion, either
581
+ // quote, with or without the zero flag — whose argument list names a
582
+ // phase-carrying variable that is NOT an `_INT` (the `$((10#$PHASE_INT))`
583
+ // pad is the sanctioned shape). `%d` cannot parse a letter id under any
584
+ // width, so the flag is not the discriminator. Captures the first such name
585
+ // so the report says what was padded.
586
+ const SHELL_PHASE_PRINTF_PAD_RE = /printf\s+(?:"%0?\d*d[^"]*"|'%0?\d*d[^']*')\s+(.*)$/;
587
+ const SHELL_VAR_NAME_RE = /\$\{?([A-Za-z_][A-Za-z0-9_]*)/g;
588
+
589
+ /**
590
+ * Pure: find every unsanctioned `printf "%02d"` re-pad of a phase-carrying,
591
+ * non-`_INT` shell variable in `text`. Skips full-line `#` comments.
592
+ * Returns [{ line, found }].
593
+ */
594
+ function findShellPhasePrintfPadDrift(text) {
595
+ const out = [];
596
+ const lines = text.split('\n');
597
+ for (let i = 0; i < lines.length; i++) {
598
+ const line = lines[i];
599
+ if (/^\s*#/.test(line)) continue;
600
+ const m = SHELL_PHASE_PRINTF_PAD_RE.exec(line);
601
+ if (!m) continue;
602
+ const offender = [...m[1].matchAll(SHELL_VAR_NAME_RE)]
603
+ .map((v) => v[1])
604
+ .find((name) => /phase/i.test(name) && !/_int$/i.test(name));
605
+ if (!offender) continue;
606
+ if (isSanctionedByPrecedingComment(lines, i, MD_OWNER_RE)) continue;
607
+ out.push({ line: i + 1, found: `printf "%0…d" …$${offender}` });
608
+ }
609
+ return out;
610
+ }
611
+
612
+ /**
613
+ * Scan the shell roots (`gsd-core/workflows/**\/*.md`, `gsd-core/references/**\/*.md`)
614
+ * for the two shell-idiom rules (a, c) and the three regex roots (those plus
615
+ * `agents/**\/*.md`) for the extraction-shape rule (b). Returns
616
+ * [{ file, kind, line, found }] with repo-relative paths.
617
+ */
618
+ function scanMarkdownLetterAxisConsumers(root) {
619
+ const violations = [];
620
+ const collect = (dirs, finder, kind) => {
621
+ for (const dir of dirs) {
622
+ for (const file of walkMd(path.join(root, dir), [])) {
623
+ const rel = path.relative(root, file);
624
+ let text;
625
+ try {
626
+ text = fs.readFileSync(file, 'utf8');
627
+ } catch {
628
+ continue;
629
+ }
630
+ for (const d of finder(text)) {
631
+ violations.push({ file: rel, kind, ...d });
632
+ }
633
+ }
634
+ }
635
+ };
636
+ collect(MD_SCAN_DIRS, findDotOnlyIntegerSplitDrift, 'dot-only-int-split');
637
+ collect(SINGLE_SEGMENT_SCAN_DIRS, findLooseDottedPhaseRegexDrift, 'loose-dotted-phase-regex');
638
+ collect(MD_SCAN_DIRS, findShellPhasePrintfPadDrift, 'shell-phase-printf-pad');
639
+ return violations;
640
+ }
641
+
392
642
  // Authored TypeScript source only (the generated bin/lib/*.cjs mirror it).
393
643
  const SCAN_DIRS = ['src'];
394
644
  const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
@@ -523,6 +773,16 @@ function scanRepo(root) {
523
773
  for (const d of findBranchSlugFallbackDrift(text)) {
524
774
  violations.push({ file: rel, kind: 'branch-slug-fallback', ...d });
525
775
  }
776
+ // #4906 Phase 5 (#4984): heading-scan literal drift, owner file exempt
777
+ // via the shared `EXEMPT` check above. Pre-existing sites this phase
778
+ // could not reach (src/phase.cts, src/roadmap-parser.cts,
779
+ // src/planning-snapshot.cts) are grandfathered with a `// phase-id-owner:`
780
+ // sanction each, per ADR-4910 §8's census/grandfather/delete Phase 6 plan
781
+ // — the allowlist is those sanction comments, not a separate list here,
782
+ // and it is deleted entry-by-entry as each site migrates.
783
+ for (const d of findPhaseHeadingScanLiteralDrift(text)) {
784
+ violations.push({ file: rel, kind: 'phase-heading-scan-literal', ...d });
785
+ }
526
786
  }
527
787
  }
528
788
  return violations;
@@ -541,6 +801,8 @@ function scanAll(root) {
541
801
  ...scanRepo(root),
542
802
  ...scanMarkdownShellArith(root),
543
803
  ...scanMarkdownSingleSegmentPhaseRegex(root),
804
+ ...scanMarkdownLetterlessPhaseMirror(root),
805
+ ...scanMarkdownLetterAxisConsumers(root),
544
806
  ];
545
807
  }
546
808
 
@@ -567,8 +829,19 @@ function main() {
567
829
  process.stderr.write('near-variant) is banned outright in gsd-core/workflows/**/*.md,\n');
568
830
  process.stderr.write('gsd-core/references/**/*.md, and agents/**/*.md — widen it to `*` (unbounded\n');
569
831
  process.stderr.write('segments) or sanction with `<!-- phase-id-owner: <reason> -->`.\n');
832
+ process.stderr.write('A digit-only unbounded-segment phase regex `[0-9]+(\\.[0-9]+)*` on the same roots\n');
833
+ process.stderr.write('is missing the canonical letter axis (#4660) — widen to `[0-9]+[A-Z]?(\\.[0-9]+)*`\n');
834
+ process.stderr.write('or sanction with `<!-- phase-id-owner: <reason> -->`.\n');
835
+ process.stderr.write('Three letter-hostile consumers of a phase id are banned on the same roots (#4748):\n');
836
+ process.stderr.write('a dot-only integer split `X_INT=${PHASE%%.*}` (split at the first non-digit,\n');
837
+ process.stderr.write('`${PHASE%%[!0-9]*}`); a `[0-9]+\\.?[0-9]*` extraction (use `[0-9]+[A-Z]?(\\.[0-9]+)*`);\n');
838
+ process.stderr.write('and a `printf "%02d"` re-pad of a phase variable (bind init\'s `padded_phase`, or pad\n');
839
+ process.stderr.write('only an `_INT` via `$((10#…))`) — or sanction with `<!-- phase-id-owner: <reason> -->`.\n');
570
840
  process.stderr.write('A `.replace(\'{slug}\', ... || \'phase\')` fallback is banned outright (#4126) —\n');
571
841
  process.stderr.write('use `renderPhaseBranchName(` or sanction with\n');
842
+ process.stderr.write('`// phase-id-owner: <reason>` on the line directly above.\n');
843
+ process.stderr.write('A hand-rolled `#{2,4}\\s*Phase\\s+` heading-scan literal is banned (#4906 Phase 5) —\n');
844
+ process.stderr.write('use `buildPhaseHeadingScanRegex(` / `phaseHeadingPrefixSrcFor(` or sanction with\n');
572
845
  process.stderr.write('`// phase-id-owner: <reason>` on the line directly above:\n');
573
846
  for (const d of violations) {
574
847
  process.stderr.write(` [${d.kind}] ${d.file}:${d.line} ${d.found}\n`);
@@ -583,10 +856,17 @@ module.exports = {
583
856
  findBracketGrammarDrift,
584
857
  findNameValidityDrift,
585
858
  findBranchSlugFallbackDrift,
859
+ findPhaseHeadingScanLiteralDrift,
586
860
  findShellPhaseArithDrift,
587
861
  findSingleSegmentPhaseRegexDrift,
862
+ findLetterlessPhaseMirrorDrift,
863
+ findDotOnlyIntegerSplitDrift,
864
+ findLooseDottedPhaseRegexDrift,
865
+ findShellPhasePrintfPadDrift,
588
866
  scanMarkdownShellArith,
589
867
  scanMarkdownSingleSegmentPhaseRegex,
868
+ scanMarkdownLetterlessPhaseMirror,
869
+ scanMarkdownLetterAxisConsumers,
590
870
  scanRepo,
591
871
  scanAll,
592
872
  countSelectorBaselines,
@@ -595,6 +875,11 @@ module.exports = {
595
875
  BRACKET_CODE_DRIFT_RE,
596
876
  NAME_VALIDITY_DRIFT_RE,
597
877
  BRANCH_SLUG_FALLBACK_DRIFT_RE,
878
+ PHASE_HEADING_SCAN_LITERAL_DRIFT_RE,
598
879
  SHELL_PHASE_ARITH_DRIFT_RE,
599
880
  SINGLE_SEGMENT_PHASE_DRIFT_RE,
881
+ LETTERLESS_PHASE_MIRROR_DRIFT_RE,
882
+ DOT_ONLY_INT_SPLIT_DRIFT_RE,
883
+ LOOSE_DOTTED_PHASE_DRIFT_RE,
884
+ SHELL_PHASE_PRINTF_PAD_RE,
600
885
  };
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * PR-branch `.planning/` path-classification drift guard (ADR-4910 §8, epic
6
+ * #4906 Phase 5; issues #4605, #4606).
7
+ *
8
+ * `src/pr-branch-patterns.cts` (compiled to
9
+ * `gsd-core/bin/lib/pr-branch-patterns.cjs`) is the single-source-of-truth
10
+ * registry for the three `.planning/` path-classification values
11
+ * `gsd-core/workflows/pr-branch.md` needs: `TRANSIENT_DIRS_SRC`,
12
+ * `STRUCTURAL_RE_SRC`, and `MILESTONE_PHASES_RE_SRC`. A workflow markdown
13
+ * file has no module system — it cannot `require()` that `.cts` source at
14
+ * runtime — so its `create_pr_branch`/`verify` steps carry a hand-copied
15
+ * mirror of each value inside a `NAME="..."` bash assignment. This guard
16
+ * asserts each mirror still matches its canonical value VERBATIM, so the two
17
+ * copies can never silently drift apart (the exact bug class ADR-2143 §3
18
+ * names for markdown-mirrored source-of-truth values, applied here to a
19
+ * bash-variable mirror instead of a pipe-table header).
20
+ *
21
+ * Modeled directly on `scripts/lint-table-schema-drift.cjs`: a standalone
22
+ * node script (not a node:test), pure detector function + `scanRepo` seam,
23
+ * wired into `lint:ci`, exit 0 clean / exit 1 + message on drift.
24
+ */
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ // Canonical-source-constant name -> the exact bash variable name it mirrors
30
+ // inside gsd-core/workflows/pr-branch.md.
31
+ const PATTERN_VARS = {
32
+ TRANSIENT_DIRS_SRC: 'TRANSIENT_DIRS',
33
+ STRUCTURAL_RE_SRC: 'STRUCTURAL_RE',
34
+ MILESTONE_PHASES_RE_SRC: 'MILESTONE_PHASES_RE',
35
+ };
36
+
37
+ const WORKFLOW_REL_PATH = path.join('gsd-core', 'workflows', 'pr-branch.md');
38
+
39
+ /** Build the exact `NAME="value"` bash assignment line for one pattern. */
40
+ function buildAssignment(bashVar, value) {
41
+ return `${bashVar}="${value}"`;
42
+ }
43
+
44
+ /**
45
+ * Pure: given `patterns` (a `{ SRC_CONST_NAME: value }` map, shaped like the
46
+ * `pr-branch-patterns.cjs` seam) and a `readFile(relPath) -> string|null`
47
+ * accessor, find every pattern whose exact `NAME="value"` assignment does
48
+ * not appear verbatim in the canonical workflow file. Returns
49
+ * `[{ constName, bashVar, expected, reason }]`; empty when clean.
50
+ */
51
+ function findPrBranchPatternDrift(patterns, readFile, vars = PATTERN_VARS, workflowPath = WORKFLOW_REL_PATH) {
52
+ const violations = [];
53
+ const content = readFile(workflowPath);
54
+
55
+ for (const [constName, bashVar] of Object.entries(vars)) {
56
+ const value = patterns[constName];
57
+ if (typeof value !== 'string') {
58
+ violations.push({
59
+ constName,
60
+ bashVar,
61
+ expected: null,
62
+ reason: `${constName} missing or not a string in the pr-branch-patterns seam`,
63
+ });
64
+ continue;
65
+ }
66
+
67
+ if (content == null) {
68
+ violations.push({
69
+ constName,
70
+ bashVar,
71
+ expected: buildAssignment(bashVar, value),
72
+ reason: 'source file not found or unreadable',
73
+ });
74
+ continue;
75
+ }
76
+
77
+ const expected = buildAssignment(bashVar, value);
78
+ if (!content.includes(expected)) {
79
+ violations.push({
80
+ constName,
81
+ bashVar,
82
+ expected,
83
+ reason: 'assignment not found verbatim in canonical workflow file',
84
+ });
85
+ }
86
+ }
87
+ return violations;
88
+ }
89
+
90
+ /**
91
+ * Load the built seam and scan the real repo tree. Returns the same shape as
92
+ * `findPrBranchPatternDrift`. If the seam hasn't been built yet (`npm run
93
+ * build:lib`), reports a single actionable violation rather than throwing.
94
+ */
95
+ function scanRepo(root) {
96
+ const seamPath = path.join(root, 'gsd-core', 'bin', 'lib', 'pr-branch-patterns.cjs');
97
+ let seam;
98
+ try {
99
+ seam = require(seamPath);
100
+ } catch (e) {
101
+ return [{
102
+ constName: null,
103
+ bashVar: null,
104
+ expected: null,
105
+ reason: `cannot load the pr-branch-patterns seam at ${path.relative(root, seamPath)} — run 'npm run build:lib' first (${e.message})`,
106
+ }];
107
+ }
108
+
109
+ const readFile = (relPath) => {
110
+ try {
111
+ return fs.readFileSync(path.join(root, relPath), 'utf8');
112
+ } catch {
113
+ return null;
114
+ }
115
+ };
116
+
117
+ return findPrBranchPatternDrift(seam, readFile);
118
+ }
119
+
120
+ function main() {
121
+ const root = path.join(__dirname, '..');
122
+ const violations = scanRepo(root);
123
+ if (violations.length === 0) {
124
+ process.stdout.write(
125
+ 'ok pr-branch-pattern-drift: every pr-branch-patterns.cts constant appears verbatim in gsd-core/workflows/pr-branch.md\n',
126
+ );
127
+ return;
128
+ }
129
+ process.stderr.write(
130
+ 'pr-branch-pattern-drift: pr-branch-patterns.cts constant(s) whose assignment is absent from gsd-core/workflows/pr-branch.md (ADR-4910 §8, #4605).\n',
131
+ );
132
+ process.stderr.write(
133
+ 'Either update gsd-core/workflows/pr-branch.md to mirror the canonical value verbatim, or update\n'
134
+ + 'src/pr-branch-patterns.cts to match — the two must never drift.\n',
135
+ );
136
+ for (const v of violations) {
137
+ const id = v.bashVar ? `${v.constName} (${v.bashVar})` : (v.constName ?? '?');
138
+ const expected = v.expected ? ` — expected ${JSON.stringify(v.expected)}` : '';
139
+ process.stderr.write(` ${id}: ${v.reason}${expected}\n`);
140
+ }
141
+ process.exitCode = 1;
142
+ }
143
+
144
+ if (require.main === module) main();
145
+
146
+ module.exports = {
147
+ findPrBranchPatternDrift, scanRepo, buildAssignment, PATTERN_VARS, WORKFLOW_REL_PATH,
148
+ };