feat(3536): Configuration Module via shared manifests + generator (Phase 2 of #3524)
Phase 2 of the CJS↔SDK hard-seam migration (parent #3524). Eliminates the structural drift surface that produced bug class After this phase, neither bin/lib/ nor sdk/src/ defines CONFIG_DEFAULTS, VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, or the four legacy-key normalizations inline. All come from one canonical source: the Configuration Module (sdk/src/configuration/index.ts) + two JSON manifests (sdk/shared/config-{defaults,schema}.manifest.json). The CJS mirror is generator-emitted (get-shit-done/bin/lib/configuration.generated.cjs) with a CI freshness check (sdk/scripts/check-configuration-fresh.mjs). - sdk/shared/config-defaults.manifest.json — canonical nested defaults, union of CJS + SDK keys (includes security_*, post_planning_gaps, agent_skills, mode, every git/workflow/hooks sub-section). - sdk/shared/config-schema.manifest.json — VALID_CONFIG_KEYS array, RUNTIME_STATE_KEYS array, DYNAMIC_KEY_PATTERNS array with source strings (regex reconstructed at runtime). - sdk/src/configuration/index.ts — source of truth. Exports loadConfig (pure read), normalizeLegacyKeys (pure, idempotent, returns Normalization[]), mergeDefaults (deep-merge), migrateOnDisk (explicit opt-in disk writeback), plus CONFIG_DEFAULTS, VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS. - sdk/src/configuration/index.test.ts — 29 vitest pinning tests. - sdk/scripts/gen-configuration.mjs — generator (Function.prototype.toString() inspection of compiled SDK dist, plus brace-balanced text scan for internal helpers, matching the Phase 1 pattern). - sdk/scripts/check-configuration-fresh.mjs — CI freshness gate. - tests/configuration-generator.test.cjs — 27 parity assertions (CJS-generated == SDK source). - tests/configuration-migrate-config.test.cjs — 3 cases for the new gsd-tools migrate-config subcommand. - bin/lib/core.cjs: CONFIG_DEFAULTS literal now sources values from CANONICAL_CONFIG_DEFAULTS (the manifest), with a thin flat projection at the load boundary to preserve the existing flat-shape return contract for the ~21 CJS test files and 100+ consumers. All four legacy-key migration blocks (branching_strategy, sub_repos, multiRepo, depth — historically lines 351-358, 388-397, 401-408, 416-423) collapse to a single normalizeLegacyKeys call in each code path. The inline platformWriteSync writeback stays for now to preserve sync loadConfig semantics; the new async migrateOnDisk is reachable via gsd-tools migrate-config. - bin/lib/config-schema.cjs: 135 → 31 lines. Re-exports from the generated Module. - bin/lib/config.cjs: adds cmdMigrateConfig handler (calls migrateOnDisk on the explicit user-driven path). - bin/gsd-tools.cjs: wires migrate-config into command dispatch. - sdk/src/config.ts: re-exports CONFIG_DEFAULTS and mergeDefaults from the Module. loadConfig now calls normalizeLegacyKeys before mergeDefaults (replaces the inline branching_strategy graft). - sdk/src/query/config-schema.ts: 160 → 36 lines. Re-exports from the Module. - tests/config-schema-sdk-parity.test.cjs: refactored from "CJS Set equals SDK Set" (trivially true post-migration) to "both sides source from the manifest" — structural plus runtime invariant. - Four other tests that text-grepped source files for valid keys (plan-review-convergence, bug-3212, bug-2492, feat-3210) are updated to use runtime VALID_CONFIG_KEYS.has() or manifest JSON lookups. - CONTEXT.md: new Configuration Module entry with full Interface contract. - Root package.json: check:configuration-fresh proxy script. - sdk/package.json: gen:configuration + check:configuration-fresh. - .githooks/pre-commit: configuration drift block. - .github/workflows/test.yml: configuration drift step after the alias drift check. - 9201 CJS tests pass (baseline pre-cycle: 9195; +6 net new tests across migrate-config + parity refactor) - 1872 SDK vitest tests pass - 29 Configuration Module vitest fixtures - 27 CJS/SDK parity fixtures - Net diff: +388 / −519 = 131-line reduction across the seven cycles, despite adding the new Module, manifests, generator, freshness check, and two new test files. 1. SDK CONFIG_DEFAULTS now includes manifest-canonical keys (resolve_model_ids: false, context_window: 200000, phase_naming, claude_md_path, git.create_tag, workflow.security_*, workflow.code_review_*, planning.*, hooks.workflow_guard, ship.*). Consumers accessing via [key: string]: unknown index get the manifest default instead of undefined. 2. SDK mergeDefaults is now proper recursive deep-merge instead of spread-per-section. Overlay { workflow: { research: false } } now preserves sibling workflow keys; previously it replaced the entire workflow section with only research + the section's defaults. Semantically identical for the common case; strictly better for partial nested overrides. 3. New gsd-tools migrate-config CLI subcommand for the explicit, opt-in on-disk migration path. Closes #3536.
This commit is contained in:
@@ -191,7 +191,11 @@ describe('loadConfig', () => {
|
||||
it('pre-project: ignores user defaults and uses built-in defaults', async () => {
|
||||
await writeUserDefaults({ resolve_model_ids: 'omit' });
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect((config as Record<string, unknown>).resolve_model_ids).toBeUndefined();
|
||||
// BEHAVIOR CHANGE (Cycle 3, #3536): CONFIG_DEFAULTS now sourced from
|
||||
// sdk/shared/config-defaults.manifest.json which includes resolve_model_ids: false.
|
||||
// The key is NOT undefined — it has the manifest default (false), not the user
|
||||
// default ('omit'), confirming that user-level ~/.gsd/defaults.json is still ignored.
|
||||
expect((config as Record<string, unknown>).resolve_model_ids).toBe(false);
|
||||
expect(config.model_profile).toBe('balanced');
|
||||
expect(config.workflow.plan_check).toBe(true);
|
||||
});
|
||||
@@ -224,8 +228,10 @@ describe('loadConfig', () => {
|
||||
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.model_profile).toBe('quality');
|
||||
// User-defaults not layered when project config present
|
||||
expect((config as Record<string, unknown>).resolve_model_ids).toBeUndefined();
|
||||
// User-defaults not layered when project config present.
|
||||
// BEHAVIOR CHANGE (Cycle 3, #3536): resolve_model_ids is now false (manifest default),
|
||||
// not undefined — confirming user defaults are still ignored (value is NOT 'omit').
|
||||
expect((config as Record<string, unknown>).resolve_model_ids).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores malformed ~/.gsd/defaults.json', async () => {
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { relPlanningPath } from './workstream-utils.js';
|
||||
import {
|
||||
CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS,
|
||||
mergeDefaults as canonicalMergeDefaults,
|
||||
normalizeLegacyKeys,
|
||||
} from './configuration/index.js';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -86,48 +91,31 @@ export interface GSDConfig {
|
||||
|
||||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const CONFIG_DEFAULTS: GSDConfig = {
|
||||
model_profile: 'balanced',
|
||||
commit_docs: true,
|
||||
parallelization: true,
|
||||
search_gitignored: false,
|
||||
brave_search: false,
|
||||
firecrawl: false,
|
||||
exa_search: false,
|
||||
git: {
|
||||
branching_strategy: 'none',
|
||||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||||
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
||||
quick_branch_template: null,
|
||||
},
|
||||
workflow: {
|
||||
research: true,
|
||||
plan_check: true,
|
||||
verifier: true,
|
||||
nyquist_validation: true,
|
||||
tdd_mode: false,
|
||||
human_verify_mode: 'end-of-phase',
|
||||
auto_advance: false,
|
||||
node_repair: true,
|
||||
node_repair_budget: 2,
|
||||
ui_phase: true,
|
||||
ui_safety_gate: true,
|
||||
text_mode: false,
|
||||
research_before_questions: false,
|
||||
discuss_mode: 'discuss',
|
||||
skip_discuss: false,
|
||||
max_discuss_passes: 3,
|
||||
subagent_timeout: 300000,
|
||||
context_coverage_gate: true,
|
||||
_auto_chain_active: false,
|
||||
},
|
||||
hooks: {
|
||||
context_warnings: true,
|
||||
},
|
||||
agent_skills: {},
|
||||
project_code: null,
|
||||
mode: 'interactive',
|
||||
};
|
||||
/**
|
||||
* Canonical CONFIG_DEFAULTS delegated to the Configuration Module (ADR-3524).
|
||||
* Cast to GSDConfig to preserve typed access for existing consumers.
|
||||
* The canonical manifest may include additional keys beyond GSDConfig's
|
||||
* declared fields (e.g. resolve_model_ids, context_window, planning.*,
|
||||
* ship.*, workflow.security_*, workflow.code_review_*); these are accessible
|
||||
* via the [key: string]: unknown index signature on GSDConfig.
|
||||
*
|
||||
* BEHAVIOR CHANGE (Cycle 3, #3536): CONFIG_DEFAULTS now includes all keys from
|
||||
* sdk/shared/config-defaults.manifest.json. Keys added vs old inline literal:
|
||||
* top-level: resolve_model_ids (false), context_window (200000),
|
||||
* phase_naming ('sequential'), claude_md_path ('./CLAUDE.md')
|
||||
* git: create_tag (true), base_branch (null)
|
||||
* workflow: ai_integration_phase (true), code_review (true),
|
||||
* code_review_depth ('standard'), code_review_command (null),
|
||||
* pattern_mapper (true), plan_bounce (false), plan_bounce_script (null),
|
||||
* plan_bounce_passes (2), auto_prune_state (false),
|
||||
* post_planning_gaps (true), security_enforcement (true),
|
||||
* security_asvs_level (1), security_block_on ('high'),
|
||||
* context_coverage_gate: true (unchanged from old literal)
|
||||
* planning: { commit_docs: true, search_gitignored: false, sub_repos: [], granularity: 'standard' }
|
||||
* hooks: workflow_guard (false)
|
||||
* ship: { pr_body_sections: [] }
|
||||
*/
|
||||
export const CONFIG_DEFAULTS: GSDConfig = CANONICAL_CONFIG_DEFAULTS as unknown as GSDConfig;
|
||||
|
||||
// ─── Loader ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -186,33 +174,29 @@ export async function loadConfig(projectDir: string, workstream?: string): Promi
|
||||
|
||||
// Project config exists — user-level defaults are ignored (CJS parity).
|
||||
// `buildNewProjectConfig` already baked them into config.json at /gsd-new-project.
|
||||
return mergeDefaults(parsed);
|
||||
// Normalize legacy top-level keys (branching_strategy → git.branching_strategy, etc.)
|
||||
// before merging with defaults, matching the Configuration Module's loadConfig pipeline.
|
||||
const { parsed: normalized } = normalizeLegacyKeys(parsed);
|
||||
return mergeDefaults(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge config with defaults using the Configuration Module's deep-merge.
|
||||
* Delegates to canonicalMergeDefaults (ADR-3524, Cycle 3, #3536).
|
||||
*
|
||||
* BEHAVIOR CHANGE (Cycle 3, #3536): The old implementation used spread-per-section
|
||||
* (shallow merge for git/workflow/hooks/agent_skills, spread for top-level).
|
||||
* The new implementation uses recursive deep-merge via canonicalMergeDefaults,
|
||||
* which means partial nested objects (e.g. { workflow: { research: false } })
|
||||
* are now deep-merged rather than replacing the entire section's defaults.
|
||||
* The practical difference: deep-merge preserves sibling default keys within
|
||||
* nested sections even when the overlay only specifies one key — which was
|
||||
* already the intended behavior of the old spread-per-section approach.
|
||||
* Legacy branching_strategy top-level → git.branching_strategy normalization
|
||||
* is now handled by normalizeLegacyKeys inside canonicalMergeDefaults's pipeline
|
||||
* (via loadConfig); for the raw mergeDefaults path, legacy key handling is
|
||||
* delegated to the canonical module.
|
||||
*/
|
||||
function mergeDefaults(parsed: Record<string, unknown>): GSDConfig {
|
||||
const legacyBranchingStrategy = typeof parsed.branching_strategy === 'string'
|
||||
? parsed.branching_strategy
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...structuredClone(CONFIG_DEFAULTS),
|
||||
...parsed,
|
||||
git: {
|
||||
...CONFIG_DEFAULTS.git,
|
||||
...(legacyBranchingStrategy ? { branching_strategy: legacyBranchingStrategy } : {}),
|
||||
...(parsed.git as Partial<GitConfig> ?? {}),
|
||||
},
|
||||
workflow: {
|
||||
...CONFIG_DEFAULTS.workflow,
|
||||
...(parsed.workflow as Partial<WorkflowConfig> ?? {}),
|
||||
},
|
||||
hooks: {
|
||||
...CONFIG_DEFAULTS.hooks,
|
||||
...(parsed.hooks as Partial<HooksConfig> ?? {}),
|
||||
},
|
||||
agent_skills: {
|
||||
...CONFIG_DEFAULTS.agent_skills,
|
||||
...(parsed.agent_skills as Record<string, unknown> ?? {}),
|
||||
},
|
||||
};
|
||||
return canonicalMergeDefaults(parsed) as unknown as GSDConfig;
|
||||
}
|
||||
|
||||
310
sdk/src/configuration/index.test.ts
Normal file
310
sdk/src/configuration/index.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Pinning tests for the Configuration Module (ADR-3524 §6).
|
||||
*
|
||||
* These tests pin the public interface contract. They are RED until
|
||||
* sdk/src/configuration/index.ts is created (Cycle 2).
|
||||
*
|
||||
* Test precedent: sdk/src/config.test.ts (vitest + fs fixtures).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import {
|
||||
loadConfig,
|
||||
normalizeLegacyKeys,
|
||||
mergeDefaults,
|
||||
migrateOnDisk,
|
||||
CONFIG_DEFAULTS,
|
||||
} from './index.js';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTmpProject(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-test-'));
|
||||
mkdirSync(join(dir, '.planning'), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeConfig(dir: string, data: unknown): void {
|
||||
writeFileSync(join(dir, '.planning', 'config.json'), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function readConfigRaw(dir: string): string {
|
||||
return readFileSync(join(dir, '.planning', 'config.json'), 'utf-8');
|
||||
}
|
||||
|
||||
function cleanupDir(dir: string): void {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── loadConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('loadConfig', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupDir(tmpDir);
|
||||
});
|
||||
|
||||
it('returns CONFIG_DEFAULTS when config.json is missing', async () => {
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config).toEqual(CONFIG_DEFAULTS);
|
||||
});
|
||||
|
||||
it('returns CONFIG_DEFAULTS when config.json is empty {}', async () => {
|
||||
writeConfig(tmpDir, {});
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config).toEqual(CONFIG_DEFAULTS);
|
||||
});
|
||||
|
||||
it('returns nested git.branching_strategy when already nested', async () => {
|
||||
writeConfig(tmpDir, { git: { branching_strategy: 'phase' } });
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.git.branching_strategy).toBe('phase');
|
||||
});
|
||||
|
||||
it('normalizes legacy top-level branching_strategy to git.branching_strategy', async () => {
|
||||
writeConfig(tmpDir, { branching_strategy: 'phase' });
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.git.branching_strategy).toBe('phase');
|
||||
});
|
||||
|
||||
it('does NOT write disk when normalizing legacy branching_strategy', async () => {
|
||||
writeConfig(tmpDir, { branching_strategy: 'phase' });
|
||||
const before = readConfigRaw(tmpDir);
|
||||
await loadConfig(tmpDir);
|
||||
const after = readConfigRaw(tmpDir);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('normalizes legacy top-level sub_repos to planning.sub_repos', async () => {
|
||||
writeConfig(tmpDir, { sub_repos: ['app1', 'app2'] });
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect((config.planning as Record<string, unknown>)?.sub_repos).toEqual(['app1', 'app2']);
|
||||
});
|
||||
|
||||
it('handles legacy depth: comprehensive → planning.granularity: fine', async () => {
|
||||
writeConfig(tmpDir, { depth: 'comprehensive' });
|
||||
const config = await loadConfig(tmpDir);
|
||||
// depth is a legacy key that maps to granularity
|
||||
const granularity = (config as Record<string, unknown>).granularity
|
||||
?? (config.planning as Record<string, unknown> | undefined)?.granularity;
|
||||
expect(granularity).toBe('fine');
|
||||
});
|
||||
|
||||
it('handles legacy depth: quick → coarse', async () => {
|
||||
writeConfig(tmpDir, { depth: 'quick' });
|
||||
const config = await loadConfig(tmpDir);
|
||||
const granularity = (config as Record<string, unknown>).granularity
|
||||
?? (config.planning as Record<string, unknown> | undefined)?.granularity;
|
||||
expect(granularity).toBe('coarse');
|
||||
});
|
||||
|
||||
it('handles legacy depth: standard → standard', async () => {
|
||||
writeConfig(tmpDir, { depth: 'standard' });
|
||||
const config = await loadConfig(tmpDir);
|
||||
const granularity = (config as Record<string, unknown>).granularity
|
||||
?? (config.planning as Record<string, unknown> | undefined)?.granularity;
|
||||
expect(granularity).toBe('standard');
|
||||
});
|
||||
|
||||
it('throws with informative error on malformed JSON', async () => {
|
||||
writeFileSync(join(tmpDir, '.planning', 'config.json'), '{bad json');
|
||||
await expect(loadConfig(tmpDir)).rejects.toThrow(/parse|invalid|json/i);
|
||||
});
|
||||
|
||||
it('does not throw when .planning/config.json is missing — returns defaults', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-noplan-'));
|
||||
// intentionally no .planning dir
|
||||
try {
|
||||
const config = await loadConfig(dir);
|
||||
expect(config).toEqual(CONFIG_DEFAULTS);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeLegacyKeys ─────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeLegacyKeys', () => {
|
||||
it('migrates top-level branching_strategy to git.branching_strategy', () => {
|
||||
const input = { branching_strategy: 'phase' };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect((parsed as Record<string, unknown>).branching_strategy).toBeUndefined();
|
||||
expect((parsed as Record<string, Record<string, unknown>>).git?.branching_strategy).toBe('phase');
|
||||
expect(normalizations).toHaveLength(1);
|
||||
expect(normalizations[0]).toMatchObject({ from: 'branching_strategy', to: 'git.branching_strategy', value: 'phase' });
|
||||
});
|
||||
|
||||
it('migrates top-level sub_repos to planning.sub_repos', () => {
|
||||
const input = { sub_repos: ['app1', 'app2'] };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect((parsed as Record<string, unknown>).sub_repos).toBeUndefined();
|
||||
expect((parsed as Record<string, Record<string, unknown>>).planning?.sub_repos).toEqual(['app1', 'app2']);
|
||||
expect(normalizations).toHaveLength(1);
|
||||
expect(normalizations[0]).toMatchObject({ from: 'sub_repos', to: 'planning.sub_repos', value: ['app1', 'app2'] });
|
||||
});
|
||||
|
||||
it('migrates multiRepo: true to planning.sub_repos marker', () => {
|
||||
const input = { multiRepo: true };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect((parsed as Record<string, unknown>).multiRepo).toBeUndefined();
|
||||
expect(normalizations).toHaveLength(1);
|
||||
expect(normalizations[0]).toMatchObject({ from: 'multiRepo', to: 'planning.sub_repos', requiresFilesystem: true });
|
||||
});
|
||||
|
||||
it('migrates top-level depth to granularity (comprehensive → fine)', () => {
|
||||
const input = { depth: 'comprehensive' };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect((parsed as Record<string, unknown>).depth).toBeUndefined();
|
||||
expect(normalizations).toHaveLength(1);
|
||||
expect(normalizations[0].from).toBe('depth');
|
||||
// The value in parsed is the mapped granularity value
|
||||
const gv = (parsed as Record<string, unknown>).granularity
|
||||
?? (parsed as Record<string, Record<string, unknown>>).planning?.granularity;
|
||||
expect(gv).toBe('fine');
|
||||
});
|
||||
|
||||
it('migrates top-level depth: quick → coarse', () => {
|
||||
const input = { depth: 'quick' };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
const gv = (parsed as Record<string, unknown>).granularity
|
||||
?? (parsed as Record<string, Record<string, unknown>>).planning?.granularity;
|
||||
expect(gv).toBe('coarse');
|
||||
expect(normalizations[0].from).toBe('depth');
|
||||
});
|
||||
|
||||
it('migrates all four legacy keys in one pass', () => {
|
||||
const input = {
|
||||
branching_strategy: 'phase',
|
||||
sub_repos: ['app1'],
|
||||
multiRepo: true,
|
||||
depth: 'comprehensive',
|
||||
};
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect(normalizations).toHaveLength(4);
|
||||
// branching_strategy → git.branching_strategy
|
||||
expect((parsed as Record<string, Record<string, unknown>>).git?.branching_strategy).toBe('phase');
|
||||
});
|
||||
|
||||
it('returns empty normalizations for already-normalized input', () => {
|
||||
const input = { git: { branching_strategy: 'phase' }, planning: { sub_repos: ['app1'] } };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect(normalizations).toHaveLength(0);
|
||||
expect(parsed).toEqual(input);
|
||||
});
|
||||
|
||||
it('is idempotent — running twice produces same result with empty normalizations second time', () => {
|
||||
const input = { branching_strategy: 'phase' };
|
||||
const first = normalizeLegacyKeys(input);
|
||||
const second = normalizeLegacyKeys(first.parsed as Record<string, unknown>);
|
||||
expect(second.normalizations).toHaveLength(0);
|
||||
expect(second.parsed).toEqual(first.parsed);
|
||||
});
|
||||
|
||||
it('preserves canonical git.branching_strategy when both top-level and nested exist', () => {
|
||||
const input = { branching_strategy: 'milestone', git: { branching_strategy: 'phase' } };
|
||||
const { parsed } = normalizeLegacyKeys(input);
|
||||
// canonical nested wins
|
||||
expect((parsed as Record<string, Record<string, unknown>>).git?.branching_strategy).toBe('phase');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mergeDefaults ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('mergeDefaults', () => {
|
||||
it('returns full CONFIG_DEFAULTS for empty input', () => {
|
||||
const result = mergeDefaults({});
|
||||
expect(result).toEqual(CONFIG_DEFAULTS);
|
||||
});
|
||||
|
||||
it('merges partial nested input without losing sibling keys', () => {
|
||||
const partial = { git: { base_branch: 'main' } };
|
||||
const result = mergeDefaults(partial);
|
||||
// base_branch from input
|
||||
expect((result.git as Record<string, unknown>).base_branch).toBe('main');
|
||||
// sibling from defaults
|
||||
expect(result.git.branching_strategy).toBe(CONFIG_DEFAULTS.git.branching_strategy);
|
||||
expect(result.git.phase_branch_template).toBe(CONFIG_DEFAULTS.git.phase_branch_template);
|
||||
});
|
||||
|
||||
it('preserves boolean false values (not overridden by truthy defaults)', () => {
|
||||
// workflow.research defaults to true; setting false should survive merge
|
||||
const partial = { workflow: { research: false } };
|
||||
const result = mergeDefaults(partial);
|
||||
expect(result.workflow.research).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves explicit null values', () => {
|
||||
const partial = { project_code: null };
|
||||
const result = mergeDefaults(partial);
|
||||
expect(result.project_code).toBeNull();
|
||||
});
|
||||
|
||||
it('user top-level keys win over defaults', () => {
|
||||
const partial = { model_profile: 'quality' };
|
||||
const result = mergeDefaults(partial);
|
||||
expect(result.model_profile).toBe('quality');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── migrateOnDisk ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('migrateOnDisk', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupDir(tmpDir);
|
||||
});
|
||||
|
||||
it('returns migrated:false, wrote:null for already-normalized config', async () => {
|
||||
writeConfig(tmpDir, { git: { branching_strategy: 'phase' } });
|
||||
const report = await migrateOnDisk(tmpDir);
|
||||
expect(report.migrated).toBe(false);
|
||||
expect(report.wrote).toBeNull();
|
||||
expect(report.normalizations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns migrated:true, writes disk when legacy key present', async () => {
|
||||
writeConfig(tmpDir, { branching_strategy: 'phase' });
|
||||
const report = await migrateOnDisk(tmpDir);
|
||||
expect(report.migrated).toBe(true);
|
||||
expect(report.wrote).not.toBeNull();
|
||||
expect(report.normalizations.length).toBeGreaterThan(0);
|
||||
// Verify disk was updated
|
||||
const onDisk = JSON.parse(readConfigRaw(tmpDir));
|
||||
expect(onDisk.branching_strategy).toBeUndefined();
|
||||
expect(onDisk.git?.branching_strategy).toBe('phase');
|
||||
});
|
||||
|
||||
it('returns report shape: { migrated, normalizations, wrote }', async () => {
|
||||
writeConfig(tmpDir, { branching_strategy: 'milestone' });
|
||||
const report = await migrateOnDisk(tmpDir);
|
||||
expect(report).toHaveProperty('migrated');
|
||||
expect(report).toHaveProperty('normalizations');
|
||||
expect(report).toHaveProperty('wrote');
|
||||
});
|
||||
|
||||
it('is a no-op when .planning/config.json is missing', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-nomig-'));
|
||||
try {
|
||||
const report = await migrateOnDisk(dir);
|
||||
expect(report.migrated).toBe(false);
|
||||
expect(report.wrote).toBeNull();
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
310
sdk/src/configuration/index.ts
Normal file
310
sdk/src/configuration/index.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Configuration Module — single source of truth for config loading,
|
||||
* legacy-key normalization, defaults merge, and explicit on-disk migration.
|
||||
*
|
||||
* Source of truth for both the SDK and (via generator) the CJS side.
|
||||
* Manifests are read from sdk/shared/*.manifest.json.
|
||||
*
|
||||
* Public API:
|
||||
* loadConfig(cwd, options?) → MergedConfig — pure read, never writes disk
|
||||
* normalizeLegacyKeys(parsed) → { parsed, normalizations[] } — pure transform
|
||||
* mergeDefaults(parsed) → MergedConfig — fills in defaults
|
||||
* migrateOnDisk(cwd) → MigrationReport — explicit, opt-in disk writeback
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ─── Manifest imports ─────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULTS_PATH = new URL('../../shared/config-defaults.manifest.json', import.meta.url);
|
||||
export const CONFIG_DEFAULTS: Record<string, unknown> = JSON.parse(
|
||||
readFileSync(fileURLToPath(DEFAULTS_PATH), 'utf-8'),
|
||||
);
|
||||
|
||||
const SCHEMA_PATH = new URL('../../shared/config-schema.manifest.json', import.meta.url);
|
||||
const _schemaManifest: {
|
||||
validKeys: string[];
|
||||
runtimeStateKeys: string[];
|
||||
dynamicKeyPatterns: Array<{ topLevel: string; source: string; description: string }>;
|
||||
} = JSON.parse(readFileSync(fileURLToPath(SCHEMA_PATH), 'utf-8'));
|
||||
|
||||
export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set(_schemaManifest.validKeys);
|
||||
export const RUNTIME_STATE_KEYS: ReadonlySet<string> = new Set(_schemaManifest.runtimeStateKeys);
|
||||
|
||||
export interface DynamicKeyPattern {
|
||||
readonly topLevel: string;
|
||||
readonly source: string;
|
||||
readonly description: string;
|
||||
readonly test: (key: string) => boolean;
|
||||
}
|
||||
|
||||
export const DYNAMIC_KEY_PATTERNS: readonly DynamicKeyPattern[] = _schemaManifest.dynamicKeyPatterns.map(
|
||||
(p) => ({ ...p, test: (key: string) => new RegExp(p.source).test(key) }),
|
||||
);
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Broad merged config type — consumers narrow as needed. */
|
||||
export type MergedConfig = Record<string, unknown>;
|
||||
|
||||
export interface Normalization {
|
||||
from: string;
|
||||
to: string;
|
||||
value: unknown;
|
||||
requiresFilesystem?: true;
|
||||
}
|
||||
|
||||
export interface NormalizationResult {
|
||||
parsed: MergedConfig;
|
||||
normalizations: Normalization[];
|
||||
}
|
||||
|
||||
export interface MigrationReport {
|
||||
migrated: boolean;
|
||||
normalizations: Normalization[];
|
||||
wrote: string | null;
|
||||
}
|
||||
|
||||
export interface LoadConfigOptions {
|
||||
/** Optional workstream name — routes to .planning/workstreams/<name>/config.json */
|
||||
workstream?: string;
|
||||
/** Optional callback to observe normalizations applied during load */
|
||||
onNormalizations?: (normalizations: Normalization[]) => void;
|
||||
}
|
||||
|
||||
// ─── Depth → Granularity mapping ─────────────────────────────────────────────
|
||||
|
||||
const DEPTH_TO_GRANULARITY: Record<string, string> = {
|
||||
quick: 'coarse',
|
||||
standard: 'standard',
|
||||
comprehensive: 'fine',
|
||||
};
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function planningDir(cwd: string, workstream?: string): string {
|
||||
if (!workstream) return join(cwd, '.planning');
|
||||
return join(cwd, '.planning', 'workstreams', workstream);
|
||||
}
|
||||
|
||||
function detectSubRepos(cwd: string): string[] {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
const entries = readdirSync(cwd, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
const gitPath = join(cwd, entry.name, '.git');
|
||||
try {
|
||||
if (existsSync(gitPath)) {
|
||||
results.push(entry.name);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return results.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merge two plain config objects. overlay wins on key conflict.
|
||||
* Explicit null in overlay overrides base (null means "unset this key").
|
||||
* Arrays are replaced, not merged. undefined in overlay falls back to base.
|
||||
*/
|
||||
function deepMergeConfig(base: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...base };
|
||||
for (const key of Object.keys(overlay)) {
|
||||
const ov = overlay[key];
|
||||
if (ov !== null && ov !== undefined && typeof ov === 'object' && !Array.isArray(ov)) {
|
||||
const bv = base[key];
|
||||
if (bv !== null && bv !== undefined && typeof bv === 'object' && !Array.isArray(bv)) {
|
||||
result[key] = deepMergeConfig(bv as Record<string, unknown>, ov as Record<string, unknown>);
|
||||
} else {
|
||||
result[key] = deepMergeConfig({}, ov as Record<string, unknown>);
|
||||
}
|
||||
} else {
|
||||
result[key] = ov;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── normalizeLegacyKeys ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pure transform: migrate legacy top-level config keys to their canonical nested locations.
|
||||
* Returns the normalized parsed object + a list of normalizations applied.
|
||||
* Idempotent: calling twice returns the same result with empty normalizations second time.
|
||||
*
|
||||
* Normalizations applied (in order):
|
||||
* 1. top-level branching_strategy → git.branching_strategy (canonical wins if both present)
|
||||
* 2. top-level sub_repos → planning.sub_repos (canonical wins if both present)
|
||||
* 3. multiRepo: true → planning.sub_repos marker (requiresFilesystem: true)
|
||||
* 4. top-level depth → granularity (top-level) with mapping quick→coarse/standard→standard/comprehensive→fine
|
||||
*/
|
||||
export function normalizeLegacyKeys(parsed: Record<string, unknown>): NormalizationResult {
|
||||
const result: Record<string, unknown> = { ...parsed };
|
||||
const normalizations: Normalization[] = [];
|
||||
|
||||
// 1. branching_strategy → git.branching_strategy
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'branching_strategy')) {
|
||||
const value = result.branching_strategy;
|
||||
const git = (result.git as Record<string, unknown> | undefined) ?? {};
|
||||
if (git.branching_strategy === undefined) {
|
||||
result.git = { ...git, branching_strategy: value };
|
||||
} else {
|
||||
// canonical nested wins — just delete the stale top-level
|
||||
result.git = { ...git };
|
||||
}
|
||||
delete result.branching_strategy;
|
||||
normalizations.push({ from: 'branching_strategy', to: 'git.branching_strategy', value });
|
||||
}
|
||||
|
||||
// 2. top-level sub_repos → planning.sub_repos
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'sub_repos')) {
|
||||
const value = result.sub_repos;
|
||||
const planning = (result.planning as Record<string, unknown> | undefined) ?? {};
|
||||
if (!planning.sub_repos) {
|
||||
result.planning = { ...planning, sub_repos: value };
|
||||
} else {
|
||||
result.planning = { ...planning };
|
||||
}
|
||||
delete result.sub_repos;
|
||||
normalizations.push({ from: 'sub_repos', to: 'planning.sub_repos', value });
|
||||
}
|
||||
|
||||
// 3. multiRepo: true → marker (filesystem detection deferred to migrateOnDisk / caller)
|
||||
if (result.multiRepo === true) {
|
||||
delete result.multiRepo;
|
||||
normalizations.push({ from: 'multiRepo', to: 'planning.sub_repos', value: true, requiresFilesystem: true });
|
||||
}
|
||||
|
||||
// 4. top-level depth → granularity
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'depth') && !Object.prototype.hasOwnProperty.call(result, 'granularity')) {
|
||||
const rawDepth = result.depth as string;
|
||||
const mapped = DEPTH_TO_GRANULARITY[rawDepth] ?? rawDepth;
|
||||
result.granularity = mapped;
|
||||
delete result.depth;
|
||||
normalizations.push({ from: 'depth', to: 'granularity', value: mapped });
|
||||
}
|
||||
|
||||
return { parsed: result, normalizations };
|
||||
}
|
||||
|
||||
// ─── mergeDefaults ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fill in CONFIG_DEFAULTS where the parsed object lacks values.
|
||||
* Deep-merges per-section (git, workflow, hooks, agent_skills, planning, ship).
|
||||
* Boolean false and explicit null are preserved — not overridden by truthy defaults.
|
||||
*/
|
||||
export function mergeDefaults(parsed: Record<string, unknown>): MergedConfig {
|
||||
// Start with a deep clone of defaults, then overlay parsed
|
||||
const defaults = JSON.parse(JSON.stringify(CONFIG_DEFAULTS)) as Record<string, unknown>;
|
||||
return deepMergeConfig(defaults, parsed);
|
||||
}
|
||||
|
||||
// ─── loadConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load project config from .planning/config.json (workstream-aware).
|
||||
* Pure read — never writes disk.
|
||||
*
|
||||
* Pipeline: parse JSON → normalizeLegacyKeys → mergeDefaults → return.
|
||||
*
|
||||
* Missing file → returns CONFIG_DEFAULTS verbatim.
|
||||
* Empty file → returns CONFIG_DEFAULTS verbatim.
|
||||
* Malformed JSON → throws with informative error.
|
||||
*/
|
||||
export async function loadConfig(cwd: string, options?: LoadConfigOptions): Promise<MergedConfig> {
|
||||
const configPath = join(planningDir(cwd, options?.workstream), 'config.json');
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8');
|
||||
} catch {
|
||||
// File missing — return defaults
|
||||
return mergeDefaults({});
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return mergeDefaults({});
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`Config at ${configPath} must be a JSON object`);
|
||||
}
|
||||
|
||||
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
|
||||
if (options?.onNormalizations && normalizations.length > 0) {
|
||||
options.onNormalizations(normalizations);
|
||||
}
|
||||
|
||||
return mergeDefaults(normalized);
|
||||
}
|
||||
|
||||
// ─── migrateOnDisk ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Explicit, opt-in disk writeback.
|
||||
* Reads raw config, runs normalizeLegacyKeys, writes back only if normalizations are non-empty.
|
||||
*
|
||||
* For multiRepo: true entries, also runs filesystem detection to populate planning.sub_repos.
|
||||
*
|
||||
* Returns MigrationReport: { migrated, normalizations, wrote }.
|
||||
*/
|
||||
export async function migrateOnDisk(cwd: string, workstream?: string): Promise<MigrationReport> {
|
||||
const configPath = join(planningDir(cwd, workstream), 'config.json');
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8');
|
||||
} catch {
|
||||
// File missing — nothing to migrate
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Malformed — can't migrate
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
|
||||
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
|
||||
|
||||
if (normalizations.length === 0) {
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
|
||||
// Resolve multiRepo filesystem detection
|
||||
const result = { ...normalized };
|
||||
for (const norm of normalizations) {
|
||||
if (norm.requiresFilesystem) {
|
||||
const detected = detectSubRepos(cwd);
|
||||
if (detected.length > 0) {
|
||||
const planning = (result.planning as Record<string, unknown> | undefined) ?? {};
|
||||
result.planning = { ...planning, sub_repos: detected, commit_docs: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(result, null, 2));
|
||||
return { migrated: true, normalizations, wrote: configPath };
|
||||
}
|
||||
@@ -1,155 +1,31 @@
|
||||
/**
|
||||
* SDK-side mirror of get-shit-done/bin/lib/config-schema.cjs.
|
||||
* Thin re-export adapter — sources schema data from the Configuration Module
|
||||
* (sdk/src/configuration/index.ts), which reads from the manifest at
|
||||
* sdk/shared/config-schema.manifest.json.
|
||||
*
|
||||
* Single source of truth for valid config key paths accepted by
|
||||
* `config-set`. MUST stay in sync with the CJS schema — enforced
|
||||
* by tests/config-schema-sdk-parity.test.cjs (CI drift guard).
|
||||
* All inline literals have been removed. The manifest is the single source
|
||||
* of truth for both the SDK and CJS sides (Phase 2 Cycle 5, #3536).
|
||||
*
|
||||
* If you add/remove a key here, make the identical change in
|
||||
* get-shit-done/bin/lib/config-schema.cjs (and vice versa). The
|
||||
* parity test asserts the two allowlists are set-equal and that
|
||||
* DYNAMIC_KEY_PATTERN_SOURCES produce identical regex source strings.
|
||||
*
|
||||
* See #2653 — CJS/SDK drift caused config-set to reject documented
|
||||
* keys. #2479 added CJS↔docs parity; #2653 adds CJS↔SDK parity.
|
||||
* Consumers of this module see an identical public API to the previous version:
|
||||
* VALID_CONFIG_KEYS — ReadonlySet<string>
|
||||
* RUNTIME_STATE_KEYS — ReadonlySet<string>
|
||||
* DYNAMIC_KEY_PATTERNS — readonly DynamicKeyPattern[]
|
||||
* DynamicKeyPattern — interface (re-exported type)
|
||||
* isValidConfigKeyPath — (keyPath: string) => boolean
|
||||
*/
|
||||
|
||||
/** Exact-match config key paths accepted by config-set. */
|
||||
export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile',
|
||||
'search_gitignored', 'brave_search', 'firecrawl', 'exa_search',
|
||||
'workflow.research', 'workflow.plan_check', 'workflow.verifier',
|
||||
'workflow.nyquist_validation', 'workflow.ai_integration_phase', 'workflow.ui_phase', 'workflow.ui_safety_gate',
|
||||
'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget',
|
||||
'workflow.tdd_mode',
|
||||
'workflow.human_verify_mode',
|
||||
'workflow.text_mode',
|
||||
'workflow.research_before_questions',
|
||||
'workflow.discuss_mode',
|
||||
'workflow.skip_discuss',
|
||||
'workflow.auto_prune_state',
|
||||
'workflow.use_worktrees',
|
||||
'workflow.worktree_skip_hooks',
|
||||
'workflow.code_review',
|
||||
'workflow.code_review_depth',
|
||||
'workflow.code_review_command',
|
||||
'workflow.pattern_mapper',
|
||||
'workflow.plan_bounce',
|
||||
'workflow.plan_bounce_script',
|
||||
'workflow.plan_bounce_passes',
|
||||
'workflow.plan_chunked',
|
||||
'workflow.plan_review_convergence',
|
||||
'workflow.post_planning_gaps',
|
||||
'workflow.security_enforcement',
|
||||
'workflow.security_asvs_level',
|
||||
'workflow.security_block_on',
|
||||
'workflow.drift_threshold',
|
||||
'workflow.drift_action',
|
||||
'code_quality.fallow.enabled',
|
||||
'code_quality.fallow.scope',
|
||||
'code_quality.fallow.profile',
|
||||
'code_quality.fallow.mcp',
|
||||
'ship.pr_body_sections',
|
||||
'git.branching_strategy', 'git.base_branch', 'git.create_tag', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
|
||||
'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos',
|
||||
'review.default_reviewers',
|
||||
'review.ollama_host', 'review.lm_studio_host', 'review.llama_cpp_host',
|
||||
'workflow.cross_ai_execution', 'workflow.cross_ai_command', 'workflow.cross_ai_timeout',
|
||||
'workflow.subagent_timeout',
|
||||
'executor.stall_detect_interval_minutes',
|
||||
'executor.stall_threshold_minutes',
|
||||
'workflow.inline_plan_threshold',
|
||||
'hooks.context_warnings',
|
||||
'hooks.workflow_guard',
|
||||
'workflow.context_coverage_gate',
|
||||
'statusline.show_last_command',
|
||||
'statusline.context_position',
|
||||
'workflow.ui_review',
|
||||
'workflow.max_discuss_passes',
|
||||
'features.thinking_partner',
|
||||
'context',
|
||||
'features.global_learnings',
|
||||
'learnings.max_inject',
|
||||
'project_code', 'phase_naming',
|
||||
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
|
||||
'response_language',
|
||||
'context_window',
|
||||
'intel.enabled',
|
||||
'graphify.enabled',
|
||||
'graphify.build_timeout',
|
||||
'claude_md_path',
|
||||
'claude_md_assembly.mode',
|
||||
// #2517 — runtime-aware model profiles
|
||||
'runtime',
|
||||
// #3162 — documented top-level key: controls model ID resolution for non-Claude runtimes
|
||||
'resolve_model_ids',
|
||||
]);
|
||||
import {
|
||||
VALID_CONFIG_KEYS,
|
||||
RUNTIME_STATE_KEYS,
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
} from '../configuration/index.js';
|
||||
|
||||
/**
|
||||
* Internal runtime-state keys accepted by config-set workflows but not exposed
|
||||
* as user-facing config options.
|
||||
*/
|
||||
export const RUNTIME_STATE_KEYS: ReadonlySet<string> = new Set([
|
||||
'workflow._auto_chain_active',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dynamic-pattern validators — keys matching these regexes are also accepted.
|
||||
* Each entry's `source` MUST equal the corresponding CJS regex `.source`
|
||||
* (the parity test enforces this).
|
||||
*/
|
||||
export interface DynamicKeyPattern {
|
||||
readonly test: (k: string) => boolean;
|
||||
readonly description: string;
|
||||
readonly source: string;
|
||||
}
|
||||
|
||||
export const DYNAMIC_KEY_PATTERNS: readonly DynamicKeyPattern[] = [
|
||||
{
|
||||
source: '^agent_skills\\.[a-zA-Z0-9_-]+$',
|
||||
description: 'agent_skills.<agent-type>',
|
||||
test: (k) => /^agent_skills\.[a-zA-Z0-9_-]+$/.test(k),
|
||||
},
|
||||
{
|
||||
source: '^review\\.models\\.[a-zA-Z0-9_-]+$',
|
||||
description: 'review.models.<cli-name>',
|
||||
test: (k) => /^review\.models\.[a-zA-Z0-9_-]+$/.test(k),
|
||||
},
|
||||
{
|
||||
source: '^features\\.[a-zA-Z0-9_]+$',
|
||||
description: 'features.<feature_name>',
|
||||
test: (k) => /^features\.[a-zA-Z0-9_]+$/.test(k),
|
||||
},
|
||||
{
|
||||
source: '^claude_md_assembly\\.blocks\\.[a-zA-Z0-9_]+$',
|
||||
description: 'claude_md_assembly.blocks.<section>',
|
||||
test: (k) => /^claude_md_assembly\.blocks\.[a-zA-Z0-9_]+$/.test(k),
|
||||
},
|
||||
// #2517 — runtime-aware model profile overrides: model_profile_overrides.<runtime>.<tier>
|
||||
{
|
||||
source: '^model_profile_overrides\\.[a-zA-Z0-9_-]+\\.(opus|sonnet|haiku)$',
|
||||
description: 'model_profile_overrides.<runtime>.<opus|sonnet|haiku>',
|
||||
test: (k) => /^model_profile_overrides\.[a-zA-Z0-9_-]+\.(opus|sonnet|haiku)$/.test(k),
|
||||
},
|
||||
// #3023 — per-phase-type model map: models.<phase_type> = <tier>
|
||||
{
|
||||
source: '^models\\.(planning|discuss|research|execution|verification|completion)$',
|
||||
description: 'models.<planning|discuss|research|execution|verification|completion>',
|
||||
test: (k) => /^models\.(planning|discuss|research|execution|verification|completion)$/.test(k),
|
||||
},
|
||||
// #3024 — dynamic routing with failure-tier escalation
|
||||
{
|
||||
source: '^dynamic_routing\\.(enabled|escalate_on_failure|max_escalations|tier_models\\.(light|standard|heavy))$',
|
||||
description: 'dynamic_routing.<enabled|escalate_on_failure|max_escalations|tier_models.<light|standard|heavy>>',
|
||||
test: (k) => /^dynamic_routing\.(enabled|escalate_on_failure|max_escalations|tier_models\.(light|standard|heavy))$/.test(k),
|
||||
},
|
||||
// #3227 — per-agent model overrides: model_overrides.<agent-id>
|
||||
{
|
||||
source: '^model_overrides\\.[a-zA-Z0-9_-]+$',
|
||||
description: 'model_overrides.<agent-id>',
|
||||
test: (k) => /^model_overrides\.[a-zA-Z0-9_-]+$/.test(k),
|
||||
},
|
||||
];
|
||||
export {
|
||||
VALID_CONFIG_KEYS,
|
||||
RUNTIME_STATE_KEYS,
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
type DynamicKeyPattern,
|
||||
} from '../configuration/index.js';
|
||||
|
||||
/** Returns true if keyPath is a valid config key (exact, runtime-state, or dynamic pattern). */
|
||||
export function isValidConfigKeyPath(keyPath: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user