@opengsd/gsd-core 1.8.0 → 1.9.1

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 (177) 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 +31 -1
  4. package/agents/gsd-code-fixer.md +107 -34
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +236 -107
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +882 -4
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +36 -9
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +61 -6
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/project-root.cjs +48 -0
  49. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  50. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  51. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  52. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  54. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  55. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  56. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  57. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  58. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  59. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  60. package/gsd-core/bin/lib/state-document.cjs +164 -20
  61. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  62. package/gsd-core/bin/lib/state.cjs +141 -21
  63. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  64. package/gsd-core/bin/lib/uat.cjs +9 -7
  65. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  66. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  67. package/gsd-core/bin/lib/validate.cjs +32 -0
  68. package/gsd-core/bin/lib/verification.cjs +51 -14
  69. package/gsd-core/bin/lib/verify.cjs +146 -22
  70. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  71. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  73. package/gsd-core/bin/shared/model-catalog.json +5 -0
  74. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  75. package/gsd-core/references/context-budget.md +40 -0
  76. package/gsd-core/references/gate-prompts.md +6 -3
  77. package/gsd-core/references/model-profile-resolution.md +64 -13
  78. package/gsd-core/references/offer-next.md +88 -0
  79. package/gsd-core/references/planning-config.md +2 -1
  80. package/gsd-core/references/reviewer-instances.md +28 -21
  81. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  82. package/gsd-core/references/ui-consideration-probe.md +2 -2
  83. package/gsd-core/references/worktree-branch-check.md +4 -4
  84. package/gsd-core/templates/summary-minimal.md +4 -0
  85. package/gsd-core/templates/summary-standard.md +4 -0
  86. package/gsd-core/templates/summary.md +7 -0
  87. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  88. package/gsd-core/workflows/audit-fix.md +4 -0
  89. package/gsd-core/workflows/audit-milestone.md +8 -0
  90. package/gsd-core/workflows/autonomous.md +19 -15
  91. package/gsd-core/workflows/check-todos.md +2 -2
  92. package/gsd-core/workflows/code-review-fix.md +14 -6
  93. package/gsd-core/workflows/code-review.md +93 -21
  94. package/gsd-core/workflows/debug.md +10 -2
  95. package/gsd-core/workflows/diagnose-issues.md +4 -0
  96. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  97. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  98. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  99. package/gsd-core/workflows/discuss-phase.md +2 -2
  100. package/gsd-core/workflows/docs-update.md +8 -0
  101. package/gsd-core/workflows/eval-review.md +1 -1
  102. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  103. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  104. package/gsd-core/workflows/execute-phase.md +85 -115
  105. package/gsd-core/workflows/execute-plan.md +5 -4
  106. package/gsd-core/workflows/explore.md +4 -0
  107. package/gsd-core/workflows/extract-learnings.md +21 -0
  108. package/gsd-core/workflows/help/modes/full.md +3 -3
  109. package/gsd-core/workflows/import.md +4 -1
  110. package/gsd-core/workflows/ingest-docs.md +4 -0
  111. package/gsd-core/workflows/map-codebase.md +13 -6
  112. package/gsd-core/workflows/new-milestone.md +10 -2
  113. package/gsd-core/workflows/new-project.md +11 -4
  114. package/gsd-core/workflows/next.md +5 -2
  115. package/gsd-core/workflows/plan-phase.md +42 -46
  116. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  117. package/gsd-core/workflows/progress.md +1 -1
  118. package/gsd-core/workflows/quick.md +14 -3
  119. package/gsd-core/workflows/review.md +146 -575
  120. package/gsd-core/workflows/scan.md +9 -1
  121. package/gsd-core/workflows/secure-phase.md +10 -2
  122. package/gsd-core/workflows/ship.md +41 -11
  123. package/gsd-core/workflows/smart-entry.md +1 -1
  124. package/gsd-core/workflows/ui-phase.md +8 -1
  125. package/gsd-core/workflows/ui-review.md +8 -1
  126. package/gsd-core/workflows/update.md +104 -5
  127. package/gsd-core/workflows/validate-phase.md +10 -2
  128. package/gsd-core/workflows/verify-work.md +8 -1
  129. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  130. package/hooks/dist/gsd-cursor-stop.js +6 -2
  131. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  132. package/hooks/dist/gsd-graphify-update.sh +9 -0
  133. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  134. package/hooks/dist/gsd-prompt-guard.js +101 -2
  135. package/hooks/dist/gsd-read-guard.js +100 -2
  136. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  137. package/hooks/dist/gsd-statusline.js +9 -6
  138. package/hooks/dist/gsd-workflow-guard.js +110 -6
  139. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  140. package/hooks/dist/lib/cursor-workspace.js +74 -0
  141. package/hooks/gsd-cursor-session-start.js +6 -2
  142. package/hooks/gsd-cursor-stop.js +6 -2
  143. package/hooks/gsd-cursor-subagent-start.js +6 -2
  144. package/hooks/gsd-graphify-update.sh +9 -0
  145. package/hooks/gsd-phase-boundary.sh +14 -2
  146. package/hooks/gsd-prompt-guard.js +101 -2
  147. package/hooks/gsd-read-guard.js +100 -2
  148. package/hooks/gsd-read-injection-scanner.js +109 -2
  149. package/hooks/gsd-statusline.js +9 -6
  150. package/hooks/gsd-workflow-guard.js +110 -6
  151. package/hooks/gsd-worktree-path-guard.js +132 -8
  152. package/hooks/lib/cursor-workspace.js +74 -0
  153. package/package.json +7 -7
  154. package/pi/gsd.cjs +26 -1
  155. package/scripts/check-coverage-gate.cjs +51 -0
  156. package/scripts/check-glossary-refs.cjs +24 -0
  157. package/scripts/ci-test-scope.cjs +67 -17
  158. package/scripts/gen-adr-index.cjs +6 -4
  159. package/scripts/gen-capability-matrix.cjs +26 -2
  160. package/scripts/gen-capability-registry.cjs +132 -34
  161. package/scripts/gen-emitted-baseline.cjs +145 -0
  162. package/scripts/gen-registry.cjs +39 -15
  163. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  164. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  165. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  166. package/scripts/lint-resolution-provenance.cjs +9 -0
  167. package/scripts/mutation-matrix.cjs +4 -0
  168. package/scripts/prompt-injection-scan.sh +6 -0
  169. package/scripts/registry-schema.cjs +372 -94
  170. package/scripts/release-notes/conventional-title.cjs +19 -1
  171. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  172. package/scripts/validate-registry.cjs +10 -6
  173. package/scripts/workflow-size.cjs +16 -8
  174. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  175. package/vscode/package.json +1 -1
  176. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  177. package/scripts/update-size-baseline.cjs +0 -68
@@ -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.
@@ -280,8 +281,15 @@ function _warnUnknownProfileOverrides(parsed, configLabel) {
280
281
  }
281
282
  // Internal helper exposed for tests so per-process warning state can be reset
282
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).
283
289
  function _resetRuntimeWarningCacheForTests() {
284
290
  _warnedConfigKeys.clear();
291
+ _warnedUnknownConfigKeys.clear();
292
+ _warnedUnusableConfig.clear();
285
293
  }
286
294
  // ─── FIX 2: Federated overlay helpers ────────────────────────────────────────
287
295
  /**
@@ -384,6 +392,102 @@ function _applyFederatedOverlay(baseConfig, userConfig, cwd) {
384
392
  _applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
385
393
  return cloned;
386
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
+ }
387
491
  /**
388
492
  * loadConfigResolved — provenance-aware config loading (#1415, ADR-1411 P2).
389
493
  *
@@ -391,13 +495,21 @@ function _applyFederatedOverlay(baseConfig, userConfig, cwd) {
391
495
  * { config, source, degraded } instead of just the config object.
392
496
  * loadConfig now delegates to this function (byte-identical back-compat).
393
497
  *
394
- * Branch → source/degraded mapping:
395
- * A1: ws set + ws config.json found → source:'workstream', degraded:false
396
- * A2: ws null + config.json found → source:'root', degraded:false
397
- * B: catch + .planning/ + rootParsed set (ws fallback) → source:'root', degraded:true
398
- * C: catch + .planning/ + rootParsed null (federated defaults) → source:'builtin-defaults', degraded:false
399
- * D: catch + no .planning/ + ~/.gsd/defaults.json readable → source:'global-defaults', degraded:false
400
- * 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.
401
513
  */
402
514
  function loadConfigResolved(cwd, options = {}) {
403
515
  // NOTE: loadConfigResolved resolves from cwd AS-IS (no walk-up).
@@ -419,14 +531,39 @@ function loadConfigResolved(cwd, options = {}) {
419
531
  cachedSubRepos = detectSubRepos(cwd);
420
532
  return cachedSubRepos.slice();
421
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
+ };
422
555
  let rootParsed = null;
423
556
  if (ws) {
424
557
  const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
425
558
  try {
426
- const raw = (0, shell_command_projection_cjs_1.platformReadSync)(rootConfigPath);
427
- if (raw === null)
428
- throw new Error('missing');
429
- 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;
430
567
  const { parsed: rootNormalized, normalizations: rootNorms } = (0, configuration_cjs_1.normalizeLegacyKeys)(rootParsed);
431
568
  if (rootNorms.length > 0) {
432
569
  for (const norm of rootNorms) {
@@ -457,10 +594,18 @@ function loadConfigResolved(cwd, options = {}) {
457
594
  const configPath = node_path_1.default.join(planningDir(cwd, ws), 'config.json');
458
595
  const defaults = CONFIG_DEFAULTS;
459
596
  try {
460
- const raw = (0, shell_command_projection_cjs_1.platformReadSync)(configPath);
461
- if (raw === null)
462
- throw new Error('missing');
463
- 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;
464
609
  let configDirty = false;
465
610
  {
466
611
  const { parsed: normalized, normalizations } = (0, configuration_cjs_1.normalizeLegacyKeys)(fileData);
@@ -631,7 +776,24 @@ function loadConfigResolved(cwd, options = {}) {
631
776
  // A1 vs A2: disambiguate by whether a real workstream was requested.
632
777
  // Fix 4: empty-string ws ('') resolves the root path → source:'root'.
633
778
  const source = wsRequested ? 'workstream' : 'root';
634
- 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 };
635
797
  }
636
798
  catch {
637
799
  // Fix 2: Early intercept — workstream requested but ws config.json absent (or dir absent)
@@ -639,7 +801,7 @@ function loadConfigResolved(cwd, options = {}) {
639
801
  // This delivers the #1366 acceptance criterion: nonexistent GSD_WORKSTREAM yields root, degraded.
640
802
  if (wsRequested && rootParsed) {
641
803
  const fb = loadConfigResolved(cwd, { workstream: null });
642
- return { config: fb.config, source: 'root', degraded: true };
804
+ return fallback({ config: fb.config, source: 'root', degraded: true });
643
805
  }
644
806
  // Branch B, C, D, E
645
807
  if (node_fs_1.default.existsSync(planningDir(cwd, ws))) {
@@ -647,24 +809,32 @@ function loadConfigResolved(cwd, options = {}) {
647
809
  // Branch B: workstream requested but ws config.json absent; root config present.
648
810
  // (Only reached when wsRequested is false — e.g. ws='' with .planning/workstreams//config.json)
649
811
  const fb = loadConfigResolved(cwd, { workstream: null });
650
- return { config: fb.config, source: 'root', degraded: true };
812
+ return fallback({ config: fb.config, source: 'root', degraded: true });
651
813
  }
652
814
  // Branch C: .planning/ exists but no config.json and no root config — federated/builtin defaults
653
815
  try {
654
- return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
816
+ return fallback({ config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false });
655
817
  }
656
818
  catch {
657
- return { config: defaults, source: 'builtin-defaults', degraded: false };
819
+ return fallback({ config: defaults, source: 'builtin-defaults', degraded: false });
658
820
  }
659
821
  }
660
822
  // Branch D or E: no .planning/
661
823
  try {
662
824
  const home = process.env['GSD_HOME'] || node_os_1.default.homedir();
663
825
  const globalDefaultsPath = node_path_1.default.join(home, '.gsd', 'defaults.json');
664
- const raw = (0, shell_command_projection_cjs_1.platformReadSync)(globalDefaultsPath);
665
- if (raw === null)
666
- throw new Error('missing');
667
- 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;
668
838
  const _globalBaseCfg = {
669
839
  ...defaults,
670
840
  model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile,
@@ -702,19 +872,19 @@ function loadConfigResolved(cwd, options = {}) {
702
872
  };
703
873
  // Branch D: global-defaults
704
874
  try {
705
- return { config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false };
875
+ return fallback({ config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false });
706
876
  }
707
877
  catch {
708
- return { config: _globalBaseCfg, source: 'global-defaults', degraded: false };
878
+ return fallback({ config: _globalBaseCfg, source: 'global-defaults', degraded: false });
709
879
  }
710
880
  }
711
881
  catch {
712
882
  // Branch E: no global defaults
713
883
  try {
714
- return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
884
+ return fallback({ config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false });
715
885
  }
716
886
  catch {
717
- return { config: defaults, source: 'builtin-defaults', degraded: false };
887
+ return fallback({ config: defaults, source: 'builtin-defaults', degraded: false });
718
888
  }
719
889
  }
720
890
  }
@@ -729,6 +899,8 @@ function loadConfig(cwd, options = {}) {
729
899
  module.exports = {
730
900
  loadConfig,
731
901
  loadConfigResolved,
902
+ CONFIG_REASON,
903
+ _warnedUnusableConfig,
732
904
  isGitIgnored,
733
905
  CONFIG_DEFAULTS,
734
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,6 +66,9 @@ 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
  };
70
73
  /**
71
74
  * Resolve a schema-level default for an absent key (#2256). Checks the legacy
@@ -704,6 +707,18 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
704
707
  error(`Invalid context_window '${val}'. Must be a positive integer (token count).`, ERROR_REASON.USAGE);
705
708
  }
706
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
+ }
707
722
  // Post-planning gap checker (#2493)
708
723
  if (kp === 'workflow.post_planning_gaps') {
709
724
  if (typeof parsedValue !== 'boolean') {
@@ -867,11 +882,21 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
867
882
  if (node_fs_1.default.existsSync(configPath)) {
868
883
  config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
869
884
  }
870
- else if (hasDefault) {
871
- emitResolvedDefault(kp, defaultValue, raw);
872
- return;
873
- }
874
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
+ }
875
900
  const sd = resolveSchemaDefault(cwd, kp);
876
901
  if (sd.found) {
877
902
  emitResolvedDefault(kp, sd.value, raw);
@@ -890,6 +915,12 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
890
915
  let current = config;
891
916
  for (const key of keys) {
892
917
  if (current === undefined || current === null || typeof current !== 'object') {
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
+ }
893
924
  if (hasDefault) {
894
925
  emitResolvedDefault(kp, defaultValue, raw);
895
926
  return;
@@ -915,6 +946,12 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
915
946
  : undefined;
916
947
  }
917
948
  if (current === undefined) {
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
+ }
918
955
  if (hasDefault) {
919
956
  emitResolvedDefault(kp, defaultValue, raw);
920
957
  return;
@@ -935,6 +972,54 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
935
972
  }
936
973
  output(current, raw, String(current));
937
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
+ }
938
1023
  /**
939
1024
  * Command to set the model profile in the config file.
940
1025
  *