@opengsd/gsd-core 1.5.0 → 1.6.0-rc.2

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 (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/agents/gsd-roadmapper.md +6 -0
  5. package/bin/install.js +199 -365
  6. package/commands/gsd/capture.md +5 -1
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +695 -5
  9. package/gsd-core/bin/lib/adr-parser.cjs +45 -23
  10. package/gsd-core/bin/lib/audit.cjs +2 -2
  11. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  12. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  14. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  15. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  16. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  17. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  18. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  19. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  20. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  22. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  23. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  24. package/gsd-core/bin/lib/commands.cjs +247 -0
  25. package/gsd-core/bin/lib/config-loader.cjs +98 -84
  26. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  27. package/gsd-core/bin/lib/config.cjs +7 -1
  28. package/gsd-core/bin/lib/decisions.cjs +149 -60
  29. package/gsd-core/bin/lib/frontmatter.cjs +7 -3
  30. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  31. package/gsd-core/bin/lib/init.cjs +91 -22
  32. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  33. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  35. package/gsd-core/bin/lib/milestone.cjs +41 -2
  36. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  37. package/gsd-core/bin/lib/phase-id.cjs +25 -11
  38. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  39. package/gsd-core/bin/lib/phase.cjs +33 -4
  40. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  41. package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
  42. package/gsd-core/bin/lib/project-root.cjs +89 -2
  43. package/gsd-core/bin/lib/resolution.cjs +26 -0
  44. package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
  45. package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
  46. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
  47. package/gsd-core/bin/lib/roadmap.cjs +5 -2
  48. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
  49. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
  50. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
  51. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  52. package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
  53. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  54. package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
  55. package/gsd-core/bin/lib/state-document.cjs +4 -2
  56. package/gsd-core/bin/lib/state.cjs +317 -161
  57. package/gsd-core/bin/lib/surface.cjs +12 -19
  58. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  59. package/gsd-core/bin/lib/uat.cjs +39 -26
  60. package/gsd-core/bin/lib/validate.cjs +5 -2
  61. package/gsd-core/bin/lib/verify.cjs +40 -15
  62. package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
  63. package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
  64. package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
  65. package/gsd-core/references/context-budget.md +8 -8
  66. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  67. package/gsd-core/references/execute-phase-context-guard.md +16 -0
  68. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  69. package/gsd-core/references/planner-antipatterns.md +48 -0
  70. package/gsd-core/references/planning-config.md +4 -0
  71. package/gsd-core/references/prohibition-probe.md +15 -9
  72. package/gsd-core/references/scout-codebase.md +2 -2
  73. package/gsd-core/workflows/autonomous.md +33 -33
  74. package/gsd-core/workflows/diagnose-issues.md +6 -1
  75. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  76. package/gsd-core/workflows/discuss-phase.md +1 -2
  77. package/gsd-core/workflows/execute-phase.md +12 -12
  78. package/gsd-core/workflows/help/modes/full.md +10 -0
  79. package/gsd-core/workflows/list-seeds.md +63 -0
  80. package/gsd-core/workflows/manager.md +37 -37
  81. package/gsd-core/workflows/pr-branch.md +156 -0
  82. package/gsd-core/workflows/quick.md +6 -1
  83. package/gsd-core/workflows/review.md +10 -2
  84. package/gsd-core/workflows/spec-phase.md +8 -3
  85. package/gsd-core/workflows/verify-phase.md +2 -2
  86. package/package.json +6 -3
  87. package/scripts/gen-capability-matrix.cjs +284 -0
  88. package/scripts/gen-capability-registry.cjs +96 -1853
  89. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  90. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  91. package/scripts/lint-resolution-provenance.cjs +192 -0
  92. package/scripts/lint-test-file-count.allowlist.json +9 -0
  93. package/scripts/prompt-injection-scan.sh +1 -0
  94. package/scripts/run-tests.cjs +14 -0
  95. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -45,6 +45,10 @@ const federatedConfigModule = require("./federated-config.cjs");
45
45
  const { mergeFederatedConfig } = federatedConfigModule;
46
46
  // The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
47
47
  // Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
48
+ // This is the FROZEN first-party registry — used as the test-seam default and the
49
+ // fallback. Overlay (installed third-party) config-key federation is cwd-dependent
50
+ // and composed PER loadConfig CALL by _federatedConfigSchema(cwd) below (ADR-1244 D2),
51
+ // never eagerly at module load.
48
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
49
53
  const _capabilityRegistryReal = require('./capability-registry.cjs');
50
54
  // Module-level registry reference. Defaults to the real generated registry.
@@ -131,6 +135,12 @@ function _deepMergeConfig(base, overlay) {
131
135
  return overlay;
132
136
  const result = { ...base };
133
137
  for (const key of Object.keys(overlay)) {
138
+ // Prototype-pollution guard — mirrors the four sibling guards in this file
139
+ // (lines ~315/319/331/341/549). Without it a workstream/root config.json with
140
+ // {"__proto__": {...}} pollutes this merged object's prototype chain and can
141
+ // spoof unset config flags. (Per-object pollution, not global Object.prototype.)
142
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
143
+ continue;
134
144
  if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) {
135
145
  result[key] = _deepMergeConfig((base[key] ?? {}), overlay[key]);
136
146
  }
@@ -324,8 +334,32 @@ function _applyFederatedValues(obj, values, validKeys) {
324
334
  * When validKeys is non-empty, applies values into a shallow clone to avoid
325
335
  * mutating shared CONFIG_DEFAULTS/module constants.
326
336
  */
327
- function _applyFederatedOverlay(baseConfig, userConfig) {
328
- const _fedRegistrySchema = _capabilityRegistry.configSchema;
337
+ // Resolve the federated capability config-schema for a project (ADR-1244 D2).
338
+ // A test override (via _setFederatedRegistryForTests) wins; otherwise, when a
339
+ // project cwd is available, compose the installed overlay for THAT project —
340
+ // LAZILY (never at module load, so a bare require never scans the filesystem and
341
+ // the result is never cached for the wrong cwd) — falling back to the frozen
342
+ // first-party schema when there is no cwd or the loader is unavailable.
343
+ function _federatedConfigSchema(cwd) {
344
+ if (_capabilityRegistry !== _capabilityRegistryReal) {
345
+ return _capabilityRegistry.configSchema; // explicit test override
346
+ }
347
+ if (typeof cwd === 'string' && cwd) {
348
+ try {
349
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
350
+ const loaderMod = require('./capability-loader.cjs');
351
+ // #1459 IC-04: thread the consent home explicitly so a consented project cap's federated config
352
+ // key resolves at the SAME user-owned home that gated its activation (never the wrong home).
353
+ const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] }).configSchema;
354
+ if (schema && typeof schema === 'object')
355
+ return schema;
356
+ }
357
+ catch { /* fall back to first-party */ }
358
+ }
359
+ return _capabilityRegistryReal.configSchema;
360
+ }
361
+ function _applyFederatedOverlay(baseConfig, userConfig, cwd) {
362
+ const _fedRegistrySchema = _federatedConfigSchema(cwd);
329
363
  if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object')
330
364
  return baseConfig;
331
365
  const _fedOverlay = mergeFederatedConfig({
@@ -341,26 +375,39 @@ function _applyFederatedOverlay(baseConfig, userConfig) {
341
375
  _applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
342
376
  return cloned;
343
377
  }
344
- function loadConfig(cwd, options = {}) {
378
+ /**
379
+ * loadConfigResolved — provenance-aware config loading (#1415, ADR-1411 P2).
380
+ *
381
+ * Identical to loadConfig in every observable way except it returns
382
+ * { config, source, degraded } instead of just the config object.
383
+ * loadConfig now delegates to this function (byte-identical back-compat).
384
+ *
385
+ * Branch → source/degraded mapping:
386
+ * A1: ws set + ws config.json found → source:'workstream', degraded:false
387
+ * A2: ws null + config.json found → source:'root', degraded:false
388
+ * B: catch + .planning/ + rootParsed set (ws fallback) → source:'root', degraded:true
389
+ * C: catch + .planning/ + rootParsed null (federated defaults) → source:'builtin-defaults', degraded:false
390
+ * D: catch + no .planning/ + ~/.gsd/defaults.json readable → source:'global-defaults', degraded:false
391
+ * E: catch + no .planning/ + no global → source:'builtin-defaults', degraded:false
392
+ */
393
+ function loadConfigResolved(cwd, options = {}) {
394
+ // NOTE: loadConfigResolved resolves from cwd AS-IS (no walk-up).
395
+ // Callers that need ancestor-anchoring (e.g. cmdAgentSkills) must do so
396
+ // themselves via findProjectRoot() before calling this function.
397
+ // This preserves back-compat for the ~30 other loadConfig callers (#1415).
345
398
  const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
346
399
  ? options['workstream']
347
400
  : (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
348
401
  ? options['workstreamContext']['ws']
349
402
  : (process.env['GSD_WORKSTREAM'] || null);
350
- // When GSD_WORKSTREAM is set, load root config first so workstream config
351
- // can inherit from it. This prevents users from duplicating model_overrides,
352
- // workflow.*, etc. across every workstream config (#2714).
353
403
  const ws = typeof activeWorkstream === 'string' ? activeWorkstream : (activeWorkstream === null ? null : null);
354
- // #315 — per-call lazy memo: all three detection sites inside this loadConfig
355
- // call operate on the same cwd and the subrepo set cannot change mid-call, so
356
- // a single scan is sufficient. The memo is scoped to THIS call (not module-level)
357
- // so separate loadConfig invocations each get a fresh scan.
404
+ // wsRequested: true when caller explicitly requested a non-empty workstream.
405
+ // Used for source labeling (Fix 4) and early absent-dir intercept (Fix 2).
406
+ const wsRequested = ws != null && ws !== '';
358
407
  let cachedSubRepos;
359
408
  const getDetectedSubRepos = () => {
360
409
  if (cachedSubRepos === undefined)
361
410
  cachedSubRepos = detectSubRepos(cwd);
362
- // Return a copy: original detectSubRepos returned a fresh array per call,
363
- // so each site must keep an independent array (avoid cross-site aliasing).
364
411
  return cachedSubRepos.slice();
365
412
  };
366
413
  let rootParsed = null;
@@ -371,10 +418,8 @@ function loadConfig(cwd, options = {}) {
371
418
  if (raw === null)
372
419
  throw new Error('missing');
373
420
  rootParsed = JSON.parse(raw);
374
- // Cycle 4: delegate all legacy-key normalization to the Configuration Module.
375
421
  const { parsed: rootNormalized, normalizations: rootNorms } = (0, configuration_cjs_1.normalizeLegacyKeys)(rootParsed);
376
422
  if (rootNorms.length > 0) {
377
- // Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos)
378
423
  for (const norm of rootNorms) {
379
424
  if (norm.requiresFilesystem && !rootNormalized.planning?.['sub_repos']) {
380
425
  const detected = getDetectedSubRepos();
@@ -406,22 +451,14 @@ function loadConfig(cwd, options = {}) {
406
451
  const raw = (0, shell_command_projection_cjs_1.platformReadSync)(configPath);
407
452
  if (raw === null)
408
453
  throw new Error('missing');
409
- // `fileData` is the parsed content of the config.json file on disk — used
410
- // for migrations and writes so we never persist merged values back to disk.
411
454
  const fileData = JSON.parse(raw);
412
- // Cycle 4: Single normalizeLegacyKeys call replaces all four inline migration
413
- // blocks (depth→granularity, multiRepo→planning.sub_repos, sub_repos→planning.sub_repos,
414
- // branching_strategy→git.branching_strategy). The Module is pure (no I/O); disk
415
- // writeback is handled below with the existing platformWriteSync pattern.
416
455
  let configDirty = false;
417
456
  {
418
457
  const { parsed: normalized, normalizations } = (0, configuration_cjs_1.normalizeLegacyKeys)(fileData);
419
458
  if (normalizations.length > 0) {
420
- // Merge normalized values back into fileData (mutation-in-place for legacy code below)
421
459
  Object.keys(fileData).forEach(k => delete fileData[k]);
422
460
  Object.assign(fileData, normalized);
423
461
  configDirty = true;
424
- // Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos).
425
462
  for (const norm of normalizations) {
426
463
  if (norm.requiresFilesystem && !fileData.planning?.['sub_repos']) {
427
464
  const detected = getDetectedSubRepos();
@@ -435,7 +472,6 @@ function loadConfig(cwd, options = {}) {
435
472
  }
436
473
  }
437
474
  }
438
- // Keep planning.sub_repos in sync with actual filesystem
439
475
  const currentSubRepos = fileData.planning?.['sub_repos'] || [];
440
476
  if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
441
477
  const detected = getDetectedSubRepos();
@@ -449,36 +485,24 @@ function loadConfig(cwd, options = {}) {
449
485
  }
450
486
  }
451
487
  }
452
- // Persist sub_repos changes (migration or sync) — write only the on-disk
453
- // file contents, never the merged result, to avoid polluting workstream configs.
454
488
  if (configDirty) {
455
489
  try {
456
490
  (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(fileData, null, 2));
457
491
  }
458
492
  catch { /* ignore */ }
459
493
  }
460
- // Now apply root→workstream inheritance. `parsed` is the effective config
461
- // used for value extraction below; fileData is kept for disk writes only.
462
494
  const parsed = rootParsed
463
495
  ? (_deepMergeConfig(rootParsed, fileData) ?? fileData)
464
496
  : fileData;
465
- // Warn about unrecognized top-level keys so users don't silently lose config.
466
497
  const KNOWN_TOP_LEVEL = new Set([
467
- // Extract top-level key names from dot-notation paths (e.g., 'workflow.research' → 'workflow')
468
498
  ...[...VALID_CONFIG_KEYS].map((k) => k.split('.')[0]),
469
- // Dynamic-pattern top-level containers (e.g. review, model_profile_overrides)
470
499
  ...DYNAMIC_KEY_PATTERNS.map(p => p.topLevel),
471
- // Internal keys loadConfig reads but config-set doesn't expose
472
500
  'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode',
473
- // Deprecated keys (still accepted for migration, not in config-set)
474
501
  'depth', 'multiRepo', 'branching_strategy', 'research',
475
502
  ]);
476
- // FIX 3: Compute federated overlay BEFORE the unknown-key warning, so that
477
- // federated top-level keys are added to KNOWN_TOP_LEVEL before the check runs.
478
- // This is hoisted out of the try-catch below so validKeys are available here.
479
503
  let _preWarningFedValidKeys = [];
480
504
  try {
481
- const _fedRegistrySchemaEarly = _capabilityRegistry.configSchema;
505
+ const _fedRegistrySchemaEarly = _federatedConfigSchema(cwd);
482
506
  if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
483
507
  const _earlyOverlay = mergeFederatedConfig({
484
508
  configSchema: _fedRegistrySchemaEarly,
@@ -495,7 +519,7 @@ function loadConfig(cwd, options = {}) {
495
519
  }
496
520
  }
497
521
  catch {
498
- // Defensive: if registry access fails here, proceed without pre-warning keys
522
+ // Defensive
499
523
  }
500
524
  const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
501
525
  if (unknownKeys.length > 0) {
@@ -505,7 +529,6 @@ function loadConfig(cwd, options = {}) {
505
529
  process.stderr.write(`gsd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`);
506
530
  }
507
531
  }
508
- // #2517 — Validate runtime/tier values
509
532
  _warnUnknownProfileOverrides(parsed, '.planning/config.json');
510
533
  const get = (key, nested) => {
511
534
  if (parsed[key] !== undefined)
@@ -530,11 +553,8 @@ function loadConfig(cwd, options = {}) {
530
553
  model_profile: get('model_profile') ?? defaults.model_profile,
531
554
  commit_docs: (() => {
532
555
  const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
533
- // If explicitly set in config, respect the user's choice
534
556
  if (explicit !== undefined)
535
557
  return explicit;
536
- // Auto-detection: when no explicit value and .planning/ is gitignored,
537
- // default to false instead of true
538
558
  if (isGitIgnored(cwd, '.planning/'))
539
559
  return false;
540
560
  return defaults.commit_docs;
@@ -565,22 +585,14 @@ function loadConfig(cwd, options = {}) {
565
585
  project_code: get('project_code') ?? defaults.project_code,
566
586
  subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
567
587
  model_overrides: (parsed['model_overrides']) || null,
568
- // #3023 — per-phase-type model map.
569
588
  models: (parsed['models']) || null,
570
- // #68 — top-level granularity
571
589
  granularity: parsed['granularity'] !== undefined ? parsed['granularity'] : null,
572
- // #68 — per-phase-type granularity map.
573
590
  granularities: (parsed['granularities']) || null,
574
- // #68 — planning sub-object
575
591
  planning: (parsed['planning']) || null,
576
- // #3024 — dynamic routing block.
577
592
  dynamic_routing: (parsed['dynamic_routing']) || null,
578
- // #2517 — runtime-aware profiles.
579
593
  runtime: (parsed['runtime']) || null,
580
594
  model_profile_overrides: (parsed['model_profile_overrides']) || null,
581
- // #49 — provider-neutral model policy presets.
582
595
  model_policy: (parsed['model_policy']) || null,
583
- // #443 — effort/fast_mode
584
596
  effort: (parsed['effort']) || null,
585
597
  fast_mode: (parsed['fast_mode']) || null,
586
598
  agent_skills: (parsed['agent_skills']) || {},
@@ -590,57 +602,53 @@ function loadConfig(cwd, options = {}) {
590
602
  claude_md_path: get('claude_md_path') || null,
591
603
  claude_md_assembly: (parsed['claude_md_assembly']) || null,
592
604
  };
593
- // ─── ADR-857 phase 3b: federated config overlay ───────────────────────────
594
- // FIX 2: Use the pre-computed _preWarningFedValidKeys (from the FIX 3 block above)
595
- // plus a fresh overlay call to get values. The KNOWN_TOP_LEVEL was already updated.
596
- // TODAY: every UI key is still in the central config-schema, so isCentralKey()
597
- // returns true for all of them → validKeys is empty → _baseConfig is returned UNCHANGED
598
- // (true no-op: no clone, no reorder, byte-identical output).
599
- // This becomes a live channel once a key is atomically removed from the central schema.
605
+ // ADR-857 phase 3b: federated config overlay
600
606
  try {
601
607
  if (_preWarningFedValidKeys.length > 0) {
602
- // There are actual federated values — re-use the already-computed overlay
603
- // (we run mergeFederatedConfig again here to get the values map; the validKeys
604
- // are guaranteed identical since it's the same inputs).
605
- const _fedRegistrySchema = _capabilityRegistry.configSchema;
608
+ const _fedRegistrySchema = _federatedConfigSchema(cwd);
606
609
  if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
607
610
  const _fedOverlay = mergeFederatedConfig({
608
611
  configSchema: _fedRegistrySchema,
609
612
  isCentralKey: (key) => _isCentralConfigKeyFn(key),
610
613
  userConfig: parsed,
611
614
  });
612
- // Apply dotted-path values (e.g. "workflow.ui_phase" → _baseConfig.workflow.ui_phase)
613
- // WITHOUT clobbering existing keys. N-level nesting supported.
614
615
  _applyFederatedValues(_baseConfig, _fedOverlay.values, _fedOverlay.validKeys);
615
616
  }
616
617
  }
617
- // Pending-migration warnings are suppressed at load time to avoid noisy output on
618
- // every loadConfig call. They are surfaced at registry-generation time (--check/--write).
619
618
  }
620
619
  catch {
621
- // Defensive: if the federated overlay throws for any reason, return the base config unchanged.
622
- // This keeps loadConfig's no-throw contract intact regardless of capability registry state.
620
+ // Defensive: keep no-throw contract
623
621
  }
624
- return _baseConfig;
622
+ // A1 vs A2: disambiguate by whether a real workstream was requested.
623
+ // Fix 4: empty-string ws ('') resolves the root path → source:'root'.
624
+ const source = wsRequested ? 'workstream' : 'root';
625
+ return { config: _baseConfig, source, degraded: false };
625
626
  }
626
627
  catch {
627
- // Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
628
+ // Fix 2: Early intercept — workstream requested but ws config.json absent (or dir absent)
629
+ // AND root config was loaded. Covers BOTH "dir exists, no config.json" AND "dir absent".
630
+ // This delivers the #1366 acceptance criterion: nonexistent GSD_WORKSTREAM yields root, degraded.
631
+ if (wsRequested && rootParsed) {
632
+ const fb = loadConfigResolved(cwd, { workstream: null });
633
+ return { config: fb.config, source: 'root', degraded: true };
634
+ }
635
+ // Branch B, C, D, E
628
636
  if (node_fs_1.default.existsSync(planningDir(cwd, ws))) {
629
637
  if (rootParsed) {
630
- // Workstream has no config.json: re-parse using root config as the sole source.
631
- // (FIX 2: overlay is applied recursively in the re-entrant loadConfig call)
632
- return loadConfig(cwd, { workstream: null });
638
+ // Branch B: workstream requested but ws config.json absent; root config present.
639
+ // (Only reached when wsRequested is false — e.g. ws='' with .planning/workstreams//config.json)
640
+ const fb = loadConfigResolved(cwd, { workstream: null });
641
+ return { config: fb.config, source: 'root', degraded: true };
633
642
  }
634
- // FIX 2: Apply the federated overlay on the no-config path.
635
- // Migrated Capability keys are surfaced from the generated registry even
636
- // when the project has no config.json, so schema defaults still apply.
643
+ // Branch C: .planning/ exists but no config.json and no root config — federated/builtin defaults
637
644
  try {
638
- return _applyFederatedOverlay(defaults, {});
645
+ return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
639
646
  }
640
647
  catch {
641
- return defaults;
648
+ return { config: defaults, source: 'builtin-defaults', degraded: false };
642
649
  }
643
650
  }
651
+ // Branch D or E: no .planning/
644
652
  try {
645
653
  const home = process.env['GSD_HOME'] || node_os_1.default.homedir();
646
654
  const globalDefaultsPath = node_path_1.default.join(home, '.gsd', 'defaults.json');
@@ -675,29 +683,35 @@ function loadConfig(cwd, options = {}) {
675
683
  agent_skills: (globalDefaults['agent_skills']) || {},
676
684
  response_language: (globalDefaults['response_language']) || null,
677
685
  };
678
- // FIX 2: Apply federated overlay on global-defaults path.
679
- // With the current registry this is a true no-op (returns _globalBaseCfg unchanged).
686
+ // Branch D: global-defaults
680
687
  try {
681
- return _applyFederatedOverlay(_globalBaseCfg, globalDefaults);
688
+ return { config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false };
682
689
  }
683
690
  catch {
684
- return _globalBaseCfg;
691
+ return { config: _globalBaseCfg, source: 'global-defaults', degraded: false };
685
692
  }
686
693
  }
687
694
  catch {
688
- // FIX 2: Apply federated overlay on the final fallback path.
689
- // With the current registry this is a true no-op (returns `defaults` unchanged).
695
+ // Branch E: no global defaults
690
696
  try {
691
- return _applyFederatedOverlay(defaults, {});
697
+ return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
692
698
  }
693
699
  catch {
694
- return defaults;
700
+ return { config: defaults, source: 'builtin-defaults', degraded: false };
695
701
  }
696
702
  }
697
703
  }
698
704
  }
705
+ /**
706
+ * loadConfig — backwards-compatible config loading, now a thin wrapper over loadConfigResolved.
707
+ * Returns the config object only; for provenance metadata use loadConfigResolved.
708
+ */
709
+ function loadConfig(cwd, options = {}) {
710
+ return loadConfigResolved(cwd, options).config;
711
+ }
699
712
  module.exports = {
700
713
  loadConfig,
714
+ loadConfigResolved,
701
715
  isGitIgnored,
702
716
  CONFIG_DEFAULTS,
703
717
  _getConfigDefault,
@@ -16,15 +16,34 @@
16
16
  * the prior hand-written .cjs; only types are added.
17
17
  */
18
18
  const configuration_cjs_1 = require("./configuration.cjs");
19
+ // Frozen first-party capability config-schema — the fallback when no project cwd
20
+ // is available (cwd-agnostic call sites).
19
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
20
22
  const capabilityRegistry = require('./capability-registry.cjs');
21
- function isCapabilityConfigKey(keyPath) {
23
+ // Resolve the capability config-schema for a project (ADR-1244 D2). When a cwd is
24
+ // supplied, compose installed overlay capabilities for THAT project — LAZILY (never
25
+ // at module load: a bare require of this module never scans the filesystem) —
26
+ // falling back to the frozen first-party schema. Without a cwd, first-party only.
27
+ function _capabilityConfigSchema(cwd) {
28
+ if (typeof cwd === 'string' && cwd) {
29
+ try {
30
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
31
+ const loaderMod = require('./capability-loader.cjs');
32
+ // #1459 IC-04: thread the consent home explicitly so a consented project cap's config key
33
+ // federates at the SAME user-owned home that gated its activation.
34
+ const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] }).configSchema;
35
+ if (schema && typeof schema === 'object')
36
+ return schema;
37
+ }
38
+ catch { /* fall back to first-party */ }
39
+ }
40
+ const fp = capabilityRegistry.configSchema;
41
+ return fp && typeof fp === 'object' ? fp : {};
42
+ }
43
+ function isCapabilityConfigKey(keyPath, cwd) {
22
44
  if (typeof keyPath !== 'string')
23
45
  return false;
24
- const schema = capabilityRegistry.configSchema;
25
- if (!schema || typeof schema !== 'object')
26
- return false;
27
- return Object.prototype.hasOwnProperty.call(schema, keyPath);
46
+ return Object.prototype.hasOwnProperty.call(_capabilityConfigSchema(cwd), keyPath);
28
47
  }
29
48
  /**
30
49
  * Returns true for keys owned by the central schema adapter rather than a
@@ -43,10 +62,10 @@ function isCentralConfigKey(keyPath) {
43
62
  * Returns true if keyPath is a valid central, runtime-state, dynamic, or
44
63
  * federated Capability config key.
45
64
  */
46
- function isValidConfigKey(keyPath) {
65
+ function isValidConfigKey(keyPath, cwd) {
47
66
  if (isCentralConfigKey(keyPath))
48
67
  return true;
49
- return isCapabilityConfigKey(keyPath);
68
+ return isCapabilityConfigKey(keyPath, cwd);
50
69
  }
51
70
  module.exports = {
52
71
  VALID_CONFIG_KEYS: configuration_cjs_1.VALID_CONFIG_KEYS,
@@ -210,6 +210,7 @@ function buildNewProjectConfig(userChoices) {
210
210
  ui_safety_gate: true,
211
211
  ai_integration_phase: true,
212
212
  human_verify_mode: 'end-of-phase',
213
+ context_guard_mode: 'warn',
213
214
  text_mode: false,
214
215
  research_before_questions: false,
215
216
  discuss_mode: 'discuss',
@@ -505,7 +506,7 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
505
506
  const kp = keyPath;
506
507
  const val = value;
507
508
  validateKnownConfigKeyPath(kp);
508
- if (!isValidConfigKey(kp)) {
509
+ if (!isValidConfigKey(kp, cwd)) {
509
510
  error(`Unknown config key: "${kp}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
510
511
  }
511
512
  // Parse value (handle booleans, numbers, and JSON arrays/objects)
@@ -556,6 +557,11 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
556
557
  if (kp === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
557
558
  error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
558
559
  }
560
+ // Context exhaustion guard mode (#1452)
561
+ const VALID_CONTEXT_GUARD_MODES = ['auto', 'warn', 'off'];
562
+ if (kp === 'workflow.context_guard_mode' && !VALID_CONTEXT_GUARD_MODES.includes(String(parsedValue))) {
563
+ error(`Invalid workflow.context_guard_mode '${val}'. Valid values: ${VALID_CONTEXT_GUARD_MODES.join(', ')}`);
564
+ }
559
565
  // Context position enum validation (#2937)
560
566
  const VALID_CONTEXT_POSITIONS = ['front', 'end'];
561
567
  if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {