diff --git a/.changeset/rapid-dogs-run.md b/.changeset/rapid-dogs-run.md new file mode 100644 index 000000000..3138f4528 --- /dev/null +++ b/.changeset/rapid-dogs-run.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 595 +--- +**Per-phase granularity overrides (`granularities.`)** — planning granularity can now be set per phase type (planning/discuss/research/execution/verification/completion) to override the global `granularity`, mirroring `models.`. Resolve with `gsd-tools query resolve-granularity `. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9c9f4515c..c8d3651f6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -146,6 +146,7 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new | `model_policy.low` | string | model ID | (none) | Low-cost tier model ID for `generic`/`custom` provider. Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) | | `model_policy.runtime_tiers..` | object | `{ model, reasoning_effort? }` | (none) | Explicit per-runtime, per-tier model entry. `tier` is one of `opus`, `sonnet`, `haiku` (matching the existing profile tier names). `reasoning_effort` is forwarded only to runtimes that support it; unsupported runtimes never receive the field. Takes precedence over `model_profile_overrides`. Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) | | `models.` | enum | `opus`, `sonnet`, `haiku`, `inherit` | (none) | Per-phase-type model tier. Six accepted slots: `planning`, `discuss`, `research`, `execution`, `verification`, `completion`. Lets you tune at the phase level ("Opus for planning, Sonnet for the rest") without learning agent names. Resolves between `model_overrides` (higher) and `model_profile` (lower); see [Per-Phase-Type Models](#per-phase-type-models-models--added-in-v140). Added in v1.40 ([#3023](https://github.com/open-gsd/gsd-core/pull/3030)) | +| `granularities.` | enum | `coarse`, `standard`, `fine` | (none) | Per-phase-type granularity override. Six accepted slots: `planning`, `discuss`, `research`, `execution`, `verification`, `completion`. Lets you tune phase count at the phase level without changing the global `granularity`. Precedence: `granularities[phaseType]` (highest, enum-guarded) → `granularity` (global) → `planning.granularity` → `'standard'` (hard default). Added in v1.43 ([#68](https://github.com/open-gsd/gsd-core/issues/68)) | | `dynamic_routing.enabled` | boolean | `true`, `false` | `false` | Master switch for [dynamic routing with failure-tier escalation](#dynamic-routing-with-failure-tier-escalation-dynamic_routing--added-in-v140). When `true`, agents resolve to `tier_models[default_tier]` and escalate one tier up on orchestrator-detected soft failure. Added in v1.40 ([#3024](https://github.com/open-gsd/gsd-core/pull/3031)) | | `dynamic_routing.tier_models.` | enum | `opus`, `sonnet`, `haiku` | (none) | Tier alias for `light`, `standard`, or `heavy`. Used when `dynamic_routing.enabled: true`. Added in v1.40 | | `dynamic_routing.escalate_on_failure` | boolean | `true`, `false` | `true` | When `false`, escalation is disabled even if `enabled: true` — every attempt uses the default tier. Added in v1.40 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 63a400d37..8bbbe10f6 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -925,6 +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)) | | `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/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index ca5984c02..85b0e860b 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -374,7 +374,7 @@ async function main() { 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' + 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' + 'learnings, list-todos, milestone, phase, phase-plan-index, phases, profile-questionnaire, ' + - 'profile-sample, progress, prompt-budget, requirements, resolve-model, roadmap, scaffold, state, ' + + 'profile-sample, progress, prompt-budget, requirements, resolve-granularity, resolve-model, roadmap, scaffold, state, ' + 'task, template, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' + 'Global flags:\n' + ' --raw Emit raw output without post-processing\n' + @@ -541,6 +541,11 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + case 'resolve-granularity': { + commands.cmdResolveGranularity(cwd, args[1], raw); + break; + } + case 'resolve-execution': { // Deterministic flag parsing: consume --flag pairs first, // then the AGENT is the single remaining positional. diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index 677ccc4f0..6ee5f7ff0 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -4,11 +4,11 @@ const fs = require('fs'); const path = require('path'); const { execGit, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs'); -const { loadConfig, isGitIgnored, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal, extractPhaseToken } = require('./core.cjs'); +const { loadConfig, isGitIgnored, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal, extractPhaseToken, resolveGranularityInternal } = require('./core.cjs'); const { renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } = require('./model-catalog.cjs'); const { planningDir, planningPaths } = require('./planning-workspace.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); -const { MODEL_PROFILES } = require('./model-profiles.cjs'); +const { MODEL_PROFILES, VALID_PHASE_TYPES } = require('./model-profiles.cjs'); const { formatGsdSlash, resolveRuntime } = require('./runtime-slash.cjs'); /** @@ -251,6 +251,17 @@ function cmdResolveModel(cwd, agentType, raw) { output(result, raw, model); } +function cmdResolveGranularity(cwd, phaseType, raw) { + if (!phaseType) { + error('phase-type required'); + } + const granularity = resolveGranularityInternal(cwd, phaseType); + const result = VALID_PHASE_TYPES.has(phaseType) + ? { granularity, phase_type: phaseType } + : { granularity, phase_type: phaseType, unknown_phase_type: true }; + output(result, raw, granularity); +} + /** * #443 — Superset execution query: model + unified effort + fast_mode. * @@ -1276,6 +1287,7 @@ module.exports = { cmdVerifyPathExists, cmdHistoryDigest, cmdResolveModel, + cmdResolveGranularity, cmdResolveExecution, cmdEffortSync, cmdCommit, diff --git a/get-shit-done/bin/lib/config-schema.cjs b/get-shit-done/bin/lib/config-schema.cjs index 6e57dc918..7f573dbc2 100644 --- a/get-shit-done/bin/lib/config-schema.cjs +++ b/get-shit-done/bin/lib/config-schema.cjs @@ -3,12 +3,12 @@ /** * Thin adapter — sources schema data from the manifest via the generated * Configuration Module. All inline literals have been removed; the manifest - * at sdk/shared/config-schema.manifest.json is the single source of truth. + * at get-shit-done/bin/shared/config-schema.manifest.json is the single source of truth. * * Imported by: * - config.cjs (isValidConfigKey validator) - * - tests/config-schema-docs-parity.test.cjs (CI drift guard) - * - tests/config-schema-sdk-parity.test.cjs (CJS↔SDK parity guard) + * - core.cjs + * - many tests (config-schema.property.test.cjs, bug-*, feat-*, etc.) * * See Phase 2 Cycle 5 (#3536) — schema manifest migration. */ diff --git a/get-shit-done/bin/lib/configuration.cjs b/get-shit-done/bin/lib/configuration.cjs index 94eb4f619..bf93442b9 100644 --- a/get-shit-done/bin/lib/configuration.cjs +++ b/get-shit-done/bin/lib/configuration.cjs @@ -13,8 +13,6 @@ function loadConfigurationManifest(fileName) { const candidates = [ // Installed runtime layout: get-shit-done/bin/shared/*.manifest.json join(__dirname, '..', 'shared', fileName), - // Source-repo dev layout: sdk/shared/*.manifest.json - join(__dirname, '..', '..', '..', 'sdk', 'shared', fileName), ]; let lastErr = null; for (const candidate of candidates) { diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index a60af82ac..01fac41c9 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -497,6 +497,19 @@ function loadConfig(cwd, options = {}) { // resolveModelInternal. Defaults to null so configs without it // behave exactly as today. models: parsed.models || null, + // #68 — top-level granularity (global override; written by new-project + // payloads and legacy depth→granularity migration). Pass through as-is so + // resolveGranularityInternal can honor user-set values without enum-guarding + // here (preserves Hyrum compat for the global slot). + granularity: parsed.granularity !== undefined ? parsed.granularity : null, + // #68 — per-phase-type granularity map. Six named slots mirroring `models`. + // Defaults to null so configs without it behave exactly as before. + granularities: parsed.granularities || null, + // #68 — planning sub-object (needed for planning.granularity fallback). + // Also used by other keys (planning.commit_docs etc.) via `get()` above, + // but those use the nested get() path; resolveGranularityInternal needs + // direct access to planning.granularity so we pass through the whole block. + planning: parsed.planning || null, // #3024 — dynamic routing block. When `enabled: true`, the // resolveModelForTier() resolver picks tier_models[default_tier] // for the agent and escalates one tier per attempt up to @@ -566,6 +579,9 @@ function loadConfig(cwd, options = {}) { subagent_timeout: globalDefaults.subagent_timeout ?? defaults.subagent_timeout, model_overrides: globalDefaults.model_overrides || null, models: globalDefaults.models || null, + granularity: globalDefaults.granularity !== undefined ? globalDefaults.granularity : null, + granularities: globalDefaults.granularities || null, + planning: globalDefaults.planning || null, dynamic_routing: globalDefaults.dynamic_routing || null, effort: globalDefaults.effort || null, fast_mode: globalDefaults.fast_mode || null, @@ -1721,6 +1737,37 @@ function resolveModelInternal(cwd, agentType) { return alias; } +const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']); + +/** + * Resolve the planning granularity for a phase type (#68). + * + * Precedence (mirrors resolveModelInternal's phase-type slot): + * 1. granularities[phaseType] — per-phase override; honored only when a + * recognized enum value (coarse|standard|fine). A typo or wrong type + * falls through so it can't silently break resolution. + * 2. top-level `granularity` — global (new-project payload / legacy depth). + * 3. planning.granularity — canonical global default (always present post-merge). + * 4. 'standard' — hard default. + */ +function resolveGranularityInternal(cwd, phaseType) { + const config = loadConfig(cwd); + const perPhase = (phaseType && config.granularities && typeof config.granularities === 'object') + ? config.granularities[phaseType] + : undefined; + if (perPhase && VALID_GRANULARITIES.has(perPhase)) { + return perPhase; + } + if (config.granularity !== undefined && config.granularity !== null && config.granularity !== '') { + return config.granularity; + } + const planningGran = config.planning && config.planning.granularity; + if (planningGran !== undefined && planningGran !== null && planningGran !== '') { + return planningGran; + } + return 'standard'; +} + /** * #3024 — Resolve a model for a specific dynamic-routing attempt. * @@ -2462,6 +2509,8 @@ module.exports = { getRoadmapPhaseInternal, resolveModelInternal, resolveModelForTier, + resolveGranularityInternal, + VALID_GRANULARITIES, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, diff --git a/get-shit-done/bin/shared/config-schema.manifest.json b/get-shit-done/bin/shared/config-schema.manifest.json index 4a39a0e17..51d953b07 100644 --- a/get-shit-done/bin/shared/config-schema.manifest.json +++ b/get-shit-done/bin/shared/config-schema.manifest.json @@ -142,6 +142,11 @@ "source": "^models\\.(planning|discuss|research|execution|verification|completion)$", "description": "models." }, + { + "topLevel": "granularities", + "source": "^granularities\\.(planning|discuss|research|execution|verification|completion)$", + "description": "granularities." + }, { "topLevel": "dynamic_routing", "source": "^dynamic_routing\\.(enabled|escalate_on_failure|max_escalations|tier_models\\.(light|standard|heavy))$", diff --git a/tests/feat-68-per-phase-granularity.test.cjs b/tests/feat-68-per-phase-granularity.test.cjs new file mode 100644 index 000000000..07e08f97c --- /dev/null +++ b/tests/feat-68-per-phase-granularity.test.cjs @@ -0,0 +1,320 @@ +/** + * Feature test for issue #68 — per-phase granularity. + * + * Adds a `granularities` block to .planning/config.json that accepts phase-type + * keys (planning / discuss / research / execution / verification / + * completion). Resolution precedence: + * + * 1. granularities[phaseType] — per-phase override (enum-guarded) + * 2. top-level `granularity` — global override (new-project / legacy depth) + * 3. planning.granularity — canonical global default (always present post-merge) + * 4. 'standard' — hard fallback + * + * Tests are typed-IR / structural — assert on the value returned by + * resolveGranularityInternal, not stdout/grep. Each test seeds a temp project + * with a fixture .planning/config.json and asserts the resolver picks + * the right granularity for each phase type. + * + * Structure mirrors tests/feat-3023-model-phase-types.test.cjs exactly. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + resolveGranularityInternal, + VALID_GRANULARITIES, +} = require('../get-shit-done/bin/lib/core.cjs'); +const commands = require('../get-shit-done/bin/lib/commands.cjs'); +const { + VALID_PHASE_TYPES, +} = require('../get-shit-done/bin/lib/model-profiles.cjs'); +const { isValidConfigKey } = require('../get-shit-done/bin/lib/config-schema.cjs'); + +const { createTempDir, runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const makeTmp = (prefix) => createTempDir(`gsd-68-${prefix}-`); + +function writeConfig(projectDir, config) { + const planningDir = path.join(projectDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config, null, 2)); +} + +// ─── Export check ──────────────────────────────────────────────────────────── + +describe('#68 exports: resolveGranularityInternal and VALID_GRANULARITIES are exported', () => { + test('resolveGranularityInternal is a function', () => { + assert.equal(typeof resolveGranularityInternal, 'function'); + }); + + test('VALID_GRANULARITIES is a Set containing coarse, standard, fine', () => { + assert.ok(VALID_GRANULARITIES instanceof Set); + assert.deepStrictEqual( + [...VALID_GRANULARITIES].sort(), + ['coarse', 'fine', 'standard'].sort() + ); + }); +}); + +// ─── Schema: granularities. validation ────────────────────────── + +describe('#68 config-schema: granularities. validation', () => { + test('granularities.planning is a valid config key', () => { + assert.equal(isValidConfigKey('granularities.planning'), true); + }); + + test('all six phase-type slots are valid config keys', () => { + for (const slot of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { + assert.equal(isValidConfigKey(`granularities.${slot}`), true, + `granularities.${slot} must be a valid config key`); + } + }); + + test('unknown phase-type is rejected', () => { + assert.equal(isValidConfigKey('granularities.bogus'), false, + 'unknown phase-type must NOT be accepted'); + assert.equal(isValidConfigKey('granularities.deployment'), false, + 'unknown phase-type must NOT be accepted'); + }); + + test('granularities alone (without a slot) is not a valid config-set key — mirrors models behavior', () => { + // Setting the whole block isn't a granular set; users edit JSON directly. + assert.equal(isValidConfigKey('granularities'), false); + }); +}); + +// ─── Resolver behavior: per-phase override wins ────────────────────────────── + +describe('#68 resolver: granularities. overrides global granularity', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('resolver'); }); + afterEach(() => { cleanup(projectDir); }); + + test('per-phase override wins: granularities.planning=fine resolves to fine', () => { + writeConfig(projectDir, { + granularity: 'standard', + granularities: { planning: 'fine' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); + }); + + test('phase type with no per-phase override falls back to global granularity', () => { + writeConfig(projectDir, { + granularity: 'coarse', + granularities: { planning: 'fine' }, + }); + // 'execution' has no per-phase override → falls back to top-level granularity + assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'coarse'); + }); + + test('all six phase types can be overridden independently', () => { + writeConfig(projectDir, { + granularity: 'standard', + granularities: { + planning: 'fine', + discuss: 'coarse', + research: 'fine', + execution: 'coarse', + verification: 'fine', + completion: 'coarse', + }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); + assert.equal(resolveGranularityInternal(projectDir, 'discuss'), 'coarse'); + assert.equal(resolveGranularityInternal(projectDir, 'research'), 'fine'); + assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'coarse'); + assert.equal(resolveGranularityInternal(projectDir, 'verification'), 'fine'); + assert.equal(resolveGranularityInternal(projectDir, 'completion'), 'coarse'); + }); +}); + +// ─── Resolver: invalid per-phase value falls through ───────────────────────── + +describe('#68 resolver: invalid per-phase value falls through to global (typo safety)', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('invalid'); }); + afterEach(() => { cleanup(projectDir); }); + + test('invalid value ultra falls through to global granularity', () => { + writeConfig(projectDir, { + granularity: 'coarse', + granularities: { planning: 'ultra' }, // not a valid enum value + }); + // Falls through to top-level granularity + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'coarse'); + }); + + test('invalid value empty-string falls through to global granularity', () => { + writeConfig(projectDir, { + granularity: 'fine', + granularities: { planning: '' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); + }); +}); + +// ─── Resolver: malformed granularities block doesn't throw ─────────────────── + +describe('#68 resolver: malformed granularities value does not throw', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('malformed'); }); + afterEach(() => { cleanup(projectDir); }); + + test('granularities as a string does not throw, returns global fallback', () => { + writeConfig(projectDir, { + granularity: 'coarse', + granularities: 'fine', // string, not an object + }); + assert.doesNotThrow(() => resolveGranularityInternal(projectDir, 'planning')); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'coarse'); + }); + + test('granularities as null does not throw, returns global fallback', () => { + writeConfig(projectDir, { + granularity: 'coarse', + granularities: null, + }); + assert.doesNotThrow(() => resolveGranularityInternal(projectDir, 'planning')); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'coarse'); + }); + + test('granularities as an array does not throw, returns global fallback', () => { + writeConfig(projectDir, { + granularity: 'fine', + granularities: ['fine'], + }); + assert.doesNotThrow(() => resolveGranularityInternal(projectDir, 'planning')); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); + }); +}); + +// ─── Backward-compat (Hyrum): no granularities key mirrors pre-feature behavior + +describe('#68 backward-compat: no granularities key resolves identically to pre-feature global', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('compat'); }); + afterEach(() => { cleanup(projectDir); }); + + test('top-level granularity=fine resolves to fine for all six phase types (no granularities key)', () => { + writeConfig(projectDir, { + granularity: 'fine', + }); + for (const phaseType of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { + assert.equal(resolveGranularityInternal(projectDir, phaseType), 'fine', + `${phaseType} must resolve to fine`); + } + }); + + test('no granularity key at all → all phase types resolve to standard (canonical default)', () => { + writeConfig(projectDir, {}); + for (const phaseType of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { + assert.equal(resolveGranularityInternal(projectDir, phaseType), 'standard', + `${phaseType} must resolve to standard (canonical default)`); + } + }); +}); + +// ─── Global precedence chain ───────────────────────────────────────────────── + +describe('#68 resolver: global fallback precedence chain', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('precedence'); }); + afterEach(() => { cleanup(projectDir); }); + + test('top-level granularity honored when present', () => { + writeConfig(projectDir, { + granularity: 'coarse', + planning: { granularity: 'fine' }, // planning.granularity is lower precedence + }); + assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'coarse'); + }); + + test('planning.granularity honored when top-level granularity absent', () => { + writeConfig(projectDir, { + planning: { granularity: 'fine' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'fine'); + }); + + test('hard default standard when neither top-level nor planning.granularity present', () => { + writeConfig(projectDir, {}); + assert.equal(resolveGranularityInternal(projectDir, 'execution'), 'standard'); + }); + + test('per-phase override beats all global sources', () => { + writeConfig(projectDir, { + granularity: 'coarse', + planning: { granularity: 'coarse' }, + granularities: { planning: 'fine' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning'), 'fine'); + }); +}); + +// ─── VALID_PHASE_TYPES consistency ────────────────────────────────────────── + +describe('#68 VALID_PHASE_TYPES covers all six slots used by granularities', () => { + test('the six granularities slots are all valid phase types', () => { + for (const slot of ['planning', 'discuss', 'research', 'execution', 'verification', 'completion']) { + assert.ok(VALID_PHASE_TYPES.has(slot), + `${slot} must be in VALID_PHASE_TYPES`); + } + }); +}); + +// ─── CMD-level: cmdResolveGranularity export + CLI behavior ───────────────── +// Mirrors the resolve-model command tests in tests/commands.test.cjs (CMD-03). + +describe('#68 exports: cmdResolveGranularity is exported as a function', () => { + test('cmdResolveGranularity is a function', () => { + assert.equal(typeof commands.cmdResolveGranularity, 'function'); + }); +}); + +describe('#68 resolve-granularity command: CLI behavior', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('(a) missing phase-type arg → command exits with error mentioning phase-type required', () => { + const result = runGsdTools('resolve-granularity', tmpDir); + assert.ok(!result.success, 'should fail without phase-type'); + assert.ok(result.error.includes('phase-type required'), `error should mention phase-type required; got: ${result.error}`); + }); + + test('(b) unknown phase type → result includes unknown_phase_type: true', () => { + const result = runGsdTools('resolve-granularity nonexistent-phase', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.unknown_phase_type, true, 'should flag unknown phase type'); + assert.ok(output.granularity, 'should still return a granularity'); + }); + + test('(c) valid phase type with granularities override → returns override granularity', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + granularity: 'standard', + granularities: { planning: 'fine' }, + }) + ); + const result = runGsdTools('resolve-granularity planning', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.granularity, 'fine', 'granularities.planning override should win'); + assert.strictEqual(output.phase_type, 'planning'); + assert.strictEqual(output.unknown_phase_type, undefined, 'known phase type must not have unknown_phase_type'); + }); +});