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:
Tom Boucher
2026-05-14 23:50:15 -04:00
parent a7f0af2ce9
commit 2de2d185fa
28 changed files with 2166 additions and 522 deletions

View File

@@ -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 () => {

View File

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

View 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);
}
});
});

View 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 };
}

View File

@@ -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 {