* feat(#703): add --granularity override flag to /gsd:plan-phase Add a `--granularity <coarse|standard|fine>` flag to /gsd:plan-phase that overrides the configured planning granularity for a single invocation. The override is a new highest-priority tier above the existing precedence chain (granularities[phaseType] -> granularity -> planning.granularity -> 'standard') in resolveGranularityInternal; when the flag is absent, resolution is byte-for-byte unchanged. cmdInitPlanPhase now resolves with phaseType 'planning' so granularities.planning participates, and emits the resolved value in the init JSON, which the plan-phase workflow forwards to the planner prompt. Invalid values are rejected at the CLI boundary via a shared assertValidGranularityOverride helper. Closes #703 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#703): set changeset pr to 750 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -318,3 +318,193 @@ describe('#68 resolve-granularity command: CLI behavior', () => {
|
||||
assert.strictEqual(output.unknown_phase_type, undefined, 'known phase type must not have unknown_phase_type');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #703 CLI override: --granularity flag ────────────────────────────────────
|
||||
|
||||
describe('#703 resolveGranularityInternal: CLI override param (3rd arg)', () => {
|
||||
let projectDir;
|
||||
beforeEach(() => { projectDir = makeTmp('cli-override'); });
|
||||
afterEach(() => { cleanup(projectDir); });
|
||||
|
||||
test('override fine beats per-phase config granularities.planning=coarse', () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'standard',
|
||||
granularities: { planning: 'coarse' },
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'planning', 'fine'), 'fine',
|
||||
'CLI override must beat per-phase config');
|
||||
});
|
||||
|
||||
test('override coarse beats top-level granularity=fine', () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'fine',
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'execution', 'coarse'), 'coarse',
|
||||
'CLI override must beat top-level granularity');
|
||||
});
|
||||
|
||||
test('override standard beats planning.granularity=fine global fallback (regardless of phase type)', () => {
|
||||
writeConfig(projectDir, {
|
||||
planning: { granularity: 'fine' },
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'execution', 'standard'), 'standard',
|
||||
'CLI override must beat planning.granularity fallback');
|
||||
});
|
||||
|
||||
test("override '' (empty string) falls through to config chain", () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'coarse',
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'planning', ''), 'coarse',
|
||||
'empty-string override must fall through to config chain');
|
||||
});
|
||||
|
||||
test('override undefined falls through to config chain', () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'fine',
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'planning', undefined), 'fine',
|
||||
'undefined override must fall through to config chain');
|
||||
});
|
||||
|
||||
test('override null falls through to config chain', () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'coarse',
|
||||
});
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'planning', null), 'coarse',
|
||||
'null override must fall through to config chain');
|
||||
});
|
||||
|
||||
test('invalid override value falls through to config chain (not rejected in resolver)', () => {
|
||||
writeConfig(projectDir, {
|
||||
granularity: 'standard',
|
||||
});
|
||||
// Invalid override reaches resolver → falls through (validation is CLI boundary's job)
|
||||
assert.equal(resolveGranularityInternal(projectDir, 'planning', 'ultra'), 'standard',
|
||||
'invalid override must fall through to config chain in resolver');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#703 cmdResolveGranularity: --granularity CLI flag', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('--granularity fine overrides config chain via CLI tool', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ granularity: 'coarse' })
|
||||
);
|
||||
const result = runGsdTools('resolve-granularity planning --granularity fine', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.granularity, 'fine', '--granularity fine must override config coarse');
|
||||
});
|
||||
|
||||
test('--granularity coarse overrides per-phase granularities.planning=fine', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({
|
||||
granularity: 'standard',
|
||||
granularities: { planning: 'fine' },
|
||||
})
|
||||
);
|
||||
const result = runGsdTools('resolve-granularity planning --granularity coarse', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.granularity, 'coarse', '--granularity coarse must beat per-phase fine');
|
||||
});
|
||||
|
||||
test('invalid --granularity value exits with error', () => {
|
||||
const result = runGsdTools('resolve-granularity planning --granularity ultra', tmpDir);
|
||||
assert.ok(!result.success, 'should fail with invalid granularity');
|
||||
assert.ok(
|
||||
result.error.includes('ultra') || result.error.includes('invalid'),
|
||||
`error should mention invalid value; got: ${result.error}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #703 end-to-end: init.plan-phase path forwards and resolves granularity ──
|
||||
//
|
||||
// Fixture mirrors tests/pattern-mapper.test.cjs: createTempProject() +
|
||||
// minimal STATE.md + ROADMAP.md + phase directory — just enough for
|
||||
// cmdInitPlanPhase to succeed without agents / git.
|
||||
|
||||
function makeInitPlanPhaseFixture(prefix) {
|
||||
const tmpDir = createTempProject(prefix);
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.writeFileSync(path.join(planningDir, 'STATE.md'), [
|
||||
'# State',
|
||||
'',
|
||||
'## Current Phase',
|
||||
'Phase 1 — Foundation',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phase 1: Foundation',
|
||||
'Build the foundation.',
|
||||
'**Status:** Planning',
|
||||
'**Requirements:** [FOUND-01]',
|
||||
].join('\n'));
|
||||
fs.mkdirSync(path.join(planningDir, 'phases', '01-foundation'), { recursive: true });
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
describe('#703 init.plan-phase end-to-end: granularity resolution via CLI', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeInitPlanPhaseFixture('gsd-68-e2e-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('(1) --granularity fine overrides config granularities.planning=coarse end-to-end', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ granularity: 'standard', granularities: { planning: 'coarse' } })
|
||||
);
|
||||
const result = runGsdTools('init plan-phase 1 --granularity fine', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.granularity, 'fine',
|
||||
'--granularity fine override must win over granularities.planning=coarse end-to-end');
|
||||
});
|
||||
|
||||
test('(2) no flag + config granularities.planning=fine → granularity=fine (Fix A: per-phase-type honored)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ granularity: 'coarse', granularities: { planning: 'fine' } })
|
||||
);
|
||||
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.granularity, 'fine',
|
||||
'granularities.planning=fine must be honored (phaseType=planning in resolveGranularityInternal)');
|
||||
});
|
||||
|
||||
test('(3) no flag + global granularity=coarse (no granularities.planning) → granularity=coarse', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ granularity: 'coarse' })
|
||||
);
|
||||
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.strictEqual(data.granularity, 'coarse',
|
||||
'global granularity=coarse must be returned when no granularities.planning is set');
|
||||
});
|
||||
|
||||
test('(4) --granularity ultra → command errors (invalid value rejected on plan-phase path)', () => {
|
||||
const result = runGsdTools('init plan-phase 1 --granularity ultra', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(!result.success, 'should fail with invalid granularity ultra');
|
||||
assert.ok(
|
||||
result.error.includes('ultra') || result.error.includes('invalid'),
|
||||
`error should mention invalid value; got: ${result.error}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user