@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
@@ -123,7 +123,13 @@ function resolveTierEntry({ runtime, tier, overrides }) {
123
123
  */
124
124
  function _resolveRuntimeTier(config, tier) {
125
125
  return resolveTierEntry({
126
- runtime: config['runtime'],
126
+ // #4505/#4495: TIER SELECTION follows the runtime that is actually
127
+ // resolving, not whatever the config file happens to say. `config.runtime`
128
+ // is frequently absent (the runtime is normally known from GSD_RUNTIME or
129
+ // the per-install marker), and reading it raw made
130
+ // `model_profile_overrides.<runtime>.<tier>` silently inert for every
131
+ // install that did not also write the key by hand.
132
+ runtime: resolveActiveRuntime(config),
127
133
  tier,
128
134
  overrides: config['model_profile_overrides'],
129
135
  });
@@ -142,10 +148,19 @@ function _resolveRuntimeTier(config, tier) {
142
148
  *
143
149
  * This helper reads ONLY the user's override entry for the effective claude
144
150
  * runtime and tier — never the builtin claude tier map — so an install with no
145
- * override is byte-identical to before the fix. The runtime is resolved the
146
- * same way steps 1-3 resolve it (config['runtime'], defaulting to 'claude'),
147
- * NOT via resolveActiveRuntime (GSD_RUNTIME/marker): the value policy must
148
- * key off the config the operator wrote, matching mapClaudeOverrideForRuntime.
151
+ * override is byte-identical to before the fix.
152
+ *
153
+ * #4505 CHANGED how the runtime reaches here. #4192 originally passed
154
+ * `config['runtime']` and recorded that the value policy "must key off the
155
+ * config the operator wrote, NOT resolveActiveRuntime". That reading turned out
156
+ * to defeat #4192's own stated principle — that an explicit pin must not be
157
+ * silently UNPINNED. With `config.runtime` absent and GSD_RUNTIME=opencode, the
158
+ * config-keyed read treated the session as claude and collapsed an explicit
159
+ * `claude-opus-4-8` pin to the bare alias `opus`, which is a Claude-only token
160
+ * the actual runtime cannot spawn. The runtime is now the ACTIVE one, so the
161
+ * claude value policy applies exactly when claude is what is running. No test
162
+ * pinned the previous behaviour; the ones covering #4192's pins and the
163
+ * alias-native posture all still pass.
149
164
  *
150
165
  * Value policy mirrors the model_overrides path (#2041/#4192): an override
151
166
  * value that maps to a current tier alias collapses to that alias
@@ -155,10 +170,12 @@ function _resolveRuntimeTier(config, tier) {
155
170
  * Malformed entries (no usable `model` string) return null so the caller falls
156
171
  * through to normal alias resolution (ADR-443 D1: invalid values fall through).
157
172
  */
158
- function resolveClaudeTierOverrideModel(configRuntime, tier, overrides) {
173
+ function resolveClaudeTierOverrideModel(
174
+ // The ACTIVE runtime (#4505), not the raw config key — see docblock.
175
+ activeRuntime, tier, overrides) {
159
176
  if (!tier || tier === 'inherit')
160
177
  return null;
161
- const effectiveRuntime = configRuntime || 'claude';
178
+ const effectiveRuntime = activeRuntime || 'claude';
162
179
  if (effectiveRuntime !== 'claude')
163
180
  return null; // non-claude runtimes resolve at step 3
164
181
  const overridesMap = overrides;
@@ -264,13 +281,18 @@ function _resetModelOverrideWarningCacheForTests() {
264
281
  * `Object.hasOwn` lookup defeats `__proto__`/`constructor` lookups on the plain
265
282
  * object literal so those reserved keys cannot return a truthy non-string.
266
283
  */
267
- function mapClaudeOverrideForRuntime(override, configRuntime, agentType) {
284
+ function mapClaudeOverrideForRuntime(override,
285
+ // The ACTIVE runtime (#4505). Previously the raw `config['runtime']`, which
286
+ // made an explicit claude pin collapse to a bare alias whenever the runtime
287
+ // was declared via GSD_RUNTIME or the install marker rather than the config —
288
+ // handing a Claude-only token to a runtime that cannot spawn it.
289
+ activeRuntime, agentType) {
268
290
  // Defensive: model_overrides is typed Record<string,string> but a malformed
269
291
  // config could surface a non-string; pass through verbatim (preserving the
270
292
  // pre-fix no-crash behaviour) and let the downstream Agent tool reject it.
271
293
  if (typeof override !== 'string')
272
294
  return override;
273
- const onClaude = !configRuntime || configRuntime === 'claude';
295
+ const onClaude = !activeRuntime || activeRuntime === 'claude';
274
296
  if (!onClaude)
275
297
  return override;
276
298
  // Object.hasOwn guards against __proto__/constructor returning a truthy
@@ -480,7 +502,7 @@ function resolveModelInternal(cwd, agentType) {
480
502
  ? modelOverrides[agentType]
481
503
  : undefined;
482
504
  if (override) {
483
- const mapped = mapClaudeOverrideForRuntime(override, config['runtime'], agentType);
505
+ const mapped = mapClaudeOverrideForRuntime(override, resolveActiveRuntime(config), agentType);
484
506
  if (mapped !== null)
485
507
  return mapped;
486
508
  // Unmappable Claude ID — fall through to tier resolution (matches model_policy).
@@ -497,10 +519,19 @@ function resolveModelInternal(cwd, agentType) {
497
519
  const agentModels = Object.hasOwn(modelProfilesMapForModel, agentType) ? modelProfilesMapForModel[agentType] : undefined;
498
520
  const tier = computeProfileTier(config, agentType);
499
521
  // 2.5. model_policy preset (#49, #1133)
522
+ // `configRuntime` is retained ONLY as the EXPLICIT-OPT-IN signal for step 3's
523
+ // precedence over the omit gate (see the note there). Every actual runtime
524
+ // question — which tier map to read, which value policy applies — now uses
525
+ // the active runtime (#4505).
500
526
  const configRuntime = config['runtime'];
527
+ // #4505/#4495: GSD_RUNTIME -> config.runtime -> per-install marker -> 'claude'.
528
+ // Never undefined, so the "no runtime declared anywhere" case still resolves
529
+ // 'claude' and step 3's deliberate claude skip (#1156/#2297/#4192) is
530
+ // unchanged for every existing install.
531
+ const activeRuntime = resolveActiveRuntime(config);
501
532
  if (tier && tier !== 'inherit') {
502
- const onClaude = !configRuntime || configRuntime === 'claude';
503
- const effectiveRuntime = configRuntime || 'claude';
533
+ const onClaude = activeRuntime === 'claude';
534
+ const effectiveRuntime = activeRuntime;
504
535
  const mergedPolicy = config['model_policy']
505
536
  ? { ...config['model_policy'], runtime: effectiveRuntime }
506
537
  : null;
@@ -525,8 +556,49 @@ function resolveModelInternal(cwd, agentType) {
525
556
  warnModelPolicyUnmappable(agentType, policyModel, tier);
526
557
  }
527
558
  }
528
- // 3. Runtime-aware resolution (#2517)
529
- if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
559
+ // #4505: the omit DECISION is computed here, ahead of step 3, though the
560
+ // return still happens at step 4 below.
561
+ //
562
+ // Step 3 was previously unreachable unless the operator had written `runtime`
563
+ // into the config. Keying it off the ACTIVE runtime — the #4495 fix — makes it
564
+ // reachable for env- and marker-declared runtimes too, which newly exposes an
565
+ // ordering the corpus already decided, in two places that only coexisted
566
+ // because step 3 could not fire:
567
+ //
568
+ // #2517 `runtime:"codex"` + resolve_model_ids:"omit" -> the codex tier
569
+ // model. "Explicit non-Claude opt-in wins" — the operator naming a
570
+ // runtime in the project config outranks an omit.
571
+ // #4717 (user-sanctioned decision a — supersedes #2297 acceptance #4):
572
+ // a DETECTED runtime now constitutes that opt-in too. The identity
573
+ // fill in config-loader materializes GSD_RUNTIME / the per-install
574
+ // marker into `config.runtime` when it is empty, so a genuinely
575
+ // installed non-Claude runtime resolves its own tier map instead of
576
+ // the omit's "". The shared "omit" remains a Claude protection:
577
+ // marker/env = claude still ignores it (native aliases), and
578
+ // garbage runtime values still fail safe to "" (guard below).
579
+ //
580
+ // So the opt-in signal is the `runtime` KEY — written by the operator or
581
+ // materialized by the #4717 fill: step 3 reads the active runtime's tier
582
+ // map, but only outranks the omit gate when that key canonicalizes to a
583
+ // recognised non-Claude runtime.
584
+ const omitApplies = config['resolve_model_ids'] === 'omit'
585
+ && (projectExplicitlySetsOmit(cwd) || !RUNTIMES_WITH_NATIVE_ALIASES.has(activeRuntime));
586
+ // CANONICALIZED, not the raw field. Comparing the raw value against the literal
587
+ // 'claude' made every spelling that is not exactly that string count as a
588
+ // non-Claude opt-in and outrank the omit gate: `runtime:"Claude"`,
589
+ // `runtime:"claude-code"` and even `runtime:5` each emitted a model id where
590
+ // origin/next returned "". That is the #2297 acceptance-#4 case this very
591
+ // block claims to preserve, failing OPEN. (Security review of #4505.)
592
+ //
593
+ // null covers both "not a string" and "not a runtime we recognise", and both
594
+ // must read as NOT an opt-in: an unrecognised value is not evidence the
595
+ // operator deliberately chose a non-Claude runtime, so it must not buy an
596
+ // escalation past an explicit omit.
597
+ const configRuntimeCanonical = (0, runtime_name_policy_cjs_1.canonicalizeRuntimeName)(configRuntime);
598
+ const explicitNonClaudeOptIn = configRuntimeCanonical !== null && configRuntimeCanonical !== 'claude';
599
+ // 3. Runtime-aware resolution (#2517), keyed off the ACTIVE runtime (#4505).
600
+ if (activeRuntime !== 'claude' && tier && tier !== 'inherit'
601
+ && (explicitNonClaudeOptIn || !omitApplies)) {
530
602
  const entry = _resolveRuntimeTier(config, tier);
531
603
  if (entry?.model)
532
604
  return entry.model;
@@ -541,8 +613,7 @@ function resolveModelInternal(cwd, agentType) {
541
613
  // NOTE: a non-Claude runtime that HAS a populated runtime-tier map already
542
614
  // returned its own model id at step 3 above, before this gate — for those the
543
615
  // explicit-project-omit honoring here is moot (step 3 wins, by #2517 design).
544
- if (config['resolve_model_ids'] === 'omit'
545
- && (projectExplicitlySetsOmit(cwd) || !RUNTIMES_WITH_NATIVE_ALIASES.has(resolveActiveRuntime(config)))) {
616
+ if (omitApplies) {
546
617
  return '';
547
618
  }
548
619
  // 4.5. Claude-runtime tier override (#4192 Finding 1). Sits AFTER the omit
@@ -554,10 +625,30 @@ function resolveModelInternal(cwd, agentType) {
554
625
  // `model_profile_overrides.claude.<tier>` entry for this tier — see
555
626
  // resolveClaudeTierOverrideModel for why the builtin map stays out.
556
627
  if (tier && tier !== 'inherit') {
557
- const claudeOverrideModel = resolveClaudeTierOverrideModel(configRuntime, tier, config['model_profile_overrides']);
628
+ const claudeOverrideModel = resolveClaudeTierOverrideModel(activeRuntime, tier, config['model_profile_overrides']);
558
629
  if (claudeOverrideModel !== null)
559
630
  return claudeOverrideModel;
560
631
  }
632
+ // 4.75 dynamic_routing.tier_models (#4505 / #3024).
633
+ //
634
+ // Position is the documented composition, not a convenience:
635
+ // docs/features/dynamic-routing-with-failure-tier-escalation.md —
636
+ // "model_overrides always wins; dynamic_routing.tier_models[<tier>] resolves
637
+ // above models.<phase_type> and model_profile."
638
+ // So it sits BELOW model_overrides (step 1), the model_policy preset (2.5),
639
+ // the runtime tier map (3), the resolve_model_ids:"omit" gate (4) and the
640
+ // claude tier override (4.5) — and ABOVE the profile lookup (5).
641
+ //
642
+ // An earlier cut of this fix routed every call site through resolveModelForTier
643
+ // instead, which returns the tier model directly and therefore skipped steps 3,
644
+ // 4 and 4.5 entirely: with `resolve_model_ids:"omit"` and a non-Claude runtime
645
+ // it handed out a model id where the gate had returned "". Putting the step
646
+ // here keeps every higher-precedence layer reachable.
647
+ if (tier !== 'inherit') {
648
+ const routed = dynamicRoutingModel(config, agentType, 0);
649
+ if (routed !== null)
650
+ return routed;
651
+ }
561
652
  // 5. Profile lookup (Claude-native default).
562
653
  if (!agentModels) {
563
654
  return profile === 'quality' ? 'opus'
@@ -614,6 +705,50 @@ function assertValidGranularityOverride(override, fail) {
614
705
  fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
615
706
  }
616
707
  }
708
+ /**
709
+ * #4505 — the ONE implementation of `dynamic_routing.tier_models` lookup.
710
+ *
711
+ * Returns the configured model for this agent's routing tier at `attempt`, or
712
+ * null when dynamic routing does not apply (disabled, absent, no tier table, an
713
+ * agent with no default routing tier, or no entry for the tier). Both entry
714
+ * points call it, so the first-spawn value and the escalated value can never
715
+ * drift apart — the "Generative Fix Divergence" this repo names by name.
716
+ *
717
+ * `attempt` 0 means "no escalation yet", which is the documented FIRST-SPAWN
718
+ * case, NOT "skip dynamic routing":
719
+ * docs/features/dynamic-routing-with-failure-tier-escalation.md —
720
+ * "enabled: true — the resolver picks tier_models[default_tier] for the first
721
+ * spawn and escalates one tier up on orchestrator-detected soft failure."
722
+ */
723
+ function dynamicRoutingModel(config, agentType, attempt) {
724
+ const dr = config['dynamic_routing'];
725
+ if (!dr || typeof dr !== 'object' || dr['enabled'] !== true)
726
+ return null;
727
+ const tierModels = dr['tier_models'];
728
+ if (!tierModels || typeof tierModels !== 'object')
729
+ return null;
730
+ const defaultTier = (AGENT_DEFAULT_TIERS)[agentType];
731
+ if (!defaultTier || !(VALID_AGENT_TIERS).has(defaultTier))
732
+ return null;
733
+ const maxEscalations = Number.isInteger(dr['max_escalations']) && dr['max_escalations'] >= 0
734
+ ? dr['max_escalations']
735
+ : 1;
736
+ const escalationEnabled = dr['escalate_on_failure'] !== false;
737
+ const effectiveAttempt = escalationEnabled ? Math.min(attempt, maxEscalations) : 0;
738
+ let tier = defaultTier;
739
+ for (let i = 0; i < effectiveAttempt; i += 1) {
740
+ const next = (nextTier)(tier);
741
+ if (!next || next === tier)
742
+ break;
743
+ tier = next;
744
+ }
745
+ // Own-property guard: `tier_models` is a config-supplied plain object, so a
746
+ // prototype-chain key must not resolve an inherited member.
747
+ const alias = Object.hasOwn(tierModels, tier) ? tierModels[tier] : undefined;
748
+ if (typeof alias !== 'string' || alias.length === 0)
749
+ return null;
750
+ return alias;
751
+ }
617
752
  /**
618
753
  * #3024 — Resolve a model for a specific dynamic-routing attempt.
619
754
  */
@@ -629,45 +764,19 @@ function resolveModelForTier(cwd, agentType, attempt) {
629
764
  ? modelOverrides[agentType]
630
765
  : undefined;
631
766
  if (override) {
632
- const mapped = mapClaudeOverrideForRuntime(override, config['runtime'], agentType);
767
+ const mapped = mapClaudeOverrideForRuntime(override, resolveActiveRuntime(config), agentType);
633
768
  if (mapped !== null)
634
769
  return mapped;
635
770
  // Unmappable Claude ID — fall through to dynamic_routing / model_policy resolution.
636
771
  }
637
- if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') {
772
+ // #4505: same active-runtime rule as resolveModelInternal step 3.
773
+ if (config['model_policy'] && resolveActiveRuntime(config) !== 'claude') {
638
774
  return resolveModelInternal(cwd, agentType);
639
775
  }
640
- const dr = config['dynamic_routing'];
641
- if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
642
- return resolveModelInternal(cwd, agentType);
643
- }
644
- const tierModels = dr['tier_models'];
645
- if (!tierModels || typeof tierModels !== 'object') {
646
- return resolveModelInternal(cwd, agentType);
647
- }
648
- const defaultTier = (AGENT_DEFAULT_TIERS)[agentType];
649
- if (!defaultTier || !(VALID_AGENT_TIERS).has(defaultTier)) {
650
- return resolveModelInternal(cwd, agentType);
651
- }
652
- const maxEscalations = Number.isInteger(dr['max_escalations']) && dr['max_escalations'] >= 0
653
- ? dr['max_escalations']
654
- : 1;
655
- const escalationEnabled = dr['escalate_on_failure'] !== false;
656
- const effectiveAttempt = escalationEnabled
657
- ? Math.min(attemptN, maxEscalations)
658
- : 0;
659
- let tier = defaultTier;
660
- for (let i = 0; i < effectiveAttempt; i += 1) {
661
- const next = (nextTier)(tier);
662
- if (!next || next === tier)
663
- break;
664
- tier = next;
665
- }
666
- const alias = tierModels[tier];
667
- if (typeof alias !== 'string' || alias.length === 0) {
668
- return resolveModelInternal(cwd, agentType);
669
- }
670
- return alias;
776
+ const alias = dynamicRoutingModel(config, agentType, attemptN);
777
+ if (alias !== null)
778
+ return alias;
779
+ return resolveModelInternal(cwd, agentType);
671
780
  }
672
781
  /**
673
782
  * Keep only usable model ids: non-empty strings. A malformed config can put
@@ -186,11 +186,16 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
186
186
  },
187
187
  'uat-passed': (_ctx) => {
188
188
  let requireVerification = false;
189
+ let uatOnly = false;
189
190
  const positional = [];
190
191
  for (const token of args.slice(2)) {
191
192
  if (token === '--require-verification') {
192
193
  requireVerification = true;
193
194
  }
195
+ else if (token === '--uat-only') {
196
+ // #4663: evaluate UAT rows only (verification-status blockers skipped).
197
+ uatOnly = true;
198
+ }
194
199
  else if (token === '--raw') {
195
200
  // --raw is handled by the outer CLI layer; accepted here silently
196
201
  }
@@ -201,7 +206,10 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
201
206
  positional.push(token);
202
207
  }
203
208
  }
204
- phase.cmdPhaseUatPassed(cwd, positional[0], raw, { policy: { requireVerification } });
209
+ if (requireVerification && uatOnly) {
210
+ return makeInvalidArgs('--uat-only', '--uat-only and --require-verification are mutually exclusive');
211
+ }
212
+ phase.cmdPhaseUatPassed(cwd, positional[0], raw, { policy: { requireVerification, uatOnly } });
205
213
  return { ok: true, data: null };
206
214
  },
207
215
  // #1437 — list plan files for a phase
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ /**
3
+ * Canonical bracket convention card (#3638 / ADR-612 PR-5).
4
+ *
5
+ * This module is the sole editable source of the compact grammar diagram.
6
+ * Render sites import phaseIdCard(); generated documentation is held to the
7
+ * same bytes by tests/phase-id-card.test.cjs. PR-6 can therefore inject the
8
+ * card broadly without inventing another copy.
9
+ */
10
+ const PHASE_ID_CARD = [
11
+ '[GSD.02] 05.03-01',
12
+ ' │ │ │ │ │',
13
+ ' │ │ │ │ └── plan 01',
14
+ ' │ │ │ └────── subphase 03',
15
+ ' │ │ └───────── phase 05',
16
+ ' │ └───────────── milestone 02',
17
+ ' └───────────────── project GSD',
18
+ ].join('\n');
19
+ const PHASE_ID_LEGEND = "milestone = bracket integer; dots = phase-levels; one hyphen = plan; "
20
+ + "no 'Phase' word, no vX.Y";
21
+ function phaseIdCard(options = {}) {
22
+ const parts = [];
23
+ if (options.title)
24
+ parts.push(options.title, '');
25
+ parts.push(PHASE_ID_CARD, '', PHASE_ID_LEGEND);
26
+ return parts.join('\n');
27
+ }
28
+ module.exports = {
29
+ phaseIdCard,
30
+ PHASE_ID_CARD,
31
+ PHASE_ID_LEGEND,
32
+ };
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /**
3
+ * Phase-ID display adapters (#3638 / ADR-612 PR-5).
4
+ *
5
+ * STATE.md and milestone metadata still expose the legacy `vN.0` marker on
6
+ * this stacked base. Display surfaces need to translate that metadata into a
7
+ * bracket identity without growing another renderer. These pure adapters do
8
+ * only the boundary normalization, then delegate identity parsing/rendering to
9
+ * phase-id.cts's canonical pair.
10
+ */
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
12
+ const phaseId = require("./phase-id.cjs");
13
+ const { parsePhaseId, renderMilestoneId, renderPhaseId } = phaseId;
14
+ function canonicalNumeric(value) {
15
+ let offset = 0;
16
+ while (offset < value.length - 1 && value[offset] === '0')
17
+ offset++;
18
+ const stripped = value.slice(offset);
19
+ return stripped.length < 2 ? stripped.padStart(2, '0') : stripped;
20
+ }
21
+ function isDigits(value) {
22
+ return value.length > 0
23
+ && [...value].every(char => char >= '0' && char <= '9');
24
+ }
25
+ function milestoneToken(value) {
26
+ if (typeof value !== 'string' && typeof value !== 'number')
27
+ return null;
28
+ const raw = String(value).replace(/^v/i, '');
29
+ const parts = raw.split('.');
30
+ if (parts.length > 2 || parts.some(part => !isDigits(part)))
31
+ return null;
32
+ return canonicalNumeric(parts[0]);
33
+ }
34
+ function phaseToken(value) {
35
+ if (typeof value !== 'string' && typeof value !== 'number')
36
+ return null;
37
+ const raw = String(value);
38
+ const parts = raw.split('.');
39
+ if (parts.length > 2 || parts.some(part => !isDigits(part)))
40
+ return null;
41
+ return parts.map(canonicalNumeric).join('.');
42
+ }
43
+ /**
44
+ * Render a bracket phase display from legacy milestone metadata.
45
+ * Returns null for incomplete or invalid metadata so cosmetic callers can
46
+ * degrade without breaking a command/statusline render.
47
+ */
48
+ function renderBracketPhaseDisplay(milestone, phase, projectCode) {
49
+ const project = typeof projectCode === 'string' ? projectCode : '';
50
+ const mm = milestoneToken(milestone);
51
+ const pp = phaseToken(phase);
52
+ if (!project || mm === null || pp === null)
53
+ return null;
54
+ try {
55
+ return renderPhaseId(parsePhaseId(`${project}.${mm}-${pp}`));
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ /** Render only the bracket milestone label, still through canonical parsing. */
62
+ function renderBracketMilestoneDisplay(milestone, projectCode) {
63
+ const project = typeof projectCode === 'string' ? projectCode : '';
64
+ const mm = milestoneToken(milestone);
65
+ if (!project || mm === null)
66
+ return null;
67
+ try {
68
+ // The sentinel phase is a validation vehicle, not a rendering artifact.
69
+ return renderMilestoneId(parsePhaseId(`${project}.${mm}-00`));
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ module.exports = {
76
+ renderBracketPhaseDisplay,
77
+ renderBracketMilestoneDisplay,
78
+ };
@@ -59,6 +59,20 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
59
59
  // (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
60
60
  // introduced outside this module without a `// phase-id-owner:` justification.
61
61
  const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
62
+ // #4764: a phase REFERENCE in depends-on PROSE — the token in context, directly
63
+ // following "Phase"/"Phases", with bare-token list continuation ("Phases 1 and 2",
64
+ // "Phase 1, 2, and 3", "Phase 1-3"). Built HERE, beside the token grammar it is
65
+ // anchored on, because two readers consume Depends-on prose (init.manager's
66
+ // dep_phases and planning-inspect's dependencies) and re-deriving the anchored
67
+ // form at each would drift exactly the way PHASE_NUMBER_TOKEN_SOURCE's own
68
+ // anti-divergence rule exists to prevent. A bare digit run is the right shape
69
+ // test for a phase id and the WRONG test for a reference: the whole-field
70
+ // scrape this replaces pulled calendar dates ("2026-09-14" → 2026/09/14), git
71
+ // shas ("8bf403100d" → 8b/403100d/…) and ledger ids (WINDOWS #1843) in as
72
+ // dependencies. The `-` separator deliberately extracts range ENDPOINTS only
73
+ // ("Phase 1-3" → 1, 3) — the pre-#4764 behavior; interior enumeration stays
74
+ // out (a range's middle is not written as a reference).
75
+ const PHASE_DEP_REF_SOURCE = `\\bphases?\\s+(${PHASE_NUMBER_TOKEN_SOURCE}(?:(?:\\s*,\\s*(?:and\\s+)?|\\s+and\\s+|\\s*&\\s*|\\s+(?:to|through)\\s+|\\s*-\\s*)${PHASE_NUMBER_TOKEN_SOURCE})*)`;
62
76
  // #2528 review: the CASE-FLEXIBLE renderings of the two sources above, for call
63
77
  // sites that scan directory names (where a project code or a variant suffix may
64
78
  // legitimately be lowercase) and therefore cannot use a case-sensitive class.
@@ -308,6 +322,30 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
308
322
  const bracketAlt = `\\[${id}\\][ \\t]*(?:Phase\\s+|(?=\\d))`;
309
323
  return `(?:${bracketAlt}|${base})`;
310
324
  }
325
+ /**
326
+ * The single owner of the "scan a whole document for every phase heading"
327
+ * pattern (#4865 / ADR-4910 §8) — anchored (`^ {0,3}#{2,4}`, so indentation up
328
+ * to 3 spaces is tolerated but a heading is never matched mid-line), global,
329
+ * multiline, convention-aware via `phaseHeadingPrefixSrcFor`. Returns the
330
+ * capture-group layout alongside the regex so a caller never re-derives the
331
+ * bracket-convention offset (`const G = convention === 'bracket' ? 1 : 0`)
332
+ * itself — a second, independently-computed offset is exactly the kind of
333
+ * drift `scripts/lint-phase-id-drift.cjs` exists to catch structurally, not
334
+ * just by pattern text.
335
+ *
336
+ * Distinct from `buildPhaseHeadingRegex` (`src/roadmap.cts`), which searches
337
+ * for ONE already-known phase number (anchored `^`, no `g` flag, phase number
338
+ * interpolated literally) — a different contract for a different question.
339
+ * This function answers "which phases exist in this content", not "does this
340
+ * specific phase exist".
341
+ */
342
+ function buildPhaseHeadingScanRegex(baseline, convention) {
343
+ const bracketGroup = convention === 'bracket' ? 1 : null;
344
+ const phaseNumGroup = bracketGroup ? 2 : 1;
345
+ const phaseNameGroup = phaseNumGroup + 1;
346
+ const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
347
+ return { regex, bracketGroup, phaseNumGroup, phaseNameGroup };
348
+ }
311
349
  function stripProjectCodePrefix(value, caseInsensitive = true) {
312
350
  const input = String(value);
313
351
  const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
@@ -451,10 +489,27 @@ function parsePhaseId(input) {
451
489
  // tokens unchanged.
452
490
  throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`);
453
491
  }
492
+ function renderMilestoneId(id) {
493
+ return `[${id.project}.${id.milestone}]`;
494
+ }
454
495
  function renderPhaseId(id) {
455
496
  const sub = id.subphase ? `.${id.subphase}` : '';
456
497
  const plan = id.plan ? `-${id.plan}` : '';
457
- return `[${id.project}.${id.milestone}] ${id.phase}${sub}${plan}`;
498
+ return `${renderMilestoneId(id)} ${id.phase}${sub}${plan}`;
499
+ }
500
+ /** Whether a legacy phase token has a lossless spelling in bracket display grammar. */
501
+ function isBracketPhaseTokenRepresentable(token) {
502
+ try {
503
+ const bracketToken = normalizePhaseName(token)
504
+ .split('.')
505
+ .map((segment) => segment.padStart(2, '0'))
506
+ .join('.');
507
+ parsePhaseId(`[GSD.00] ${bracketToken}`);
508
+ return true;
509
+ }
510
+ catch {
511
+ return false;
512
+ }
458
513
  }
459
514
  // PhaseId is a structural type: nothing forces a caller through parsePhaseId,
460
515
  // so toDir cannot trust project/milestone/phase/subphase are already
@@ -1017,7 +1072,7 @@ function isPhaseArtifact(fileName, phaseDirName, convention) {
1017
1072
  * whichever path produced the candidates, which is what makes a bracket dir
1018
1073
  * read exactly what its legacy twin reads.
1019
1074
  */
1020
- function matchesPhaseTokenCandidates(fileName, rawCandidates) {
1075
+ function expandPhaseTokenCandidates(rawCandidates) {
1021
1076
  // Each reading is compared in BOTH its padded and de-padded form: files are
1022
1077
  // written padded by `normalizePhaseName` (`cmdScaffold`) while directories
1023
1078
  // are often not (`1-unpadded`), and legacy trees carry the reverse pairing.
@@ -1025,9 +1080,19 @@ function matchesPhaseTokenCandidates(fileName, rawCandidates) {
1025
1080
  // sub-phase (`03A`, `03.1`) has no meaningful de-padded form and is left
1026
1081
  // alone, so this only ever ADDS a reading and can never drop one.
1027
1082
  const depad = (t) => (/^\d+$/.test(t) ? String(Number(t)) : t);
1028
- const candidates = new Set(rawCandidates
1083
+ return new Set(rawCandidates
1029
1084
  .flatMap(t => [t, normalizePhaseName(t), depad(t)])
1030
1085
  .map(t => t.toUpperCase()));
1086
+ }
1087
+ function matchesPhaseTokenCandidates(fileName, rawCandidates) {
1088
+ const candidates = expandPhaseTokenCandidates(rawCandidates);
1089
+ if (matchPhaseTokenCandidateSpan(fileName, candidates) !== null)
1090
+ return true;
1091
+ // FIX 2: token-less filename (bare "VERIFICATION.md"/"UAT.md") — containment
1092
+ // in this phase's own directory listing is sufficient.
1093
+ return derivePhaseTokenSegments(fileName).tokenSegments.length === 0;
1094
+ }
1095
+ function matchPhaseTokenCandidateSpan(fileName, candidates) {
1031
1096
  const fileUpper = fileName.toUpperCase();
1032
1097
  for (const candidate of candidates) {
1033
1098
  // A dotted sub-phase segment (e.g. `01.1-CONTEXT.md`) is a legitimate
@@ -1053,11 +1118,43 @@ function matchesPhaseTokenCandidates(fileName, rawCandidates) {
1053
1118
  if (fileUpper.startsWith(`${candidate}-`) ||
1054
1119
  fileUpper.startsWith(`${candidate}.`) ||
1055
1120
  fileUpper.startsWith(`${candidate}_`))
1056
- return true;
1121
+ return { start: 0, end: candidate.length };
1057
1122
  }
1058
- // FIX 2: token-less filename (bare "VERIFICATION.md"/"UAT.md") — containment
1059
- // in this phase's own directory listing is sufficient.
1060
- return derivePhaseTokenSegments(fileName).tokenSegments.length === 0;
1123
+ return null;
1124
+ }
1125
+ /**
1126
+ * Return the exact leading token span by which the phase-artifact reader
1127
+ * attributes a phase-qualified filename to `phaseDirName`.
1128
+ *
1129
+ * Membership is first decided by `isPhaseArtifact`, then the span is selected
1130
+ * from the same padded, de-padded, case-folded candidate set used by
1131
+ * `matchesPhaseTokenCandidates`. Token-less containment fallbacks return null
1132
+ * because there is no phase token in the filename to replace.
1133
+ */
1134
+ function phaseArtifactTokenSpan(fileName, phaseDirName, convention) {
1135
+ if (!isPhaseArtifact(fileName, phaseDirName, convention))
1136
+ return null;
1137
+ if (convention === 'bracket') {
1138
+ const bracketDir = phaseDirName.match(BRACKET_DIR_TOKEN_RE);
1139
+ if (bracketDir) {
1140
+ const qualified = fileName.match(BRACKET_QUALIFIED_KEY_RE);
1141
+ if (qualified && bracketQualifiedKey(fileName, convention) !== null) {
1142
+ return { start: 0, end: qualified[0].length };
1143
+ }
1144
+ const bracketCandidates = expandPhaseTokenCandidates([bracketDir[1]]);
1145
+ return matchPhaseTokenCandidateSpan(fileName, bracketCandidates);
1146
+ }
1147
+ }
1148
+ const { tokenSegments } = derivePhaseTokenSegments(phaseDirName);
1149
+ if (tokenSegments.length === 0)
1150
+ return null;
1151
+ const literalToken = extractPhaseToken(phaseDirName);
1152
+ const strippedDir = stripProjectCodePrefix(phaseDirName);
1153
+ const strippedToken = strippedDir !== phaseDirName ? extractPhaseToken(strippedDir) : literalToken;
1154
+ const leadingRunMatch = strippedDir.match(LEADING_DIGIT_RUN_RE);
1155
+ const rawCandidates = [literalToken, strippedToken, leadingRunMatch?.[1]].filter((token) => Boolean(token));
1156
+ const candidates = expandPhaseTokenCandidates(rawCandidates);
1157
+ return matchPhaseTokenCandidateSpan(fileName, candidates);
1061
1158
  }
1062
1159
  /**
1063
1160
  * #3511: scope `fileNames` to the subset that passes
@@ -1538,6 +1635,7 @@ module.exports = {
1538
1635
  OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
1539
1636
  OPTIONAL_PHASE_TAG_SOURCE,
1540
1637
  PHASE_NUMBER_TOKEN_SOURCE,
1638
+ PHASE_DEP_REF_SOURCE,
1541
1639
  CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE,
1542
1640
  CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE,
1543
1641
  PHASE_CONTINUATION_SEGMENT_SOURCE,
@@ -1554,6 +1652,7 @@ module.exports = {
1554
1652
  BASE_PHASE_LABEL_PREFIX_SRC,
1555
1653
  PHASE_HEADING_BASELINE,
1556
1654
  phaseHeadingPrefixSrcFor,
1655
+ buildPhaseHeadingScanRegex,
1557
1656
  foldBracketId,
1558
1657
  bracketQualifiedKey,
1559
1658
  stripProjectCodePrefix,
@@ -1561,7 +1660,9 @@ module.exports = {
1561
1660
  getMilestoneFromPhaseId,
1562
1661
  getPhaseDirFromPhaseId,
1563
1662
  parsePhaseId,
1663
+ renderMilestoneId,
1564
1664
  renderPhaseId,
1665
+ isBracketPhaseTokenRepresentable,
1565
1666
  toDir,
1566
1667
  SENTINEL_RANGES,
1567
1668
  isSentinelPhaseId,
@@ -1571,6 +1672,7 @@ module.exports = {
1571
1672
  comparePhaseNum,
1572
1673
  extractPhaseToken,
1573
1674
  isPhaseArtifact,
1675
+ phaseArtifactTokenSpan,
1574
1676
  scopeToPhase,
1575
1677
  phaseTokenMatches,
1576
1678
  matchPhaseDirs,