Implements ADR-1411 P2 / #1415, closing #1366. Part 1 — config-loader.cts: - Adds `loadConfigResolved(cwd, options) → ConfigResolution { config, source, degraded }` with six tagged branch return paths: A1 ws+wsconfig → source:'workstream', degraded:false A2 no-ws+config → source:'root', degraded:false B ws requested, wsconfig absent → source:'root', degraded:true (intercepts recursive call) C .planning/ exists, no config → source:'builtin-defaults', degraded:false D no .planning/, global defaults readable → source:'global-defaults', degraded:false E no .planning/, no global → source:'builtin-defaults', degraded:false - `loadConfigResolved` calls `findProjectRoot` at entry to anchor resolution to the nearest .planning/ ancestor (cwd-drift fix, heuristic 4 from P1) - `loadConfig` becomes a one-line delegation: `return loadConfigResolved(cwd, options).config` - Exports `loadConfigResolved` in the `export =` block Part 2 — init.cts cmdAgentSkills: - Imports `findProjectRoot` from `./project-root.cjs` - Anchors to project root before loading config (fixes #1366 cwd-drift) - Uses `loadConfigResolved` for provenance; passes projectRoot to buildAgentSkillsBlock - Computes `configured` + `reason` (AgentSkillsReason enum): 'resolved' | 'not_configured' | 'configured_empty' | 'configured_unresolved' - configured_empty and configured_unresolved emit stderr WARNING; not_configured is silent - --json IR gains: configured, reason, source, degraded (in addition to existing agent_type, block, skills_count, warnings) Tests (TDD): - tests/config-loader.test.cjs: 8 new provenance tests (RED before impl, GREEN after) - tests/agent-skills.test.cjs: 7 new diagnostic tests (RED before impl, GREEN after) - All 306 tests across 4 suites pass (config-loader:33, agent-skills:72, init:105, workstream:96) Docs: - CONTEXT.md: Config Loader Module entry updated with loadConfigResolved interface; Resolution Provenance entry notes P2 is now implemented - docs/CLI-TOOLS.md: --json field reference table added for agent-skills Closes #1366 Part of #1411 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1423,3 +1423,151 @@ describe('bug #1243: plugin-namespaced agent skills', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Resolution Provenance diagnostics (#1415 / #1366) ────────────────────────
|
||||
//
|
||||
// Verifies that cmdAgentSkills uses findProjectRoot (cwd-drift anchor) and
|
||||
// loadConfigResolved (provenance-aware config loading), and that the --json IR
|
||||
// includes the new fields: configured, reason, source, degraded.
|
||||
|
||||
describe('agent-skills — Resolution Provenance (#1415)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('--json IR includes configured, reason, source, degraded fields', () => {
|
||||
// Minimal smoke: just the field presence
|
||||
const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], tmpDir);
|
||||
assert.ok(r.success, `Command failed: ${r.error}`);
|
||||
assert.ok('configured' in r.ir, 'IR must include "configured" field');
|
||||
assert.ok('reason' in r.ir, 'IR must include "reason" field');
|
||||
assert.ok('source' in r.ir, 'IR must include "source" field');
|
||||
assert.ok('degraded' in r.ir, 'IR must include "degraded" field');
|
||||
});
|
||||
|
||||
test('not_configured: agent not in map → configured:false, reason:not_configured, no stderr warning', () => {
|
||||
writeConfig(tmpDir, {
|
||||
agent_skills: { 'gsd-executor': ['skills/foo'] },
|
||||
});
|
||||
const r = runGsdToolsWithStderr(['agent-skills', '--json', 'gsd-planner'], tmpDir, {
|
||||
HOME: tmpDir,
|
||||
USERPROFILE: tmpDir,
|
||||
});
|
||||
assert.ok(r.success, `Command failed: ${r.stderr}`);
|
||||
const ir = JSON.parse(r.stdout);
|
||||
assert.strictEqual(ir.configured, false);
|
||||
assert.strictEqual(ir.reason, 'not_configured');
|
||||
// No warning on stderr for not_configured
|
||||
assert.ok(
|
||||
!r.stderr.includes('WARNING'),
|
||||
`Should NOT emit WARNING for not_configured agent, got stderr: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('configured_empty: agent_skills[X]=[] → configured:true, reason:configured_empty, stderr WARNING, skills_count:0', () => {
|
||||
writeConfig(tmpDir, {
|
||||
agent_skills: { 'gsd-executor': [] },
|
||||
});
|
||||
const r = runGsdToolsWithStderr(['agent-skills', '--json', 'gsd-executor'], tmpDir, {
|
||||
HOME: tmpDir,
|
||||
USERPROFILE: tmpDir,
|
||||
});
|
||||
assert.ok(r.success, `Command failed: ${r.stderr}`);
|
||||
const ir = JSON.parse(r.stdout);
|
||||
assert.strictEqual(ir.configured, true);
|
||||
assert.strictEqual(ir.reason, 'configured_empty');
|
||||
assert.strictEqual(ir.skills_count, 0);
|
||||
assert.strictEqual(ir.block, '');
|
||||
assert.ok(
|
||||
r.stderr.includes('WARNING') || r.stderr.toLowerCase().includes('warning'),
|
||||
`Should emit WARNING for configured_empty, got stderr: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('configured_unresolved: configured path that does not exist → reason:configured_unresolved, stderr WARNING', () => {
|
||||
writeConfig(tmpDir, {
|
||||
agent_skills: { 'gsd-executor': ['skills/nonexistent-1415'] },
|
||||
});
|
||||
const r = runGsdToolsWithStderr(['agent-skills', '--json', 'gsd-executor'], tmpDir, {
|
||||
HOME: tmpDir,
|
||||
USERPROFILE: tmpDir,
|
||||
});
|
||||
assert.ok(r.success, `Command failed: ${r.stderr}`);
|
||||
const ir = JSON.parse(r.stdout);
|
||||
assert.strictEqual(ir.configured, true);
|
||||
assert.strictEqual(ir.reason, 'configured_unresolved');
|
||||
assert.strictEqual(ir.block, '');
|
||||
assert.ok(
|
||||
r.stderr.includes('WARNING') || r.stderr.toLowerCase().includes('warning'),
|
||||
`Should emit WARNING for configured_unresolved, got stderr: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolved: valid configured path → configured:true, reason:resolved, block non-empty', () => {
|
||||
const skillDir = path.join(tmpDir, 'skills', 'my-skill-1415');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# My Skill\n');
|
||||
writeConfig(tmpDir, {
|
||||
agent_skills: { 'gsd-executor': ['skills/my-skill-1415'] },
|
||||
});
|
||||
const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], tmpDir);
|
||||
assert.ok(r.success, `Command failed: ${r.error}`);
|
||||
assert.strictEqual(r.ir.configured, true);
|
||||
assert.strictEqual(r.ir.reason, 'resolved');
|
||||
assert.ok(r.ir.block.includes('<agent_skills>'), 'block must be non-empty for resolved');
|
||||
});
|
||||
|
||||
test('cwd-drift: invoking from descendant subdir resolves config from project root', () => {
|
||||
const skillDir = path.join(tmpDir, 'skills', 'drift-skill');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Drift Skill\n');
|
||||
writeConfig(tmpDir, {
|
||||
agent_skills: { 'gsd-executor': ['skills/drift-skill'] },
|
||||
});
|
||||
// Invoke from a descendant subdirectory
|
||||
const deepDir = path.join(tmpDir, 'src', 'feature');
|
||||
fs.mkdirSync(deepDir, { recursive: true });
|
||||
const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], deepDir);
|
||||
assert.ok(r.success, `Command failed: ${r.error}`);
|
||||
assert.strictEqual(r.ir.configured, true);
|
||||
assert.strictEqual(r.ir.reason, 'resolved');
|
||||
assert.ok(r.ir.block.includes('<agent_skills>'), `block must be non-empty for drift test, got: ${r.ir.block}`);
|
||||
});
|
||||
|
||||
test('source field matches config provenance (root when config.json present)', () => {
|
||||
writeConfig(tmpDir, {
|
||||
agent_skills: { 'gsd-executor': [] },
|
||||
});
|
||||
const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], tmpDir);
|
||||
assert.ok(r.success, `Command failed: ${r.error}`);
|
||||
assert.strictEqual(r.ir.source, 'root');
|
||||
assert.strictEqual(r.ir.degraded, false);
|
||||
});
|
||||
|
||||
test('Fix 3: agent_skills[X]="" (empty string) → configured_empty, skills_count:0, stderr WARNING', () => {
|
||||
writeConfig(tmpDir, {
|
||||
agent_skills: { 'gsd-executor': '' },
|
||||
});
|
||||
const r = runGsdToolsWithStderr(['agent-skills', '--json', 'gsd-executor'], tmpDir, {
|
||||
HOME: tmpDir,
|
||||
USERPROFILE: tmpDir,
|
||||
});
|
||||
assert.ok(r.success, `Command failed: ${r.stderr}`);
|
||||
const ir = JSON.parse(r.stdout);
|
||||
assert.strictEqual(ir.configured, true, 'should be configured');
|
||||
assert.strictEqual(ir.reason, 'configured_empty',
|
||||
`empty string must yield configured_empty, got: ${ir.reason}`);
|
||||
assert.strictEqual(ir.skills_count, 0, 'skills_count must be 0 for empty string');
|
||||
assert.strictEqual(ir.block, '', 'block must be empty');
|
||||
assert.ok(
|
||||
r.stderr.includes('WARNING') || r.stderr.toLowerCase().includes('warning'),
|
||||
`Should emit WARNING for empty-string configured_empty, got stderr: ${r.stderr}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
|
||||
|
||||
const { loadConfig, _resetRuntimeWarningCacheForTests } = configLoader;
|
||||
const { loadConfig, loadConfigResolved, _resetRuntimeWarningCacheForTests } = configLoader;
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -333,3 +333,162 @@ describe('loadConfig — adversarial fixtures', () => {
|
||||
assert.equal(config.model_profile, 'balanced');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadConfigResolved — provenance ──────────────────────────────────────────
|
||||
|
||||
describe('loadConfigResolved — provenance', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||
|
||||
test('source is "root" when config.json exists and no workstream requested', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'quality' });
|
||||
const result = loadConfigResolved(tmpDir);
|
||||
assert.equal(result.source, 'root');
|
||||
assert.equal(result.degraded, false);
|
||||
assert.ok(typeof result.config === 'object', 'config must be an object');
|
||||
assert.equal(result.config.model_profile, 'quality');
|
||||
});
|
||||
|
||||
test('source is "workstream", degraded:false when workstream config.json present', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'balanced' });
|
||||
writeWorkstreamConfig(tmpDir, 'ws-a', { model_profile: 'quality' });
|
||||
const result = loadConfigResolved(tmpDir, { workstream: 'ws-a' });
|
||||
assert.equal(result.source, 'workstream');
|
||||
assert.equal(result.degraded, false);
|
||||
assert.equal(result.config.model_profile, 'quality');
|
||||
});
|
||||
|
||||
test('source is "root", degraded:true when workstream requested but ws config.json absent', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'budget' });
|
||||
// Create ws directory without config.json
|
||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'ws-no-config');
|
||||
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
||||
const result = loadConfigResolved(tmpDir, { workstream: 'ws-no-config' });
|
||||
assert.equal(result.source, 'root');
|
||||
assert.equal(result.degraded, true);
|
||||
assert.equal(result.config.model_profile, 'budget');
|
||||
});
|
||||
|
||||
test('source is "builtin-defaults" when .planning exists but config.json is absent', () => {
|
||||
// tmpDir already has .planning/ but no config.json
|
||||
const result = loadConfigResolved(tmpDir);
|
||||
assert.equal(result.source, 'builtin-defaults');
|
||||
assert.equal(result.degraded, false);
|
||||
assert.ok('model_profile' in result.config);
|
||||
});
|
||||
|
||||
test('source is "global-defaults" when no .planning exists but ~/.gsd/defaults.json readable', () => {
|
||||
const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-test-'));
|
||||
const origGsdHome = process.env['GSD_HOME'];
|
||||
try {
|
||||
const gsdDir = path.join(homeTmp, '.gsd');
|
||||
fs.mkdirSync(gsdDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({ model_profile: 'home-defaults' }), 'utf-8');
|
||||
process.env['GSD_HOME'] = homeTmp;
|
||||
const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-noplanning-'));
|
||||
try {
|
||||
const result = loadConfigResolved(noPlanning);
|
||||
assert.equal(result.source, 'global-defaults');
|
||||
assert.equal(result.degraded, false);
|
||||
assert.equal(result.config.model_profile, 'home-defaults');
|
||||
} finally {
|
||||
cleanup(noPlanning);
|
||||
}
|
||||
} finally {
|
||||
if (origGsdHome === undefined) delete process.env['GSD_HOME'];
|
||||
else process.env['GSD_HOME'] = origGsdHome;
|
||||
cleanup(homeTmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('source is "builtin-defaults" when no .planning and no global defaults', () => {
|
||||
const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-noplanning2-'));
|
||||
const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-nohome-'));
|
||||
const origGsdHome = process.env['GSD_HOME'];
|
||||
try {
|
||||
// Point GSD_HOME to a directory with no .gsd/defaults.json
|
||||
process.env['GSD_HOME'] = homeTmp;
|
||||
const result = loadConfigResolved(noPlanning);
|
||||
assert.equal(result.source, 'builtin-defaults');
|
||||
assert.equal(result.degraded, false);
|
||||
assert.ok('model_profile' in result.config);
|
||||
} finally {
|
||||
if (origGsdHome === undefined) delete process.env['GSD_HOME'];
|
||||
else process.env['GSD_HOME'] = origGsdHome;
|
||||
cleanup(noPlanning);
|
||||
cleanup(homeTmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('back-compat: loadConfig(tmp) deepEquals loadConfigResolved(tmp).config', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'quality', research: 'minimal' });
|
||||
const fromLoadConfig = loadConfig(tmpDir);
|
||||
const { config: fromResolved } = loadConfigResolved(tmpDir);
|
||||
assert.deepEqual(fromLoadConfig, fromResolved);
|
||||
});
|
||||
|
||||
test('back-compat: loadConfigResolved(descendant) does NOT walk up — returns defaults, not ancestor config', () => {
|
||||
// Fix 1: loadConfigResolved must NOT call findProjectRoot internally.
|
||||
// Calling from a descendant that has no .planning/ of its own must return
|
||||
// defaults (builtin-defaults source), NOT the ancestor's config value.
|
||||
writeConfig(tmpDir, { model_profile: 'ancestor-config-should-not-appear' });
|
||||
const deepDir = path.join(tmpDir, 'src', 'deep');
|
||||
fs.mkdirSync(deepDir, { recursive: true });
|
||||
const result = loadConfigResolved(deepDir);
|
||||
// No .planning/ in deepDir → must fall back to defaults, NOT walk up to tmpDir.
|
||||
assert.notEqual(result.config.model_profile, 'ancestor-config-should-not-appear',
|
||||
'loadConfigResolved must NOT walk up to find ancestor config');
|
||||
// The source must be a defaults source (builtin-defaults or global-defaults),
|
||||
// NOT "root" (which would imply a config.json was found).
|
||||
assert.ok(
|
||||
result.source === 'builtin-defaults' || result.source === 'global-defaults',
|
||||
`Expected a defaults source, got: ${result.source}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('Fix 4: loadConfigResolved(tmp, { workstream: "" }) → source:"root"', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'quality' });
|
||||
// empty-string ws resolves the root path → source must be "root"
|
||||
const result = loadConfigResolved(tmpDir, { workstream: '' });
|
||||
assert.equal(result.source, 'root', 'empty-string workstream should yield source:"root"');
|
||||
assert.equal(result.degraded, false);
|
||||
});
|
||||
|
||||
test('Fix 2a: GSD_WORKSTREAM set to nonexistent workstream (dir absent) → source:"root", degraded:true', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'root-value' });
|
||||
const origWs = process.env['GSD_WORKSTREAM'];
|
||||
try {
|
||||
process.env['GSD_WORKSTREAM'] = 'nonexistent-ws';
|
||||
// Do NOT create the workstream directory
|
||||
const result = loadConfigResolved(tmpDir);
|
||||
assert.equal(result.source, 'root', 'nonexistent workstream should fall back to source:"root"');
|
||||
assert.equal(result.degraded, true, 'should be degraded when workstream dir is absent');
|
||||
assert.equal(result.config.model_profile, 'root-value', 'config should equal root config');
|
||||
} finally {
|
||||
if (origWs === undefined) delete process.env['GSD_WORKSTREAM'];
|
||||
else process.env['GSD_WORKSTREAM'] = origWs;
|
||||
}
|
||||
});
|
||||
|
||||
test('Fix 2b: options.workstream missing dir → source:"root", degraded:true', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'root-val' });
|
||||
// workstream dir NOT created
|
||||
const result = loadConfigResolved(tmpDir, { workstream: 'missing-ws' });
|
||||
assert.equal(result.source, 'root');
|
||||
assert.equal(result.degraded, true);
|
||||
assert.equal(result.config.model_profile, 'root-val');
|
||||
});
|
||||
|
||||
test('Fix 2c: workstream dir exists but no config.json → source:"root", degraded:true (existing case still works)', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'root-val-c' });
|
||||
// Create ws dir but no config.json
|
||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'ws-no-cfg');
|
||||
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
||||
const result = loadConfigResolved(tmpDir, { workstream: 'ws-no-cfg' });
|
||||
assert.equal(result.source, 'root');
|
||||
assert.equal(result.degraded, true);
|
||||
assert.equal(result.config.model_profile, 'root-val-c');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user