@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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/bin/install.js +108 -34
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +677 -2
- package/gsd-core/bin/lib/adr-parser.cjs +24 -17
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/config-loader.cjs +92 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +29 -0
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/verify.cjs +29 -13
- package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +3 -0
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +4 -6
- package/package.json +3 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/run-tests.cjs +14 -0
- 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
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
217
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
622
|
-
// This keeps loadConfig's no-throw contract intact regardless of capability registry state.
|
|
614
|
+
// Defensive: keep no-throw contract
|
|
623
615
|
}
|
|
624
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
631
|
-
// (
|
|
632
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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)
|