@opengsd/gsd-core 1.7.0 → 1.9.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 (261) 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 +45 -1
  4. package/README.md +2 -0
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +78 -4
  8. package/agents/gsd-debugger.md +87 -29
  9. package/agents/gsd-executor.md +49 -9
  10. package/agents/gsd-intel-updater.md +3 -3
  11. package/agents/gsd-phase-researcher.md +4 -2
  12. package/agents/gsd-plan-checker.md +20 -0
  13. package/agents/gsd-planner.md +44 -59
  14. package/agents/gsd-project-researcher.md +2 -2
  15. package/agents/gsd-ui-auditor.md +0 -40
  16. package/agents/gsd-verifier.md +2 -2
  17. package/bin/install.js +1338 -135
  18. package/commands/gsd/ai-integration-phase.md +1 -1
  19. package/commands/gsd/mempalace-capture.md +9 -5
  20. package/commands/gsd/new-milestone.md +1 -1
  21. package/commands/gsd/plan-phase.md +5 -3
  22. package/commands/gsd/plan-review-convergence.md +7 -2
  23. package/gsd-core/bin/gsd-tools.cjs +2690 -2472
  24. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  25. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  26. package/gsd-core/bin/lib/api-coverage.cjs +360 -53
  27. package/gsd-core/bin/lib/audit.cjs +8 -8
  28. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  29. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  30. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  31. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  32. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  33. package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
  34. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  35. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  36. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  37. package/gsd-core/bin/lib/check-command-router.cjs +140 -27
  38. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  39. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
  41. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  42. package/gsd-core/bin/lib/commands.cjs +326 -21
  43. package/gsd-core/bin/lib/config-loader.cjs +214 -30
  44. package/gsd-core/bin/lib/config.cjs +158 -22
  45. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  46. package/gsd-core/bin/lib/decisions.cjs +32 -8
  47. package/gsd-core/bin/lib/docs.cjs +6 -0
  48. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  49. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  50. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  51. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  52. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  53. package/gsd-core/bin/lib/init.cjs +155 -66
  54. package/gsd-core/bin/lib/install-engine.cjs +299 -23
  55. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  56. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  57. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  58. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  59. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  60. package/gsd-core/bin/lib/milestone.cjs +248 -14
  61. package/gsd-core/bin/lib/model-catalog.cjs +69 -4
  62. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  63. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  64. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  65. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  66. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  67. package/gsd-core/bin/lib/phase-id.cjs +304 -9
  68. package/gsd-core/bin/lib/phase.cjs +258 -17
  69. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  70. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  71. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  72. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  73. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  74. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  75. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  76. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  77. package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
  78. package/gsd-core/bin/lib/roadmap.cjs +23 -7
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
  82. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  83. package/gsd-core/bin/lib/smart-entry.cjs +70 -5
  84. package/gsd-core/bin/lib/state-document.cjs +171 -24
  85. package/gsd-core/bin/lib/state-transition.cjs +50 -11
  86. package/gsd-core/bin/lib/state.cjs +206 -32
  87. package/gsd-core/bin/lib/surface.cjs +51 -9
  88. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  89. package/gsd-core/bin/lib/uat.cjs +428 -11
  90. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  91. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  92. package/gsd-core/bin/lib/validate.cjs +44 -8
  93. package/gsd-core/bin/lib/verification.cjs +163 -31
  94. package/gsd-core/bin/lib/verify.cjs +348 -42
  95. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  96. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  97. package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
  98. package/gsd-core/bin/shared/model-catalog.json +5 -0
  99. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  100. package/gsd-core/references/api-coverage.md +37 -7
  101. package/gsd-core/references/checkpoints.md +1 -1
  102. package/gsd-core/references/common-bug-patterns.md +13 -0
  103. package/gsd-core/references/context-budget.md +40 -0
  104. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  105. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  106. package/gsd-core/references/debugger-philosophy.md +1 -0
  107. package/gsd-core/references/debugger-prevention.md +98 -0
  108. package/gsd-core/references/debugger-rca-branching.md +98 -0
  109. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  110. package/gsd-core/references/debugger-sbfl.md +110 -0
  111. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  112. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  113. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  114. package/gsd-core/references/execute-phase-response-language.md +7 -0
  115. package/gsd-core/references/gate-prompts.md +6 -3
  116. package/gsd-core/references/model-profile-resolution.md +64 -13
  117. package/gsd-core/references/offer-next.md +88 -0
  118. package/gsd-core/references/planner-antipatterns.md +6 -0
  119. package/gsd-core/references/planner-mvp-mode.md +12 -13
  120. package/gsd-core/references/planner-preconditions.md +156 -0
  121. package/gsd-core/references/planner-reversibility.md +132 -0
  122. package/gsd-core/references/planning-config.md +2 -1
  123. package/gsd-core/references/reviewer-instances.md +28 -19
  124. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  125. package/gsd-core/references/skeleton-template.md +1 -1
  126. package/gsd-core/references/thinking-models-planning.md +3 -1
  127. package/gsd-core/references/ui-consideration-probe.md +2 -2
  128. package/gsd-core/references/worktree-branch-check.md +4 -4
  129. package/gsd-core/templates/DEBUG.md +5 -3
  130. package/gsd-core/templates/summary-minimal.md +4 -0
  131. package/gsd-core/templates/summary-standard.md +4 -0
  132. package/gsd-core/templates/summary.md +7 -0
  133. package/gsd-core/workflows/add-phase.md +2 -0
  134. package/gsd-core/workflows/add-tests.md +3 -1
  135. package/gsd-core/workflows/add-todo.md +32 -1
  136. package/gsd-core/workflows/ai-integration-phase.md +8 -6
  137. package/gsd-core/workflows/audit-fix.md +6 -2
  138. package/gsd-core/workflows/audit-milestone.md +8 -0
  139. package/gsd-core/workflows/autonomous.md +19 -15
  140. package/gsd-core/workflows/check-todos.md +5 -3
  141. package/gsd-core/workflows/cleanup.md +7 -1
  142. package/gsd-core/workflows/code-review-fix.md +14 -6
  143. package/gsd-core/workflows/code-review.md +93 -24
  144. package/gsd-core/workflows/complete-milestone.md +3 -0
  145. package/gsd-core/workflows/debug.md +35 -7
  146. package/gsd-core/workflows/diagnose-issues.md +5 -1
  147. package/gsd-core/workflows/discovery-phase.md +7 -0
  148. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  149. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  150. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  151. package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
  152. package/gsd-core/workflows/discuss-phase.md +2 -2
  153. package/gsd-core/workflows/do.md +7 -1
  154. package/gsd-core/workflows/docs-update.md +9 -0
  155. package/gsd-core/workflows/eval-review.md +4 -1
  156. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  157. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  158. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  159. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  160. package/gsd-core/workflows/execute-phase.md +110 -149
  161. package/gsd-core/workflows/execute-plan.md +20 -8
  162. package/gsd-core/workflows/explore.md +4 -0
  163. package/gsd-core/workflows/extract-learnings.md +21 -0
  164. package/gsd-core/workflows/graduation.md +3 -0
  165. package/gsd-core/workflows/health.md +7 -1
  166. package/gsd-core/workflows/help/modes/full.md +9 -5
  167. package/gsd-core/workflows/import.md +11 -2
  168. package/gsd-core/workflows/inbox.md +7 -0
  169. package/gsd-core/workflows/ingest-docs.md +19 -10
  170. package/gsd-core/workflows/manager.md +3 -1
  171. package/gsd-core/workflows/map-codebase.md +17 -10
  172. package/gsd-core/workflows/mvp-phase.md +3 -0
  173. package/gsd-core/workflows/new-milestone.md +79 -23
  174. package/gsd-core/workflows/new-project.md +28 -19
  175. package/gsd-core/workflows/new-workspace.md +3 -1
  176. package/gsd-core/workflows/next.md +5 -2
  177. package/gsd-core/workflows/onboard.md +3 -0
  178. package/gsd-core/workflows/plan-phase.md +56 -51
  179. package/gsd-core/workflows/plan-review-convergence.md +61 -12
  180. package/gsd-core/workflows/plant-seed.md +3 -0
  181. package/gsd-core/workflows/profile-user.md +7 -1
  182. package/gsd-core/workflows/progress.md +31 -3
  183. package/gsd-core/workflows/quick.md +33 -10
  184. package/gsd-core/workflows/remove-workspace.md +3 -0
  185. package/gsd-core/workflows/review.md +172 -585
  186. package/gsd-core/workflows/scan.md +10 -2
  187. package/gsd-core/workflows/secure-phase.md +13 -2
  188. package/gsd-core/workflows/settings-integrations.md +3 -0
  189. package/gsd-core/workflows/settings.md +3 -0
  190. package/gsd-core/workflows/ship.md +88 -11
  191. package/gsd-core/workflows/sketch.md +3 -0
  192. package/gsd-core/workflows/smart-entry.md +4 -1
  193. package/gsd-core/workflows/spike.md +7 -1
  194. package/gsd-core/workflows/ui-phase.md +11 -2
  195. package/gsd-core/workflows/ui-review.md +11 -1
  196. package/gsd-core/workflows/undo.md +7 -0
  197. package/gsd-core/workflows/update.md +106 -5
  198. package/gsd-core/workflows/validate-phase.md +13 -2
  199. package/gsd-core/workflows/verify-phase.md +2 -2
  200. package/gsd-core/workflows/verify-work.md +15 -4
  201. package/hooks/dist/gsd-context-monitor.js +27 -9
  202. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  203. package/hooks/dist/gsd-cursor-stop.js +6 -2
  204. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  205. package/hooks/dist/gsd-graphify-update.sh +9 -0
  206. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  207. package/hooks/dist/gsd-prompt-guard.js +101 -2
  208. package/hooks/dist/gsd-read-guard.js +100 -2
  209. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  210. package/hooks/dist/gsd-statusline.js +97 -9
  211. package/hooks/dist/gsd-workflow-guard.js +110 -6
  212. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  213. package/hooks/dist/lib/cursor-workspace.js +74 -0
  214. package/hooks/gsd-context-monitor.js +27 -9
  215. package/hooks/gsd-cursor-session-start.js +6 -2
  216. package/hooks/gsd-cursor-stop.js +6 -2
  217. package/hooks/gsd-cursor-subagent-start.js +6 -2
  218. package/hooks/gsd-graphify-update.sh +9 -0
  219. package/hooks/gsd-phase-boundary.sh +14 -2
  220. package/hooks/gsd-prompt-guard.js +101 -2
  221. package/hooks/gsd-read-guard.js +100 -2
  222. package/hooks/gsd-read-injection-scanner.js +109 -2
  223. package/hooks/gsd-statusline.js +97 -9
  224. package/hooks/gsd-workflow-guard.js +110 -6
  225. package/hooks/gsd-worktree-path-guard.js +132 -8
  226. package/hooks/lib/cursor-workspace.js +74 -0
  227. package/package.json +10 -8
  228. package/pi/gsd.cjs +34 -3
  229. package/scripts/changeset/lint.cjs +1 -0
  230. package/scripts/changeset/parse.cjs +26 -0
  231. package/scripts/check-coverage-gate.cjs +51 -0
  232. package/scripts/check-glossary-refs.cjs +244 -0
  233. package/scripts/ci-rebase-check.cjs +48 -4
  234. package/scripts/ci-test-scope.cjs +67 -17
  235. package/scripts/gen-adr-index.cjs +528 -0
  236. package/scripts/gen-capability-matrix.cjs +26 -2
  237. package/scripts/gen-capability-registry.cjs +132 -34
  238. package/scripts/gen-emitted-baseline.cjs +145 -0
  239. package/scripts/gen-test-timings.cjs +201 -0
  240. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  241. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  242. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  243. package/scripts/lint-portable-timeout.cjs +140 -0
  244. package/scripts/lint-resolution-provenance.cjs +9 -0
  245. package/scripts/lint-test-file-count.allowlist.json +1 -0
  246. package/scripts/mutation-matrix.cjs +4 -0
  247. package/scripts/prompt-injection-scan.sh +6 -0
  248. package/scripts/registry-schema.cjs +57 -8
  249. package/scripts/release-notes/conventional-title.cjs +19 -1
  250. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  251. package/scripts/release-tarball-smoke.cjs +18 -11
  252. package/scripts/run-tests.cjs +420 -58
  253. package/scripts/workflow-size.cjs +16 -8
  254. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  255. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  256. package/skills/gsd-new-milestone/SKILL.md +1 -1
  257. package/skills/gsd-plan-phase/SKILL.md +5 -3
  258. package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
  259. package/vscode/package.json +1 -1
  260. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  261. package/scripts/update-size-baseline.cjs +0 -68
@@ -18,7 +18,7 @@
18
18
  * - ./planning-workspace.cjs (planningDir, planningRoot)
19
19
  * - ./shell-command-projection.cjs (execGit, platformWriteSync, platformReadSync)
20
20
  * - ./core-utils.cjs (detectSubRepos)
21
- * - ./model-catalog.cjs (KNOWN_RUNTIMES, KNOWN_PROVIDERS)
21
+ * - ./model-catalog.cjs (KNOWN_RUNTIMES, KNOWN_PROVIDERS, ADAPTIVE_TIER_VALUES)
22
22
  */
23
23
  var __importDefault = (this && this.__importDefault) || function (mod) {
24
24
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -120,6 +120,7 @@ const CONFIG_DEFAULTS = {
120
120
  security_asvs_level: _getNestedConfigDefault('workflow', 'security_asvs_level'),
121
121
  security_block_on: _getNestedConfigDefault('workflow', 'security_block_on'),
122
122
  post_planning_gaps: _getNestedConfigDefault('workflow', 'post_planning_gaps'),
123
+ smart_zone_tokens: _getNestedConfigDefault('workflow', 'smart_zone_tokens'),
123
124
  };
124
125
  /**
125
126
  * Deep-merge two plain config objects. `overlay` wins on key conflict.
@@ -172,7 +173,11 @@ function isGitIgnored(cwd, targetPath) {
172
173
  return ignored;
173
174
  }
174
175
  // ─── Model alias resolution ───────────────────────────────────────────────────
175
- const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']);
176
+ // Catalog-derived (model-catalog.cts) so this vocabulary can never drift from
177
+ // VALID_TIERS in verify.cts — see #2070 "Generative Fix Divergence". Excludes
178
+ // 'inherit' (unlike VALID_TIERS): runtime overrides always resolve to a
179
+ // concrete tier, never the adaptive sentinel.
180
+ const RUNTIME_OVERRIDE_TIERS = model_catalog_cjs_1.ADAPTIVE_TIER_VALUES;
176
181
  const _warnedConfigKeys = new Set();
177
182
  function _warnUnknownProfileOverrides(parsed, configLabel) {
178
183
  if (!parsed || typeof parsed !== 'object')
@@ -276,8 +281,15 @@ function _warnUnknownProfileOverrides(parsed, configLabel) {
276
281
  }
277
282
  // Internal helper exposed for tests so per-process warning state can be reset
278
283
  // between cases that intentionally exercise the warning path repeatedly.
284
+ // Clears BOTH dedup sets: _warnedConfigKeys (runtime/model-policy/tier warnings)
285
+ // and _warnedUnknownConfigKeys (unknown top-level keys). Omitting the latter made
286
+ // this a silent no-op for the suite that exists to test it — the leaked state
287
+ // suppressed any later case reusing a key, and the existing cases only passed
288
+ // because each picked a key name no other case reused (#2674).
279
289
  function _resetRuntimeWarningCacheForTests() {
280
290
  _warnedConfigKeys.clear();
291
+ _warnedUnknownConfigKeys.clear();
292
+ _warnedUnusableConfig.clear();
281
293
  }
282
294
  // ─── FIX 2: Federated overlay helpers ────────────────────────────────────────
283
295
  /**
@@ -380,6 +392,102 @@ function _applyFederatedOverlay(baseConfig, userConfig, cwd) {
380
392
  _applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
381
393
  return cloned;
382
394
  }
395
+ /**
396
+ * Result of loadConfigResolved — wraps the config object with provenance metadata.
397
+ * - source: which layer supplied the config
398
+ * - degraded: true when the resolution did not deliver the configuration it
399
+ * should have — either a workstream was requested but its
400
+ * config.json was absent (fell back to root), or a file on the
401
+ * resolution path exists but is unusable (#1880). `reason` says which.
402
+ */
403
+ /**
404
+ * Machine-readable outcome of a config resolution (#1880, ADR-1411 amendment
405
+ * "corrupt is not absent"). `Resolution<T>`'s four documented values all
406
+ * describe a resolution *miss*; the two `config_un*` values below are the
407
+ * unusable-input class that amendment introduced, and they are what makes a
408
+ * corrupt file distinguishable from an absent one.
409
+ *
410
+ * Frozen enum rather than bare strings so tests assert on the typed surface
411
+ * instead of diagnostic prose (CONTRIBUTING.md — Prohibited: Raw Text Matching
412
+ * on Test Outputs).
413
+ */
414
+ const CONFIG_REASON = Object.freeze({
415
+ /** A config file was found, parsed, and supplied at least one setting. */
416
+ RESOLVED: 'resolved',
417
+ /** No config file exists at the resolved path. Genuine absence — NOT degraded. */
418
+ NOT_CONFIGURED: 'not_configured',
419
+ /** A config file exists and parsed, but carried no settings (`{}`). */
420
+ CONFIGURED_EMPTY: 'configured_empty',
421
+ /** A workstream was requested but had no config; fell back to root. */
422
+ WORKSTREAM_FALLBACK: 'workstream_fallback',
423
+ /** The file exists but is not valid JSON — settings were NOT applied. */
424
+ CONFIG_UNPARSEABLE: 'config_unparseable',
425
+ /** The file exists but could not be read (EACCES/EIO/…) — NOT applied. */
426
+ CONFIG_UNREADABLE: 'config_unreadable',
427
+ });
428
+ /**
429
+ * Read + JSON-parse a config file, keeping *absent* distinguishable from
430
+ * *unusable*. `platformReadSync` returns null on ENOENT and re-throws every
431
+ * other errno, which is the seam that makes this separable at all.
432
+ */
433
+ function _readConfigFile(filePath) {
434
+ let raw;
435
+ try {
436
+ raw = (0, shell_command_projection_cjs_1.platformReadSync)(filePath);
437
+ }
438
+ catch (err) {
439
+ const code = err.code ?? 'EUNKNOWN';
440
+ return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNREADABLE, path: filePath, code } };
441
+ }
442
+ if (raw === null)
443
+ return { kind: 'absent' };
444
+ let parsed;
445
+ try {
446
+ parsed = JSON.parse(raw);
447
+ }
448
+ catch {
449
+ return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNPARSEABLE, path: filePath, code: '' } };
450
+ }
451
+ // Shape, not just parseability (ADR-227). `0`, `"x"`, `[]` and `null` are all
452
+ // valid JSON but are not a config object. Accepting them let a PRESENT file
453
+ // parse "ok", then throw downstream, and be reported not_configured by the
454
+ // outer catch — a corrupt file indistinguishable from an absent one, which is
455
+ // the exact defect this change closes. Caught by the fast-check property.
456
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
457
+ return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNPARSEABLE, path: filePath, code: '' } };
458
+ }
459
+ return { kind: 'ok', data: parsed };
460
+ }
461
+ /**
462
+ * Dedup set for the unusable-config diagnostic. Keyed on resolved path + errno
463
+ * per the ADR-1411 amendment — never on message text, which would couple the
464
+ * guard to wording, and never on the errno alone, which would suppress a
465
+ * genuine second failure in a different file.
466
+ */
467
+ const _warnedUnusableConfig = new Set();
468
+ /**
469
+ * The wiring clause (ADR-1411 amendment). `reason` lives on `ConfigResolution`,
470
+ * but `loadConfig` — the wrapper roughly fifty call sites use — returns
471
+ * `.config` alone and would never surface it. Without this diagnostic the field
472
+ * is unreachable to almost every consumer, and the user whose config was
473
+ * silently discarded still gets no signal. That was the whole defect in #1880.
474
+ */
475
+ function _warnUnusableConfig(fault) {
476
+ // The NUL separators are load-bearing: without them `path`+`reason`+`code` is bare
477
+ // concatenation and two distinct faults can key alike. They are written as escapes rather
478
+ // than literal 0x00 bytes because a literal NUL makes the whole file binary to file(1) and
479
+ // grep(1), which silently skipped it — RULESET.AUDIT.search-source-not-generated tells
480
+ // agents to search this exact source to confirm an invariant exists, and it was returning
481
+ // nothing. Same runtime string, still greppable.
482
+ const key = `${fault.path}\u0000${fault.reason}\u0000${fault.code}`;
483
+ if (_warnedUnusableConfig.has(key))
484
+ return;
485
+ _warnedUnusableConfig.add(key);
486
+ const what = fault.reason === CONFIG_REASON.CONFIG_UNPARSEABLE
487
+ ? 'is not valid JSON'
488
+ : `could not be read (${fault.code})`;
489
+ process.stderr.write(`gsd-tools: warning: ${fault.path} ${what} — its settings were NOT applied; using defaults instead\n`);
490
+ }
383
491
  /**
384
492
  * loadConfigResolved — provenance-aware config loading (#1415, ADR-1411 P2).
385
493
  *
@@ -387,13 +495,21 @@ function _applyFederatedOverlay(baseConfig, userConfig, cwd) {
387
495
  * { config, source, degraded } instead of just the config object.
388
496
  * loadConfig now delegates to this function (byte-identical back-compat).
389
497
  *
390
- * Branch → source/degraded mapping:
391
- * A1: ws set + ws config.json found → source:'workstream', degraded:false
392
- * A2: ws null + config.json found → source:'root', degraded:false
393
- * B: catch + .planning/ + rootParsed set (ws fallback) → source:'root', degraded:true
394
- * C: catch + .planning/ + rootParsed null (federated defaults) → source:'builtin-defaults', degraded:false
395
- * D: catch + no .planning/ + ~/.gsd/defaults.json readable → source:'global-defaults', degraded:false
396
- * E: catch + no .planning/ + no global → source:'builtin-defaults', degraded:false
498
+ * Branch → source/degraded/reason mapping:
499
+ * A1: ws set + ws config.json found → source:'workstream', degraded:false, reason:'resolved'|'configured_empty'
500
+ * A2: ws null + config.json found → source:'root', degraded:false, reason:'resolved'|'configured_empty'
501
+ * B: catch + .planning/ + rootParsed set (ws fallback) → source:'root', degraded:true, reason:'workstream_fallback'
502
+ * C: catch + .planning/ + rootParsed null (federated defaults) → source:'builtin-defaults', degraded:false, reason:'not_configured'
503
+ * D: catch + no .planning/ + ~/.gsd/defaults.json readable → source:'global-defaults', degraded:false, reason:'not_configured'
504
+ * E: catch + no .planning/ + no global → source:'builtin-defaults', degraded:false, reason:'not_configured'
505
+ *
506
+ * ORTHOGONAL to all of the above (#1880, ADR-1411 "corrupt is not absent"): if
507
+ * any config file on the resolution path exists but is UNUSABLE — invalid JSON,
508
+ * or an errno such as EACCES — every branch instead returns degraded:true with
509
+ * reason:'config_unparseable'|'config_unreadable', and a deduplicated stderr
510
+ * diagnostic names the file. Before this, a trailing comma in config.json was
511
+ * byte-identical to the file not existing: builtin defaults, degraded:false,
512
+ * and the user's entire configuration silently discarded.
397
513
  */
398
514
  function loadConfigResolved(cwd, options = {}) {
399
515
  // NOTE: loadConfigResolved resolves from cwd AS-IS (no walk-up).
@@ -415,14 +531,39 @@ function loadConfigResolved(cwd, options = {}) {
415
531
  cachedSubRepos = detectSubRepos(cwd);
416
532
  return cachedSubRepos.slice();
417
533
  };
534
+ // Faults are captured, not thrown: the existing control flow (one broad catch
535
+ // that falls back to defaults) is preserved exactly — see #1880. All that is
536
+ // added is knowing WHY the fallback fired, which is the whole defect.
537
+ let configFault = null;
538
+ /**
539
+ * Stamp a fallback return with its reason. Every branch below reaches defaults
540
+ * (or the root config) — what differs is WHY, and before #1880 that was
541
+ * unrecoverable: a corrupt file and an absent one produced identical objects.
542
+ *
543
+ * An unusable file always wins and always sets `degraded:true`; genuine
544
+ * absence keeps whatever `degraded` the branch already decided, so the
545
+ * existing #1366 workstream-fallback semantics are untouched.
546
+ */
547
+ const fallback = (r) => {
548
+ if (configFault)
549
+ return { ...r, degraded: true, reason: configFault.reason };
550
+ return {
551
+ ...r,
552
+ reason: r.degraded ? CONFIG_REASON.WORKSTREAM_FALLBACK : CONFIG_REASON.NOT_CONFIGURED,
553
+ };
554
+ };
418
555
  let rootParsed = null;
419
556
  if (ws) {
420
557
  const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
421
558
  try {
422
- const raw = (0, shell_command_projection_cjs_1.platformReadSync)(rootConfigPath);
423
- if (raw === null)
424
- throw new Error('missing');
425
- rootParsed = JSON.parse(raw);
559
+ const rootRead = _readConfigFile(rootConfigPath);
560
+ if (rootRead.kind === 'fault') {
561
+ configFault = rootRead.fault;
562
+ _warnUnusableConfig(rootRead.fault);
563
+ }
564
+ if (rootRead.kind !== 'ok')
565
+ throw new Error('root config absent or unusable');
566
+ rootParsed = rootRead.data;
426
567
  const { parsed: rootNormalized, normalizations: rootNorms } = (0, configuration_cjs_1.normalizeLegacyKeys)(rootParsed);
427
568
  if (rootNorms.length > 0) {
428
569
  for (const norm of rootNorms) {
@@ -453,10 +594,18 @@ function loadConfigResolved(cwd, options = {}) {
453
594
  const configPath = node_path_1.default.join(planningDir(cwd, ws), 'config.json');
454
595
  const defaults = CONFIG_DEFAULTS;
455
596
  try {
456
- const raw = (0, shell_command_projection_cjs_1.platformReadSync)(configPath);
457
- if (raw === null)
458
- throw new Error('missing');
459
- const fileData = JSON.parse(raw);
597
+ const read = _readConfigFile(configPath);
598
+ if (read.kind === 'fault') {
599
+ // The workstream/root config that ACTUALLY governs this resolution is
600
+ // unusable. This outranks any earlier root-config fault for reporting.
601
+ configFault = read.fault;
602
+ _warnUnusableConfig(read.fault);
603
+ }
604
+ if (read.kind !== 'ok')
605
+ throw new Error('config absent or unusable');
606
+ const fileData = read.data;
607
+ // Snapshot BEFORE normalizeLegacyKeys mutates fileData in place.
608
+ const fileHadKeys = Object.keys(read.data).length > 0;
460
609
  let configDirty = false;
461
610
  {
462
611
  const { parsed: normalized, normalizations } = (0, configuration_cjs_1.normalizeLegacyKeys)(fileData);
@@ -627,7 +776,24 @@ function loadConfigResolved(cwd, options = {}) {
627
776
  // A1 vs A2: disambiguate by whether a real workstream was requested.
628
777
  // Fix 4: empty-string ws ('') resolves the root path → source:'root'.
629
778
  const source = wsRequested ? 'workstream' : 'root';
630
- return { config: _baseConfig, source, degraded: false };
779
+ // This config parsed — but a DIFFERENT file on the resolution path may not
780
+ // have. A workstream config that loads cleanly while the root config it
781
+ // inherits from is corrupt is still a degraded resolution: the root's
782
+ // settings were silently dropped. Reporting `resolved` here would reopen
783
+ // the exact hole this change closes, for the common case of a project that
784
+ // uses workstreams at all.
785
+ if (configFault) {
786
+ return { config: _baseConfig, source, degraded: true, reason: configFault.reason };
787
+ }
788
+ // Emptiness is judged on the FILE THAT WAS READ, not on `parsed` (the
789
+ // root+workstream merge). An empty workstream file inheriting a non-empty
790
+ // root would otherwise report `resolved` while carrying no settings of its
791
+ // own — the opposite of the not-configured/configured-empty distinction
792
+ // ADR-1411 rule 3 requires.
793
+ const reason = fileHadKeys
794
+ ? CONFIG_REASON.RESOLVED
795
+ : CONFIG_REASON.CONFIGURED_EMPTY;
796
+ return { config: _baseConfig, source, degraded: false, reason };
631
797
  }
632
798
  catch {
633
799
  // Fix 2: Early intercept — workstream requested but ws config.json absent (or dir absent)
@@ -635,7 +801,7 @@ function loadConfigResolved(cwd, options = {}) {
635
801
  // This delivers the #1366 acceptance criterion: nonexistent GSD_WORKSTREAM yields root, degraded.
636
802
  if (wsRequested && rootParsed) {
637
803
  const fb = loadConfigResolved(cwd, { workstream: null });
638
- return { config: fb.config, source: 'root', degraded: true };
804
+ return fallback({ config: fb.config, source: 'root', degraded: true });
639
805
  }
640
806
  // Branch B, C, D, E
641
807
  if (node_fs_1.default.existsSync(planningDir(cwd, ws))) {
@@ -643,24 +809,32 @@ function loadConfigResolved(cwd, options = {}) {
643
809
  // Branch B: workstream requested but ws config.json absent; root config present.
644
810
  // (Only reached when wsRequested is false — e.g. ws='' with .planning/workstreams//config.json)
645
811
  const fb = loadConfigResolved(cwd, { workstream: null });
646
- return { config: fb.config, source: 'root', degraded: true };
812
+ return fallback({ config: fb.config, source: 'root', degraded: true });
647
813
  }
648
814
  // Branch C: .planning/ exists but no config.json and no root config — federated/builtin defaults
649
815
  try {
650
- return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
816
+ return fallback({ config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false });
651
817
  }
652
818
  catch {
653
- return { config: defaults, source: 'builtin-defaults', degraded: false };
819
+ return fallback({ config: defaults, source: 'builtin-defaults', degraded: false });
654
820
  }
655
821
  }
656
822
  // Branch D or E: no .planning/
657
823
  try {
658
824
  const home = process.env['GSD_HOME'] || node_os_1.default.homedir();
659
825
  const globalDefaultsPath = node_path_1.default.join(home, '.gsd', 'defaults.json');
660
- const raw = (0, shell_command_projection_cjs_1.platformReadSync)(globalDefaultsPath);
661
- if (raw === null)
662
- throw new Error('missing');
663
- const globalDefaults = JSON.parse(raw);
826
+ const globalRead = _readConfigFile(globalDefaultsPath);
827
+ if (globalRead.kind === 'fault') {
828
+ // ~/.gsd/defaults.json is present but unusable. Only report it when the
829
+ // project config did not already fail — the nearer file is the one the
830
+ // user is most likely to be able to act on.
831
+ if (!configFault)
832
+ configFault = globalRead.fault;
833
+ _warnUnusableConfig(globalRead.fault);
834
+ }
835
+ if (globalRead.kind !== 'ok')
836
+ throw new Error('global defaults absent or unusable');
837
+ const globalDefaults = globalRead.data;
664
838
  const _globalBaseCfg = {
665
839
  ...defaults,
666
840
  model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile,
@@ -687,22 +861,30 @@ function loadConfigResolved(cwd, options = {}) {
687
861
  fast_mode: (globalDefaults['fast_mode']) || null,
688
862
  agent_skills: (globalDefaults['agent_skills']) || {},
689
863
  response_language: (globalDefaults['response_language']) || null,
864
+ // #2069: forward model_policy / model_profile_overrides / runtime so the global-defaults
865
+ // path is at parity with the project-config path (which forwards these three from
866
+ // parsed['…'] at the top of this function). Without these entries, ~/.gsd/defaults.json
867
+ // silently drops them — model_policy/provider/budget etc. are honored when set in a
868
+ // project but ignored when set globally.
869
+ runtime: (globalDefaults['runtime']) || null,
870
+ model_profile_overrides: (globalDefaults['model_profile_overrides']) || null,
871
+ model_policy: (globalDefaults['model_policy']) || null,
690
872
  };
691
873
  // Branch D: global-defaults
692
874
  try {
693
- return { config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false };
875
+ return fallback({ config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false });
694
876
  }
695
877
  catch {
696
- return { config: _globalBaseCfg, source: 'global-defaults', degraded: false };
878
+ return fallback({ config: _globalBaseCfg, source: 'global-defaults', degraded: false });
697
879
  }
698
880
  }
699
881
  catch {
700
882
  // Branch E: no global defaults
701
883
  try {
702
- return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
884
+ return fallback({ config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false });
703
885
  }
704
886
  catch {
705
- return { config: defaults, source: 'builtin-defaults', degraded: false };
887
+ return fallback({ config: defaults, source: 'builtin-defaults', degraded: false });
706
888
  }
707
889
  }
708
890
  }
@@ -717,6 +899,8 @@ function loadConfig(cwd, options = {}) {
717
899
  module.exports = {
718
900
  loadConfig,
719
901
  loadConfigResolved,
902
+ CONFIG_REASON,
903
+ _warnedUnusableConfig,
720
904
  isGitIgnored,
721
905
  CONFIG_DEFAULTS,
722
906
  _getConfigDefault,
@@ -21,7 +21,7 @@ const { CONFIG_DEFAULTS } = configLoader;
21
21
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
22
22
  // eslint-disable-next-line @typescript-eslint/no-require-imports
23
23
  const planningWorkspace = require("./planning-workspace.cjs");
24
- const { planningDir, withPlanningLock } = planningWorkspace;
24
+ const { planningDir, planningRoot, withPlanningLock } = planningWorkspace;
25
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
26
26
  const modelProfiles = require("./model-profiles.cjs");
27
27
  const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles;
@@ -66,7 +66,49 @@ const SCHEMA_DEFAULTS = {
66
66
  'executor.stall_detect_interval_minutes': 5,
67
67
  'executor.stall_threshold_minutes': 10,
68
68
  'git.create_tag': true,
69
+ // Derived from the defaults manifest rather than restated, so the manifest
70
+ // stays the single source of truth for the smart-zone budget (#2630).
71
+ 'workflow.smart_zone_tokens': CONFIG_DEFAULTS.smart_zone_tokens,
69
72
  };
73
+ /**
74
+ * Resolve a schema-level default for an absent key (#2256). Checks the legacy
75
+ * hardcoded SCHEMA_DEFAULTS first, then the capability-registry configSchema
76
+ * default — the same registry default the runtime's capability-activation
77
+ * resolver (resolveConfigKey Level 4, capability-activation.cts) already honors,
78
+ * so `query config-get` can no longer disagree with the runtime about an absent
79
+ * key's effective value.
80
+ */
81
+ function resolveSchemaDefault(cwd, kp) {
82
+ if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
83
+ return { found: true, value: SCHEMA_DEFAULTS[kp] };
84
+ }
85
+ const capSchema = getCapabilityConfigSchema(cwd);
86
+ if (capSchema && typeof capSchema === 'object'
87
+ && Object.prototype.hasOwnProperty.call(capSchema, kp)) {
88
+ const entry = capSchema[kp];
89
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
90
+ const def = entry['default'];
91
+ if (def !== undefined)
92
+ return { found: true, value: def };
93
+ }
94
+ }
95
+ return { found: false, value: undefined };
96
+ }
97
+ /**
98
+ * Emit a schema-resolved default (#2256), applying the same secret-masking
99
+ * invariant the found-key path applies. getCapabilityConfigSchema is a
100
+ * federated, third-party-extensible surface (ADR-1244) — a future key-name
101
+ * collision with a secret key must not leak a declared default in plaintext.
102
+ * Centralizing emission here means masking can't be missed at a call site.
103
+ */
104
+ function emitResolvedDefault(kp, value, raw) {
105
+ if ((0, secrets_cjs_1.isSecretKey)(kp)) {
106
+ const masked = (0, secrets_cjs_1.maskSecret)(value);
107
+ output(masked, raw, masked);
108
+ return;
109
+ }
110
+ output(value, raw, String(value));
111
+ }
70
112
  // ─── Validation helpers ───────────────────────────────────────────────────────
71
113
  function validateKnownConfigKeyPath(keyPath) {
72
114
  const suggested = CONFIG_KEY_SUGGESTIONS[keyPath];
@@ -665,6 +707,18 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
665
707
  error(`Invalid context_window '${val}'. Must be a positive integer (token count).`, ERROR_REASON.USAGE);
666
708
  }
667
709
  }
710
+ // Smart-zone token budget (#2630, ADR-2629). Same shape as context_window:
711
+ // a positive integer token count. A POLICY default, not a benchmark constant.
712
+ // Number.isSafeInteger, NOT Number.isInteger: the read side
713
+ // (estimate-cli readSmartZoneBudget) accepts only safe integers, so an
714
+ // isInteger-only gate would let config-set 'succeed' on a value past 2^53
715
+ // that estimate-check then silently ignores in favour of the default.
716
+ // Accept and honour must agree.
717
+ if (kp === 'workflow.smart_zone_tokens') {
718
+ if (typeof parsedValue !== 'number' || !Number.isSafeInteger(parsedValue) || parsedValue < 1) {
719
+ error(`Invalid workflow.smart_zone_tokens '${val}'. Must be a positive integer (token count).`, ERROR_REASON.USAGE);
720
+ }
721
+ }
668
722
  // Post-planning gap checker (#2493)
669
723
  if (kp === 'workflow.post_planning_gaps') {
670
724
  if (typeof parsedValue !== 'boolean') {
@@ -698,6 +752,10 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
698
752
  error(`Invalid statusline.show_context_tokens '${val}'. Must be a boolean (true or false).`);
699
753
  }
700
754
  }
755
+ // Statusline GSD-state format enum validation
756
+ const VALID_STATE_FORMATS = ['full', 'compact'];
757
+ if (kp === 'statusline.state_format')
758
+ assertEnumValue(parsedValue, val, VALID_STATE_FORMATS, 'statusline.state_format');
701
759
  // statusline.show_git — boolean only
702
760
  if (kp === 'statusline.show_git') {
703
761
  if (typeof parsedValue !== 'boolean') {
@@ -824,17 +882,26 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
824
882
  if (node_fs_1.default.existsSync(configPath)) {
825
883
  config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
826
884
  }
827
- else if (hasDefault) {
828
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
829
- output(defaultValue, raw, String(defaultValue));
830
- return;
831
- }
832
- else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
833
- const def = SCHEMA_DEFAULTS[kp];
834
- output(def, raw, String(def));
835
- return;
836
- }
837
885
  else {
886
+ // #2702: when a workstream is active and has no config.json of its own, fall
887
+ // back to the project ROOT config first — a key the user configured at root is
888
+ // a real, present value and must inherit (per #1893: a present key wins over
889
+ // --default). Only when root also misses do --default / schema default apply.
890
+ // (When no workstream is active, resolveFromRootConfig is a no-op: same file.)
891
+ const rootVal = resolveFromRootConfig(cwd, kp);
892
+ if (rootVal.found) {
893
+ emitResolvedDefault(kp, rootVal.value, raw);
894
+ return;
895
+ }
896
+ if (hasDefault) {
897
+ emitResolvedDefault(kp, defaultValue, raw);
898
+ return;
899
+ }
900
+ const sd = resolveSchemaDefault(cwd, kp);
901
+ if (sd.found) {
902
+ emitResolvedDefault(kp, sd.value, raw);
903
+ return;
904
+ }
838
905
  error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
839
906
  }
840
907
  }
@@ -848,29 +915,50 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
848
915
  let current = config;
849
916
  for (const key of keys) {
850
917
  if (current === undefined || current === null || typeof current !== 'object') {
851
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
918
+ // #2702: root-config inheritance before --default / schema default (see above).
919
+ const rootVal = resolveFromRootConfig(cwd, kp);
920
+ if (rootVal.found) {
921
+ emitResolvedDefault(kp, rootVal.value, raw);
922
+ return;
923
+ }
852
924
  if (hasDefault) {
853
- output(defaultValue, raw, String(defaultValue));
925
+ emitResolvedDefault(kp, defaultValue, raw);
854
926
  return;
855
927
  }
856
- if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
857
- const def = SCHEMA_DEFAULTS[kp];
858
- output(def, raw, String(def));
928
+ const sd = resolveSchemaDefault(cwd, kp);
929
+ if (sd.found) {
930
+ emitResolvedDefault(kp, sd.value, raw);
859
931
  return;
860
932
  }
861
933
  error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
862
934
  }
863
- current = current[key];
935
+ // Own-property gate: bracket access on a plain object walks the
936
+ // prototype chain, so an unqualified `current[key]` would resolve
937
+ // '__proto__' / 'constructor' / 'hasOwnProperty' (and other
938
+ // Object.prototype members) to their inherited values instead of
939
+ // correctly reporting them as absent. hasOwnProperty.call only
940
+ // returns true for a key JSON.parse actually assigned as data on
941
+ // this object (including a literal "__proto__" JSON key, which
942
+ // JSON.parse defines as an own data property, not the accessor) —
943
+ // never for something inherited from the prototype chain.
944
+ current = Object.prototype.hasOwnProperty.call(current, key)
945
+ ? current[key]
946
+ : undefined;
864
947
  }
865
948
  if (current === undefined) {
866
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
949
+ // #2702: root-config inheritance before --default / schema default (see above).
950
+ const rootVal = resolveFromRootConfig(cwd, kp);
951
+ if (rootVal.found) {
952
+ emitResolvedDefault(kp, rootVal.value, raw);
953
+ return;
954
+ }
867
955
  if (hasDefault) {
868
- output(defaultValue, raw, String(defaultValue));
956
+ emitResolvedDefault(kp, defaultValue, raw);
869
957
  return;
870
958
  }
871
- if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
872
- const def = SCHEMA_DEFAULTS[kp];
873
- output(def, raw, String(def));
959
+ const sd = resolveSchemaDefault(cwd, kp);
960
+ if (sd.found) {
961
+ emitResolvedDefault(kp, sd.value, raw);
874
962
  return;
875
963
  }
876
964
  error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
@@ -884,6 +972,54 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
884
972
  }
885
973
  output(current, raw, String(current));
886
974
  }
975
+ /**
976
+ * #2702: resolve a dot-notation key against the project ROOT config
977
+ * (`.planning/config.json`), ignoring any active workstream scope. Returns
978
+ * `{found:false}` when the root config is absent, unparseable, or does not
979
+ * contain the key. This is the inheritance rung `cmdConfigGet` was missing —
980
+ * when a workstream's own config doesn't set a key, the project root value
981
+ * must show through (workstream overrides root; it never fully replaces it),
982
+ * exactly as `loadConfigResolved`'s root+workstream merge already does for
983
+ * every other config consumer. No-op (found:false) when no workstream is
984
+ * active, because `planningDir === planningRoot` and the caller already read
985
+ * that file directly.
986
+ */
987
+ function resolveFromRootConfig(cwd, kp) {
988
+ // Only meaningful when a workstream is active (GSD_WORKSTREAM set) — that is what
989
+ // redirects planningDir away from root AND what loadConfigResolved gates root-reading
990
+ // on. Gating on `process.env.GSD_WORKSTREAM` (not on a planningDir !== planningRoot
991
+ // path inequality) avoids a false trigger under GSD_PROJECT alone, where planningDir
992
+ // diverges from planningRoot without a workstream and loadConfigResolved does NOT
993
+ // inherit root — matching the runtime's own `if (ws)` gate keeps the two surfaces
994
+ // from diverging on the project-scoped (non-workstream) case.
995
+ if (!process.env['GSD_WORKSTREAM'])
996
+ return { found: false, value: undefined };
997
+ const root = planningRoot(cwd);
998
+ const rootConfigPath = node_path_1.default.join(root, 'config.json');
999
+ let rootConfig;
1000
+ try {
1001
+ if (!node_fs_1.default.existsSync(rootConfigPath))
1002
+ return { found: false, value: undefined };
1003
+ rootConfig = JSON.parse(node_fs_1.default.readFileSync(rootConfigPath, 'utf-8'));
1004
+ }
1005
+ catch {
1006
+ // Unparseable root config → don't inherit (do not let a corrupt root file
1007
+ // change config-get's verdict). Fall through to schema default / error.
1008
+ return { found: false, value: undefined };
1009
+ }
1010
+ let current = rootConfig;
1011
+ for (const key of kp.split('.')) {
1012
+ if (current === undefined || current === null || typeof current !== 'object') {
1013
+ return { found: false, value: undefined };
1014
+ }
1015
+ current = Object.prototype.hasOwnProperty.call(current, key)
1016
+ ? current[key]
1017
+ : undefined;
1018
+ }
1019
+ if (current === undefined)
1020
+ return { found: false, value: undefined };
1021
+ return { found: true, value: current };
1022
+ }
887
1023
  /**
888
1024
  * Command to set the model profile in the config file.
889
1025
  *
@@ -186,8 +186,13 @@ function extractCanonicalPlanId(filename) {
186
186
  // or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
187
187
  // so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
188
188
  const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
189
+ // #2232: the PAIRED plan component is a zero-padded continuation segment
190
+ // (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
191
+ // bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
192
+ // \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
193
+ const planTokenRe = new RegExp(`^(?:${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`, 'i');
189
194
  const phaseIdx = parts.findIndex(p => tokenRe.test(p));
190
- if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
195
+ if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
191
196
  return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
192
197
  }
193
198
  return base;