diff --git a/.changeset/703-plan-phase-granularity-flag.md b/.changeset/703-plan-phase-granularity-flag.md new file mode 100644 index 000000000..74fe53f0d --- /dev/null +++ b/.changeset/703-plan-phase-granularity-flag.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 750 +--- +`/gsd:plan-phase` now accepts a `--granularity ` 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) diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 6eeabea88..af3d0daf8 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -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:** `. 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 | diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6bd444ac6..2fd855646 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -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 ` | 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 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index cdda06ee3..e6e5a6db9 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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.` | 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.` | enum | (none) | Per-phase-type granularity override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `coarse`, `standard`, `fine`. Mirrors `models.` for granularity. See [CONFIGURATION.md](CONFIGURATION.md#core-settings). Added in v1.43 ([#68](https://github.com/open-gsd/gsd-core/issues/68)) | +| `granularities.` | enum | (none) | Per-phase-type granularity override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `coarse`, `standard`, `fine`. Mirrors `models.` 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 ` 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 | diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index db20742c9..d2e96cabf 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -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 flag (space form only); positional is phase-type. + // The =form (--granularity=) 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; } diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index f578042e8..161d76b2b 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -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 `, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd `, `--ingest `, `--ingest-format `, `--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 `, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd `, `--ingest `, `--ingest-format `, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--tdd`, `--granularity `, `--force` (override closed-phase gate, see §1.5)). **`--research-phase ` — research-only mode (#3042 + #3044).** When this flag is present, parse `` 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 ` — CLI override (#703).** When present, this value is the resolved granularity passed to the planner — it wins over any per-phase `granularities.` 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' ? ` diff --git a/src/commands.cts b/src/commands.cts index a5cc33820..4ce1f87be 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -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 }; diff --git a/src/core.cts b/src/core.cts index a62e1c38f..e5c24760d 100644 --- a/src/core.cts +++ b/src/core.cts @@ -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 | 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, diff --git a/src/init-command-router.cts b/src/init-command-router.cts index 6f864e415..317ce0059 100644 --- a/src/init-command-router.cts +++ b/src/init-command-router.cts @@ -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), diff --git a/src/init.cts b/src/init.cts index a74ed97f6..f31d362c5 100644 --- a/src/init.cts +++ b/src/init.cts @@ -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 = { 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, diff --git a/tests/feat-68-per-phase-granularity.test.cjs b/tests/feat-68-per-phase-granularity.test.cjs index 29d8c0646..9159ee50e 100644 --- a/tests/feat-68-per-phase-granularity.test.cjs +++ b/tests/feat-68-per-phase-granularity.test.cjs @@ -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}` + ); + }); +});