@opengsd/gsd-core 1.5.0 → 1.6.0-rc.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 (63) 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/bin/install.js +108 -34
  5. package/gemini-extension.json +1 -1
  6. package/gsd-core/bin/gsd-tools.cjs +677 -2
  7. package/gsd-core/bin/lib/adr-parser.cjs +24 -17
  8. package/gsd-core/bin/lib/audit.cjs +2 -2
  9. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  10. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  12. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  14. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  15. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  16. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  17. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  18. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  20. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  21. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  22. package/gsd-core/bin/lib/config-loader.cjs +92 -84
  23. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  24. package/gsd-core/bin/lib/config.cjs +1 -1
  25. package/gsd-core/bin/lib/decisions.cjs +149 -60
  26. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  27. package/gsd-core/bin/lib/init.cjs +91 -22
  28. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  29. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  31. package/gsd-core/bin/lib/milestone.cjs +41 -2
  32. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  33. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  34. package/gsd-core/bin/lib/phase.cjs +29 -0
  35. package/gsd-core/bin/lib/project-root.cjs +89 -2
  36. package/gsd-core/bin/lib/resolution.cjs +26 -0
  37. package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
  38. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  39. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  40. package/gsd-core/bin/lib/state-document.cjs +4 -2
  41. package/gsd-core/bin/lib/state.cjs +317 -161
  42. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  43. package/gsd-core/bin/lib/uat.cjs +39 -26
  44. package/gsd-core/bin/lib/verify.cjs +29 -13
  45. package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
  46. package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
  47. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  48. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  49. package/gsd-core/references/planner-antipatterns.md +48 -0
  50. package/gsd-core/references/planning-config.md +3 -0
  51. package/gsd-core/references/scout-codebase.md +2 -2
  52. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  53. package/gsd-core/workflows/discuss-phase.md +1 -2
  54. package/gsd-core/workflows/execute-phase.md +4 -6
  55. package/package.json +3 -3
  56. package/scripts/gen-capability-matrix.cjs +284 -0
  57. package/scripts/gen-capability-registry.cjs +96 -1853
  58. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  59. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  60. package/scripts/lint-resolution-provenance.cjs +192 -0
  61. package/scripts/lint-test-file-count.allowlist.json +9 -0
  62. package/scripts/run-tests.cjs +14 -0
  63. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -24,7 +24,13 @@
24
24
  */
25
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
26
26
  const ioMod = require("./io.cjs");
27
- const { output: coreOutput, error: coreError } = ioMod;
27
+ const { output: coreOutput } = ioMod;
28
+ // ExitError (NOT process.exit) is how every gsd-tools command signals a non-zero exit: runMain
29
+ // translates it to process.exitCode so buffered stdout flushes first. Calling process.exit() here
30
+ // truncates a just-written --raw JSON payload before the reader sees it (a real silent-output bug).
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ const cliExitMod = require("./cli-exit.cjs");
33
+ const { ExitError } = cliExitMod;
28
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
35
  const capabilityStateMod = require("./capability-state.cjs");
30
36
  const { resolveCapabilityRuntimeState, _resolveManifest, _resolveCommandsGsdDir } = capabilityStateMod;
@@ -322,7 +328,8 @@ function cmdCapabilitySet(cwd, runtimeConfigDir, capId, options, raw) {
322
328
  // Do NOT print human stderr lines — raw consumers parse the JSON.
323
329
  coreOutput({ capabilities: result.capabilities, warnings: result.warnings, errors: result.errors }, true);
324
330
  if (result.errors.length > 0) {
325
- process.exit(1);
331
+ // Throw (don't process.exit) so the JSON written just above flushes before the process ends.
332
+ throw new ExitError(1);
326
333
  }
327
334
  return;
328
335
  }
@@ -333,10 +340,12 @@ function cmdCapabilitySet(cwd, runtimeConfigDir, capId, options, raw) {
333
340
  for (const e of result.errors) {
334
341
  process.stderr.write(`capability set: error: ${e}\n`);
335
342
  }
336
- // Exit non-zero if any errors (hard failures — requested action was not realized).
343
+ // Exit non-zero if any errors (hard failures — requested action was not realized). The per-error
344
+ // lines were already written to stderr above; signal the exit code via ExitError (not process.exit)
345
+ // so any pending stdout/stderr flushes — runMain maps it to process.exitCode.
337
346
  if (result.errors.length > 0) {
338
- coreError(`capability set: ${String(result.errors.length)} error(s) — see above`);
339
- return; // unreachable — coreError calls process.exit(1)
347
+ process.stderr.write(`Error: capability set: ${String(result.errors.length)} error(s) — see above\n`);
348
+ throw new ExitError(1);
340
349
  }
341
350
  // Human-readable summary: focus on the target capability
342
351
  const cap = result.capabilities.find((c) => c.id === capId);
@@ -22,6 +22,7 @@ const { planningDir } = planningWorkspaceMod;
22
22
  const phaseLocatorMod = require("./phase-locator.cjs");
23
23
  const { findPhaseInternal } = phaseLocatorMod;
24
24
  const decisions_cjs_1 = require("./decisions.cjs");
25
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
25
26
  const ui_safety_gate_cjs_1 = require("./ui-safety-gate.cjs");
26
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
28
  const verifyModule = require("./verify.cjs");
@@ -128,10 +129,11 @@ function loadPlanContents(phaseDir) {
128
129
  const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
129
130
  const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi;
130
131
  function stripCommentsAndFences(text) {
131
- return text
132
- .replace(/<!--[\s\S]*?-->/g, ' ')
133
- .replace(/```[\s\S]*?```/g, ' ')
134
- .replace(/~~~[\s\S]*?~~~/g, ' ');
132
+ // HTML-comment stripping stays caller-side (the seam does not strip HTML comments).
133
+ const htmlStripped = text.replace(/<!--[\s\S]*?-->/g, ' ');
134
+ // Fenced-code stripping: delegate to the canonical CommonMark-correct seam.
135
+ // replaces the prior independent regex copy (```` ``` ``` ```` + `~~~ ~~~`).
136
+ return (0, markdown_sectionizer_cjs_1.stripFencedCode)(htmlStripped).text;
135
137
  }
136
138
  function extractYamlBlock(frontmatter, key) {
137
139
  const match = frontmatter.match(new RegExp(`^${key}\\s*:(.*)$`, 'm'));
@@ -169,18 +171,19 @@ function extractPlanDesignatedSections(planContent) {
169
171
  if (block)
170
172
  parts.push(block);
171
173
  }
174
+ // Replace hand-rolled split(/\r?\n/) + heading walk with the seam's collectSections.
175
+ // stopPredicate fires on EVERY heading (collectSections needs to start a section at
176
+ // each heading), then we filter to designated ones — same semantics as the prior
177
+ // inDesignated flag: emit the heading line + body only when DESIGNATED_HEADINGS_RE matches.
178
+ const sections = (0, markdown_sectionizer_cjs_1.collectSections)(body, () => true);
172
179
  const bodyParts = [];
173
- let inDesignated = false;
174
- for (const line of body.split(/\r?\n/)) {
175
- const heading = /^#{1,6}\s+/.test(line);
176
- if (heading) {
177
- inDesignated = DESIGNATED_HEADINGS_RE.test(line);
178
- if (inDesignated)
179
- bodyParts.push(line);
180
- continue;
180
+ for (const section of sections) {
181
+ const headingLine = '#'.repeat(section.heading.level) + ' ' + section.heading.text;
182
+ if (DESIGNATED_HEADINGS_RE.test(headingLine)) {
183
+ bodyParts.push(headingLine);
184
+ if (section.body)
185
+ bodyParts.push(section.body);
181
186
  }
182
- if (inDesignated)
183
- bodyParts.push(line);
184
187
  }
185
188
  parts.push(bodyParts.join('\n'));
186
189
  parts.push(extractXmlTagBodies(cleaned));
@@ -213,8 +216,12 @@ function buildVerifyMessage(notHonored) {
213
216
  'This is a soft warning - verification status is unchanged.',
214
217
  ].join('\n');
215
218
  }
216
- function loadTrackableDecisions(contextPath) {
217
- return (0, decisions_cjs_1.parseDecisions)(readIfExists(contextPath)).filter((decision) => decision.trackable);
219
+ function loadDecisionExtraction(contextPath) {
220
+ const extraction = (0, decisions_cjs_1.extractDecisions)(readIfExists(contextPath));
221
+ return {
222
+ trackable: extraction.decisions.filter((d) => d.trackable),
223
+ outcome: extraction.outcome,
224
+ };
218
225
  }
219
226
  function cmdDecisionCoveragePlan(projectDir, args, raw) {
220
227
  const phaseDir = args[2] ? resolvePath(args[2], projectDir) : '';
@@ -227,7 +234,31 @@ function cmdDecisionCoveragePlan(projectDir, args, raw) {
227
234
  output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
228
235
  return;
229
236
  }
230
- const decisions = loadTrackableDecisions(contextPath);
237
+ const { trackable: decisions, outcome } = loadDecisionExtraction(contextPath);
238
+ // #1365 fail-loud gate: any could-not-parse outcome must NOT silently pass —
239
+ // even when some decisions were extracted (e.g. D-01 valid but D-02 malformed).
240
+ // A parse-miss on ANY bullet means the gate cannot certify full coverage.
241
+ // Fire independent of decisions.length so a partial-parse still blocks.
242
+ if (outcome === 'could-not-parse') {
243
+ const partialParse = decisions.length > 0;
244
+ output({
245
+ passed: false,
246
+ skipped: false,
247
+ reason: 'could-not-parse',
248
+ total: decisions.length,
249
+ covered: 0,
250
+ uncovered: [],
251
+ message: partialParse
252
+ ? 'Decision coverage gate: decisions could not be fully parsed — one or more ' +
253
+ '`- **D-NN ...**` bullets appear malformed (missing `:` or ` — ` separator). ' +
254
+ 'Fix the bullet format so all D-NN decisions can be read before re-running the gate.'
255
+ : 'Decision coverage gate: could not parse decisions — possible format mismatch. ' +
256
+ 'The CONTEXT.md appears to be decision-shaped (has a <decisions> block, a decisions heading, ' +
257
+ 'or D- tokens) but no D-NN bullets could be extracted. Check the formatting of the decisions ' +
258
+ 'block and ensure bullets follow the `- **D-NN:** text` or `- **D-NN — title** body` form.',
259
+ }, raw, undefined);
260
+ return;
261
+ }
231
262
  if (decisions.length === 0) {
232
263
  output({ passed: true, skipped: true, reason: 'no trackable decisions', total: 0, covered: 0, uncovered: [], message: 'No trackable decisions in CONTEXT.md.' }, raw, undefined);
233
264
  return;
@@ -305,7 +336,27 @@ function cmdDecisionCoverageVerify(projectDir, args, raw) {
305
336
  output({ skipped: true, blocking: false, reason: 'CONTEXT.md missing', total: 0, honored: 0, not_honored: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
306
337
  return;
307
338
  }
308
- const decisions = loadTrackableDecisions(contextPath);
339
+ const { trackable: decisions, outcome: decisionOutcome } = loadDecisionExtraction(contextPath);
340
+ // Mirror could-not-parse surface for verify (non-blocking advisory WARN).
341
+ // Fire independent of decisions.length — a parse-miss on any bullet must surface,
342
+ // even when some decisions were partially extracted (#1365 fix-parity with plan gate).
343
+ if (decisionOutcome === 'could-not-parse') {
344
+ const partialParse = decisions.length > 0;
345
+ output({
346
+ skipped: false,
347
+ blocking: false,
348
+ reason: 'could-not-parse',
349
+ total: decisions.length,
350
+ honored: 0,
351
+ not_honored: [],
352
+ message: partialParse
353
+ ? 'Decision coverage verify (warning): decisions could not be fully parsed — one or more ' +
354
+ '`- **D-NN ...**` bullets appear malformed. Fix the bullet format in the CONTEXT.md decisions block.'
355
+ : 'Decision coverage verify (warning): could not parse decisions — possible format mismatch. ' +
356
+ 'Check the formatting of the CONTEXT.md decisions block.',
357
+ }, raw, undefined);
358
+ return;
359
+ }
309
360
  if (decisions.length === 0) {
310
361
  output({ skipped: true, blocking: false, reason: 'no trackable decisions', total: 0, honored: 0, not_honored: [], message: 'No trackable decisions in CONTEXT.md.' }, raw, undefined);
311
362
  return;
@@ -444,6 +444,14 @@ exports.PHASE_COMMAND_ALIASES = [
444
444
  ],
445
445
  "subcommand": "scaffold",
446
446
  "mutation": true
447
+ },
448
+ {
449
+ "canonical": "phase.list-plans",
450
+ "aliases": [
451
+ "phase list-plans"
452
+ ],
453
+ "subcommand": "list-plans",
454
+ "mutation": false
447
455
  }
448
456
  ];
449
457
  exports.PHASES_COMMAND_ALIASES = [
@@ -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.
@@ -324,8 +328,32 @@ function _applyFederatedValues(obj, values, validKeys) {
324
328
  * When validKeys is non-empty, applies values into a shallow clone to avoid
325
329
  * mutating shared CONFIG_DEFAULTS/module constants.
326
330
  */
327
- function _applyFederatedOverlay(baseConfig, userConfig) {
328
- const _fedRegistrySchema = _capabilityRegistry.configSchema;
331
+ // Resolve the federated capability config-schema for a project (ADR-1244 D2).
332
+ // A test override (via _setFederatedRegistryForTests) wins; otherwise, when a
333
+ // project cwd is available, compose the installed overlay for THAT project —
334
+ // LAZILY (never at module load, so a bare require never scans the filesystem and
335
+ // the result is never cached for the wrong cwd) — falling back to the frozen
336
+ // first-party schema when there is no cwd or the loader is unavailable.
337
+ function _federatedConfigSchema(cwd) {
338
+ if (_capabilityRegistry !== _capabilityRegistryReal) {
339
+ return _capabilityRegistry.configSchema; // explicit test override
340
+ }
341
+ if (typeof cwd === 'string' && cwd) {
342
+ try {
343
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
344
+ const loaderMod = require('./capability-loader.cjs');
345
+ // #1459 IC-04: thread the consent home explicitly so a consented project cap's federated config
346
+ // key resolves at the SAME user-owned home that gated its activation (never the wrong home).
347
+ const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] }).configSchema;
348
+ if (schema && typeof schema === 'object')
349
+ return schema;
350
+ }
351
+ catch { /* fall back to first-party */ }
352
+ }
353
+ return _capabilityRegistryReal.configSchema;
354
+ }
355
+ function _applyFederatedOverlay(baseConfig, userConfig, cwd) {
356
+ const _fedRegistrySchema = _federatedConfigSchema(cwd);
329
357
  if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object')
330
358
  return baseConfig;
331
359
  const _fedOverlay = mergeFederatedConfig({
@@ -341,26 +369,39 @@ function _applyFederatedOverlay(baseConfig, userConfig) {
341
369
  _applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
342
370
  return cloned;
343
371
  }
344
- function loadConfig(cwd, options = {}) {
372
+ /**
373
+ * loadConfigResolved — provenance-aware config loading (#1415, ADR-1411 P2).
374
+ *
375
+ * Identical to loadConfig in every observable way except it returns
376
+ * { config, source, degraded } instead of just the config object.
377
+ * loadConfig now delegates to this function (byte-identical back-compat).
378
+ *
379
+ * Branch → source/degraded mapping:
380
+ * A1: ws set + ws config.json found → source:'workstream', degraded:false
381
+ * A2: ws null + config.json found → source:'root', degraded:false
382
+ * B: catch + .planning/ + rootParsed set (ws fallback) → source:'root', degraded:true
383
+ * C: catch + .planning/ + rootParsed null (federated defaults) → source:'builtin-defaults', degraded:false
384
+ * D: catch + no .planning/ + ~/.gsd/defaults.json readable → source:'global-defaults', degraded:false
385
+ * E: catch + no .planning/ + no global → source:'builtin-defaults', degraded:false
386
+ */
387
+ function loadConfigResolved(cwd, options = {}) {
388
+ // NOTE: loadConfigResolved resolves from cwd AS-IS (no walk-up).
389
+ // Callers that need ancestor-anchoring (e.g. cmdAgentSkills) must do so
390
+ // themselves via findProjectRoot() before calling this function.
391
+ // This preserves back-compat for the ~30 other loadConfig callers (#1415).
345
392
  const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
346
393
  ? options['workstream']
347
394
  : (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
348
395
  ? options['workstreamContext']['ws']
349
396
  : (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
397
  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.
398
+ // wsRequested: true when caller explicitly requested a non-empty workstream.
399
+ // Used for source labeling (Fix 4) and early absent-dir intercept (Fix 2).
400
+ const wsRequested = ws != null && ws !== '';
358
401
  let cachedSubRepos;
359
402
  const getDetectedSubRepos = () => {
360
403
  if (cachedSubRepos === undefined)
361
404
  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
405
  return cachedSubRepos.slice();
365
406
  };
366
407
  let rootParsed = null;
@@ -371,10 +412,8 @@ function loadConfig(cwd, options = {}) {
371
412
  if (raw === null)
372
413
  throw new Error('missing');
373
414
  rootParsed = JSON.parse(raw);
374
- // Cycle 4: delegate all legacy-key normalization to the Configuration Module.
375
415
  const { parsed: rootNormalized, normalizations: rootNorms } = (0, configuration_cjs_1.normalizeLegacyKeys)(rootParsed);
376
416
  if (rootNorms.length > 0) {
377
- // Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos)
378
417
  for (const norm of rootNorms) {
379
418
  if (norm.requiresFilesystem && !rootNormalized.planning?.['sub_repos']) {
380
419
  const detected = getDetectedSubRepos();
@@ -406,22 +445,14 @@ function loadConfig(cwd, options = {}) {
406
445
  const raw = (0, shell_command_projection_cjs_1.platformReadSync)(configPath);
407
446
  if (raw === null)
408
447
  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
448
  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
449
  let configDirty = false;
417
450
  {
418
451
  const { parsed: normalized, normalizations } = (0, configuration_cjs_1.normalizeLegacyKeys)(fileData);
419
452
  if (normalizations.length > 0) {
420
- // Merge normalized values back into fileData (mutation-in-place for legacy code below)
421
453
  Object.keys(fileData).forEach(k => delete fileData[k]);
422
454
  Object.assign(fileData, normalized);
423
455
  configDirty = true;
424
- // Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos).
425
456
  for (const norm of normalizations) {
426
457
  if (norm.requiresFilesystem && !fileData.planning?.['sub_repos']) {
427
458
  const detected = getDetectedSubRepos();
@@ -435,7 +466,6 @@ function loadConfig(cwd, options = {}) {
435
466
  }
436
467
  }
437
468
  }
438
- // Keep planning.sub_repos in sync with actual filesystem
439
469
  const currentSubRepos = fileData.planning?.['sub_repos'] || [];
440
470
  if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
441
471
  const detected = getDetectedSubRepos();
@@ -449,36 +479,24 @@ function loadConfig(cwd, options = {}) {
449
479
  }
450
480
  }
451
481
  }
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
482
  if (configDirty) {
455
483
  try {
456
484
  (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(fileData, null, 2));
457
485
  }
458
486
  catch { /* ignore */ }
459
487
  }
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
488
  const parsed = rootParsed
463
489
  ? (_deepMergeConfig(rootParsed, fileData) ?? fileData)
464
490
  : fileData;
465
- // Warn about unrecognized top-level keys so users don't silently lose config.
466
491
  const KNOWN_TOP_LEVEL = new Set([
467
- // Extract top-level key names from dot-notation paths (e.g., 'workflow.research' → 'workflow')
468
492
  ...[...VALID_CONFIG_KEYS].map((k) => k.split('.')[0]),
469
- // Dynamic-pattern top-level containers (e.g. review, model_profile_overrides)
470
493
  ...DYNAMIC_KEY_PATTERNS.map(p => p.topLevel),
471
- // Internal keys loadConfig reads but config-set doesn't expose
472
494
  '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
495
  'depth', 'multiRepo', 'branching_strategy', 'research',
475
496
  ]);
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
497
  let _preWarningFedValidKeys = [];
480
498
  try {
481
- const _fedRegistrySchemaEarly = _capabilityRegistry.configSchema;
499
+ const _fedRegistrySchemaEarly = _federatedConfigSchema(cwd);
482
500
  if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
483
501
  const _earlyOverlay = mergeFederatedConfig({
484
502
  configSchema: _fedRegistrySchemaEarly,
@@ -495,7 +513,7 @@ function loadConfig(cwd, options = {}) {
495
513
  }
496
514
  }
497
515
  catch {
498
- // Defensive: if registry access fails here, proceed without pre-warning keys
516
+ // Defensive
499
517
  }
500
518
  const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
501
519
  if (unknownKeys.length > 0) {
@@ -505,7 +523,6 @@ function loadConfig(cwd, options = {}) {
505
523
  process.stderr.write(`gsd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`);
506
524
  }
507
525
  }
508
- // #2517 — Validate runtime/tier values
509
526
  _warnUnknownProfileOverrides(parsed, '.planning/config.json');
510
527
  const get = (key, nested) => {
511
528
  if (parsed[key] !== undefined)
@@ -530,11 +547,8 @@ function loadConfig(cwd, options = {}) {
530
547
  model_profile: get('model_profile') ?? defaults.model_profile,
531
548
  commit_docs: (() => {
532
549
  const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
533
- // If explicitly set in config, respect the user's choice
534
550
  if (explicit !== undefined)
535
551
  return explicit;
536
- // Auto-detection: when no explicit value and .planning/ is gitignored,
537
- // default to false instead of true
538
552
  if (isGitIgnored(cwd, '.planning/'))
539
553
  return false;
540
554
  return defaults.commit_docs;
@@ -565,22 +579,14 @@ function loadConfig(cwd, options = {}) {
565
579
  project_code: get('project_code') ?? defaults.project_code,
566
580
  subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
567
581
  model_overrides: (parsed['model_overrides']) || null,
568
- // #3023 — per-phase-type model map.
569
582
  models: (parsed['models']) || null,
570
- // #68 — top-level granularity
571
583
  granularity: parsed['granularity'] !== undefined ? parsed['granularity'] : null,
572
- // #68 — per-phase-type granularity map.
573
584
  granularities: (parsed['granularities']) || null,
574
- // #68 — planning sub-object
575
585
  planning: (parsed['planning']) || null,
576
- // #3024 — dynamic routing block.
577
586
  dynamic_routing: (parsed['dynamic_routing']) || null,
578
- // #2517 — runtime-aware profiles.
579
587
  runtime: (parsed['runtime']) || null,
580
588
  model_profile_overrides: (parsed['model_profile_overrides']) || null,
581
- // #49 — provider-neutral model policy presets.
582
589
  model_policy: (parsed['model_policy']) || null,
583
- // #443 — effort/fast_mode
584
590
  effort: (parsed['effort']) || null,
585
591
  fast_mode: (parsed['fast_mode']) || null,
586
592
  agent_skills: (parsed['agent_skills']) || {},
@@ -590,57 +596,53 @@ function loadConfig(cwd, options = {}) {
590
596
  claude_md_path: get('claude_md_path') || null,
591
597
  claude_md_assembly: (parsed['claude_md_assembly']) || null,
592
598
  };
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.
599
+ // ADR-857 phase 3b: federated config overlay
600
600
  try {
601
601
  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;
602
+ const _fedRegistrySchema = _federatedConfigSchema(cwd);
606
603
  if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
607
604
  const _fedOverlay = mergeFederatedConfig({
608
605
  configSchema: _fedRegistrySchema,
609
606
  isCentralKey: (key) => _isCentralConfigKeyFn(key),
610
607
  userConfig: parsed,
611
608
  });
612
- // Apply dotted-path values (e.g. "workflow.ui_phase" → _baseConfig.workflow.ui_phase)
613
- // WITHOUT clobbering existing keys. N-level nesting supported.
614
609
  _applyFederatedValues(_baseConfig, _fedOverlay.values, _fedOverlay.validKeys);
615
610
  }
616
611
  }
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
612
  }
620
613
  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.
614
+ // Defensive: keep no-throw contract
623
615
  }
624
- return _baseConfig;
616
+ // A1 vs A2: disambiguate by whether a real workstream was requested.
617
+ // Fix 4: empty-string ws ('') resolves the root path → source:'root'.
618
+ const source = wsRequested ? 'workstream' : 'root';
619
+ return { config: _baseConfig, source, degraded: false };
625
620
  }
626
621
  catch {
627
- // Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
622
+ // Fix 2: Early intercept — workstream requested but ws config.json absent (or dir absent)
623
+ // AND root config was loaded. Covers BOTH "dir exists, no config.json" AND "dir absent".
624
+ // This delivers the #1366 acceptance criterion: nonexistent GSD_WORKSTREAM yields root, degraded.
625
+ if (wsRequested && rootParsed) {
626
+ const fb = loadConfigResolved(cwd, { workstream: null });
627
+ return { config: fb.config, source: 'root', degraded: true };
628
+ }
629
+ // Branch B, C, D, E
628
630
  if (node_fs_1.default.existsSync(planningDir(cwd, ws))) {
629
631
  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 });
632
+ // Branch B: workstream requested but ws config.json absent; root config present.
633
+ // (Only reached when wsRequested is false — e.g. ws='' with .planning/workstreams//config.json)
634
+ const fb = loadConfigResolved(cwd, { workstream: null });
635
+ return { config: fb.config, source: 'root', degraded: true };
633
636
  }
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.
637
+ // Branch C: .planning/ exists but no config.json and no root config — federated/builtin defaults
637
638
  try {
638
- return _applyFederatedOverlay(defaults, {});
639
+ return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
639
640
  }
640
641
  catch {
641
- return defaults;
642
+ return { config: defaults, source: 'builtin-defaults', degraded: false };
642
643
  }
643
644
  }
645
+ // Branch D or E: no .planning/
644
646
  try {
645
647
  const home = process.env['GSD_HOME'] || node_os_1.default.homedir();
646
648
  const globalDefaultsPath = node_path_1.default.join(home, '.gsd', 'defaults.json');
@@ -675,29 +677,35 @@ function loadConfig(cwd, options = {}) {
675
677
  agent_skills: (globalDefaults['agent_skills']) || {},
676
678
  response_language: (globalDefaults['response_language']) || null,
677
679
  };
678
- // FIX 2: Apply federated overlay on global-defaults path.
679
- // With the current registry this is a true no-op (returns _globalBaseCfg unchanged).
680
+ // Branch D: global-defaults
680
681
  try {
681
- return _applyFederatedOverlay(_globalBaseCfg, globalDefaults);
682
+ return { config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false };
682
683
  }
683
684
  catch {
684
- return _globalBaseCfg;
685
+ return { config: _globalBaseCfg, source: 'global-defaults', degraded: false };
685
686
  }
686
687
  }
687
688
  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).
689
+ // Branch E: no global defaults
690
690
  try {
691
- return _applyFederatedOverlay(defaults, {});
691
+ return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
692
692
  }
693
693
  catch {
694
- return defaults;
694
+ return { config: defaults, source: 'builtin-defaults', degraded: false };
695
695
  }
696
696
  }
697
697
  }
698
698
  }
699
+ /**
700
+ * loadConfig — backwards-compatible config loading, now a thin wrapper over loadConfigResolved.
701
+ * Returns the config object only; for provenance metadata use loadConfigResolved.
702
+ */
703
+ function loadConfig(cwd, options = {}) {
704
+ return loadConfigResolved(cwd, options).config;
705
+ }
699
706
  module.exports = {
700
707
  loadConfig,
708
+ loadConfigResolved,
701
709
  isGitIgnored,
702
710
  CONFIG_DEFAULTS,
703
711
  _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,
@@ -505,7 +505,7 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
505
505
  const kp = keyPath;
506
506
  const val = value;
507
507
  validateKnownConfigKeyPath(kp);
508
- if (!isValidConfigKey(kp)) {
508
+ if (!isValidConfigKey(kp, cwd)) {
509
509
  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
510
  }
511
511
  // Parse value (handle booleans, numbers, and JSON arrays/objects)