@opengsd/gsd-core 1.7.0-rc.6 → 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 (195) 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 +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -10,7 +10,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
10
10
  return (mod && mod.__esModule) ? mod : { "default": mod };
11
11
  };
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.RUNTIMES_WITH_FAST_MODE = exports.EFFORT_RENDERING = exports.KNOWN_PROVIDERS = exports.PROVIDER_PRESETS = exports.RUNTIMES_WITH_REASONING_EFFORT = exports.KNOWN_RUNTIMES = exports.RUNTIME_PROFILE_MAP = exports.MODEL_ALIAS_MAP = exports.AGENT_DEFAULT_TIERS = exports.AGENT_TO_PHASE_TYPE = exports.MODEL_PROFILES = exports.VALID_AGENT_TIERS = exports.VALID_PHASE_TYPES = exports.VALID_PROFILES = exports.catalog = void 0;
13
+ exports.RUNTIMES_WITH_FAST_MODE = exports.EFFORT_RENDERING = exports.KNOWN_PROVIDERS = exports.PROVIDER_PRESETS = exports.RUNTIMES_WITH_REASONING_EFFORT = exports.KNOWN_RUNTIMES = exports.RUNTIME_PROFILE_MAP = exports.MODEL_ALIAS_MAP = exports.AGENT_DEFAULT_TIERS = exports.AGENT_TO_PHASE_TYPE = exports.MODEL_PROFILES = exports.ADAPTIVE_TIER_VALUES = exports.VALID_TIERS = exports.VALID_AGENT_TIERS = exports.VALID_PHASE_TYPES = exports.VALID_PROFILES = exports.catalog = void 0;
14
14
  exports.nextTier = nextTier;
15
15
  exports.formatAgentToModelMapAsTable = formatAgentToModelMapAsTable;
16
16
  exports.getAgentToModelMapForProfile = getAgentToModelMapForProfile;
@@ -23,11 +23,19 @@ const _require = require;
23
23
  // works in every layout:
24
24
  //
25
25
  // 1. Co-located install path — gsd-core/bin/shared/model-catalog.json
26
- // 2. Source-repo dev path — sdk/shared/model-catalog.json
27
- // 3. GSD_MODEL_CATALOG env override
26
+ // 2. GSD_MODEL_CATALOG env override
27
+ //
28
+ // A third candidate — `sdk/shared/model-catalog.json`, three levels up — used to
29
+ // sit between them. It was the legacy source-repo path kept as a fallback by the
30
+ // #3288 fix, whose contract was "check the co-located path FIRST, before the
31
+ // legacy source-repo path". ADR-0174 then retired the `@opengsd/gsd-sdk` package
32
+ // boundary and deleted the `sdk/` tree outright, so that candidate can no longer
33
+ // resolve in any layout: in a source repo there is no `sdk/`, and in an install
34
+ // layout it points at `~/.claude/sdk/shared/`, which the installer never writes
35
+ // (the original #3288 bug). It is removed rather than left as dead weight that
36
+ // implies a package boundary this repo no longer has.
28
37
  const _catalogCandidates = [
29
38
  node_path_1.default.resolve(__dirname, '..', 'shared', 'model-catalog.json'),
30
- node_path_1.default.resolve(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'),
31
39
  ...(process.env['GSD_MODEL_CATALOG'] ? [node_path_1.default.resolve(process.env['GSD_MODEL_CATALOG'])] : []),
32
40
  ];
33
41
  let catalog = null;
@@ -54,6 +62,13 @@ exports.catalog = _catalog;
54
62
  exports.VALID_PROFILES = [..._catalog.profiles];
55
63
  exports.VALID_PHASE_TYPES = new Set(_catalog.phaseTypes);
56
64
  exports.VALID_AGENT_TIERS = new Set(Object.keys(_catalog.adaptiveTierMap));
65
+ // Catalog-derived so this can never drift from the resolver's tier gate:
66
+ // Object.values(adaptiveTierMap) === ['opus', 'sonnet', 'haiku'] today, plus 'inherit'.
67
+ exports.VALID_TIERS = new Set([...Object.values(_catalog.adaptiveTierMap), 'inherit']);
68
+ // Same catalog-derived tier values as VALID_TIERS but WITHOUT 'inherit' — used
69
+ // by config-loader's runtime-override validation (model_profile_overrides /
70
+ // model_policy.runtime_tiers), which does not accept 'inherit' as a tier.
71
+ exports.ADAPTIVE_TIER_VALUES = new Set(Object.values(_catalog.adaptiveTierMap));
57
72
  exports.MODEL_PROFILES = Object.fromEntries(Object.entries(_catalog.agents).map(([agent, meta]) => [agent, {
58
73
  quality: meta.golden,
59
74
  balanced: meta.balanced,
@@ -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 = {
@@ -137,7 +137,32 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
137
137
  return { ok: true, data: null };
138
138
  },
139
139
  complete: (_ctx) => {
140
- phase.cmdPhaseComplete(cwd, args[2], raw);
140
+ // #2201: accept --phase N as well as the positional form (the state
141
+ // family already accepts --phase). An unrecognized flag is a usage
142
+ // error, not "Phase --phase not found".
143
+ let phaseNum = null;
144
+ for (let i = 2; i < args.length; i++) {
145
+ if (args[i] === '--phase') {
146
+ phaseNum = args[++i];
147
+ if (!phaseNum || phaseNum.startsWith('--'))
148
+ return makeInvalidArgs('--phase', '--phase requires a value');
149
+ }
150
+ else if (args[i].startsWith('--phase=')) {
151
+ phaseNum = args[i].slice(8);
152
+ }
153
+ else if (args[i] === '--raw') {
154
+ continue;
155
+ }
156
+ else if (args[i].startsWith('--')) {
157
+ return makeInvalidArgs(args[i], `phase complete does not support ${args[i]}`);
158
+ }
159
+ else {
160
+ phaseNum = args[i];
161
+ }
162
+ }
163
+ if (!phaseNum)
164
+ return makeInvalidArgs('--phase', 'phase number required (positional or --phase N)');
165
+ phase.cmdPhaseComplete(cwd, phaseNum, raw);
141
166
  return { ok: true, data: null };
142
167
  },
143
168
  'uat-passed': (_ctx) => {
@@ -162,7 +187,30 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
162
187
  },
163
188
  // #1437 — list plan files for a phase
164
189
  'list-plans': (_ctx) => {
165
- phase.cmdPhaseListPlans(cwd, args[2], raw);
190
+ // #2201: accept --phase N as well as positional.
191
+ let phaseNum = null;
192
+ for (let i = 2; i < args.length; i++) {
193
+ if (args[i] === '--phase') {
194
+ phaseNum = args[++i];
195
+ if (!phaseNum || phaseNum.startsWith('--'))
196
+ return makeInvalidArgs('--phase', '--phase requires a value');
197
+ }
198
+ else if (args[i].startsWith('--phase=')) {
199
+ phaseNum = args[i].slice(8);
200
+ }
201
+ else if (args[i] === '--raw') {
202
+ continue;
203
+ }
204
+ else if (args[i].startsWith('--')) {
205
+ return makeInvalidArgs(args[i], `phase list-plans does not support ${args[i]}`);
206
+ }
207
+ else {
208
+ phaseNum = args[i];
209
+ }
210
+ }
211
+ if (!phaseNum)
212
+ return makeInvalidArgs('--phase', 'phase number required (positional or --phase N)');
213
+ phase.cmdPhaseListPlans(cwd, phaseNum, raw);
166
214
  return { ok: true, data: null };
167
215
  },
168
216
  },
@@ -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,
@@ -23,53 +23,79 @@
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.deriveProgressFromRoadmap = deriveProgressFromRoadmap;
25
25
  exports.clampPercent = clampPercent;
26
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
26
27
  /**
27
28
  * Derive completed_phases, total_phases, and total_plans from ROADMAP content.
28
29
  * Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
30
+ *
31
+ * ADR-2143 §3 ("addressed by NAME, never ordinal"): the Progress table is
32
+ * located via the markdown-table seam's `findTableWithColumns`, which is
33
+ * column-NAME/order/count-invariant — it matches the first table whose header
34
+ * is a SUPERSET of the canonical `Phase` / `Plans Complete` / `Status` /
35
+ * `Completed` names, in any order, tolerating extra/injected unrelated
36
+ * columns (#2137's fast-check property test shuffles headers and injects
37
+ * columns and asserts the derived counts never change). This supersedes the
38
+ * earlier `findTableBySchema` exact-schema lookup, which required an exact
39
+ * canonical column SET+ORDER and returned all-null on any reordering or
40
+ * injection.
41
+ *
42
+ * Scoped to the `## Progress` section when the document has one (#2012 decoy
43
+ * avoidance — a differently-headed table sharing the same column names must
44
+ * not be picked up instead); a headingless milestone slice (#1445) falls back
45
+ * to scanning the whole input, preserving the "Progress table not under a
46
+ * `## Progress` heading, or not the first table in the document, still
47
+ * resolves" behaviour.
48
+ *
49
+ * Cells are read by column NAME (`r['Status']`, `r['Plans Complete']`,
50
+ * `r['Phase']`), fixing #2137 (the old position-based regex assumed "Status"
51
+ * was always the 3rd cell and "Plans Complete" the 2nd, which broke for the
52
+ * 5-column milestone-grouped variant that inserts a `Milestone` column ahead
53
+ * of them).
29
54
  */
30
55
  function deriveProgressFromRoadmap(roadmapContent) {
31
56
  let completedPhases = null;
32
57
  let totalPhases = null;
33
58
  let totalPlans = null;
34
- try {
35
- // Count Complete rows in the progress table (Status column = "Complete").
36
- // Pattern: row where the phase cell starts with a digit (data row, not header),
37
- // followed by any cell content, then a "Complete" status cell.
38
- // Handles both short form ("| 4. |") and long form ("| 01. Foundation |").
39
- // See phase-lifecycle.ts ~line 1655 for the original SDK pattern.
40
- const tableCompletePattern = /\|\s*\d+[^|]*\|\s*[^|]*\|\s*Complete\s*\|/gi;
41
- const completeMatches = roadmapContent.match(tableCompletePattern);
42
- completedPhases = completeMatches ? completeMatches.length : null;
43
- // Count total phase rows in the progress table.
44
- // Identify the table by looking for Phase|...|Status|...|Completed header.
45
- const progressTableMatch = roadmapContent.match(
46
- // allow-adhoc-markdown: table-scoped regex with heading lookahead as stop; table parsing, out of seam scope; pending #1372
47
- /\|\s*Phase\s*\|[^|]*\|[^|]*Status[^|]*\|[^|]*Completed[^|]*\|[\s\S]*?(?=\n\n|\n##|$)/i);
48
- if (progressTableMatch) {
49
- const tableText = progressTableMatch[0];
50
- // Count data rows (rows starting with pipe then a phase number),
51
- // excluding 999.x backlog phases. Mirrors init.cts /^999(?:\.|$)/ filter.
52
- const dataRowPattern = /^\|\s*(\d+[^|]*)\|/gm;
53
- let dataRowCount = 0;
54
- let drm;
55
- while ((drm = dataRowPattern.exec(tableText)) !== null) {
56
- if (/^999\b/.test(drm[1].trim()))
57
- continue;
58
- dataRowCount++;
59
- }
60
- totalPhases = dataRowCount > 0 ? dataRowCount : null;
61
- }
62
- // Sum plan counts from M/N columns in progress table
59
+ // ADR-2143 §5 (fail-loud, no null-swallow): this used to be wrapped in a
60
+ // try/catch that silently fell through to the existing (null) values on any
61
+ // thrown error. `findTableWithColumns`/`parseMarkdownTable` never throw —
62
+ // an unparseable or absent table resolves to `null` /
63
+ // `{ ok: false, reason }`, not an exception — so the catch was masking
64
+ // nothing but dead code paths. Removed per ADR-2143 §5; the public
65
+ // `RoadmapProgress` contract (nulls = absent) is unchanged.
66
+ //
67
+ // ADR-2143 §3: read the Progress table by column NAME (order/injection-invariant),
68
+ // via the markdown-table seam. Scope to the `## Progress` section when present
69
+ // (#2012 decoy avoidance); a headingless milestone slice (#1445) falls back to the
70
+ // whole input. Requires the canonical Phase/Plans Complete/Status/Completed columns
71
+ // in any order (extra columns ignored) — supersedes findTableBySchema's exact-schema lookup.
72
+ const progressMatch = roadmapContent.match(/^##[ \t]+Progress\b/im);
73
+ let scoped = roadmapContent;
74
+ if (progressMatch && progressMatch.index !== undefined) {
75
+ const afterHeading = roadmapContent.slice(progressMatch.index);
76
+ const nextHeading = afterHeading.search(/\n#{1,2}[ \t]/);
77
+ scoped = nextHeading >= 0 ? afterHeading.slice(0, nextHeading) : afterHeading;
78
+ }
79
+ const table = (0, markdown_table_cjs_1.findTableWithColumns)(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
80
+ if (table) {
81
+ const allRows = table.rows;
82
+ const completed = allRows.filter((r) => /^complete$/i.test((r['Status'] ?? '').trim())).length;
83
+ completedPhases = completed > 0 ? completed : null;
84
+ // Data rows only (exclude 999.x backlog phases). Mirrors init.cts /^999(?:\.|$)/ filter.
85
+ const dataRows = allRows.filter((r) => {
86
+ const phase = (r['Phase'] ?? '').trim();
87
+ return /^\d/.test(phase) && !/^999\b/.test(phase);
88
+ });
89
+ totalPhases = dataRows.length > 0 ? dataRows.length : null;
63
90
  let totalPlansSum = 0;
64
- const planCellPattern = /\|\s*\d+[^|]*\|\s*(\d+)\/(\d+)\s*\|/gi;
65
- let pm;
66
- while ((pm = planCellPattern.exec(roadmapContent)) !== null) {
67
- totalPlansSum += parseInt(pm[2], 10);
91
+ for (const r of allRows) {
92
+ const cell = (r['Plans Complete'] ?? '').trim();
93
+ const m = /(\d+)\s*\/\s*(\d+)/.exec(cell);
94
+ if (m)
95
+ totalPlansSum += parseInt(m[2], 10);
68
96
  }
69
- if (totalPlansSum > 0)
70
- totalPlans = totalPlansSum;
97
+ totalPlans = totalPlansSum > 0 ? totalPlansSum : null;
71
98
  }
72
- catch { /* intentionally empty — fall through to existing values */ }
73
99
  return { completedPhases, totalPhases, totalPlans };
74
100
  }
75
101
  /**
@@ -34,9 +34,30 @@ const { planningDir } = planningWorkspace;
34
34
  function searchPhaseInDir(baseDir, relBase, normalized) {
35
35
  try {
36
36
  const dirs = readSubdirectories(baseDir, true);
37
- const match = dirs.find(d => phaseTokenMatches(d, normalized));
38
- if (!match)
37
+ const matches = dirs.filter(d => phaseTokenMatches(d, normalized));
38
+ if (matches.length === 0)
39
39
  return null;
40
+ // #2237: fail loud when multiple directories match the same bare phase
41
+ // number — this happens when unrelated projects share a .planning/phases/
42
+ // tree. Silently taking the first match risks cross-project file writes.
43
+ if (matches.length > 1) {
44
+ return {
45
+ found: false,
46
+ directory: '',
47
+ phase_number: normalized,
48
+ phase_name: null,
49
+ phase_slug: null,
50
+ plans: [],
51
+ summaries: [],
52
+ incomplete_plans: [],
53
+ has_research: false,
54
+ has_context: false,
55
+ has_verification: false,
56
+ has_reviews: false,
57
+ ambiguous_matches: matches,
58
+ };
59
+ }
60
+ const match = matches[0];
40
61
  const phaseToken = extractPhaseToken(match);
41
62
  const phaseNumber = phaseToken || normalized;
42
63
  const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');