Closes #68. Per-phase-type granularity overrides via granularities.<phaseType>, mirroring models.<phaseType>. Includes maintainer-authorized sdk-seam reference cleanup.
This commit is contained in:
5
.changeset/rapid-dogs-run.md
Normal file
5
.changeset/rapid-dogs-run.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 595
|
||||
---
|
||||
**Per-phase granularity overrides (`granularities.<phaseType>`)** — planning granularity can now be set per phase type (planning/discuss/research/execution/verification/completion) to override the global `granularity`, mirroring `models.<phaseType>`. Resolve with `gsd-tools query resolve-granularity <phaseType>`.
|
||||
@@ -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.<runtime>.<tier>` | 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.<phase_type>` | 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.<phase_type>` | 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.<tier>` | 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 |
|
||||
|
||||
@@ -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.<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)) |
|
||||
| `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 |
|
||||
|
||||
@@ -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 <value> pairs first,
|
||||
// then the AGENT is the single remaining positional.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -142,6 +142,11 @@
|
||||
"source": "^models\\.(planning|discuss|research|execution|verification|completion)$",
|
||||
"description": "models.<planning|discuss|research|execution|verification|completion>"
|
||||
},
|
||||
{
|
||||
"topLevel": "granularities",
|
||||
"source": "^granularities\\.(planning|discuss|research|execution|verification|completion)$",
|
||||
"description": "granularities.<planning|discuss|research|execution|verification|completion>"
|
||||
},
|
||||
{
|
||||
"topLevel": "dynamic_routing",
|
||||
"source": "^dynamic_routing\\.(enabled|escalate_on_failure|max_escalations|tier_models\\.(light|standard|heavy))$",
|
||||
|
||||
320
tests/feat-68-per-phase-granularity.test.cjs
Normal file
320
tests/feat-68-per-phase-granularity.test.cjs
Normal file
@@ -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.<phase_type> validation ──────────────────────────
|
||||
|
||||
describe('#68 config-schema: granularities.<phase_type> 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.<phase_type> 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user