@opengsd/gsd-core 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-executor.md +63 -35
  5. package/agents/gsd-plan-checker.md +76 -57
  6. package/agents/gsd-planner.md +14 -0
  7. package/agents/gsd-ui-checker.md +19 -3
  8. package/agents/gsd-ui-researcher.md +29 -0
  9. package/agents/gsd-verifier.md +23 -1
  10. package/bin/install.js +239 -67
  11. package/commands/gsd/execute-phase.md +1 -1
  12. package/commands/gsd/ns-workflow.md +2 -1
  13. package/commands/gsd/phase.md +1 -1
  14. package/commands/gsd/quick-batch.md +105 -0
  15. package/commands/gsd/surface.md +18 -8
  16. package/gsd-core/bin/gsd-tools.cjs +195 -50
  17. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +514 -114
  19. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  20. package/gsd-core/bin/lib/capability-validator.cjs +120 -4
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  22. package/gsd-core/bin/lib/check-command-router.cjs +85 -2
  23. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  24. package/gsd-core/bin/lib/clusters.cjs +1 -0
  25. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  26. package/gsd-core/bin/lib/commands.cjs +337 -13
  27. package/gsd-core/bin/lib/config-loader.cjs +3 -0
  28. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  29. package/gsd-core/bin/lib/decisions.cjs +213 -1
  30. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  31. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  32. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  33. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  34. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  35. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  36. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  37. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  38. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  39. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  40. package/gsd-core/bin/lib/init.cjs +132 -15
  41. package/gsd-core/bin/lib/install-engine.cjs +184 -12
  42. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  43. package/gsd-core/bin/lib/install-profiles.cjs +22 -14
  44. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  45. package/gsd-core/bin/lib/io.cjs +35 -0
  46. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  47. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  48. package/gsd-core/bin/lib/milestone.cjs +22 -2
  49. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  50. package/gsd-core/bin/lib/phase-id.cjs +251 -9
  51. package/gsd-core/bin/lib/phase.cjs +774 -35
  52. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  53. package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
  54. package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
  55. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  56. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  57. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  58. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  59. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  60. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  61. package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
  62. package/gsd-core/bin/lib/roadmap.cjs +187 -58
  63. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
  64. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  65. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
  66. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
  67. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  68. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  69. package/gsd-core/bin/lib/state-document.cjs +30 -5
  70. package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
  71. package/gsd-core/bin/lib/state-transition.cjs +333 -44
  72. package/gsd-core/bin/lib/state.cjs +684 -125
  73. package/gsd-core/bin/lib/surface.cjs +23 -8
  74. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  75. package/gsd-core/bin/lib/uat.cjs +1419 -515
  76. package/gsd-core/bin/lib/update-context.cjs +6 -2
  77. package/gsd-core/bin/lib/validate.cjs +230 -12
  78. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  79. package/gsd-core/bin/lib/verification.cjs +273 -12
  80. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  81. package/gsd-core/bin/lib/verify.cjs +346 -16
  82. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
  84. package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
  85. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  86. package/gsd-core/references/agent-contracts.md +3 -3
  87. package/gsd-core/references/edge-probe.md +17 -13
  88. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  89. package/gsd-core/references/execute-phase-response-language.md +6 -0
  90. package/gsd-core/references/executor-examples.md +42 -0
  91. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  92. package/gsd-core/references/mvp-concepts.md +2 -2
  93. package/gsd-core/references/plan-checker-examples.md +41 -0
  94. package/gsd-core/references/planner-antipatterns.md +25 -0
  95. package/gsd-core/references/planner-chunked.md +5 -1
  96. package/gsd-core/references/planner-coupling.md +42 -0
  97. package/gsd-core/references/planner-quick-batch.md +71 -0
  98. package/gsd-core/references/planner-reviews.md +47 -0
  99. package/gsd-core/references/planner-revision.md +75 -2
  100. package/gsd-core/references/planning-config.md +2 -1
  101. package/gsd-core/references/response-language-directive.md +9 -0
  102. package/gsd-core/references/revision-loop.md +118 -11
  103. package/gsd-core/references/tdd.md +14 -9
  104. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  105. package/gsd-core/templates/phase-prompt.md +4 -0
  106. package/gsd-core/templates/verification-report.md +5 -0
  107. package/gsd-core/workflows/add-backlog.md +2 -0
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +1 -1
  110. package/gsd-core/workflows/add-todo.md +1 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  112. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  113. package/gsd-core/workflows/audit-fix.md +2 -0
  114. package/gsd-core/workflows/audit-milestone.md +2 -0
  115. package/gsd-core/workflows/audit-uat.md +2 -0
  116. package/gsd-core/workflows/autonomous.md +2 -0
  117. package/gsd-core/workflows/check-todos.md +1 -1
  118. package/gsd-core/workflows/cleanup.md +1 -1
  119. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
  120. package/gsd-core/workflows/code-review-fix.md +2 -0
  121. package/gsd-core/workflows/code-review.md +73 -31
  122. package/gsd-core/workflows/complete-milestone.md +13 -4
  123. package/gsd-core/workflows/debug.md +1 -1
  124. package/gsd-core/workflows/diagnose-issues.md +5 -1
  125. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  126. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  127. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  128. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  129. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  130. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  131. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  132. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  133. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  134. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  135. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  136. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  137. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  138. package/gsd-core/workflows/discuss-phase.md +1 -1
  139. package/gsd-core/workflows/do.md +43 -13
  140. package/gsd-core/workflows/docs-update.md +1 -1
  141. package/gsd-core/workflows/edit-phase.md +2 -0
  142. package/gsd-core/workflows/eval-review.md +1 -1
  143. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
  144. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
  145. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  146. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  147. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  148. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  149. package/gsd-core/workflows/execute-phase.md +32 -14
  150. package/gsd-core/workflows/execute-plan.md +8 -8
  151. package/gsd-core/workflows/explore.md +2 -0
  152. package/gsd-core/workflows/extract-learnings.md +2 -0
  153. package/gsd-core/workflows/fast.md +6 -0
  154. package/gsd-core/workflows/forensics.md +2 -0
  155. package/gsd-core/workflows/graduation.md +1 -1
  156. package/gsd-core/workflows/health.md +1 -1
  157. package/gsd-core/workflows/help/modes/brief.md +2 -0
  158. package/gsd-core/workflows/help/modes/default.md +2 -0
  159. package/gsd-core/workflows/help/modes/full.md +12 -0
  160. package/gsd-core/workflows/help/modes/topic.md +2 -0
  161. package/gsd-core/workflows/help.md +2 -0
  162. package/gsd-core/workflows/import.md +3 -3
  163. package/gsd-core/workflows/inbox.md +1 -1
  164. package/gsd-core/workflows/ingest-docs.md +1 -1
  165. package/gsd-core/workflows/insert-phase.md +2 -0
  166. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  167. package/gsd-core/workflows/list-seeds.md +2 -0
  168. package/gsd-core/workflows/list-workspaces.md +2 -0
  169. package/gsd-core/workflows/manager.md +3 -3
  170. package/gsd-core/workflows/map-codebase.md +2 -0
  171. package/gsd-core/workflows/milestone-summary.md +2 -0
  172. package/gsd-core/workflows/mvp-phase.md +1 -1
  173. package/gsd-core/workflows/new-milestone.md +1 -1
  174. package/gsd-core/workflows/new-project.md +5 -3
  175. package/gsd-core/workflows/new-workspace.md +1 -1
  176. package/gsd-core/workflows/next.md +2 -0
  177. package/gsd-core/workflows/node-repair.md +2 -0
  178. package/gsd-core/workflows/note.md +2 -0
  179. package/gsd-core/workflows/onboard.md +1 -1
  180. package/gsd-core/workflows/pause-work.md +19 -4
  181. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  182. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  183. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  184. package/gsd-core/workflows/plan-phase.md +130 -12
  185. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  186. package/gsd-core/workflows/plant-seed.md +1 -1
  187. package/gsd-core/workflows/pr-branch.md +11 -3
  188. package/gsd-core/workflows/profile-user.md +1 -1
  189. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  190. package/gsd-core/workflows/progress.md +25 -3
  191. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  192. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  193. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  194. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  195. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  196. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  197. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  198. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  199. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  200. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  201. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  202. package/gsd-core/workflows/quick-batch.md +203 -0
  203. package/gsd-core/workflows/quick.md +13 -3
  204. package/gsd-core/workflows/reapply-patches.md +2 -0
  205. package/gsd-core/workflows/remove-phase.md +2 -0
  206. package/gsd-core/workflows/remove-workspace.md +1 -1
  207. package/gsd-core/workflows/resume-project.md +6 -2
  208. package/gsd-core/workflows/review.md +215 -10
  209. package/gsd-core/workflows/scan.md +2 -0
  210. package/gsd-core/workflows/section-manifest.json +12 -0
  211. package/gsd-core/workflows/secure-phase.md +1 -1
  212. package/gsd-core/workflows/session-report.md +2 -0
  213. package/gsd-core/workflows/settings-advanced.md +2 -0
  214. package/gsd-core/workflows/settings-integrations.md +9 -8
  215. package/gsd-core/workflows/settings.md +1 -1
  216. package/gsd-core/workflows/ship.md +10 -10
  217. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  218. package/gsd-core/workflows/sketch.md +1 -1
  219. package/gsd-core/workflows/smart-entry.md +1 -1
  220. package/gsd-core/workflows/spec-phase.md +24 -19
  221. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  222. package/gsd-core/workflows/spike.md +1 -1
  223. package/gsd-core/workflows/stats.md +2 -0
  224. package/gsd-core/workflows/sync-skills.md +12 -4
  225. package/gsd-core/workflows/thread.md +2 -0
  226. package/gsd-core/workflows/transition.md +2 -0
  227. package/gsd-core/workflows/ui-phase.md +26 -5
  228. package/gsd-core/workflows/ui-review.md +1 -1
  229. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  230. package/gsd-core/workflows/undo.md +1 -1
  231. package/gsd-core/workflows/update.md +41 -38
  232. package/gsd-core/workflows/validate-phase.md +1 -1
  233. package/gsd-core/workflows/verify-work.md +49 -3
  234. package/hooks/dist/gsd-check-update-worker.js +19 -2
  235. package/hooks/dist/gsd-context-monitor.js +283 -12
  236. package/hooks/dist/gsd-node-runner.sh +1 -0
  237. package/hooks/dist/gsd-prompt-guard.js +30 -5
  238. package/hooks/dist/gsd-read-guard.js +2 -0
  239. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  240. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  241. package/hooks/dist/gsd-statusline.js +7 -3
  242. package/hooks/dist/gsd-validate-commit.sh +444 -7
  243. package/hooks/dist/gsd-workflow-guard.js +2 -1
  244. package/hooks/dist/lib/git-cmd.js +210 -1
  245. package/hooks/dist/lib/injection-patterns.js +36 -6
  246. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  247. package/hooks/gsd-check-update-worker.js +19 -2
  248. package/hooks/gsd-context-monitor.js +283 -12
  249. package/hooks/gsd-node-runner.sh +1 -0
  250. package/hooks/gsd-prompt-guard.js +30 -5
  251. package/hooks/gsd-read-guard.js +2 -0
  252. package/hooks/gsd-read-injection-scanner.js +5 -5
  253. package/hooks/gsd-secret-read-guard.js +1079 -0
  254. package/hooks/gsd-statusline.js +7 -3
  255. package/hooks/gsd-validate-commit.sh +444 -7
  256. package/hooks/gsd-workflow-guard.js +2 -1
  257. package/hooks/hooks.json +6 -0
  258. package/hooks/lib/git-cmd.js +210 -1
  259. package/hooks/lib/injection-patterns.js +36 -6
  260. package/hooks/managed-hooks-registry.cjs +1 -0
  261. package/package.json +5 -5
  262. package/scripts/build-hooks.js +11 -4
  263. package/scripts/ci-test-scope.cjs +7 -0
  264. package/scripts/docs-guard-registry.cjs +10 -0
  265. package/scripts/gen-loop-host-contract.cjs +67 -15
  266. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  267. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  268. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  269. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  270. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
  271. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  272. package/scripts/lint-phase-id-drift.cjs +133 -8
  273. package/scripts/lint-portable-grep.cjs +176 -0
  274. package/scripts/lint-response-language-coverage.cjs +524 -0
  275. package/scripts/lint-test-file-count.allowlist.json +3 -1
  276. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  277. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  278. package/scripts/npm-audit-baseline.cjs +376 -0
  279. package/scripts/prompt-injection-scan.sh +8 -0
  280. package/scripts/require-issue-link-policy.cjs +16 -1
  281. package/skills/gsd-execute-phase/SKILL.md +1 -1
  282. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  283. package/skills/gsd-phase/SKILL.md +1 -1
  284. package/skills/gsd-quick-batch/SKILL.md +105 -0
  285. package/skills/gsd-surface/SKILL.md +18 -8
  286. package/vscode/package.json +1 -1
@@ -0,0 +1,247 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * shellcheck-fetch.cjs
5
+ *
6
+ * Dependency-free replacement for the `shellcheck` npm package (removed in
7
+ * #4120 — its extraction step pulled in `decompress@4.2.1`, which carries an
8
+ * unpatched CRITICAL zip-slip vulnerability, GHSA-mp2f-45pm-3cg9, CVSS 9.1,
9
+ * plus two moderate findings, with no patched version available upstream).
10
+ *
11
+ * This module fetches a PINNED koalaman/shellcheck release directly from
12
+ * GitHub releases (never "latest" — see SHELLCHECK_VERSION below), extracts
13
+ * the single `shellcheck` binary from the release's `.tar.gz` asset using
14
+ * only Node's built-in `https`/`zlib` modules plus a small hand-written tar
15
+ * reader (no third-party archive library), and caches the extracted binary
16
+ * for reuse across runs.
17
+ *
18
+ * Zip-slip defense: unlike `decompress`, which wrote extracted files using
19
+ * PATHS TAKEN FROM THE ARCHIVE (the exact defect class in GHSA-mp2f-45pm-
20
+ * 3cg9 — a malicious archive entry named e.g. `../../etc/passwd` gets
21
+ * written there verbatim), this reader NEVER uses an archive-supplied name
22
+ * as a filesystem path. `extractFileFromTar` only ever returns the matched
23
+ * entry's raw byte content; the caller (`resolveShellcheckBin`) writes those
24
+ * bytes to a path it constructs itself (`<cacheDir>/shellcheck`), and the
25
+ * archive's own name field is used only for a string-equality/suffix CHECK
26
+ * (`name === targetName || name.endsWith('/' + targetName)`), never
27
+ * interpolated into a path passed to `fs.writeFileSync`/`fs.mkdirSync`/etc.
28
+ */
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const zlib = require('node:zlib');
33
+ const https = require('node:https');
34
+ const { ExitError } = require('./cli-exit.cjs');
35
+
36
+ // Pinned explicitly — verified against `koalaman/shellcheck`'s GitHub
37
+ // releases API as the current latest tag at the time this was written
38
+ // (2026-08-31). Never resolved dynamically ("latest") — a moving target
39
+ // would make this lint's exact ShellCheck version, and therefore its exact
40
+ // finding set against scripts/lint-workflow-shellcheck-baseline.json,
41
+ // non-reproducible across runs/machines/CI.
42
+ const SHELLCHECK_VERSION = 'v0.11.0';
43
+
44
+ const ROOT = path.join(__dirname, '..', '..');
45
+ const CACHE_DIR = path.join(ROOT, 'node_modules', '.cache', 'shellcheck', SHELLCHECK_VERSION);
46
+ const CACHED_BIN_PATH = path.join(CACHE_DIR, 'shellcheck');
47
+
48
+ // process.arch -> the arch token ShellCheck's release asset names use.
49
+ // Only the two architectures that actually matter for this repo (per
50
+ // .github/workflows/test.yml: the lint-tests job that runs this script only
51
+ // runs on ubuntu-latest, which is x86_64; and Apple Silicon dev machines are
52
+ // aarch64) are supported — anything else fails with a clear error rather
53
+ // than guessing.
54
+ const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64' };
55
+
56
+ const MAX_REDIRECTS = 5;
57
+
58
+ // Bounds each individual HTTP hop (the initial request AND every redirect
59
+ // hop get their own fresh 30s budget, rather than one shared budget across
60
+ // the whole redirect chain) — a stalled connection on any single hop is
61
+ // caught in a bounded time, mirroring lint-workflow-shellcheck.cjs's own
62
+ // SHELLCHECK_TIMEOUT_MS bound on the ShellCheck subprocess. A bare `timeout`
63
+ // option on the request does NOT abort it by itself — Node only emits a
64
+ // 'timeout' event, which must be handled by destroying the request (see the
65
+ // `req.on('timeout', ...)` below).
66
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
67
+
68
+ /**
69
+ * Issue one real HTTPS request. Exists as its own function purely so tests
70
+ * can inject a fake in its place (see `httpsGetFollowingRedirects`'s
71
+ * `requestFn` parameter) — production callers never pass an override, so
72
+ * the real download path always uses this exact implementation.
73
+ */
74
+ function defaultRequestFn(url, options, callback) {
75
+ return https.get(url, options, callback);
76
+ }
77
+
78
+ /**
79
+ * GET `url` following HTTP redirects manually — `https.get` does NOT follow
80
+ * redirects automatically, and GitHub release asset URLs redirect through
81
+ * `objects.githubusercontent.com`. Resolves with the full response body as a
82
+ * Buffer once a 200 response is received.
83
+ *
84
+ * `requestFn` defaults to a real `https.get`-based transport
85
+ * (`defaultRequestFn`) and is only ever overridden in tests, so calling this
86
+ * with zero/one arg from `resolveShellcheckBin` is unchanged behavior.
87
+ */
88
+ function httpsGetFollowingRedirects(url, redirectsLeft = MAX_REDIRECTS, requestFn = defaultRequestFn) {
89
+ return new Promise((resolve, reject) => {
90
+ const req = requestFn(
91
+ url,
92
+ { headers: { 'User-Agent': 'gsd-core-shellcheck-fetch' }, timeout: DOWNLOAD_TIMEOUT_MS },
93
+ (res) => {
94
+ const status = res.statusCode || 0;
95
+ if (status >= 300 && status < 400 && res.headers.location) {
96
+ res.resume(); // drain so the socket can be reused/closed
97
+ if (redirectsLeft <= 0) {
98
+ reject(new Error(`too many redirects fetching ${url}`));
99
+ return;
100
+ }
101
+ const next = new URL(res.headers.location, url).toString();
102
+ httpsGetFollowingRedirects(next, redirectsLeft - 1, requestFn).then(resolve, reject);
103
+ return;
104
+ }
105
+ if (status !== 200) {
106
+ res.resume();
107
+ reject(new Error(`unexpected HTTP ${status} fetching ${url}`));
108
+ return;
109
+ }
110
+ const chunks = [];
111
+ res.on('data', (chunk) => chunks.push(chunk));
112
+ res.on('end', () => resolve(Buffer.concat(chunks)));
113
+ res.on('error', reject);
114
+ },
115
+ );
116
+ req.on('error', reject);
117
+ // `timeout` in the options above only ARMS a timer — Node emits a
118
+ // 'timeout' event on the request but does not abort it. Without this
119
+ // handler the request (and this Promise) would hang indefinitely past
120
+ // the configured bound on a stalled connection.
121
+ req.on('timeout', () => {
122
+ req.destroy(new Error(`timed out after ${DOWNLOAD_TIMEOUT_MS}ms fetching ${url}`));
123
+ });
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Find the entry named (or path-ending-in) `targetName` inside a raw
129
+ * (already gunzipped) POSIX tar byte stream and return its content as a
130
+ * Buffer, or `null` if not found.
131
+ *
132
+ * Tar format: a sequence of 512-byte headers — name at offset 0/length 100,
133
+ * size at offset 124/length 12 (octal ASCII), typeflag at offset 156 — each
134
+ * followed by that many content bytes, padded up to the next 512-byte
135
+ * boundary, terminated by an all-zero 512-byte block. This deliberately
136
+ * implements only enough to locate ONE known entry name (no general
137
+ * multi-file extraction, no symlink handling, no GNU long-name `@LongLink`
138
+ * entries — ShellCheck's own release tarballs never need them) — see this
139
+ * module's header comment for why the entry's NAME is never used as a
140
+ * filesystem path.
141
+ */
142
+ function extractFileFromTar(buffer, targetName) {
143
+ let offset = 0;
144
+ while (offset + 512 <= buffer.length) {
145
+ const header = buffer.subarray(offset, offset + 512);
146
+ if (header.every((b) => b === 0)) break; // end-of-archive marker
147
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '');
148
+ const sizeRaw = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim();
149
+ const size = sizeRaw === '' ? 0 : parseInt(sizeRaw, 8);
150
+ const typeflag = String.fromCharCode(header[156]);
151
+ const dataStart = offset + 512;
152
+ const isRegularFile = typeflag === '0' || typeflag === '\0';
153
+ if (isRegularFile && (name === targetName || name.endsWith(`/${targetName}`))) {
154
+ return buffer.subarray(dataStart, dataStart + size);
155
+ }
156
+ const contentBlocks = Math.ceil(size / 512);
157
+ offset = dataStart + contentBlocks * 512;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Resolve the local path to a working, executable `shellcheck` binary,
164
+ * downloading and caching the pinned release on first use. Subsequent calls
165
+ * (same version) reuse the cached binary with no network activity — mirrors
166
+ * the `fs.accessSync(bin, F_OK | X_OK)` cache-check pattern already used by
167
+ * this script's own `runShellcheck`.
168
+ */
169
+ async function resolveShellcheckBin() {
170
+ try {
171
+ fs.accessSync(CACHED_BIN_PATH, fs.constants.F_OK | fs.constants.X_OK);
172
+ return CACHED_BIN_PATH;
173
+ } catch {
174
+ // not cached yet — fall through to download
175
+ }
176
+
177
+ if (process.platform === 'win32') {
178
+ throw new ExitError(
179
+ 1,
180
+ 'lint-workflow-shellcheck: automatic ShellCheck download is not supported on Windows yet ' +
181
+ '(this lint only ever runs in the ubuntu-latest lint-tests CI job — see .github/workflows/test.yml — ' +
182
+ 'so this is an honest platform gap, not expected to be hit in CI).',
183
+ );
184
+ }
185
+ const platform = process.platform === 'darwin' || process.platform === 'linux' ? process.platform : null;
186
+ if (!platform) {
187
+ throw new ExitError(
188
+ 1,
189
+ `lint-workflow-shellcheck: unsupported platform '${process.platform}' for ShellCheck auto-download ` +
190
+ `(supported: linux, darwin).`,
191
+ );
192
+ }
193
+ const arch = ARCH_MAP[process.arch];
194
+ if (!arch) {
195
+ throw new ExitError(
196
+ 1,
197
+ `lint-workflow-shellcheck: unsupported architecture '${process.arch}' for ShellCheck auto-download ` +
198
+ `(supported: x86_64 [node arch 'x64'], aarch64 [node arch 'arm64']).`,
199
+ );
200
+ }
201
+
202
+ const assetName = `shellcheck-${SHELLCHECK_VERSION}.${platform}.${arch}.tar.gz`;
203
+ const url = `https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/${assetName}`;
204
+
205
+ let gz;
206
+ try {
207
+ gz = await httpsGetFollowingRedirects(url);
208
+ } catch (e) {
209
+ throw new ExitError(1, `lint-workflow-shellcheck: failed to download ShellCheck (${url}): ${e.message}`);
210
+ }
211
+
212
+ let tarBuf;
213
+ try {
214
+ tarBuf = zlib.gunzipSync(gz);
215
+ } catch (e) {
216
+ throw new ExitError(1, `lint-workflow-shellcheck: failed to gunzip downloaded ShellCheck archive: ${e.message}`);
217
+ }
218
+
219
+ const entry = extractFileFromTar(tarBuf, 'shellcheck');
220
+ if (!entry) {
221
+ throw new ExitError(
222
+ 1,
223
+ `lint-workflow-shellcheck: could not find a 'shellcheck' entry inside downloaded archive ${assetName}`,
224
+ );
225
+ }
226
+
227
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
228
+ // Write to a per-process temp path and rename into place — avoids any
229
+ // other concurrent invocation observing (and trying to execute) a
230
+ // partially-written binary at the real cache path.
231
+ const tmpPath = path.join(CACHE_DIR, `.shellcheck.tmp-${process.pid}`);
232
+ fs.writeFileSync(tmpPath, entry);
233
+ fs.chmodSync(tmpPath, 0o755);
234
+ fs.renameSync(tmpPath, CACHED_BIN_PATH);
235
+
236
+ return CACHED_BIN_PATH;
237
+ }
238
+
239
+ module.exports = {
240
+ SHELLCHECK_VERSION,
241
+ CACHED_BIN_PATH,
242
+ MAX_REDIRECTS,
243
+ DOWNLOAD_TIMEOUT_MS,
244
+ extractFileFromTar,
245
+ httpsGetFollowingRedirects,
246
+ resolveShellcheckBin,
247
+ };
@@ -24,7 +24,6 @@
24
24
  "tests/code-review-pipeline-regression.test.cjs :: source-text-is-the-product",
25
25
  "tests/code-review.test.cjs :: source-text-is-the-product",
26
26
  "tests/codebuddy-install.test.cjs :: source-text-is-the-product",
27
- "tests/codex-config.test.cjs :: source-text-is-the-product",
28
27
  "tests/command-contract.test.cjs :: source-text-is-the-product",
29
28
  "tests/commands.test.cjs :: source-text-is-the-product",
30
29
  "tests/config-field-docs.test.cjs :: docs-parity",
@@ -52,7 +51,6 @@
52
51
  "tests/frontmatter-cli.test.cjs :: source-text-is-the-product",
53
52
  "tests/gates-taxonomy.test.cjs :: source-text-is-the-product",
54
53
  "tests/git-base-branch.test.cjs :: source-text-is-the-product",
55
- "tests/gsd-check-update-worker-platform-gate.test.cjs :: structural-regression-guard",
56
54
  "tests/gsd-researcher-app-aware.test.cjs :: source-text-is-the-product",
57
55
  "tests/gsd-researcher-flow-diagram.test.cjs :: source-text-is-the-product",
58
56
  "tests/gsd-settings-advanced.test.cjs :: source-text-is-the-product",
@@ -74,13 +72,11 @@
74
72
  "tests/next-safety-gates.test.cjs :: source-text-is-the-product",
75
73
  "tests/next-up-clear-order.test.cjs :: source-text-is-the-product",
76
74
  "tests/no-hardcoded-home-gsd-tools.test.cjs :: source-text-is-the-product",
77
- "tests/orphaned-hooks.test.cjs :: structural-regression-guard",
78
75
  "tests/package-legitimacy-gate.test.cjs :: source-text-is-the-product",
79
76
  "tests/parallel-dependent-plans.test.cjs :: source-text-is-the-product",
80
77
  "tests/path-replacement.test.cjs :: source-text-is-the-product",
81
78
  "tests/phase.test.cjs :: source-text-is-the-product",
82
79
  "tests/phase6-capability-docs.test.cjs :: source-text-is-the-product",
83
- "tests/phase6-capstone-conformance.test.cjs :: source-text-is-the-product",
84
80
  "tests/phase6-planning-capabilities.test.cjs :: source-text-is-the-product",
85
81
  "tests/plan-bounce.test.cjs :: source-text-is-the-product",
86
82
  "tests/plan-phase-drift-guard.test.cjs :: source-text-is-the-product",
@@ -105,7 +101,6 @@
105
101
  "tests/research-agent-profiles.test.cjs :: source-text-is-the-product",
106
102
  "tests/roadmap.test.cjs :: source-text-is-the-product",
107
103
  "tests/runtime-launcher-parity.test.cjs :: structural-regression-guard",
108
- "tests/runtime-name-policy.test.cjs :: source-text-is-the-product",
109
104
  "tests/scan-command.test.cjs :: source-text-is-the-product",
110
105
  "tests/secret-scan-lint.security.test.cjs :: source-text-is-the-product",
111
106
  "tests/secure-phase.test.cjs :: source-text-is-the-product",
@@ -113,7 +108,6 @@
113
108
  "tests/security-scan.security.test.cjs :: source-text-is-the-product",
114
109
  "tests/seed-scan-new-milestone.test.cjs :: source-text-is-the-product",
115
110
  "tests/settings-integrations.test.cjs :: source-text-is-the-product",
116
- "tests/settings-jsonc.test.cjs :: structural-regression-guard",
117
111
  "tests/skill-frontmatter-contract.test.cjs :: source-text-is-the-product",
118
112
  "tests/spawn-liveness-banner.test.cjs :: source-text-is-the-product",
119
113
  "tests/state.test.cjs :: source-text-is-the-product",
@@ -1,4 +1,4 @@
1
1
  {
2
- "maxSites": 10,
2
+ "maxSites": 84,
3
3
  "grace": 2
4
4
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "maxFiles": 282
2
+ "maxFiles": 283
3
3
  }
@@ -31,6 +31,7 @@
31
31
  * `docs-guard-exempt:` marker for its specific reason.
32
32
  */
33
33
  const DOCS_GUARD_EXEMPT_BASELINE = [
34
+ 'adr-612-bracket-phase-counting.test.cjs',
34
35
  'adr-parser.test.cjs',
35
36
  'adr-parser.unit.test.cjs',
36
37
  'agent-marker-documentation-guard.test.cjs',
@@ -103,6 +104,10 @@ const DOCS_GUARD_EXEMPT_BASELINE = [
103
104
  * exact same scan.
104
105
  */
105
106
  const DOCS_GUARD_EXEMPT_DOCS_PATHS = {
107
+ // #2761: cites docs/adr/612-bracket-phase-id-convention.md in an explanatory
108
+ // comment describing ADR-612's Decision 1; the file never reads that (or any)
109
+ // docs/ file — every read call it makes targets a tmpdir .planning fixture.
110
+ 'adr-612-bracket-phase-counting.test.cjs': ['docs/adr/612-bracket-phase-id-convention.md'],
106
111
  'adr-parser.test.cjs': ['docs/adr/0001.md', 'docs/adr/0002.md', 'docs/adr/0010.md', 'docs/adr/NNNN.md'],
107
112
  'adr-parser.unit.test.cjs': ['docs/adr/0001.md', 'docs/adr/0099.md', 'docs/adr/NNNN.md'],
108
113
  'agent-marker-documentation-guard.test.cjs': ['docs/reference', 'docs/reference/workflow-fragments.md'],
@@ -84,9 +84,13 @@
84
84
  * shape as `collectDiskPhases` and the `audit.cts` scanners; it must see
85
85
  * every phase directory regardless of milestone window to catch a
86
86
  * naming/duplicate defect wherever it lives.
87
- * - `src/verify.cts` `cmdVerifySchemaDrift`: resolves ONE caller-supplied
87
+ * - `src/verify.cts` `resolvePhaseDirByToken`: resolves ONE caller-supplied
88
88
  * `phase` argument to its directory (falling back to an exact-name
89
89
  * match) — a single-phase LOOKUP, not a current-milestone enumeration.
90
+ * Originally `cmdVerifySchemaDrift`'s own inline block; #3348 lifted it
91
+ * into this shared helper (also used by the new `cmdVerifyContextDrift`)
92
+ * without changing what question it asks, so the exemption moved with
93
+ * the call site rather than multiplying.
90
94
  * - `src/init.cts` `detectHasPriorPhases`: answers "has this project EVER
91
95
  * completed a phase", explicitly excluding the current one. A history
92
96
  * probe across all milestones, not a current-milestone enumeration.
@@ -128,8 +132,10 @@
128
132
  * `--include-archived` merge are phase LOCATION and archive
129
133
  * enumeration, not current-milestone enumeration; both legitimately
130
134
  * read the physical set. Its ENUMERATION path routes through the owner.
131
- * - `src/roadmap-parser.cts` `getMilestonePhaseFilter` and its #3262-extracted
132
- * set-building owner `scanMilestonePhaseIds` (the same two heading/
135
+ * - `src/roadmap-parser.cts` `getMilestonePhaseFilter` and its shared
136
+ * set-building implementation `scanMilestonePhaseIdSets` (the public
137
+ * `scanMilestonePhaseIds` wrapper remains a directly iterable Set; the
138
+ * same two heading/
133
139
  * #3577 `collectTablePhaseRows` — the table-scan sibling feeding the same
134
140
  * membership set; its local 999-only exclusion mirrors the owner's
135
141
  * deliberate NOT-isSentinelPhaseId choice (a leading 0 is a real decimal
@@ -142,6 +148,18 @@
142
148
  * ("00.1" is a real phase, not milestone 0). This scan asks a narrower
143
149
  * question — "which phase ids does this milestone's window declare" —
144
150
  * where only the 999 icebox range is excluded.
151
+ * - `src/state.cts` `countRoadmapPhaseHeadings`: its bracket branch composes
152
+ * bracket-milestone sentinel detection through `isSentinelPhaseId`, but
153
+ * retains a separate bare-token `999` rule. That rule intentionally does
154
+ * NOT use the convention-blind canonical predicate: the latter also reads
155
+ * leading `0`/`00.1` as milestone-0 sentinels, while a legacy-spelled bare
156
+ * zero token inside an opted-in bracket project is a real mid-migration
157
+ * phase. This is the state-counter twin of the roadmap-parser exemption.
158
+ * The guard has no per-detector exemption scope: it ORs the enumeration
159
+ * and sentinel detectors before consulting this function-only map. The
160
+ * whole-function exemption is therefore accepted; it is bounded because
161
+ * this counter performs no phases-directory `readdirSync` and needs the
162
+ * exemption only for its intentional bare-token `999` sentinel literal.
145
163
  * - `src/state.cts` `cmdStateValidate` ("Gate 1: Validate STATE.md against
146
164
  * filesystem"): resolves ONE directory — the disk match for STATE.md's
147
165
  * own `Current Phase` field — by prefix, a single-phase LOOKUP, not an
@@ -291,7 +309,7 @@ const OWNER_FILES = new Set([
291
309
  // `lint-milestone-window-drift.cjs`'s FUNCTION_SCOPED_EXEMPTIONS mechanism.
292
310
  // See the header comment for the full written reason behind each entry.
293
311
  const FUNCTION_SCOPED_EXEMPTIONS = new Map([
294
- [path.join('src', 'verify.cts'), new Set(['cmdValidateHealth', 'cmdVerifySchemaDrift'])],
312
+ [path.join('src', 'verify.cts'), new Set(['cmdValidateHealth', 'resolvePhaseDirByToken'])],
295
313
  [path.join('src', 'init.cts'), new Set(['detectHasPriorPhases', 'detectUiPhaseActive'])],
296
314
  [path.join('src', 'milestone.cts'), new Set(['archivePhaseDirectories', 'cmdMilestoneComplete', 'cmdPhasesClear'])],
297
315
  // #3849: collectSiblingWorktreePhaseNums reads a SIBLING worktree's phases dir —
@@ -300,10 +318,10 @@ const FUNCTION_SCOPED_EXEMPTIONS = new Map([
300
318
  [path.join('src', 'phase.cts'), new Set(['cmdPhasesList', 'cmdPhaseNextDecimal', 'cmdPhasePlanIndex', 'cmdPhaseInsert', 'renameDecimalPhases', 'renameIntegerPhases', 'collectSiblingWorktreePhaseNums'])],
301
319
  [path.join('src', 'audit.cts'), new Set(['listAuditPhaseTargets'])],
302
320
  [path.join('src', 'commands.cts'), new Set(['cmdHistoryDigest'])],
303
- [path.join('src', 'state.cts'), new Set(['cmdStateValidate', 'cmdStateSync', 'cmdStateRebuild'])],
321
+ [path.join('src', 'state.cts'), new Set(['cmdStateValidate', 'cmdStateSync', 'cmdStateRebuild', 'countRoadmapPhaseHeadings'])],
304
322
  [path.join('src', 'roadmap-upgrade.cts'), new Set(['computeMigrationPlan'])],
305
323
  [path.join('src', 'smart-entry.cts'), new Set(['detectVerifyFailed'])],
306
- [path.join('src', 'roadmap-parser.cts'), new Set(['getMilestonePhaseFilter', 'scanMilestonePhaseIds', 'collectTablePhaseRows'])],
324
+ [path.join('src', 'roadmap-parser.cts'), new Set(['getMilestonePhaseFilter', 'scanMilestonePhaseIdSets', 'collectTablePhaseRows'])],
307
325
  [path.join('src', 'planning-snapshot.cts'), new Set(['buildAllPhaseDirNamesField'])],
308
326
  ]);
309
327
 
@@ -53,6 +53,35 @@ const TOKEN_DRIFT_RE = /(?:\\{1,2}d|\[0-9\])\+\[A-Z(?:a-z)?\]\??\(\?:(?:\\{1,2}\
53
53
  const OWNER_RE = /^\s*\/\/.*phase-id-owner:/;
54
54
  const CANON_REF = 'PHASE_NUMBER_TOKEN_SOURCE';
55
55
 
56
+ // #2761 M3 (trek-e review): the SECOND grammar this seam owns — the BRACKET
57
+ // project-code class of `[CODE.MM]`, spelled `[A-Z][A-Z0-9_]*` (with its
58
+ // case-widened `[A-Za-z]`/`[A-Za-z0-9_]` variant tolerated so a trivial rewrite
59
+ // does not evade the rule). The token guard above only ever knew the phase-
60
+ // NUMBER grammar, so three files re-typed this class verbatim — roadmap-parser's
61
+ // bracket-fallback selector, state's `isMilestoneBounded`, verify's
62
+ // `checkBracketCoherence` — and `check:phase-id-drift` reported clean the whole
63
+ // time. That is the blind spot which let #2761's own "no token literal outside
64
+ // src/phase-id.cts" gate pass while being violated. Build from
65
+ // `BRACKET_PROJECT_CODE_SRC`, `BRACKET_ID_SRC`, `bracketMilestoneIntroSrcFor`
66
+ // or `BRACKET_MILESTONE_INTRO_CAPTURING_SRC` instead.
67
+ const BRACKET_CODE_DRIFT_RE = /\[A-Z(?:a-z)?\]\[A-Z(?:a-z)?0-9_\]\*/;
68
+
69
+ // This rule has NO counterpart to the token rule's `line.includes(CANON_REF)`
70
+ // escape, and that omission is the point.
71
+ //
72
+ // That escape is LINE-level: a line naming the canonical source anywhere on it
73
+ // is taken as built-from-the-owner. verify.cts's copy read
74
+ //
75
+ // new RegExp(`^\\[[A-Z][A-Z0-9_]*\\.(${BRACKET_MILESTONE_NUMERIC_SRC})\\]`, 'i')
76
+ //
77
+ // — an owner reference for the MILESTONE field sharing a line with a re-typed
78
+ // PROJECT-CODE class. A line-level escape waves that through, so a bracket rule
79
+ // that copied it would have kept reporting clean on the very site under review.
80
+ // Partial ownership IS the drift. Only a `// phase-id-owner:` sanction
81
+ // suppresses this rule, and a sanction has to state which half is deliberate.
82
+ const BRACKET_OWNER_HINT =
83
+ 'BRACKET_PROJECT_CODE_SRC / BRACKET_ID_SRC / bracketMilestoneIntroSrcFor / BRACKET_MILESTONE_INTRO_CAPTURING_SRC';
84
+
56
85
  /**
57
86
  * Pure: find every literal re-derivation of the canonical phase-number token in
58
87
  * `text` that is NOT sanctioned. A site is sanctioned when the nearest preceding
@@ -79,6 +108,28 @@ function findPhaseIdRegexDrift(text) {
79
108
  return out;
80
109
  }
81
110
 
111
+ /**
112
+ * Pure: find every literal re-derivation of the BRACKET project-code grammar in
113
+ * `text` that is NOT sanctioned. Same sanction mechanism as the token rule — a
114
+ * dedicated `// phase-id-owner:` comment on the nearest preceding non-blank
115
+ * line — but deliberately WITHOUT its line-level owner-reference escape, so a
116
+ * site that references the owner for one field while re-typing the other is
117
+ * still reported (see BRACKET_CODE_DRIFT_RE's note). Returns [{ line, found }].
118
+ */
119
+ function findBracketGrammarDrift(text) {
120
+ const out = [];
121
+ const lines = text.split('\n');
122
+ for (let i = 0; i < lines.length; i++) {
123
+ const m = BRACKET_CODE_DRIFT_RE.exec(lines[i]);
124
+ if (!m) continue;
125
+ let j = i - 1;
126
+ while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line
127
+ if (j >= 0 && OWNER_RE.test(lines[j])) continue;
128
+ out.push({ line: i + 1, found: m[0] });
129
+ }
130
+ return out;
131
+ }
132
+
82
133
  // Authored TypeScript source only (the generated bin/lib/*.cjs mirror it).
83
134
  const SCAN_DIRS = ['src'];
84
135
  const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
@@ -104,6 +155,67 @@ function walk(dir, acc) {
104
155
  return acc;
105
156
  }
106
157
 
158
+ // ─── #2761 M4: the heading-baseline selector census ────────────────────────
159
+ //
160
+ // `phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.<MODE>)` is the other half
161
+ // of this seam: it decides which intro grammar a call site compiles, and the
162
+ // MODE argument is a fact about that site's history that no behavioural test
163
+ // can recover — flipping verify's milestone-complete site from LABEL_ONLY to
164
+ // ANY_BRACKET grants a tolerance it has never had, and every behavioural test
165
+ // still passes. Pinning it therefore requires reading the authored source.
166
+ //
167
+ // That reading lives HERE, not in the test suite. `tests/**` runs
168
+ // `local/no-source-grep` at ERROR, and its documented exemption
169
+ // (CONTEXT.md: RULESET.TESTS.no-source-grep.exemption) is reserved for tests
170
+ // whose subject is a runtime CONTRACT FILE — STATE.md, config.toml,
171
+ // hooks.json, agent .md — which `src/*.cts` is not. The suite had claimed that
172
+ // exemption anyway. Scripts are the sanctioned home for source scanning (the
173
+ // rule runs at `warn` in `scripts/**`, and this file already scans src/ for the
174
+ // grammar rules above), so the scan is exported as structured data and the test
175
+ // asserts on the returned census instead of on file text.
176
+ const SELECTOR_CALL_RE = /phaseHeadingPrefixSrcFor\(/g;
177
+ const SELECTOR_BASELINE_RE = /phaseHeadingPrefixSrcFor\(\s*PHASE_HEADING_BASELINE\.(ANY_BRACKET|LABEL_ONLY)/g;
178
+
179
+ /**
180
+ * Pure: census the heading-baseline selector calls in `text`.
181
+ *
182
+ * `total` counts EVERY invocation, so a call that does not name a
183
+ * `PHASE_HEADING_BASELINE` member shows up as `total > ANY_BRACKET +
184
+ * LABEL_ONLY` — a hole in the pin rather than a silently uncounted site.
185
+ * Returns { ANY_BRACKET, LABEL_ONLY, total }.
186
+ */
187
+ function countSelectorBaselines(text) {
188
+ const out = { ANY_BRACKET: 0, LABEL_ONLY: 0, total: 0 };
189
+ for (const m of text.matchAll(SELECTOR_BASELINE_RE)) out[m[1]] += 1;
190
+ out.total = (text.match(SELECTOR_CALL_RE) || []).length;
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * Scan the authored source tree and return the selector census keyed by
196
+ * repo-relative path, for every file that consumes the selector at least once.
197
+ * `phase-id.cts` is excluded: it DEFINES the selector, so its own occurrences
198
+ * are the declaration, not a consumer's choice of baseline.
199
+ */
200
+ function scanSelectorBaselines(root) {
201
+ const census = {};
202
+ for (const dir of SCAN_DIRS) {
203
+ for (const file of walk(path.join(root, dir), [])) {
204
+ const rel = path.relative(root, file);
205
+ if (EXEMPT.has(rel)) continue;
206
+ let text;
207
+ try {
208
+ text = fs.readFileSync(file, 'utf8');
209
+ } catch {
210
+ continue;
211
+ }
212
+ const counts = countSelectorBaselines(text);
213
+ if (counts.total > 0) census[path.basename(file)] = counts;
214
+ }
215
+ }
216
+ return census;
217
+ }
218
+
107
219
  /**
108
220
  * Scan the authored source tree and return every unsanctioned phase-token
109
221
  * re-derivation, each annotated with the repo-relative file path.
@@ -121,7 +233,11 @@ function scanRepo(root) {
121
233
  continue;
122
234
  }
123
235
  for (const d of findPhaseIdRegexDrift(text)) {
124
- violations.push({ file: rel, ...d });
236
+ violations.push({ file: rel, kind: 'token', ...d });
237
+ }
238
+ // #2761 M3: the bracket grammar is the second thing this seam owns.
239
+ for (const d of findBracketGrammarDrift(text)) {
240
+ violations.push({ file: rel, kind: 'bracket', ...d });
125
241
  }
126
242
  }
127
243
  }
@@ -132,19 +248,28 @@ function main() {
132
248
  const root = path.join(__dirname, '..');
133
249
  const violations = scanRepo(root);
134
250
  if (violations.length === 0) {
135
- process.stdout.write('ok phase-id-drift: no unsanctioned phase-token re-derivations outside phase-id.cts\n');
251
+ process.stdout.write('ok phase-id-drift: no unsanctioned phase-token or bracket-grammar re-derivations outside phase-id.cts\n');
136
252
  return;
137
253
  }
138
- process.stderr.write('phase-id-drift: literal re-derivation(s) of the canonical phase-number token found.\n');
139
- process.stderr.write('Build the regex from phase-id.cjs `PHASE_NUMBER_TOKEN_SOURCE` (or phaseMarkdownRegexSource for a\n');
140
- process.stderr.write('known number), or sanction the site with a dedicated `// phase-id-owner: <reason>`\n');
141
- process.stderr.write('comment on the line directly above the regex:\n');
254
+ process.stderr.write('phase-id-drift: literal re-derivation(s) of a canonical grammar found.\n');
255
+ process.stderr.write(`Build the regex from phase-id.cjs \`${CANON_REF}\` (or phaseMarkdownRegexSource for a\n`);
256
+ process.stderr.write(`known number) for the phase-number token, or from ${BRACKET_OWNER_HINT}\n`);
257
+ process.stderr.write('for the bracket grammar — or sanction the site with a dedicated\n');
258
+ process.stderr.write('`// phase-id-owner: <reason>` comment on the line directly above the regex:\n');
142
259
  for (const d of violations) {
143
- process.stderr.write(` ${d.file}:${d.line} ${d.found}\n`);
260
+ process.stderr.write(` [${d.kind}] ${d.file}:${d.line} ${d.found}\n`);
144
261
  }
145
262
  process.exitCode = 1;
146
263
  }
147
264
 
148
265
  if (require.main === module) main();
149
266
 
150
- module.exports = { findPhaseIdRegexDrift, scanRepo, TOKEN_DRIFT_RE };
267
+ module.exports = {
268
+ findPhaseIdRegexDrift,
269
+ findBracketGrammarDrift,
270
+ scanRepo,
271
+ countSelectorBaselines,
272
+ scanSelectorBaselines,
273
+ TOKEN_DRIFT_RE,
274
+ BRACKET_CODE_DRIFT_RE,
275
+ };