@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
@@ -337,6 +337,30 @@ function resolveRegistryIsolation(cwd, configPath) {
337
337
  if (!useWorktrees) isolation = 'none';
338
338
  }
339
339
 
340
+ // #4734: a harness worktree can never be created in a directory that is not
341
+ // a git repository (or a repository with no commits) — git's definitive
342
+ // exit-128 answer on `rev-parse HEAD`. Demanding the isolation flag there
343
+ // blocked every flag-less dispatch the moment the sentinel went stale, on a
344
+ // root where no worktree can exist. Degrade to 'none' exactly like the
345
+ // use_worktrees opt-out above; the classification is owned by
346
+ // `classifyGitHead` (worktree-base-ref.cjs) — the same single owner the
347
+ // base-check's degrade decision uses — and ambiguous or failed resolutions
348
+ // keep the conservative (enforce) default, matching that check's own
349
+ // fail-closed treatment of the same classes.
350
+ if (isolation === 'harness-worktree') {
351
+ try {
352
+ ensureRuntimeBuild();
353
+ const { classifyGitHead } = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
354
+ if (classifyGitHead({ cwd }).status === 'definitive-absence') {
355
+ isolation = 'none';
356
+ }
357
+ } catch {
358
+ // Unbuilt or unreadable runtime library — keep the conservative
359
+ // (enforce) default rather than silently disabling the guard, same
360
+ // posture as the ladder fallback above.
361
+ }
362
+ }
363
+
340
364
  return { isolation, harnessFlag };
341
365
  }
342
366
 
@@ -86,7 +86,12 @@
86
86
  // on plain commands, without arming the compound-`cd` prompt". Writes to
87
87
  // secret files are out of scope (Write/Edit were never gated). Commands
88
88
  // over 1 MiB are denied outright (`command-too-large`) rather than
89
- // scanned partially or waved through.
89
+ // scanned partially or waved through. (#4639 adds one more, by design: the
90
+ // value of `--env-file` under a container runtime is exempt, so the
91
+ // container's own command can print the interpolated environment
92
+ // (`alpine printenv`, `docker compose config`) — the same exposure class
93
+ // as the pre-existing volume-mount gap (`-v .env:/s`); the flag's value
94
+ // itself is a name, never contents.)
90
95
  //
91
96
  // Triggers on: Read, Grep, Bash tool calls (Kimi: ReadFile, Grep, Shell)
92
97
  // Action: BLOCK (decision: 'block', exit 2) — codes secret-read |
@@ -127,6 +132,15 @@ const NON_READING_COMMANDS = new Set([
127
132
  'basename', 'dirname', 'realpath', 'file', 'echo', 'printf',
128
133
  ]);
129
134
 
135
+ // #4639: container runtimes take `--env-file <file>` — the runtime opens the
136
+ // file itself, interpolates it into the container environment, and returns
137
+ // nothing to the agent, so the flag's VALUE is a name, never contents. The
138
+ // same category NON_READING_COMMANDS encodes, expressed as a flag value. Only
139
+ // the flag's value under these runtimes is exempt; every other operand in the
140
+ // segment is still checked, so the carve-out cannot launder a read.
141
+ const CONTAINER_RUNTIMES = new Set(['docker', 'docker-compose', 'podman', 'nerdctl']);
142
+ const ENV_FILE_FLAG_RE = /^--env-file(=|$)/;
143
+
130
144
  // Shell interpreters that run a script from `-c`, a file operand, or stdin
131
145
  // (heredoc / here-string / piped `echo`|`printf`). `su` is here for its `-c`
132
146
  // form (`su [user] -c 'cmd'`); a bare `su user` resolves to file mode, which
@@ -840,7 +854,19 @@ function findSecretRead(command, depth) {
840
854
 
841
855
  if (NON_READING_COMMANDS.has(base)) continue;
842
856
 
857
+ // #4639: exempt ONLY the value of `--env-file`, and only when the
858
+ // segment's command word is a container runtime. A bare `--env-file`
859
+ // consumes the next operand; `--env-file=<value>` is a single word.
860
+ const envFileExempt = CONTAINER_RUNTIMES.has(base);
861
+ let skipNext = false;
843
862
  for (const w of operands) {
863
+ if (envFileExempt) {
864
+ if (skipNext) { skipNext = false; continue; }
865
+ if (ENV_FILE_FLAG_RE.test(w.text)) {
866
+ if (!w.text.includes('=')) skipNext = true; // bare flag consumes the next operand
867
+ continue;
868
+ }
869
+ }
844
870
  if (namesSecret(normalizeOperand(w.text))) return w.text;
845
871
  }
846
872
  }
@@ -42,6 +42,10 @@ if (require.main === module) {
42
42
  const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs');
43
43
  const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
44
44
  const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
45
+ const {
46
+ renderBracketPhaseDisplay,
47
+ renderBracketMilestoneDisplay,
48
+ } = require('../gsd-core/bin/lib/phase-id-display.cjs');
45
49
  // #2850: reuse the existing workstream resolution seams rather than
46
50
  // re-implementing CLI>env>store precedence or path construction inline.
47
51
  // peekActiveWorkstream is the read-only sibling of the store-tier lookup
@@ -359,16 +363,26 @@ function renderProgressBar(percent) {
359
363
  * Progress bar is opt-in: appended to the milestone segment only when
360
364
  * progress.percent is present in frontmatter; absent → empty string.
361
365
  */
362
- function formatGsdState(s) {
366
+ function formatGsdState(s, opts = {}) {
363
367
  // #2850: workstream mode with nothing resolvable — an observable signal,
364
368
  // never silent emptiness (distinguishes from "GSD isn't installed here").
365
369
  if (s.noActiveWorkstream) return NO_ACTIVE_WORKSTREAM_LABEL;
366
370
 
367
371
  const parts = [];
372
+ const bracket = opts.convention === 'bracket';
373
+ const bracketMilestone = bracket
374
+ ? renderBracketMilestoneDisplay(s.milestone, opts.projectCode)
375
+ : null;
376
+ const phaseLabel = (phase) => {
377
+ const display = bracket
378
+ ? renderBracketPhaseDisplay(s.milestone, phase, opts.projectCode)
379
+ : null;
380
+ return display ?? `Phase ${phase}`;
381
+ };
368
382
 
369
383
  // Milestone segment: version + name + (opt-in) progress bar
370
384
  if (s.milestone || s.milestoneName) {
371
- const ver = s.milestone || '';
385
+ const ver = bracketMilestone ?? s.milestone ?? '';
372
386
  const name = (s.milestoneName && s.milestoneName !== 'milestone') ? s.milestoneName : '';
373
387
  const bar = renderProgressBar(s.percent);
374
388
  const pieces = [ver, name, bar].filter(Boolean);
@@ -384,7 +398,8 @@ function formatGsdState(s) {
384
398
  // stage = whichever lifecycle status was written by the orchestrator
385
399
  // (discussing / planning / executing / verifying)
386
400
  const stage = s.status || '';
387
- parts.push(stage ? `Phase ${s.activePhase} ${stage}` : `Phase ${s.activePhase}`);
401
+ const phase = phaseLabel(s.activePhase);
402
+ parts.push(stage ? `${phase} ${stage}` : phase);
388
403
  } else if (s.nextAction && phasesStr) {
389
404
  // Scene 2: idle + a recommended next command is visible to the user.
390
405
  // Surfaces "what to run next" without the user opening STATE.md.
@@ -402,9 +417,16 @@ function formatGsdState(s) {
402
417
  // earlier so no existing project's status-line changes shape.
403
418
  if (s.status) parts.push(s.status);
404
419
  if (s.phaseNum && s.phaseTotal) {
420
+ const bracketPhase = bracket
421
+ ? renderBracketPhaseDisplay(s.milestone, s.phaseNum, opts.projectCode)
422
+ : null;
405
423
  const phase = s.phaseName
406
- ? `${s.phaseName} (${s.phaseNum}/${s.phaseTotal})`
407
- : `ph ${s.phaseNum}/${s.phaseTotal}`;
424
+ ? bracketPhase
425
+ ? `${bracketPhase} ${s.phaseName} (${s.phaseNum}/${s.phaseTotal})`
426
+ : `${s.phaseName} (${s.phaseNum}/${s.phaseTotal})`
427
+ : bracketPhase
428
+ ? `${bracketPhase} (${s.phaseNum}/${s.phaseTotal})`
429
+ : `ph ${s.phaseNum}/${s.phaseTotal}`;
408
430
  parts.push(phase);
409
431
  }
410
432
  }
@@ -490,17 +512,24 @@ function shortGsdStatus(status) {
490
512
  * default format — and collapses narrative statuses via shortGsdStatus().
491
513
  * The default "full" format is untouched.
492
514
  */
493
- function formatGsdStateCompact(s) {
515
+ function formatGsdStateCompact(s, opts = {}) {
494
516
  // #2850: mirrors formatGsdState's observable "nothing resolvable" signal.
495
517
  if (s.noActiveWorkstream) return NO_ACTIVE_WORKSTREAM_LABEL;
496
518
 
497
519
  const parts = [];
520
+ const bracket = opts.convention === 'bracket';
521
+ const bracketMilestone = bracket
522
+ ? renderBracketMilestoneDisplay(s.milestone, opts.projectCode)
523
+ : null;
498
524
 
499
- if (s.milestone) parts.push(s.milestone);
525
+ if (s.milestone) parts.push(bracketMilestone ?? s.milestone);
500
526
 
501
527
  const phaseId = s.activePhase || s.phaseNum;
502
528
  if (phaseId) {
503
- parts.push(s.phaseTotal ? `P${phaseId}/${s.phaseTotal}` : `P${phaseId}`);
529
+ const bracketPhase = bracket
530
+ ? renderBracketPhaseDisplay(s.milestone, phaseId, opts.projectCode)
531
+ : null;
532
+ parts.push(bracketPhase ?? (s.phaseTotal ? `P${phaseId}/${s.phaseTotal}` : `P${phaseId}`));
504
533
  }
505
534
 
506
535
  // Scene exclusivity mirrors formatGsdState's if/else chain: an in-flight
@@ -747,7 +776,7 @@ function formatStateFreshness(fresh) {
747
776
  * entry points — this collapses both onto one resolver.
748
777
  *
749
778
  * @param {object} cfg — parsed .planning/config.json (readGsdConfig())
750
- * @returns {{ showLastCommand: boolean, position: 'end'|'front', stateFormat: 'full'|'compact', showGit: boolean, showStateFreshness: boolean }}
779
+ * @returns {{ showLastCommand: boolean, position: 'end'|'front', stateFormat: 'full'|'compact', showGit: boolean, showStateFreshness: boolean, convention: string|null, projectCode: string|null }}
751
780
  */
752
781
  function resolveStatuslineOptions(cfg) {
753
782
  const showLastCommand = getConfigValue(cfg, 'statusline.show_last_command') === true;
@@ -759,7 +788,22 @@ function resolveStatuslineOptions(cfg) {
759
788
  const stateFormat = getConfigValue(cfg, 'statusline.state_format') === 'compact' ? 'compact' : 'full';
760
789
  const showGit = getConfigValue(cfg, 'statusline.show_git') === true;
761
790
  const showStateFreshness = getConfigValue(cfg, 'statusline.show_state_freshness') === true;
762
- return { showLastCommand, position, stateFormat, showGit, showStateFreshness };
791
+ const convention = getConfigValue(cfg, 'phase_id_convention') === 'bracket'
792
+ ? 'bracket'
793
+ : null;
794
+ const projectCodeValue = getConfigValue(cfg, 'project_code');
795
+ const projectCode = typeof projectCodeValue === 'string' && projectCodeValue !== ''
796
+ ? projectCodeValue
797
+ : null;
798
+ return {
799
+ showLastCommand,
800
+ position,
801
+ stateFormat,
802
+ showGit,
803
+ showStateFreshness,
804
+ convention,
805
+ projectCode,
806
+ };
763
807
  }
764
808
 
765
809
  // --- stdin ------------------------------------------------------------------
@@ -934,7 +978,9 @@ function runStatusline() {
934
978
  // freshness git spawn here would spend a subprocess on discarded output.
935
979
  if (!task) {
936
980
  const state = readGsdState(dir, { stateFreshness: options.showStateFreshness }) || {};
937
- gsdStateStr = options.stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
981
+ gsdStateStr = options.stateFormat === 'compact'
982
+ ? formatGsdStateCompact(state, options)
983
+ : formatGsdState(state, options);
938
984
  }
939
985
 
940
986
  // Output
@@ -1042,6 +1088,7 @@ module.exports = {
1042
1088
  STATE_HEAD_ADVISORY_COMMITS, isValidStateHeadStamp,
1043
1089
  readStateHeadCommits, parseRevListCounts, deriveStateFreshness,
1044
1090
  formatStateFreshness, resolveStatuslineOptions,
1091
+ renderBracketPhaseDisplay, renderBracketMilestoneDisplay,
1045
1092
  };
1046
1093
 
1047
1094
  /**
@@ -1059,7 +1106,15 @@ function renderStatusline(data) {
1059
1106
  // key from reaching only one of them.
1060
1107
  let lastCmdSuffix = '';
1061
1108
  let gitSuffix = '';
1062
- let options = { showLastCommand: false, position: 'end', stateFormat: 'full', showGit: false, showStateFreshness: false };
1109
+ let options = {
1110
+ showLastCommand: false,
1111
+ position: 'end',
1112
+ stateFormat: 'full',
1113
+ showGit: false,
1114
+ showStateFreshness: false,
1115
+ convention: null,
1116
+ projectCode: null,
1117
+ };
1063
1118
  try {
1064
1119
  const cfg = readGsdConfig(dir);
1065
1120
  options = resolveStatuslineOptions(cfg);
@@ -1075,7 +1130,9 @@ function renderStatusline(data) {
1075
1130
  } catch (e) { /* swallow */ }
1076
1131
 
1077
1132
  const state = readGsdState(dir, { stateFreshness: options.showStateFreshness }) || {};
1078
- const gsdStateStr = options.stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
1133
+ const gsdStateStr = options.stateFormat === 'compact'
1134
+ ? formatGsdStateCompact(state, options)
1135
+ : formatGsdState(state, options);
1079
1136
  const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
1080
1137
  return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position: options.position });
1081
1138
  }
@@ -16,6 +16,21 @@ set -euo pipefail
16
16
  # Idempotent and failure-proof by construction: unset vars expand to "" (a
17
17
  # no-op rm -f target), and `|| true` guarantees the trap itself never changes
18
18
  # the script's exit status.
19
+ # Subprocess exit statuses, pre-initialised so they can never be inherited from
20
+ # the ambient environment. Each is captured as `... || VAR=$?`, which assigns
21
+ # ONLY on the failure branch; on success the variable keeps whatever it already
22
+ # held, and `${VAR:-0}` defaults only when unset or empty. So an EXPORTED
23
+ # CONFIG_STATUS / CMD_STATUS / CLASSIFY_STATUS — from a CI wrapper, a .envrc, or
24
+ # another hook — survived into the success path and was read as "the subprocess
25
+ # failed". Measured: `CLASSIFY_STATUS=3 git commit -m "nope: bad"` printed
26
+ # "validator disabled for this call" and exited 0, silently accepting a
27
+ # non-conforming commit. Same for CONFIG_STATUS and CMD_STATUS. Found by the
28
+ # security review of #4429; the gate is fail-open by design on a genuine
29
+ # subprocess failure (#3838), which is exactly what made this bypass quiet.
30
+ CONFIG_STATUS=0
31
+ CMD_STATUS=0
32
+ CLASSIFY_STATUS=0
33
+
19
34
  ENABLED_ERR=""
20
35
  CMD_ERR=""
21
36
  CLASSIFY_ERR=""
@@ -61,6 +76,12 @@ if [ -f .planning/config.json ]; then
61
76
  process.exit(3);
62
77
  }
63
78
  " 2>"$ENABLED_ERR") || CONFIG_STATUS=$?
79
+ # Pre-initialised, NOT left to `${...:-0}` alone: the capture below only
80
+ # assigns on the `||` branch, so on SUCCESS the variable keeps whatever it
81
+ # already held — and an EXPORTED variable of this name is inherited from the
82
+ # ambient environment. `${VAR:-0}` defaults only when unset/empty, so
83
+ # `CONFIG_STATUS=3 git commit …` made this hook print "validator disabled" and exit 0,
84
+ # silently accepting a non-conforming commit. Found by review of #4429.
64
85
  CONFIG_STATUS=${CONFIG_STATUS:-0}
65
86
  if [ "$CONFIG_STATUS" != "0" ]; then
66
87
  # Could not determine the opt-in flag at all (node missing, JSON parse
@@ -103,6 +124,12 @@ CMD=$(echo "$INPUT" | node -e "
103
124
  }
104
125
  });
105
126
  " 2>"$CMD_ERR") || CMD_STATUS=$?
127
+ # Pre-initialised, NOT left to `${...:-0}` alone: the capture below only
128
+ # assigns on the `||` branch, so on SUCCESS the variable keeps whatever it
129
+ # already held — and an EXPORTED variable of this name is inherited from the
130
+ # ambient environment. `${VAR:-0}` defaults only when unset/empty, so
131
+ # `CMD_STATUS=3 git commit …` made this hook print "validator disabled" and exit 0,
132
+ # silently accepting a non-conforming commit. Found by review of #4429.
106
133
  CMD_STATUS=${CMD_STATUS:-0}
107
134
  if [ "$CMD_STATUS" != "0" ]; then
108
135
  # Could not extract tool_input.command at all (node missing, malformed
@@ -127,6 +154,12 @@ GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" node -e "
127
154
  process.exit(3);
128
155
  }
129
156
  " "$CMD" 2>"$CLASSIFY_ERR" || CLASSIFY_STATUS=$?
157
+ # Pre-initialised, NOT left to `${...:-0}` alone: the capture below only
158
+ # assigns on the `||` branch, so on SUCCESS the variable keeps whatever it
159
+ # already held — and an EXPORTED variable of this name is inherited from the
160
+ # ambient environment. `${VAR:-0}` defaults only when unset/empty, so
161
+ # `CLASSIFY_STATUS=3 git commit …` made this hook print "validator disabled" and exit 0,
162
+ # silently accepting a non-conforming commit. Found by review of #4429.
130
163
  CLASSIFY_STATUS=${CLASSIFY_STATUS:-0}
131
164
  if [ "$CLASSIFY_STATUS" != "0" ] && [ "$CLASSIFY_STATUS" != "1" ]; then
132
165
  # 0 = is a git commit (validate below); 1 = genuinely not a git commit
@@ -557,7 +590,7 @@ if [ "$CLASSIFY_STATUS" = "0" ]; then
557
590
  fi
558
591
  # Single source of truth for the accepted commit-type list (#3811): the
559
592
  # 10 built-ins plus whatever passed the safe-token filter above. Both the
560
- # regex alternation and the human-readable error text below are derived
593
+ # membership test and the human-readable error text below are derived
561
594
  # from this ONE array — no hand-synced second copy.
562
595
  #
563
596
  # The `"${EXTRA_COMMIT_TYPES[@]+"${EXTRA_COMMIT_TYPES[@]}"}"` form (not
@@ -568,7 +601,6 @@ if [ "$CLASSIFY_STATUS" = "0" ]; then
568
601
  # /bin/bash 3.2.57 on macOS. The `${arr[@]+word}` form is the
569
602
  # nounset-safe idiom for "expand if set, empty otherwise" on empty arrays.
570
603
  COMMIT_TYPES=("${BUILTIN_COMMIT_TYPES[@]}" "${EXTRA_COMMIT_TYPES[@]+"${EXTRA_COMMIT_TYPES[@]}"}")
571
- COMMIT_TYPE_ALT=$(IFS='|'; echo "${COMMIT_TYPES[*]}")
572
604
  COMMIT_TYPE_LIST=$(printf '%s, ' "${COMMIT_TYPES[@]}")
573
605
  COMMIT_TYPE_LIST="${COMMIT_TYPE_LIST%, }"
574
606
  # Typed `valid_types` array (#3811 review finding): CONTRIBUTING.md bans
@@ -580,8 +612,35 @@ if [ "$CLASSIFY_STATUS" = "0" ]; then
580
612
  # or `\`.
581
613
  COMMIT_TYPES_JSON=$(printf '"%s",' "${COMMIT_TYPES[@]}")
582
614
  COMMIT_TYPES_JSON="[${COMMIT_TYPES_JSON%,}]"
583
- # Validate Conventional Commits format
584
- if ! [[ "$SUBJECT" =~ ^($COMMIT_TYPE_ALT)(\(.+\))?:[[:space:]].+ ]]; then
615
+ # Validate Conventional Commits format.
616
+ #
617
+ # #4429: do NOT build `^(type1|type2|...)` out of COMMIT_TYPES. That
618
+ # alternation grows with the CONFIGURED list, and how large a pattern can be
619
+ # compiled is a property of the platform's regex engine. bash 3.2.57 / BSD
620
+ # libc (macOS, this file's stated target) caps it at 64 KiB - bisected: a
621
+ # 65504-byte alternation compiles, 65515 fails. bash 5.2 / glibc has no
622
+ # reachable cap, so this half never bit Linux. Past a cap `[[ =~ ]]`
623
+ # returns 2, and `if !` cannot tell a COMPILE ERROR from "the subject does
624
+ # not conform" - so a valid `feat(auth): ...` was blocked with
625
+ # CONVENTIONAL_COMMITS_VIOLATION while `feat` sat in its own valid_types.
626
+ #
627
+ # Match the SHAPE with a fixed-size pattern, then test membership against
628
+ # the array. The regex no longer depends on how many types are configured,
629
+ # and the loop adds no subprocess or pipe (the #4429 hazard this file
630
+ # already avoids elsewhere). The character class is exactly the safe-token
631
+ # filter `^[a-z][a-z0-9-]*$` applied above, so it captures every type that
632
+ # can legally reach COMMIT_TYPES and no token that cannot.
633
+ SUBJECT_TYPE=''
634
+ if [[ "$SUBJECT" =~ ^([a-z][a-z0-9-]*)(\(.+\))?:[[:space:]].+ ]]; then
635
+ SUBJECT_TYPE="${BASH_REMATCH[1]}"
636
+ fi
637
+ COMMIT_TYPE_OK=0
638
+ if [ -n "$SUBJECT_TYPE" ]; then
639
+ for _known_type in "${COMMIT_TYPES[@]}"; do
640
+ if [ "$_known_type" = "$SUBJECT_TYPE" ]; then COMMIT_TYPE_OK=1; break; fi
641
+ done
642
+ fi
643
+ if [ "$COMMIT_TYPE_OK" -ne 1 ]; then
585
644
  # Emit typed `code` and `valid_types` fields alongside `reason` (#2974,
586
645
  # #3811). Tests assert on the stable code string and the typed array;
587
646
  # the reason is the human-readable copy, never grepped by tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "main": ".opencode/plugins/gsd-core.js",
6
6
  "bin": {
@@ -124,7 +124,7 @@
124
124
  "lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs",
125
125
  "lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs",
126
126
  "lint:response-language": "node scripts/lint-response-language-coverage.cjs",
127
- "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/lint-portable-grep.cjs && node scripts/lint-allowed-tools-parity.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-slug-derivation-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs && node scripts/lint-seam-enforcement.cjs && node scripts/lint-workflow-shellcheck.cjs && npm run lint:response-language",
127
+ "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && npm run lint:retired-runtime-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/lint-portable-grep.cjs && node scripts/lint-allowed-tools-parity.cjs && node scripts/lint-phase-arg-assignment.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-pr-branch-pattern-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-slug-derivation-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs && node scripts/lint-seam-enforcement.cjs && node scripts/lint-workflow-shellcheck.cjs && npm run lint:response-language",
128
128
  "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
129
129
  "lint:regression-names": "node scripts/lint-regression-test-names.cjs",
130
130
  "lint:descriptions": "node scripts/lint-descriptions.cjs",
@@ -136,6 +136,7 @@
136
136
  "lint:docs": "node scripts/lint-docs-required.cjs",
137
137
  "lint:qa-smells": "node scripts/qa-smell-ratchet.cjs",
138
138
  "lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",
139
+ "lint:retired-runtime-name": "node scripts/lint-retired-runtime-name.cjs",
139
140
  "lint:seam-enforcement": "node scripts/lint-seam-enforcement.cjs",
140
141
  "lint:docs-command-form": "node scripts/lint-docs-command-form.cjs",
141
142
  "lint:hooks-runtime-build-seam": "node scripts/lint-hooks-runtime-build-seam.cjs",
@@ -11,16 +11,25 @@ const path = require('path');
11
11
  const vm = require('vm');
12
12
 
13
13
  const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
14
+ const REPO_ROOT = path.join(HOOKS_DIR, '..');
14
15
  const DIST_DIR = path.join(HOOKS_DIR, 'dist');
15
16
  // Per-process staging directory for atomic writes. Using process.pid in the
16
17
  // name eliminates all contention between concurrent builders: each process
17
18
  // owns its own staging dir and never races with another builder's cleanup.
18
- // Lives under hooks/ so it shares a filesystem with DIST_DIR (POSIX
19
- // rename(2) is only atomic within the same filesystem) but is NOT inside
20
- // DIST_DIR — so readers that readdirSync(DIST_DIR) (e.g. bin/install.js,
21
- // install-hooks-copy tests) never observe a transient ".tmp" sibling.
22
- // The parent pattern hooks/.dist-staging-*/ is gitignored.
23
- const STAGE_DIR = path.join(HOOKS_DIR, `.dist-staging-${process.pid}`);
19
+ // A SIBLING of hooks/ (not inside it) — still on the same filesystem as
20
+ // DIST_DIR (both live under the repo checkout), so rename(2) stays atomic —
21
+ // but this way nothing that walks or copies hooks/ as a whole can ever
22
+ // observe this directory being created/populated/deleted concurrently.
23
+ // tests/gsd-validate-commit-sigpipe.test.cjs's fixture setup does exactly
24
+ // that (fs.cpSync(HOOKS_DIR, ..., {recursive: true})), and when its walk
25
+ // entered a staging dir that a concurrent build-hooks.js run deleted
26
+ // mid-iteration, Node's native recursive-copy (std::filesystem under the
27
+ // hood) threw an uncaught C++ exception and aborted the whole process with
28
+ // SIGABRT — not a catchable JS error. Moving the staging dir out of hooks/
29
+ // closes that race for every current and future hooks/-tree walker, not
30
+ // just that one call site (which also gained its own defensive filter).
31
+ // The parent pattern .dist-staging-*/ (repo root) is gitignored.
32
+ const STAGE_DIR = path.join(REPO_ROOT, `.dist-staging-${process.pid}`);
24
33
 
25
34
  // Hooks to copy (pure Node.js, no bundling needed)
26
35
  const HOOKS_TO_COPY = [
@@ -27,6 +27,10 @@
27
27
  * 7. Reverse direction: a workflow/command matching a quoted `## TOKEN`
28
28
  * no agent declares or emits is dispatch-on-phantom
29
29
  * (unmatched_consumer_token) — F9's shape from the consumer side.
30
+ * 8. Every reference pointer an agent file carries either resolves to
31
+ * exactly the regular file it names, contained under
32
+ * gsd-core/references/, or is reported (unresolved_reference_include) —
33
+ * a pointer the follower declines is never silently dropped (#4930).
30
34
  *
31
35
  * Exit 0 = clean. Exit 1 = violations (with diagnostics on stderr).
32
36
  */
@@ -35,6 +39,9 @@
35
39
 
36
40
  const fs = require('fs');
37
41
  const path = require('path');
42
+ // The repo's ONE containment decision (ADR-4650, src/security.cts) — realpath-resolved, so an
43
+ // intermediate component referring outside the root is refused before this loop reads the file.
44
+ const { tryWithinRoot } = require('../gsd-core/bin/lib/security.cjs');
38
45
 
39
46
  function resolveRoot(argv) {
40
47
  const idx = argv.indexOf('--root');
@@ -92,8 +99,9 @@ function toRepoRelative(absPath) {
92
99
  /**
93
100
  * referenceIncludes(content)
94
101
  *
95
- * Plain scan for `@~/.claude/gsd-core/references/*.md` tokens anywhere in an
96
- * agent file's content -- inside an `<execution_context>` block (already
102
+ * Plain scan for `@~/.claude/gsd-core/references/*.md` tokens (and the bare
103
+ * `@gsd-core/references/*.md` spelling, #4841) anywhere in an agent file's
104
+ * content -- inside an `<execution_context>` block (already
97
105
  * covered structurally by `executionContextRefs` in command-contract-helpers,
98
106
  * but a raw regex over the whole string picks those up too) and, just as
99
107
  * importantly, OUTSIDE one: agents frequently point at a reference doc from
@@ -105,17 +113,94 @@ function toRepoRelative(absPath) {
105
113
  * lives in `gsd-core/references/planner-guidance.md`, which the agent only
106
114
  * `@`-includes -- so the producer scan below must follow these includes to
107
115
  * see markers an agent's contract legitimately delegates to a reference doc.
108
- * Returns ROOT-relative paths (`gsd-core/references/foo.md`), de-duplicated.
116
+ * Returns `{ includes, declined }`: `includes` are ROOT-relative paths
117
+ * (`gsd-core/references/foo.md`), de-duplicated; `declined` are the pointers
118
+ * this scan refused to follow, as written, each with its reason (#4930).
109
119
  */
110
120
  function referenceIncludes(content) {
111
121
  const seen = new Set();
112
- const re = /@~\/\.claude\/gsd-core\/references\/[A-Za-z0-9._-]+\.md/g;
122
+ const declined = new Map();
123
+ // Both spellings the agent corpus has carried: the installed-path form the
124
+ // installer rewrites per profile, and the bare repo-relative `@gsd-core/…`
125
+ // form (#4841) that it does not. The bare form is now refused in agents/ by
126
+ // tests/shipped-reference-cites.test.cjs; it is followed here so a pointer
127
+ // that slips past that gate is still scanned rather than silently dropped.
128
+ //
129
+ // THE TOKEN IS CAPTURED WHOLE AND THE NAME GRAMMAR IS ANCHORED AT BOTH ENDS.
130
+ // Nested names are allowed (`few-shot-examples/verifier.md`); every segment
131
+ // starts with `[A-Za-z0-9_-]` — narrower than "non-dot", since `+x.md` and
132
+ // `é.md` do not match either — so a `.` or `..` segment is never a name.
133
+ // Anchoring is what makes that a statement about the whole pointer rather
134
+ // than about its first few segments: an earlier form of this pattern ended
135
+ // at `\.md` with no following boundary, so `…/references/tdd.md/xx/yy` was
136
+ // followed as `tdd.md`, and THIS FUNCTION'S CALLER READS THE PATH IT
137
+ // RETURNS — `fs.readFileSync` in main()'s agent loop — so a truncated prefix
138
+ // folded the wrong file's text into the scanned corpus. Matching the whole
139
+ // whitespace-delimited token and requiring it to satisfy the grammar end to
140
+ // end closes that by construction, and for any separator spelling rather
141
+ // than the ones a boundary lookahead happens to enumerate.
142
+ //
143
+ // ANCHORING DOES NOT ESTABLISH CONTAINMENT, and an earlier revision of this
144
+ // comment said it did. The grammar's refusal of `.`/`..` segments covers the
145
+ // TEXTUAL half only: an intermediate component that refers outside the tree
146
+ // is resolved transparently on the way to the file. Containment is decided
147
+ // separately, below, by `tryWithinRoot` — and it has to be, because this
148
+ // loop READS what it resolves.
149
+ //
150
+ // A token that does not parse WHOLE is declined rather than truncated — the
151
+ // conservative half of the same rule — and the decline is RETURNED, not
152
+ // dropped (#4930): main() reports it as unresolved_reference_include. A
153
+ // skipped pointer is a scan this tool did not perform, and the verdict it
154
+ // prints depends on that scan, so the skip has to be visible in the tool's
155
+ // own output. tests/shipped-reference-cites.test.cjs reports the same token
156
+ // for the shipped agents/ tree; this is the half that holds for any --root.
157
+ // FOUR spellings. `@$HOME/.claude/` is a second installed-path form the installer rewrites
158
+ // explicitly (applyAgentPathRewritesInner's `/\$HOME\/\.claude\//g` replace, beside the `~/.claude/`
159
+ // one). The fourth is a FAMILY rather than a string: `--relative-includes` (#4377) makes a local
160
+ // install emit project-relative includes whose prefix is DERIVED from the resolved config dir, so it
161
+ // is matched by SHAPE — one or MORE leading segments before `gsd-core` (a config dir nested under
162
+ // the project root emits `@config/nested/gsd-core/…`), none of them `.` or `..`. The `+` keeps the
163
+ // bare form the bare form by construction, since it needs a segment BEFORE `gsd-core`.
164
+ //
165
+ // THE SHAPE IS NARROWER THAN THE FAMILY, and saying otherwise would be the overstatement this
166
+ // function has already had to retract once. `_computePathPrefix` can emit a prefix this character
167
+ // class does not match — a config dir named `config+nested` or `ümlaut` — and widening the class to
168
+ // arbitrary directory names is what would turn every `@scope/…` token in prose into a pointer. So a
169
+ // prefix outside the class is not followed, which is where this follower was for ALL
170
+ // project-relative spellings before #4841. The gate's own comment states the same bound; the two
171
+ // must not drift apart, because between them they are the only record of it.
172
+ const re = /@(?:(?:~|\$HOME)\/\.claude\/|(?:(?!\.\.?\/)[A-Za-z0-9._-]+\/)+)?gsd-core\/references\/(\S+)/g;
173
+ const name = /^(?:[A-Za-z0-9_-][A-Za-z0-9._-]*\/)*[A-Za-z0-9_-][A-Za-z0-9._-]*\.md$/;
174
+ // Trailing prose punctuation is not part of a filename — a pointer may end a
175
+ // sentence or sit inside backticks, parentheses or bold markers. The class
176
+ // cannot eat into `.md`, which ends at `d`.
177
+ // START-ANCHORED: this tests a CUT SUFFIX, so it must be punctuation END TO END. An unanchored
178
+ // `/…$/` answers true for `/xx?`, which would let the loop cut a separator and call the remainder a
179
+ // name — `tdd.md/xx?` following as `tdd.md`, the defect this whole function was rewritten to close.
180
+ const trailingProseOnly = /^[.,;:!?)\]}>"'`*]+$/;
113
181
  let m;
114
182
  while ((m = re.exec(content)) !== null) {
115
- const relPath = 'gsd-core/references/' + m[0].slice('@~/.claude/gsd-core/references/'.length);
116
- seen.add(relPath);
183
+ const raw = m[1];
184
+ // MINIMAL strip, same rule as the gate: shortest trailing run whose removal yields a valid name.
185
+ let candidate = null;
186
+ for (let cut = 0; cut <= raw.length; cut++) {
187
+ const probe = raw.slice(0, raw.length - cut);
188
+ if (cut > 0 && !trailingProseOnly.test(raw.slice(raw.length - cut))) break;
189
+ if (name.test(probe)) { candidate = probe; break; }
190
+ }
191
+ if (candidate === null) {
192
+ declined.set(m[0], 'does not name a reference whole — the text continues past the name, so nothing is followed');
193
+ continue;
194
+ }
195
+ // AMBIGUITY, mirrored from the gate. If the UNSTRIPPED token also names something, the strip would
196
+ // pick one of two readings — and this loop READS what it picks, so it declines rather than guess.
197
+ if (candidate !== raw && fs.existsSync(path.join(ROOT, 'gsd-core', 'references', raw))) {
198
+ declined.set(m[0], `is ambiguous — \`${raw}\` itself names something on disk, so stripping it to \`${candidate}\` would pick one of two readings; neither is followed`);
199
+ continue;
200
+ }
201
+ seen.add('gsd-core/references/' + candidate);
117
202
  }
118
- return [...seen];
203
+ return { includes: [...seen], declined: [...declined].map(([pointer, reason]) => ({ pointer, reason })) };
119
204
  }
120
205
 
121
206
  function remedyFor(kind) {
@@ -158,6 +243,7 @@ function main() {
158
243
  const candidateMarkers = new Map();
159
244
  const agentTexts = new Map();
160
245
  const unclosedFenceViolations = [];
246
+ const includeViolations = [];
161
247
 
162
248
  // #4407: .compact.md variant siblings are an alternate rendering of their
163
249
  // canonical agent's SAME contract, not a distinct one — excluded so they
@@ -173,12 +259,41 @@ function main() {
173
259
  // Single pass over the agent's @-included references: each file is read
174
260
  // once and feeds BOTH the read-tag fold (agentTexts) and marker
175
261
  // extraction (producer/candidate attribution).
262
+ // #4930: every include this loop does NOT fold in is reported, never
263
+ // swallowed. An unfollowed include is text the marker verdict below was
264
+ // computed without, so a decline that leaves no trace in the output makes
265
+ // that verdict unexplainable. (lint-command-contract's @-ref existence rule
266
+ // covers commands' <execution_context> only, not agents.)
176
267
  const includeTexts = [];
177
- for (const refRelPath of referenceIncludes(content)) {
268
+ const reportInclude = (pointer, reason) => includeViolations.push({
269
+ kind: 'unresolved_reference_include',
270
+ agent,
271
+ marker: null,
272
+ detail: `${toRepoRelative(abs)}: ${pointer} ${reason}`,
273
+ });
274
+ const { includes, declined } = referenceIncludes(content);
275
+ for (const { pointer, reason } of declined) reportInclude(pointer, reason);
276
+ for (const refRelPath of includes) {
277
+ // CONTAIN BEFORE READ. The name grammar refuses `.`/`..` segments, which covers the TEXTUAL
278
+ // half of containment and nothing else: an intermediate path component that refers outside
279
+ // the tree is resolved transparently on the way to the file, so a textually-clean name can
280
+ // still address something outside `references/`. This loop READS what it resolves and folds
281
+ // the text into the scanned corpus, so the real path is what has to be checked.
282
+ const refsDir = path.join(ROOT, 'gsd-core', 'references');
283
+ const contained = tryWithinRoot(refRelPath.slice('gsd-core/references/'.length), refsDir);
284
+ if (contained === null) {
285
+ reportInclude(refRelPath, 'does not resolve inside gsd-core/references/');
286
+ continue;
287
+ }
178
288
  try {
179
- includeTexts.push(fs.readFileSync(path.join(ROOT, refRelPath), 'utf-8'));
180
- } catch {
181
- // include miss — lint-command-contract rule 4 owns @-ref existence
289
+ // Read the ContainedPath the predicate returned, never a re-joined path (ADR-4650).
290
+ if (!fs.lstatSync(contained).isFile()) {
291
+ reportInclude(refRelPath, 'is not a regular file');
292
+ continue;
293
+ }
294
+ includeTexts.push(fs.readFileSync(contained, 'utf-8'));
295
+ } catch (e) {
296
+ reportInclude(refRelPath, e && e.code === 'ENOENT' ? 'does not exist' : `could not be read (${e && e.code})`);
182
297
  }
183
298
  }
184
299
  agentTexts.set(agent, [content, ...includeTexts].join('\n'));
@@ -237,6 +352,7 @@ function main() {
237
352
  const allViolations = [
238
353
  ...parseViolations,
239
354
  ...unclosedFenceViolations,
355
+ ...includeViolations,
240
356
  ...contractViolationsList,
241
357
  ...readTagViolationsList,
242
358
  ...reverseViolationsList,