feat(#703): add --granularity override flag to /gsd:plan-phase (#750)

* 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:
Tom Boucher
2026-06-06 23:56:53 -04:00
committed by GitHub
parent cf8bd3cd5e
commit 7e76f1a736
11 changed files with 255 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 750
---
`/gsd:plan-phase` now accepts a `--granularity <coarse|standard|fine>` flag to override the configured planning granularity for a single invocation. The flag takes precedence over `granularities.planning`, top-level `granularity`, and `planning.granularity` config. Invalid values are rejected. (#703)

View File

@@ -407,6 +407,8 @@ Plans should complete within ~50% context (not 80%). No context anxiety, quality
## Granularity Calibration
The resolved granularity is provided in the planning context as `**Granularity:** <value>`. Read that value and apply the corresponding row below. When no explicit value is present, default to Standard.
| Granularity | Typical Plans/Phase | Tasks/Plan |
|-------------|---------------------|------------|
| Coarse | 1-3 | 2-3 |

View File

@@ -157,6 +157,7 @@ Research, plan, and verify a phase.
| `--skip-bounce` | Skip plan bounce even if enabled in config |
| `--mvp` | Vertical MVP mode — planner organizes tasks as feature slices (UI→API→DB) instead of horizontal layers. On Phase 1 of a new project with no prior phase summaries, also emits `SKELETON.md` (Walking Skeleton). Can be persisted on a phase via `**Mode:** mvp` in ROADMAP.md, which applies `--mvp` automatically without the flag. |
| `--tdd` | TDD mode — planner applies `type: tdd` to eligible behavior-adding tasks so each begins with a failing test. Composable with `--mvp`: `--mvp --tdd` produces vertical slices where every behavior-adding task starts red-green. |
| `--granularity <coarse\|standard\|fine>` | Override the planning granularity for this invocation, ignoring config. Valid values: `coarse`, `standard`, `fine`. Takes precedence over `granularities.planning`, top-level `granularity`, and `planning.granularity` config. |
**Prerequisites:** `.planning/ROADMAP.md` exists
**Produces:** `{phase}-RESEARCH.md`, `{phase}-{N}-PLAN.md`, `{phase}-VALIDATION.md`; `{phase}/SKELETON.md` when Walking Skeleton mode fires

View File

@@ -925,7 +925,7 @@ continues. Drift detection cannot fail verification.
| `granularity` | enum | `standard` | `coarse`, `standard`, or `fine` |
| `model_profile` | enum | `balanced` | `quality`, `balanced`, `budget`, or `inherit` |
| `models.<phase_type>` | enum | (none) | Per-phase-type tier override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `opus`, `sonnet`, `haiku`, `inherit`. Coarse phase-level tuning that wins over `model_profile` but loses to per-agent `model_overrides`. See [CONFIGURATION.md](CONFIGURATION.md#per-phase-type-models-models--added-in-v140). Added in v1.40 |
| `granularities.<phase_type>` | enum | (none) | Per-phase-type granularity override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `coarse`, `standard`, `fine`. Mirrors `models.<phase_type>` for granularity. See [CONFIGURATION.md](CONFIGURATION.md#core-settings). Added in v1.43 ([#68](https://github.com/open-gsd/gsd-core/issues/68)) |
| `granularities.<phase_type>` | enum | (none) | Per-phase-type granularity override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `coarse`, `standard`, `fine`. Mirrors `models.<phase_type>` for granularity. See [CONFIGURATION.md](CONFIGURATION.md#core-settings). Added in v1.43 ([#68](https://github.com/open-gsd/gsd-core/issues/68)). `/gsd:plan-phase --granularity <coarse\|standard\|fine>` overrides all config-based granularity for a single invocation (takes precedence over `granularities.planning`, top-level `granularity`, and `planning.granularity`). ([#703](https://github.com/open-gsd/gsd-core/issues/703)) |
| `dynamic_routing.enabled` | boolean | `false` | Master switch for failure-tier escalation. When `true`, agents resolve to `tier_models[default_tier]` and escalate one tier on orchestrator-detected soft failure. Capped by `max_escalations`. See [CONFIGURATION.md](CONFIGURATION.md#dynamic-routing-with-failure-tier-escalation-dynamic_routing--added-in-v140). Added in v1.40 |
| `workflow.research` | boolean | `true` | Domain research before planning |
| `workflow.plan_check` | boolean | `true` | Plan verification loop |

View File

@@ -547,7 +547,22 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
case 'resolve-granularity': {
commands.cmdResolveGranularity(cwd, args[1], raw);
// Parse optional --granularity <val> flag (space form only); positional is phase-type.
// The =form (--granularity=<val>) is intentionally not supported: parseNamedArgs and
// the /gsd:plan-phase + init plan-phase paths accept only the space form, so supporting
// = here alone would create an inconsistency (#703).
const granArgs = args.slice(1);
let granOverride;
const granPositionals = [];
for (let i = 0; i < granArgs.length; i++) {
const a = granArgs[i];
if (a === '--granularity' && granArgs[i + 1] !== undefined && !granArgs[i + 1].startsWith('--')) {
if (granOverride === undefined) { granOverride = granArgs[++i]; } else { ++i; }
} else {
granPositionals.push(a);
}
}
commands.cmdResolveGranularity(cwd, granPositionals[0], raw, granOverride);
break;
}

View File

@@ -32,7 +32,8 @@ Load all context in one call (paths only to minimize orchestrator context):
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi
INIT=$(gsd_run query init.plan-phase "$PHASE")
GRAN_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--granularity[[:space:]]+([^[:space:]-][^[:space:]]*) ]]; then GRAN_PARAM="--granularity ${BASH_REMATCH[2]}"; fi
INIT=$(gsd_run query init.plan-phase "$PHASE" $GRAN_PARAM)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS_RESEARCHER=$(gsd_run query agent-skills gsd-phase-researcher)
AGENT_SKILLS_PLANNER=$(gsd_run query agent-skills gsd-planner)
@@ -46,7 +47,7 @@ When `TDD_MODE` is `true`, the planner agent is instructed to apply `type: tdd`
When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior phase CONTEXT.md and SUMMARY.md files PLUS any phases explicitly listed in the current phase's `Depends on:` field in ROADMAP.md. Explicit dependencies always load regardless of recency (e.g., Phase 7 declaring `Depends on: Phase 2` always sees Phase 2's context). Bounded recency keeps the planner's context budget focused on recent work.
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`, `granularity`.
**If `response_language` is set:** Include `response_language: {value}` in all spawned subagent prompts so any user-facing output stays in the configured language.
@@ -99,7 +100,7 @@ The gate fires only on `Complete`. `Executed` and `Needs Review` are not gated
## 2. Parse and Normalize Arguments
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--tdd`, `--force` (override closed-phase gate, see §1.5)).
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--tdd`, `--granularity <coarse|standard|fine>`, `--force` (override closed-phase gate, see §1.5)).
**`--research-phase <N>` — research-only mode (#3042 + #3044).** When this flag is present, parse `<N>` as the phase number (overrides any positional phase argument), set `RESEARCH_ONLY=true`, and treat the rest of this workflow as a research-dispatch only — the planner spawn (step 8), plan-checker, verification, gaps, bounce, and post-planning-gaps blocks all skip on `RESEARCH_ONLY`. Use this for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops. Replaces the deleted `/gsd-research-phase` command.
@@ -120,6 +121,8 @@ if $RESEARCH_ONLY && [[ "$ARGUMENTS" =~ (^|[[:space:]])--view([[:space:]]|$) ]];
fi
```
**`--granularity <coarse|standard|fine>` — CLI override (#703).** When present, this value is the resolved granularity passed to the planner — it wins over any per-phase `granularities.<type>` config, top-level `granularity` config, or project defaults. The init JSON always includes a `granularity` field reflecting the resolved value; read it from there. Invalid values (anything other than `coarse`, `standard`, `fine`) cause an error at the CLI boundary.
Set `TEXT_MODE=true` if `--text` is present in $ARGUMENTS OR `text_mode` from init JSON is `true`. When `TEXT_MODE` is active, replace every `AskUserQuestion` call with a plain-text numbered list and ask the user to type their choice number. This is required for Claude Code remote sessions (`/rc` mode) where TUI menus don't work through the Claude App.
**MVP_MODE resolution.** Resolve `MVP_MODE` once via the centralized `phase.mvp-mode` query verb. Precedence (first hit wins): CLI flag → ROADMAP.md `**Mode:** mvp` → `workflow.mvp_mode` config → false. The verb is the single source of truth — do not re-implement the chain.
@@ -935,6 +938,7 @@ Each TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence.
**MVP_MODE:** ${MVP_MODE} (when true, follow vertical-slice rules from `~/.claude/gsd-core/references/planner-mvp-mode.md`; when false, ignore MVP guidance entirely.)
**WALKING_SKELETON:** ${WALKING_SKELETON} (when true, the first deliverable must be a Walking Skeleton — Read the template at `~/.claude/gsd-core/references/skeleton-template.md` and produce SKELETON.md alongside PLAN.md.)
**Granularity:** {granularity}
${MVP_MODE === 'true' ? `
<mvp_mode_active>

View File

@@ -34,6 +34,7 @@ const {
getRoadmapPhaseInternal,
extractPhaseToken,
resolveGranularityInternal,
assertValidGranularityOverride,
} = core;
import { renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } from './model-catalog.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -340,11 +341,12 @@ function cmdResolveModel(cwd: string, agentType: string | undefined, raw: boolea
output(result, raw, model);
}
function cmdResolveGranularity(cwd: string, phaseType: string | undefined, raw: boolean): void {
function cmdResolveGranularity(cwd: string, phaseType: string | undefined, raw: boolean, override?: string): void {
if (!phaseType) {
error('phase-type required');
}
const granularity = resolveGranularityInternal(cwd, phaseType);
assertValidGranularityOverride(override, error);
const granularity = resolveGranularityInternal(cwd, phaseType, override);
const result = (VALID_PHASE_TYPES).has(phaseType!)
? { granularity, phase_type: phaseType }
: { granularity, phase_type: phaseType, unknown_phase_type: true };

View File

@@ -1590,7 +1590,12 @@ const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']);
/**
* Resolve the planning granularity for a phase type (#68).
*/
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined): string {
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string {
if (override !== undefined && override !== null && override !== '') {
if (VALID_GRANULARITIES.has(override)) {
return override;
}
}
const config = loadConfig(cwd);
const configGranularities = config['granularities'] as Record<string, string> | null | undefined;
const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object')
@@ -1610,6 +1615,19 @@ function resolveGranularityInternal(cwd: string, phaseType: string | null | unde
return 'standard';
}
/**
* Validate a CLI granularity override at the command boundary. Empty/null/undefined
* are treated as "no override" (no-op). An invalid non-empty value calls `fail`.
*/
function assertValidGranularityOverride(
override: string | null | undefined,
fail: (msg: string) => never,
): void {
if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) {
fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
}
}
/**
* #3024 — Resolve a model for a specific dynamic-routing attempt.
*/
@@ -2200,6 +2218,7 @@ export = {
resolveModelForTier,
resolveGranularityInternal,
VALID_GRANULARITIES,
assertValidGranularityOverride,
resolveEffortInternal,
resolveFastModeInternal,
resolveEffortForTier,

View File

@@ -66,8 +66,8 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
},
'plan-phase': () => {
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
const namedArgs = parseNamedArgs(args, ['granularity'], ['validate', 'tdd']);
init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'], granularity: namedArgs['granularity'] });
},
'new-project': () => init.cmdInitNewProject(cwd, raw),
'new-milestone': () => init.cmdInitNewMilestone(cwd, raw),

View File

@@ -29,6 +29,8 @@ import frontmatterMod = require('./frontmatter.cjs');
const {
loadConfig,
resolveModelInternal,
resolveGranularityInternal,
assertValidGranularityOverride,
findPhaseInternal,
getRoadmapPhaseInternal,
pathExistsInternal,
@@ -375,12 +377,17 @@ function cmdInitPlanPhase(
}
}
const granularityOverride = options['granularity'] as string | undefined;
assertValidGranularityOverride(granularityOverride, error);
const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined);
const result: Record<string, unknown> = {
researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
tdd_mode: options['tdd'] || config.tdd_mode || false,
granularity,
research_enabled: config.research,
plan_checker_enabled: config.plan_checker,
nyquist_validation_enabled: config.nyquist_validation,

View File

@@ -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}`
);
});
});