@opengsd/gsd-core 1.7.0 → 1.8.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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +29 -2
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-verifier.md +2 -2
  10. package/bin/install.js +1152 -80
  11. package/commands/gsd/ai-integration-phase.md +1 -1
  12. package/commands/gsd/mempalace-capture.md +9 -5
  13. package/commands/gsd/new-milestone.md +1 -1
  14. package/commands/gsd/plan-phase.md +5 -3
  15. package/commands/gsd/plan-review-convergence.md +3 -2
  16. package/gsd-core/bin/gsd-tools.cjs +1878 -2507
  17. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  18. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  19. package/gsd-core/bin/lib/api-coverage.cjs +338 -45
  20. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  21. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  22. package/gsd-core/bin/lib/capability-registry.cjs +155 -86
  23. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  24. package/gsd-core/bin/lib/check-command-router.cjs +128 -25
  25. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  26. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  27. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  28. package/gsd-core/bin/lib/commands.cjs +81 -4
  29. package/gsd-core/bin/lib/config-loader.cjs +14 -2
  30. package/gsd-core/bin/lib/config.cjs +69 -18
  31. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  32. package/gsd-core/bin/lib/decisions.cjs +32 -8
  33. package/gsd-core/bin/lib/docs.cjs +6 -0
  34. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  35. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  36. package/gsd-core/bin/lib/init.cjs +111 -47
  37. package/gsd-core/bin/lib/install-engine.cjs +298 -23
  38. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  39. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  40. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  41. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  42. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  43. package/gsd-core/bin/lib/milestone.cjs +246 -12
  44. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  45. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  46. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  47. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  48. package/gsd-core/bin/lib/phase.cjs +201 -12
  49. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  50. package/gsd-core/bin/lib/roadmap-parser.cjs +7 -4
  51. package/gsd-core/bin/lib/roadmap.cjs +13 -3
  52. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -1
  53. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -8
  54. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +16 -0
  55. package/gsd-core/bin/lib/smart-entry.cjs +69 -4
  56. package/gsd-core/bin/lib/state-document.cjs +7 -4
  57. package/gsd-core/bin/lib/state-transition.cjs +22 -1
  58. package/gsd-core/bin/lib/state.cjs +65 -11
  59. package/gsd-core/bin/lib/surface.cjs +51 -9
  60. package/gsd-core/bin/lib/uat.cjs +420 -5
  61. package/gsd-core/bin/lib/validate.cjs +12 -8
  62. package/gsd-core/bin/lib/verification.cjs +112 -17
  63. package/gsd-core/bin/lib/verify.cjs +220 -22
  64. package/gsd-core/bin/shared/config-schema.manifest.json +3 -2
  65. package/gsd-core/references/api-coverage.md +37 -7
  66. package/gsd-core/references/checkpoints.md +1 -1
  67. package/gsd-core/references/common-bug-patterns.md +13 -0
  68. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  69. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  70. package/gsd-core/references/debugger-philosophy.md +1 -0
  71. package/gsd-core/references/debugger-prevention.md +98 -0
  72. package/gsd-core/references/debugger-rca-branching.md +98 -0
  73. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  74. package/gsd-core/references/debugger-sbfl.md +110 -0
  75. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  76. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  77. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  78. package/gsd-core/references/execute-phase-response-language.md +7 -0
  79. package/gsd-core/references/planner-antipatterns.md +6 -0
  80. package/gsd-core/references/planner-mvp-mode.md +12 -13
  81. package/gsd-core/references/planner-preconditions.md +156 -0
  82. package/gsd-core/references/planner-reversibility.md +132 -0
  83. package/gsd-core/references/reviewer-instances.md +9 -7
  84. package/gsd-core/references/skeleton-template.md +1 -1
  85. package/gsd-core/references/thinking-models-planning.md +3 -1
  86. package/gsd-core/templates/DEBUG.md +5 -3
  87. package/gsd-core/workflows/add-phase.md +2 -0
  88. package/gsd-core/workflows/add-tests.md +3 -1
  89. package/gsd-core/workflows/add-todo.md +32 -1
  90. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  91. package/gsd-core/workflows/audit-fix.md +2 -2
  92. package/gsd-core/workflows/check-todos.md +3 -1
  93. package/gsd-core/workflows/cleanup.md +7 -1
  94. package/gsd-core/workflows/code-review.md +17 -5
  95. package/gsd-core/workflows/complete-milestone.md +3 -0
  96. package/gsd-core/workflows/debug.md +25 -5
  97. package/gsd-core/workflows/diagnose-issues.md +1 -1
  98. package/gsd-core/workflows/discovery-phase.md +7 -0
  99. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  100. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  101. package/gsd-core/workflows/do.md +7 -1
  102. package/gsd-core/workflows/docs-update.md +1 -0
  103. package/gsd-core/workflows/eval-review.md +3 -0
  104. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  105. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  106. package/gsd-core/workflows/execute-phase.md +25 -34
  107. package/gsd-core/workflows/execute-plan.md +15 -4
  108. package/gsd-core/workflows/graduation.md +3 -0
  109. package/gsd-core/workflows/health.md +7 -1
  110. package/gsd-core/workflows/help/modes/full.md +6 -2
  111. package/gsd-core/workflows/import.md +8 -2
  112. package/gsd-core/workflows/inbox.md +7 -0
  113. package/gsd-core/workflows/ingest-docs.md +15 -10
  114. package/gsd-core/workflows/manager.md +3 -1
  115. package/gsd-core/workflows/map-codebase.md +4 -4
  116. package/gsd-core/workflows/mvp-phase.md +3 -0
  117. package/gsd-core/workflows/new-milestone.md +69 -21
  118. package/gsd-core/workflows/new-project.md +17 -15
  119. package/gsd-core/workflows/new-workspace.md +3 -1
  120. package/gsd-core/workflows/onboard.md +3 -0
  121. package/gsd-core/workflows/plan-phase.md +14 -5
  122. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  123. package/gsd-core/workflows/plant-seed.md +3 -0
  124. package/gsd-core/workflows/profile-user.md +7 -1
  125. package/gsd-core/workflows/progress.md +31 -3
  126. package/gsd-core/workflows/quick.md +19 -7
  127. package/gsd-core/workflows/remove-workspace.md +3 -0
  128. package/gsd-core/workflows/review.md +89 -73
  129. package/gsd-core/workflows/scan.md +1 -1
  130. package/gsd-core/workflows/secure-phase.md +3 -0
  131. package/gsd-core/workflows/settings-integrations.md +3 -0
  132. package/gsd-core/workflows/settings.md +3 -0
  133. package/gsd-core/workflows/ship.md +50 -3
  134. package/gsd-core/workflows/sketch.md +3 -0
  135. package/gsd-core/workflows/smart-entry.md +3 -0
  136. package/gsd-core/workflows/spike.md +7 -1
  137. package/gsd-core/workflows/ui-phase.md +3 -1
  138. package/gsd-core/workflows/ui-review.md +3 -0
  139. package/gsd-core/workflows/undo.md +7 -0
  140. package/gsd-core/workflows/update.md +2 -0
  141. package/gsd-core/workflows/validate-phase.md +3 -0
  142. package/gsd-core/workflows/verify-phase.md +2 -2
  143. package/gsd-core/workflows/verify-work.md +7 -3
  144. package/hooks/dist/gsd-context-monitor.js +27 -9
  145. package/hooks/dist/gsd-statusline.js +88 -3
  146. package/hooks/gsd-context-monitor.js +27 -9
  147. package/hooks/gsd-statusline.js +88 -3
  148. package/package.json +6 -4
  149. package/pi/gsd.cjs +8 -2
  150. package/scripts/changeset/lint.cjs +1 -0
  151. package/scripts/changeset/parse.cjs +26 -0
  152. package/scripts/check-glossary-refs.cjs +220 -0
  153. package/scripts/ci-rebase-check.cjs +48 -4
  154. package/scripts/gen-adr-index.cjs +526 -0
  155. package/scripts/gen-test-timings.cjs +201 -0
  156. package/scripts/lint-portable-timeout.cjs +140 -0
  157. package/scripts/lint-test-file-count.allowlist.json +1 -0
  158. package/scripts/release-tarball-smoke.cjs +18 -11
  159. package/scripts/run-tests.cjs +420 -58
  160. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  161. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  162. package/skills/gsd-new-milestone/SKILL.md +1 -1
  163. package/skills/gsd-plan-phase/SKILL.md +5 -3
  164. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  165. package/vscode/package.json +1 -1
@@ -11,12 +11,17 @@
11
11
  * epic #1267; callers import resolvers from model-resolver.cjs directly.
12
12
  *
13
13
  * Dependencies (leaf modules only):
14
- * - node:fs / node:path (stdlib, not currently needed — included for future use)
14
+ * - node:fs / node:path (read the per-install .gsd-runtime marker + project config for the #2297 omit gate)
15
+ * - ./runtime-name-policy.cjs (resolveRuntimeNameFromCandidates — canonicalize the active runtime)
16
+ * - ./planning-workspace.cjs (planningDir — workstream/project-aware project-config path)
15
17
  * - ./config-loader.cjs (loadConfig)
16
18
  * - ./configuration.cjs (CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS)
17
19
  * - ./model-profiles.cjs (MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier)
18
- * - ./model-catalog.cjs (MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS)
20
+ * - ./model-catalog.cjs (MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS, VALID_TIERS)
19
21
  */
22
+ var __importDefault = (this && this.__importDefault) || function (mod) {
23
+ return (mod && mod.__esModule) ? mod : { "default": mod };
24
+ };
20
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
26
  const configLoaderModule = require("./config-loader.cjs");
22
27
  const { loadConfig } = configLoaderModule;
@@ -26,6 +31,92 @@ const configuration_cjs_1 = require("./configuration.cjs");
26
31
  const modelProfiles = require("./model-profiles.cjs");
27
32
  const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
28
33
  const model_catalog_cjs_1 = require("./model-catalog.cjs");
34
+ const node_fs_1 = __importDefault(require("node:fs"));
35
+ const node_path_1 = __importDefault(require("node:path"));
36
+ const runtime_name_policy_cjs_1 = require("./runtime-name-policy.cjs");
37
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const planningWorkspaceMod = require("./planning-workspace.cjs");
39
+ const { planningDir } = planningWorkspaceMod;
40
+ // ─── #2297: per-install runtime identity for the resolve_model_ids:"omit" gate ─
41
+ //
42
+ // The installer writes `resolve_model_ids:"omit"` into the SHARED
43
+ // ~/.gsd/defaults.json for every runtime that lacks native model aliases (#1156).
44
+ // Because that file is machine-wide, a non-Claude install would otherwise poison
45
+ // a Claude no-project resolution into returning '' — silently defeating Claude's
46
+ // adaptive tier aliases. The "omit" must therefore apply only when a runtime that
47
+ // genuinely lacks native aliases is the one resolving.
48
+ //
49
+ // In a no-project session there is no `.planning/config.json` (so config.runtime
50
+ // is null) and GSD_RUNTIME is not exported by gsd-core, so the only reliable
51
+ // current-runtime signal is the per-install marker the installer co-locates next
52
+ // to VERSION at <install>/gsd-core/.gsd-runtime (this file's dir is
53
+ // <install>/gsd-core/bin/lib). Precedence for the gate: project config.runtime →
54
+ // GSD_RUNTIME env (manual/CI override + test seam) → install marker → 'claude'.
55
+ //
56
+ // `claude` is currently the ONLY runtime with nativeModelAliases:true; a
57
+ // registry-parity test guards this set so a future alias-capable runtime fails
58
+ // loudly here instead of silently omitting.
59
+ const RUNTIMES_WITH_NATIVE_ALIASES = new Set(['claude']);
60
+ let _installMarkerCache;
61
+ function readInstallRuntimeMarker() {
62
+ if (_installMarkerCache !== undefined)
63
+ return _installMarkerCache;
64
+ try {
65
+ const markerPath = node_path_1.default.join(__dirname, '..', '..', '.gsd-runtime');
66
+ const raw = node_fs_1.default.readFileSync(markerPath, 'utf8').trim();
67
+ _installMarkerCache = raw || null;
68
+ }
69
+ catch {
70
+ // No marker: dev/source tree, or an install predating #2297. Fall through to
71
+ // the 'claude' default (keeps tier aliases — never worse than the bug).
72
+ _installMarkerCache = null;
73
+ }
74
+ return _installMarkerCache;
75
+ }
76
+ // Test seams for the install-marker rung (the dev/source tree has no marker, so
77
+ // the file read always bottoms out at 'claude' — these let tests exercise the
78
+ // third precedence rung and reset the module-level cache between cases).
79
+ function _setInstallRuntimeMarkerForTests(value) {
80
+ _installMarkerCache = value;
81
+ }
82
+ function _resetInstallRuntimeMarkerCacheForTests() {
83
+ _installMarkerCache = undefined;
84
+ }
85
+ // The runtime whose install is actually resolving, canonicalized so an alias or
86
+ // case variant (e.g. "claude-code"/"Claude") cannot defeat the native-alias
87
+ // check below (#2297 review). Precedence mirrors resolveRuntime()
88
+ // (runtime-slash.cts): GSD_RUNTIME env → project config.runtime → per-install
89
+ // .gsd-runtime marker → 'claude'.
90
+ function resolveActiveRuntime(config) {
91
+ return (0, runtime_name_policy_cjs_1.resolveRuntimeNameFromCandidates)(process.env['GSD_RUNTIME'], config['runtime'], readInstallRuntimeMarker()) || 'claude';
92
+ }
93
+ // Did the PROJECT's own config (root `.planning/config.json` or the active
94
+ // workstream/project override) explicitly set resolve_model_ids to "omit"?
95
+ // Project config takes precedence over the shared ~/.gsd/defaults.json (#2297
96
+ // out-of-scope guard + #2517 finding #4): an explicit project "omit" is honored
97
+ // regardless of runtime, whereas an "omit" that came only from the global
98
+ // defaults is ignored by native-alias runtimes. Workstream/project-scope aware
99
+ // via planningDir (mirrors loadConfig's precedence: workstream value wins over
100
+ // root); a plain read avoids loadConfig's normalization side effects.
101
+ function projectExplicitlySetsOmit(cwd) {
102
+ const wsDir = planningDir(cwd);
103
+ const rootDir = node_path_1.default.join(cwd, '.planning');
104
+ const layers = wsDir === rootDir ? [rootDir] : [wsDir, rootDir]; // workstream > root
105
+ for (const dir of layers) {
106
+ try {
107
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, 'config.json'), 'utf8'));
108
+ const value = parsed?.['resolve_model_ids'];
109
+ // First layer that sets the key wins (matches loadConfig's deep-merge
110
+ // precedence). A layer that omits the key falls through to the next.
111
+ if (value !== undefined)
112
+ return value === 'omit';
113
+ }
114
+ catch {
115
+ // Absent/unreadable layer — try the next.
116
+ }
117
+ }
118
+ return false;
119
+ }
29
120
  /**
30
121
  * #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
31
122
  */
@@ -209,8 +300,7 @@ function resolveModelInternal(cwd, agentType) {
209
300
  const phaseTypeTier = (phaseType && configModels && typeof configModels === 'object')
210
301
  ? configModels[phaseType]
211
302
  : undefined;
212
- const VALID_TIERS = new Set(['opus', 'sonnet', 'haiku', 'inherit']);
213
- const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
303
+ const tier = (phaseTypeTier && model_catalog_cjs_1.VALID_TIERS.has(phaseTypeTier))
214
304
  ? phaseTypeTier
215
305
  : (profile === 'inherit'
216
306
  ? 'inherit'
@@ -248,8 +338,18 @@ function resolveModelInternal(cwd, agentType) {
248
338
  if (entry?.model)
249
339
  return entry.model;
250
340
  }
251
- // 4. resolve_model_ids: "omit"
252
- if (config['resolve_model_ids'] === 'omit') {
341
+ // 4. resolve_model_ids: "omit" — runtime-aware (#2297). Honor "omit" when the
342
+ // PROJECT explicitly set it (user intent — project config wins, #2517 finding
343
+ // #4) OR when the active runtime genuinely lacks native model aliases. Only a
344
+ // native-alias runtime (Claude) ignores an "omit" that came solely from the
345
+ // SHARED ~/.gsd/defaults.json — the #2297 poisoning fix — and falls through to
346
+ // its tier aliases below. Active runtime: GSD_RUNTIME → config.runtime → the
347
+ // per-install .gsd-runtime marker → 'claude' (canonicalized).
348
+ // NOTE: a non-Claude runtime that HAS a populated runtime-tier map already
349
+ // returned its own model id at step 3 above, before this gate — for those the
350
+ // explicit-project-omit honoring here is moot (step 3 wins, by #2517 design).
351
+ if (config['resolve_model_ids'] === 'omit'
352
+ && (projectExplicitlySetsOmit(cwd) || !RUNTIMES_WITH_NATIVE_ALIASES.has(resolveActiveRuntime(config)))) {
253
353
  return '';
254
354
  }
255
355
  // 5. Profile lookup (Claude-native default).
@@ -262,7 +362,11 @@ function resolveModelInternal(cwd, agentType) {
262
362
  if (tier === 'inherit')
263
363
  return 'inherit';
264
364
  const alias = tier;
265
- if (config['resolve_model_ids']) {
365
+ // Only the explicit `true` opt-in materializes full model IDs (#1569). Guard
366
+ // against the loose-truthy check catching a "omit" that a native-alias runtime
367
+ // ignored above (#2297): "omit" must fall through to the tier ALIAS here, not
368
+ // be materialized into a full ID Claude's Agent tool cannot spawn.
369
+ if (config['resolve_model_ids'] === true) {
266
370
  return model_catalog_cjs_1.MODEL_ALIAS_MAP[alias] || alias;
267
371
  }
268
372
  return alias;
@@ -353,6 +457,80 @@ function resolveModelForTier(cwd, agentType, attempt) {
353
457
  }
354
458
  return alias;
355
459
  }
460
+ /**
461
+ * Keep only usable model ids: non-empty strings. A malformed config can put
462
+ * anything in here (nulls, numbers, blank strings), and a blank model id would
463
+ * resolve to an unusable agent invocation rather than failing visibly. Invalid
464
+ * entries are dropped and the surviving order is preserved, so the ladder stays
465
+ * predictable (ADR 227 — validate shape, not just type).
466
+ */
467
+ function sanitizeProviderEscalation(raw) {
468
+ if (!Array.isArray(raw))
469
+ return [];
470
+ return raw.filter((entry) => typeof entry === 'string' && entry.trim().length > 0);
471
+ }
472
+ /**
473
+ * #2296 — Resolve the model for one attempt of the PROVIDER escalation ladder.
474
+ *
475
+ * The tier ladder (`resolveModelForTier`) escalates within one provider's
476
+ * `tier_models`, which does not help when that provider is the thing that is
477
+ * throttled. This walks `dynamic_routing.provider_escalation` instead: an
478
+ * ordered list of alternative model ids, capped by
479
+ * `min(max_escalations, list length)`.
480
+ *
481
+ * `applicable` is the caller's policy decision (only a quota-exceeded
482
+ * classification should consult this ladder). It is a parameter rather than a
483
+ * class check here so this module keeps depending only on leaf modules, per
484
+ * CONTEXT.md's Model Resolution module contract.
485
+ *
486
+ * Attempt 0 — and every non-applicable call — stays on the source model.
487
+ * `exhausted` reports that the ladder is spent so the caller can fail loudly
488
+ * naming every model it tried.
489
+ */
490
+ function resolveProviderEscalation(cwd, agentType, attempt, applicable) {
491
+ // The model that would be used with no provider escalation at all.
492
+ const from = resolveModelForTier(cwd, agentType, 0);
493
+ const stay = (exhausted = false) => ({
494
+ from,
495
+ to: from,
496
+ escalated: false,
497
+ exhausted,
498
+ attempted: [from],
499
+ index: 0,
500
+ });
501
+ if (!applicable)
502
+ return stay();
503
+ const config = loadConfig(cwd);
504
+ const dr = config['dynamic_routing'];
505
+ if (!dr || typeof dr !== 'object' || dr['enabled'] !== true)
506
+ return stay();
507
+ if (dr['escalate_on_failure'] === false)
508
+ return stay();
509
+ const list = sanitizeProviderEscalation(dr['provider_escalation']);
510
+ if (list.length === 0)
511
+ return stay();
512
+ // Same default and same validity rule as the tier ladder above — a negative or
513
+ // non-integer max_escalations is invalid config, not a request for zero.
514
+ const maxEscalations = Number.isInteger(dr['max_escalations']) && dr['max_escalations'] >= 0
515
+ ? dr['max_escalations']
516
+ : 1;
517
+ const cap = Math.min(maxEscalations, list.length);
518
+ // An explicit cap of 0 means the ladder exists but is spent before it starts.
519
+ if (cap === 0)
520
+ return stay(true);
521
+ const attemptN = Number.isInteger(attempt) && attempt > 0 ? attempt : 0;
522
+ if (attemptN === 0)
523
+ return stay();
524
+ const index = Math.min(attemptN, cap);
525
+ return {
526
+ from,
527
+ to: list[index - 1],
528
+ escalated: true,
529
+ exhausted: attemptN > cap,
530
+ attempted: [from, ...list.slice(0, index)],
531
+ index,
532
+ };
533
+ }
356
534
  // ─── #443 — Unified effort + fast_mode resolvers ─────────────────────────────
357
535
  const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
358
536
  const EFFORT_SET = new Set(VALID_EFFORTS);
@@ -515,14 +693,18 @@ function resolveEffortForTier(cwd, agentType, attempt) {
515
693
  }
516
694
  module.exports = {
517
695
  resolveTierEntry,
696
+ CLAUDE_AGENT_ALIASES,
518
697
  resolveModelPolicy,
519
698
  resolveModelInternal,
520
699
  _resetModelPolicyWarningCacheForTests,
521
700
  _resetModelOverrideWarningCacheForTests,
701
+ _setInstallRuntimeMarkerForTests,
702
+ _resetInstallRuntimeMarkerCacheForTests,
522
703
  VALID_GRANULARITIES,
523
704
  resolveGranularityInternal,
524
705
  assertValidGranularityOverride,
525
706
  resolveModelForTier,
707
+ resolveProviderEscalation,
526
708
  VALID_EFFORTS,
527
709
  EFFORT_SET,
528
710
  nextEffort,
@@ -269,7 +269,9 @@ function buildOnboardProjection(cwd, options) {
269
269
  projectExists,
270
270
  mapReadiness: mapReadinessValue,
271
271
  onboardingSummaryExists,
272
- onboardingSummaryPath: toPosixPath(node_path_1.default.relative(cwd, onboardingSummaryPath)),
272
+ // #2376: absolute (anchored on cwd/project_root), not orchestrator-cwd-relative —
273
+ // a spawned subagent's own cwd may differ from the orchestrator's.
274
+ onboardingSummaryPath: toPosixPath(onboardingSummaryPath),
273
275
  hasPlanningArtifacts,
274
276
  missingPlanningFiles,
275
277
  handoffCommands,
@@ -289,13 +291,14 @@ function buildOnboardProjection(cwd, options) {
289
291
  doc_candidate_count: docCandidates.length,
290
292
  doc_candidates: docCandidates,
291
293
  onboarding_summary_exists: onboardingSummaryExists,
292
- onboarding_summary_path: toPosixPath(node_path_1.default.relative(cwd, onboardingSummaryPath)),
293
- project_path: toPosixPath(node_path_1.default.relative(cwd, node_fs_1.default.existsSync(projectRootPath) ? projectRootPath : projectScopedPath)),
294
- requirements_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md'))),
295
- roadmap_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'))),
296
- state_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningDir(cwd), 'STATE.md'))),
297
- codebase_dir: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningRoot(cwd), 'codebase'))),
298
- onboarding_dir: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningRoot(cwd), 'onboarding'))),
294
+ // #2376: absolute — see comment on onboardingSummaryPath above.
295
+ onboarding_summary_path: toPosixPath(onboardingSummaryPath),
296
+ project_path: toPosixPath(node_fs_1.default.existsSync(projectRootPath) ? projectRootPath : projectScopedPath),
297
+ requirements_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md')),
298
+ roadmap_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
299
+ state_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'STATE.md')),
300
+ codebase_dir: toPosixPath(node_path_1.default.join(planningRoot(cwd), 'codebase')),
301
+ onboarding_dir: toPosixPath(node_path_1.default.join(planningRoot(cwd), 'onboarding')),
299
302
  };
300
303
  }
301
304
  module.exports = {
@@ -48,6 +48,24 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
48
48
  // (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
49
49
  // introduced outside this module without a `// phase-id-owner:` justification.
50
50
  const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
51
+ // #2232: the canonical CONTINUATION-segment grammar — a dash-separated segment
52
+ // that extends a phase token (a zero-padded sub-phase or plan number, e.g. the
53
+ // "01" in "02-01-setup"). getPhaseDirFromPhaseId writes these zero-padded to
54
+ // exactly 2 digits, so the digit RUN of a genuine continuation is exactly 2:
55
+ // #2043's `\d{2,}` (2-or-more) over-collected a slug word that merely leads
56
+ // with ≥2 digits (a year: "14-2026-photos-…" yielded token "14-2026", so every
57
+ // phase-locating verb reported the phase as missing). The `(?!\d)` guard caps
58
+ // the run at 2 without anchoring what may follow, so call sites keep their own
59
+ // trailing grammar (letter suffixes, dotted sub-phases, segment boundaries).
60
+ // POLICY (locked by boundary tests): sub-phase/plan numbers ≥100 are out of the
61
+ // dir-token grammar — the LEADING phase number stays unbounded (`\d+`), only
62
+ // continuation segments are width-capped. Shared from here so the five #2043
63
+ // call sites cannot drift independently (see scripts/lint-phase-id-drift.cjs).
64
+ const PHASE_CONTINUATION_SEGMENT_SOURCE = '\\d{2}(?!\\d)';
65
+ const PHASE_CONTINUATION_SEGMENT_PREFIX_RE = new RegExp(`^${PHASE_CONTINUATION_SEGMENT_SOURCE}`);
66
+ function isPhaseContinuationSegment(seg) {
67
+ return PHASE_CONTINUATION_SEGMENT_PREFIX_RE.test(seg);
68
+ }
51
69
  function stripProjectCodePrefix(value, caseInsensitive = true) {
52
70
  const input = String(value);
53
71
  const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
@@ -211,9 +229,11 @@ function extractPhaseToken(dirName) {
211
229
  }
212
230
  const segments = rest.split('-');
213
231
  const tokenSegments = [];
214
- // #2043: distinguish a real (zero-padded, ≥2-digit) phase/sub-phase segment
215
- // from a single-digit slug word. A pure-numeric leading segment ("46") only
216
- // continues with ≥2-digit segments, so "46-6-rs-…" yields "46" (the "6" is the
232
+ // #2043: distinguish a real (zero-padded) phase/sub-phase segment from a
233
+ // single-digit slug word. A pure-numeric leading segment ("46") only
234
+ // continues with exactly-2-digit segments (#2232: a ≥3-digit run is a slug
235
+ // word such as a year — "14-2026-photos-…" yields "14", not "14-2026"), so
236
+ // "46-6-rs-…" yields "46" (the "6" is the
217
237
  // slug's first word), not "46-6". Milestone-prefixed ids like "M1-2" reach here
218
238
  // with "M1-" already stripped as a project-code prefix (see
219
239
  // PROJECT_CODE_PREFIX_CAPTURE_RE_I), so "2" is the leading segment and the same
@@ -236,7 +256,7 @@ function extractPhaseToken(dirName) {
236
256
  break;
237
257
  }
238
258
  }
239
- else if (/^\d{2,}/.test(seg) || (firstLetterPrefixed && /^\d/.test(seg))) {
259
+ else if (isPhaseContinuationSegment(seg) || (firstLetterPrefixed && /^\d/.test(seg))) {
240
260
  tokenSegments.push(seg);
241
261
  }
242
262
  else {
@@ -358,6 +378,8 @@ module.exports = {
358
378
  OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
359
379
  OPTIONAL_PHASE_TAG_SOURCE,
360
380
  PHASE_NUMBER_TOKEN_SOURCE,
381
+ PHASE_CONTINUATION_SEGMENT_SOURCE,
382
+ isPhaseContinuationSegment,
361
383
  stripProjectCodePrefix,
362
384
  normalizePhaseName,
363
385
  getMilestoneFromPhaseId,
@@ -106,6 +106,19 @@ function updateTraceabilityCell(text, match, column, newValue) {
106
106
  return result;
107
107
  return { ok: true, value: before + result.value + after };
108
108
  }
109
+ /**
110
+ * Extract the MAJOR version segment from a version-ish string: "v1", "v1.3",
111
+ * "V1.0", and "1.0" all yield "1"; "v2" yields "2". Used (#2334 BLOCKER fix)
112
+ * to compare a `## v<N> ...` REQUIREMENTS.md heading against the current
113
+ * milestone's version at MAJOR-version granularity only — "v1" heading vs
114
+ * milestone "v1.3" is the SAME major version and must not be treated as a
115
+ * version mismatch. Returns null when `raw` has no leading digit run (not a
116
+ * version-shaped string), which the caller treats as "cannot resolve".
117
+ */
118
+ function extractMajorVersion(raw) {
119
+ const m = raw.trim().match(/^v?(\d+)/i);
120
+ return m ? m[1] : null;
121
+ }
109
122
  function describeNonCanonicalPlans(dirFiles, matchedFiles) {
110
123
  const matched = new Set(matchedFiles);
111
124
  const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
@@ -127,8 +140,13 @@ function extractCanonicalPlanId(filename) {
127
140
  // or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
128
141
  // so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
129
142
  const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
143
+ // #2232: the PAIRED plan component is a zero-padded continuation segment
144
+ // (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
145
+ // bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
146
+ // \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
147
+ const planTokenRe = new RegExp(`^(?:${phaseIdMod.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`, 'i');
130
148
  const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
131
- if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
149
+ if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
132
150
  return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
133
151
  }
134
152
  return base;
@@ -603,6 +621,29 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
603
621
  result['warnings'] = warnings;
604
622
  output(result, raw);
605
623
  }
624
+ // #2390 — phase.add title-shape heuristic. A description at or under this many
625
+ // characters, and with no sentence-ending punctuation followed by more text,
626
+ // reads as a short Title. Anything longer or multi-sentence reads as a Goal,
627
+ // not a Title. phase.add still writes the phase verbatim (it never mangles
628
+ // ROADMAP.md), but when the description looks goal-shaped the JSON result
629
+ // gains a `warning` key naming the gap, so the caller — or the orchestrating
630
+ // add-phase workflow — can split title vs. goal instead of the whole paragraph
631
+ // landing silently in the `### Phase N:` header.
632
+ const PHASE_ADD_TITLE_MAX_LEN = 80;
633
+ const PHASE_ADD_MULTI_SENTENCE_RE = /[.!?]['")\]]?\s+\S/;
634
+ function describeGoalShapedTitle(description) {
635
+ const trimmed = description.trim();
636
+ const tooLong = trimmed.length > PHASE_ADD_TITLE_MAX_LEN;
637
+ const multiSentence = PHASE_ADD_MULTI_SENTENCE_RE.test(trimmed);
638
+ if (!tooLong && !multiSentence)
639
+ return null;
640
+ const reasons = [
641
+ tooLong ? `${trimmed.length} chars (over the ${PHASE_ADD_TITLE_MAX_LEN}-char title threshold)` : null,
642
+ multiSentence ? 'multiple sentences' : null,
643
+ ].filter(Boolean).join(', ');
644
+ return (`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
645
+ `as the phase title; consider a short title with the detail moved to **Goal:**.`);
646
+ }
606
647
  function cmdPhaseAdd(cwd, description, raw, customId) {
607
648
  if (!description) {
608
649
  error('description required for phase add');
@@ -690,6 +731,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
690
731
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
691
732
  return { newPhaseId: _newPhaseId, dirName: _dirName };
692
733
  });
734
+ const titleWarning = describeGoalShapedTitle(description);
693
735
  const result = {
694
736
  phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
695
737
  padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
@@ -698,7 +740,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
698
740
  directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
699
741
  naming_mode: config.phase_naming,
700
742
  };
701
- output(result, raw, result.padded);
743
+ if (titleWarning)
744
+ result['warning'] = titleWarning;
745
+ output(result, raw, result['padded']);
702
746
  }
703
747
  function cmdPhaseAddBatch(cwd, descriptions, raw) {
704
748
  if (!Array.isArray(descriptions) || descriptions.length === 0) {
@@ -1553,13 +1597,43 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1553
1597
  const reqMatch = sectionText.match(/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i);
1554
1598
  const originalReqContent = node_fs_1.default.readFileSync(reqPath, 'utf-8');
1555
1599
  let reqContent = originalReqContent;
1600
+ // #2316: `citedReqIds` — the REQ-IDs ROADMAP's own **Requirements:**
1601
+ // line for this phase actually cites — is hoisted out of the
1602
+ // `if (reqMatch)` block (previously scoped only inside it) so the
1603
+ // ghost-ID cross-check below (~#2316-1) can consult it. `TBD` is the
1604
+ // literal placeholder `phase.add`/`-batch`/`-insert` seed
1605
+ // (`**Requirements**: TBD`, src/phase.cts:833,920,1078) — never a
1606
+ // real REQ-ID, so it is filtered out wherever a cited-ID list feeds
1607
+ // a warning (#2316-7 boundary).
1608
+ const isPlaceholderReqId = (id) => id.toUpperCase() === 'TBD';
1609
+ let citedReqIds = [];
1610
+ // #2316-1: Traceability-row writes that matched NO row (ghost or
1611
+ // otherwise) — the `if (reqUpdate.ok)` below previously had no
1612
+ // `else`, discarding this fact silently instead of surfacing it.
1613
+ const traceabilityWriteMisses = [];
1556
1614
  if (reqMatch) {
1557
- const reqIds = reqMatch[1]
1615
+ // #2334 HIGH 3: filter the tokenized capture to the REQ-ID SHAPE —
1616
+ // the SAME shape bodyReqIds (`\*\*([A-Z][A-Z0-9]*-\d+)\*\*`, below)
1617
+ // and tableReqIds (`([A-Z][A-Z0-9]*-\d+)`, below) already require —
1618
+ // so the ghost-ID / unregistered comparisons stay shape-symmetric.
1619
+ // Without this, `[^\n]+` split on `[,\s]+` turned EVERY word after
1620
+ // the ID list into a "cited REQ-ID": the shipped
1621
+ // `templates/roadmap.md:32` line
1622
+ // `**Requirements**: [REQ-01, REQ-02] <!-- brackets optional, ... -->`
1623
+ // warned to register `<!--`, `brackets`, `optional`, `-->`, etc., and
1624
+ // `**Requirements:** None` warned to register the literal word
1625
+ // `None`. This subsumes the `TBD` placeholder special-case (`TBD`
1626
+ // does not match the REQ-ID shape either); `isPlaceholderReqId` is
1627
+ // kept below as a defensive no-op for any caller that still hands
1628
+ // it a raw token.
1629
+ const REQ_ID_SHAPE_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
1630
+ citedReqIds = reqMatch[1]
1558
1631
  .replace(/[\[\]]/g, '')
1559
1632
  .split(/[,\s]+/)
1560
1633
  .map((r) => r.trim())
1561
- .filter(Boolean);
1562
- for (const reqId of reqIds) {
1634
+ .filter(Boolean)
1635
+ .filter((r) => REQ_ID_SHAPE_RE.test(r));
1636
+ for (const reqId of citedReqIds) {
1563
1637
  const reqEscaped = escapeRegex(reqId);
1564
1638
  reqContent = reqContent.replace(new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), '$1x$2');
1565
1639
  // Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
@@ -1578,14 +1652,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1578
1652
  // Complete" gate is folded into the newValue callback so one
1579
1653
  // updateTableCell call both probes and writes.
1580
1654
  const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => /^(?:pending|in progress)$/i.test(current.trim()) ? ' Complete ' : current);
1581
- if (reqUpdate.ok)
1655
+ if (reqUpdate.ok) {
1582
1656
  reqContent = reqUpdate.value;
1657
+ }
1658
+ else if (!isPlaceholderReqId(reqId)) {
1659
+ traceabilityWriteMisses.push(reqId);
1660
+ }
1583
1661
  }
1584
1662
  }
1585
1663
  // #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
1586
1664
  // Requirements under headings whose text contains "deferred", "backlog",
1587
- // "future", or "v2" (case-insensitive) are explicitly out of current scope
1588
- // and must not be flagged as missing from the Traceability table.
1665
+ // "future", or an OFF-milestone `v<N>` (case-insensitive) are explicitly
1666
+ // out of current scope and must not be flagged as missing from the
1667
+ // Traceability table.
1589
1668
  //
1590
1669
  // Strategy: walk lines, track heading depth, and toggle a "deferred" flag
1591
1670
  // when a heading matching the pattern is encountered. A sub-heading (higher
@@ -1593,7 +1672,47 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1593
1672
  // opens a same-or-shallower heading that does NOT match the pattern.
1594
1673
  // Lines inside fenced code blocks (``` or ~~~) are treated as content, not
1595
1674
  // headings, to avoid false deferred-section detection from code examples.
1596
- const DEFERRED_HEADING_RE = /\b(?:deferred|backlog|future|v\d+)\b/i;
1675
+ //
1676
+ // #2334 BLOCKER fix (regresses closed bug #1159 against GSD's OWN
1677
+ // shipped template): #2316-4a dropped the bare `v\d+` alternative
1678
+ // entirely to stop it over-matching an ACTIVE heading like "## v1
1679
+ // Requirements" — but the shipped `templates/requirements.md:35`
1680
+ // scaffold ships `## v2 Requirements` / "Deferred to future release"
1681
+ // as its ONLY deferred marker, and `v\d+` was the ONLY alternative
1682
+ // that ever matched a bare version heading (the deferred-ness lives
1683
+ // in body prose, not the heading text). Dropping it regressed #1159
1684
+ // for every project scaffolded from the shipped template.
1685
+ //
1686
+ // Fix: make the `v<N>` alternative MILESTONE-AWARE instead of
1687
+ // deleting it. A `## v<N> ...` heading is deferred ONLY when `<N>`
1688
+ // (MAJOR version only — "v1" vs milestone "v1.3" is the SAME major
1689
+ // version) does not match the CURRENT milestone's major version,
1690
+ // resolved via `stateExtractField` against STATE.md's `milestone:`
1691
+ // frontmatter field (the same seam `getMilestoneInfo`/state.cts's
1692
+ // frontmatter builder already use — no bespoke frontmatter parsing).
1693
+ // "## v1 Requirements" while the milestone is v1.x is the ACTIVE
1694
+ // milestone's own section (#2316's original ask) and must NOT be
1695
+ // swallowed; "## v2 Requirements" while the milestone is v1.x is a
1696
+ // genuinely future milestone (#1159's ask, and the literal shipped-
1697
+ // template shape) and MUST stay suppressed. `deferred`/`backlog`/
1698
+ // `future` are unaffected by milestone resolution — a genuinely
1699
+ // deferred heading always spells one of those words too (see
1700
+ // #2316-5 regression guard: "## Deferred v2 Requirements", "##
1701
+ // Future Backlog", "## Deferred", "## Backlog", "## Future").
1702
+ //
1703
+ // Fail-safe: when the milestone version cannot be resolved at all
1704
+ // (no STATE.md, or no `milestone:` field), fall back to the OLD
1705
+ // pre-#2316-4a behavior and treat every `v\d+` heading as deferred.
1706
+ // A false "deferred" here only ever SUPPRESSES a warning — strictly
1707
+ // safer than spamming a warning on every v\d+-headed scaffold when
1708
+ // we cannot tell whether it names the active milestone.
1709
+ const DEFERRED_KEYWORD_RE = /\b(?:deferred|backlog|future)\b/i;
1710
+ const HEADING_VERSION_RE = /\bv(\d+)(?:\.\d+)*\b/i;
1711
+ const stateRawForMilestone = node_fs_1.default.existsSync(statePath) ? node_fs_1.default.readFileSync(statePath, 'utf-8') : null;
1712
+ const currentMilestoneRaw = stateRawForMilestone
1713
+ ? stateExtractField(stateRawForMilestone, 'milestone')
1714
+ : null;
1715
+ const currentMilestoneMajor = currentMilestoneRaw ? extractMajorVersion(currentMilestoneRaw) : null;
1597
1716
  const bodyReqIds = [];
1598
1717
  // deferredDepth: the heading level that opened the current deferred block,
1599
1718
  // or 0 when we are in an active section.
@@ -1617,11 +1736,21 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1617
1736
  }
1618
1737
  // Heading at same level or shallower than current deferred opener,
1619
1738
  // or no active deferred block yet.
1620
- if (DEFERRED_HEADING_RE.test(text)) {
1739
+ if (DEFERRED_KEYWORD_RE.test(text)) {
1621
1740
  deferredDepth = depth; // enter a deferred block
1622
1741
  }
1623
1742
  else {
1624
- deferredDepth = 0; // back in an active section
1743
+ const versionMatch = text.match(HEADING_VERSION_RE);
1744
+ if (versionMatch) {
1745
+ const headingMajor = versionMatch[1];
1746
+ deferredDepth =
1747
+ currentMilestoneMajor === null || headingMajor !== currentMilestoneMajor
1748
+ ? depth // unresolved milestone (fail-safe) or off-milestone version -> deferred
1749
+ : 0; // same major version as the current milestone -> active
1750
+ }
1751
+ else {
1752
+ deferredDepth = 0; // back in an active section
1753
+ }
1625
1754
  }
1626
1755
  continue;
1627
1756
  }
@@ -1654,8 +1783,68 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1654
1783
  if (unregistered.length > 0) {
1655
1784
  warnings.push(`REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`);
1656
1785
  }
1786
+ // #2316-1: ghost REQ-IDs — cited by ROADMAP's own **Requirements:**
1787
+ // line for this phase, but registered NOWHERE in REQUIREMENTS.md
1788
+ // (neither its body nor its Traceability table). The `unregistered`
1789
+ // check above only ever compares REQUIREMENTS.md's own body against
1790
+ // its own Traceability table; it never consults `citedReqIds`, so an
1791
+ // ID that ROADMAP cites but REQUIREMENTS.md never defines at all was
1792
+ // previously invisible to every guard. `TBD` (the phase.add/-batch/
1793
+ // -insert placeholder) is excluded — see #2316-7 boundary.
1794
+ //
1795
+ // #2334 HIGH 2: classify "ghost" by PROBING THE ACTUAL WRITE
1796
+ // SURFACES this same function just wrote to (:1947 checkbox,
1797
+ // :1967 Traceability row) — case-insensitively — mirroring
1798
+ // milestone.cts's `notFound`/`hasRow`/`doneCheckbox` classification
1799
+ // (src/milestone.cts:117-141,209-215), instead of set-differencing
1800
+ // `bodyReqIds` (deferred-filtered, case-sensitive, bold-only) and
1801
+ // `tableReqIds` (case-sensitive) against `citedReqIds`. Those two
1802
+ // indexes can disagree with the writes: an ID under a `##
1803
+ // Deferred` heading gets its checkbox ticked by the write loop
1804
+ // above but is deliberately EXCLUDED from `bodyReqIds` by the
1805
+ // deferred-heading filter (#1159), so the old set-diff reported it
1806
+ // as an unregistered ghost in the SAME response that just ticked
1807
+ // its checkbox; a case-mismatched citation (`known-01` vs
1808
+ // `**KNOWN-01**`) lands its write via the writes' case-insensitive
1809
+ // regexes but failed the old set-diff's case-SENSITIVE
1810
+ // `Array.includes`/`Set.has`. An ID whose checkbox OR Traceability
1811
+ // row actually matched is registered — not a ghost — regardless of
1812
+ // which section (deferred or not) it lives under.
1813
+ const reqIsRegisteredAnywhere = (id) => {
1814
+ const reqEscaped = escapeRegex(id);
1815
+ // Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
1816
+ // insensitive: existence check, not the write's space-only match.
1817
+ if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
1818
+ return true;
1819
+ }
1820
+ // Surface 2 — Traceability row exists at all (any Status value),
1821
+ // via the SAME no-op-probe-through-updateTraceabilityCell
1822
+ // technique milestone.cts's `hasRow` uses (:210-214): a case-
1823
+ // insensitive first-cell match, regardless of current Status.
1824
+ const rowProbeMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
1825
+ return updateTraceabilityCell(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
1826
+ };
1827
+ const ghostReqIds = citedReqIds.filter((id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id));
1828
+ if (ghostReqIds.length > 0) {
1829
+ warnings.push(`ROADMAP Phase ${phaseNum} cites REQ-ID(s) not registered anywhere in REQUIREMENTS.md (neither body nor Traceability table): ${ghostReqIds.join(', ')} — add them to REQUIREMENTS.md or correct the ROADMAP citation`);
1830
+ }
1831
+ // #2316-1 cont.: a cited ID whose Traceability-row write matched no
1832
+ // row for a reason OTHER than being a ghost (e.g. a malformed table)
1833
+ // still deserves a warning instead of a silent discard — but skip
1834
+ // IDs already reported above as ghosts to avoid a duplicate message
1835
+ // for the same root cause.
1836
+ const traceabilityWriteFailures = traceabilityWriteMisses.filter((id) => !ghostReqIds.includes(id));
1837
+ if (traceabilityWriteFailures.length > 0) {
1838
+ warnings.push(`REQUIREMENTS.md: Traceability row write skipped for REQ-ID(s) cited by ROADMAP (no matching row found): ${traceabilityWriteFailures.join(', ')}`);
1839
+ }
1657
1840
  writes.push({ filePath: reqPath, before: originalReqContent, after: reqContent });
1658
- requirementsUpdated = true;
1841
+ // #2316-3: `requirements_updated` must reflect whether REQUIREMENTS.md
1842
+ // content actually CHANGED, not merely that the file existed in the
1843
+ // transaction — mirrors the `writes.push({filePath,before,after})`
1844
+ // diff-tracking pattern used for the ROADMAP write above. A phase
1845
+ // whose citations match nothing (ghost REQ-IDs only) must report
1846
+ // `false`, not a bare "the file was present" `true`.
1847
+ requirementsUpdated = reqContent !== originalReqContent;
1659
1848
  }
1660
1849
  }
1661
1850
  try {