Files
msd-core/src/config.cts
BeeHiggs 0967358b8b enhance(#3638): render bracket phase IDs on progress, stats, manager and statusline surfaces (epic #612 PR-5) (#4111)
* enhance(#3638): render bracket IDs on display surfaces

Gate progress, stats, manager, and statusline projections on the bracket convention; validate phase_id_convention and single-source the convention card.

Forward note: the uat.cts bracket co-change remains deliberately deferred to its owning slice.

* chore(#3638): point the changeset at PR #4111

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#3638): close bracket display review gaps

* docs(#3638): register phase display modules

* chore(#3638): re-trigger CI after macOS shard SIGTERM

`full test (macos-latest, 24, shard 3/3)` failed on 20ce98cd1 in
`tests/lint-compiled-artifact-sync.test.cjs` — the spawned
`scripts/lint-compiled-artifact-sync.cjs` was killed at 60024ms
(`exited null (signal SIGTERM)`, stdout and stderr both empty), 24ms past
the test's own `TSC_COMPILE_TIMEOUT_MS`. That is the failure mode the
constant's comment already documents ("under CI shard load that compile
can exceed the budget, dying to a SIGTERM with empty piped stdout").

No content change; this empty commit exists only to re-run the matrix,
since re-running a job needs write access on the upstream repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-15 04:47:47 -04:00

1467 lines
64 KiB
TypeScript

/**
* Config — Planning config CRUD operations
*
* ADR-457 build-at-publish: the hand-written bin/lib/config.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
const { output, error, ERROR_REASON } = io;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cliExitMod = require('./cli-exit.cjs');
const { ExitError } = cliExitMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoader = require('./config-loader.cjs');
const { CONFIG_DEFAULTS } = configLoader;
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir, planningRoot, resolveEnvWorkstream, withPlanningLock } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import modelProfiles = require('./model-profiles.cjs');
const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configSchema = require('./config-schema.cjs');
const { VALID_CONFIG_KEYS, isValidConfigKey, getCapabilityConfigSchema } = configSchema;
import { isSecretKey, maskSecret } from './secrets.cjs';
import { normalizeConfiguredDefaultReviewers, INSTANCE_NAME_PATTERN, KNOWN_REVIEWER_SLUGS } from './review-reviewer-selection.cjs';
import { migrateOnDisk } from './configuration.cjs';
// #3760: the ADR-1411 out-of-band diagnostic. It lives here rather than inside
// `migrateOnDisk` because `configuration.cjs` must stay loadable from an install
// layout holding only itself plus its manifests (#3571) — see the note at the top
// of configuration.cts.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import unusableInputModule = require('./unusable-input.cjs');
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputModule;
// ─── Types ────────────────────────────────────────────────────────────────────
interface SetConfigValueResult {
updated: boolean;
key: string;
value: unknown;
previousValue: unknown;
}
interface UnsetConfigValueResult {
updated: boolean;
unset: true;
key: string;
value: null;
previousValue: unknown;
}
interface WorkstreamContext {
configPath?: string;
[key: string]: unknown;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const CONFIG_KEY_SUGGESTIONS: Record<string, string> = {
'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation',
'agents.nyquist_validation_enabled': 'workflow.nyquist_validation',
'nyquist.validation_enabled': 'workflow.nyquist_validation',
'hooks.research_questions': 'workflow.research_before_questions',
'workflow.research_questions': 'workflow.research_before_questions',
'workflow.codereview': 'workflow.code_review',
'workflow.review_command': 'workflow.code_review_command',
'workflow.review': 'workflow.code_review',
'workflow.code_review_level': 'workflow.code_review_depth',
'workflow.review_depth': 'workflow.code_review_depth',
'review.model': 'review.models.<cli-name>',
'sub_repos': 'planning.sub_repos',
'plan_checker': 'workflow.plan_check',
};
const SHIP_PR_BODY_SECTION_KEYS = new Set(['heading', 'enabled', 'source', 'fallback', 'template']);
const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([
'phase_number',
'phase_name',
'phase_dir',
'base_branch',
'padded_phase',
]);
const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/;
// ADR-612 PR-5: configuration accepts every convention the runtime can read.
// Keep this distinct from roadmap-upgrade's supported target set: sequential
// is valid project configuration but is not a migration destination.
const VALID_PHASE_ID_CONVENTIONS: readonly string[] = Object.freeze([
'sequential',
'milestone-prefixed',
'bracket',
]);
/**
* Schema-level defaults for well-known config keys.
* When a key is absent from config.json and no --default flag was supplied,
* cmdConfigGet checks here before emitting "Key not found".
*/
const SCHEMA_DEFAULTS: Record<string, unknown> = {
'context_window': 200000,
'executor.stall_detect_interval_minutes': 5,
'executor.stall_threshold_minutes': 10,
'planner.stall_detect_interval_minutes': 5,
'planner.stall_threshold_minutes': 10,
'git.create_tag': true,
// #1689: per-plan agent_hint executor routing — default-on. A no-op for plans
// without an agent_hint field, so existing dispatch is byte-identical.
'workflow.agent_hint_routing': true,
// #4401: Compact Content mode gate — derived from the defaults manifest via
// CONFIG_DEFAULTS (added in config-loader.cts) so the manifest stays the
// single source of truth, matching workflow.smart_zone_tokens /
// planning.pr_strict / workflow.inline_plan_threshold below.
'workflow.compact_content': CONFIG_DEFAULTS.compact_content,
// Derived from the defaults manifest rather than restated, so the manifest
// stays the single source of truth for the smart-zone budget (#2630).
'workflow.smart_zone_tokens': CONFIG_DEFAULTS.smart_zone_tokens,
// #2971: /gsd:pr-branch reads this key directly; an absent key must resolve to the
// manifest default rather than "Key not found". Derived from the defaults manifest so
// the manifest stays the single source of truth.
'planning.pr_strict': CONFIG_DEFAULTS.pr_strict,
// #3801: execute-plan reads this key on every run; an absent key must resolve
// to the manifest default (2) rather than "Key not Found" — previously the
// effective default existed only as the workflow's shell fallback and the
// docs disagreed (settings-advanced said 3). Manifest stays the one owner.
'workflow.inline_plan_threshold': CONFIG_DEFAULTS.inline_plan_threshold,
// #4285 review: an absent threshold resolved to "Key not found" while the
// hook silently used 35/25 — the query surface disagreeing with the reader.
//
// Restated here rather than derived: `CONFIG_DEFAULTS` is re-exported with a
// FLATTENED shape that drops the manifest's nested blocks, so
// `CONFIG_DEFAULTS.hooks` is undefined at runtime and the manifest cannot
// feed these two rows the way `workflow.smart_zone_tokens` above is fed.
//
// Not added to `buildNewProjectConfig` either, and that one is deliberate
// rather than incidental: it writes a `hooks` object into every NEW project's
// config.json, which would freeze today's fire-points as an explicit
// per-project override everywhere — the opposite of this PR's premise that an
// absent key tracks the shipped default. (The manifest alone would NOT have
// that effect; `buildNewProjectConfig` builds its own literal. Correcting an
// earlier version of this comment that ran the two together.)
//
// That leaves ONE copy of 35/25 outside the hook — these two rows — and
// `tests/config.test.cjs` pins them against the hook's exported
// WARNING_THRESHOLD/CRITICAL_THRESHOLD so the copies cannot drift.
'hooks.context_warning_threshold': 35,
'hooks.context_critical_threshold': 25,
};
/**
* Resolve a schema-level default for an absent key (#2256). Checks the legacy
* hardcoded SCHEMA_DEFAULTS first, then the capability-registry configSchema
* default — the same registry default the runtime's capability-activation
* resolver (resolveConfigKey Level 4, capability-activation.cts) already honors,
* so `query config-get` can no longer disagree with the runtime about an absent
* key's effective value.
*/
function resolveSchemaDefault(cwd: string, kp: string): { found: boolean; value: unknown } {
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
return { found: true, value: SCHEMA_DEFAULTS[kp] };
}
const capSchema = getCapabilityConfigSchema(cwd);
if (capSchema && typeof capSchema === 'object'
&& Object.prototype.hasOwnProperty.call(capSchema, kp)) {
const entry = capSchema[kp];
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
const def = (entry as Record<string, unknown>)['default'];
if (def !== undefined) return { found: true, value: def };
}
}
return { found: false, value: undefined };
}
/**
* Emit a schema-resolved default (#2256), applying the same secret-masking
* invariant the found-key path applies. getCapabilityConfigSchema is a
* federated, third-party-extensible surface (ADR-1244) — a future key-name
* collision with a secret key must not leak a declared default in plaintext.
* Centralizing emission here means masking can't be missed at a call site.
*/
function emitResolvedDefault(kp: string, value: unknown, raw: boolean): void {
if (isSecretKey(kp)) {
const masked = maskSecret(value as Parameters<typeof maskSecret>[0]);
output(masked, raw, masked);
return;
}
output(value, raw, String(value));
}
// ─── Validation helpers ───────────────────────────────────────────────────────
function validateKnownConfigKeyPath(keyPath: string): void {
const suggested = CONFIG_KEY_SUGGESTIONS[keyPath];
if (suggested) {
error(`Unknown config key: ${keyPath}. Did you mean ${suggested}?`, ERROR_REASON.CONFIG_INVALID_KEY);
}
}
/**
* Is `value` an acceptable `git.protected_branches` list (#3552)?
*
* A non-empty array whose every element is a string with non-whitespace
* content. Exported so a property test can pin this predicate against the
* resolver's own per-entry filter in `git-base-branch.cts` — `config-set` must
* only accept lists the resolver will honour in full, with nothing rejected.
* The two are deliberately different shapes (all-or-nothing here, per-entry
* there, because a direct file edit bypasses this check), so nothing keeps them
* agreeing except a test that asks both.
*/
function isValidProtectedBranches(value: unknown): boolean {
if (!Array.isArray(value) || value.length === 0) return false;
const entries = value as unknown[];
// Index, do NOT use `.every()`. `.every()` SKIPS holes, so a sparse array
// (`["main", , "develop"]`) passed this check while the resolver's `for...of`
// — which yields `undefined` for a hole — rejected that element. The two
// surfaces then disagreed about the same value. JSON cannot express a hole,
// so neither surface meets one in production, but "unreachable" is not a
// reason to leave two definitions of the same predicate contradicting each
// other (round-4 external review).
for (let i = 0; i < entries.length; i += 1) {
const branch = entries[i];
if (typeof branch !== 'string' || branch.trim().length === 0) return false;
}
return true;
}
function validateShipPrBodySections(value: unknown): void {
if (!Array.isArray(value)) {
error('Invalid ship.pr_body_sections value. Expected a JSON array of section objects.');
}
(value as unknown[]).forEach((section: unknown, index: number) => {
const prefix = `Invalid ship.pr_body_sections[${index}]`;
if (!section || typeof section !== 'object' || Array.isArray(section)) {
error(`${prefix}. Expected an object.`);
}
const sectionObj = section as Record<string, unknown>;
const unknownKeys = Object.keys(sectionObj).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key));
if (unknownKeys.length > 0) {
error(`${prefix}. Unknown field(s): ${unknownKeys.join(', ')}.`);
}
if (typeof sectionObj['heading'] !== 'string' || sectionObj['heading'].trim() === '') {
error(`${prefix}. heading must be a non-empty string.`);
}
if (/[\r\n]/.test(sectionObj['heading'] as string)) {
error(`${prefix}. heading must be a single line.`);
}
if ('enabled' in sectionObj && typeof sectionObj['enabled'] !== 'boolean') {
error(`${prefix}. enabled must be true or false.`);
}
for (const field of ['source', 'fallback', 'template']) {
if (field in sectionObj && typeof sectionObj[field] !== 'string') {
error(`${prefix}. ${field} must be a string.`);
}
}
const hasContent = ['source', 'fallback', 'template'].some((field) => {
const v = sectionObj[field];
return typeof v === 'string' && v.trim() !== '';
});
if (!hasContent) {
error(`${prefix}. Provide at least one of source, fallback, or template.`);
}
if (typeof sectionObj['source'] === 'string' && sectionObj['source'].trim() !== '') {
const selectors = sectionObj['source'].split('||').map((selector) => selector.trim()).filter(Boolean);
if (selectors.length === 0 || selectors.some((selector) => !SHIP_PR_BODY_SOURCE_RE.test(selector))) {
error(`${prefix}. source must use selectors like "PLAN.md ## Risks", separated with "||".`);
}
}
if (typeof sectionObj['template'] === 'string') {
const tokens = sectionObj['template'].matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g);
for (const match of tokens) {
if (!SHIP_PR_BODY_TEMPLATE_TOKENS.has(match[1])) {
error(`${prefix}. Unsupported template token: {${match[1]}}.`);
}
}
}
});
}
// ─── Core config operations ───────────────────────────────────────────────────
/**
* Build a fully-materialized config object for a new project.
*
* Merges (increasing priority):
* 1. Hardcoded defaults — every key that loadConfig() resolves, plus mode/granularity
* 2. User-level defaults from ~/.gsd/defaults.json (if present)
* 3. userChoices — the settings the user explicitly selected during /gsd:new-project
*
* Uses the canonical `git` namespace for branching keys (consistent with VALID_CONFIG_KEYS
* and the settings workflow). loadConfig() handles both flat and nested formats, so this
* is backward-compatible with existing projects that have flat keys.
*
* Returns a plain object — does NOT write any files.
*/
function buildNewProjectConfig(userChoices: Record<string, unknown>): Record<string, unknown> {
const choices = userChoices || {};
const homedir = os.homedir();
// Detect API key availability
const braveKeyFile = path.join(homedir, '.gsd', 'brave_api_key');
const hasBraveSearch = !!(process.env['BRAVE_API_KEY'] || fs.existsSync(braveKeyFile));
const firecrawlKeyFile = path.join(homedir, '.gsd', 'firecrawl_api_key');
const hasFirecrawl = !!(process.env['FIRECRAWL_API_KEY'] || fs.existsSync(firecrawlKeyFile));
const exaKeyFile = path.join(homedir, '.gsd', 'exa_api_key');
const hasExaSearch = !!(process.env['EXA_API_KEY'] || fs.existsSync(exaKeyFile));
const tavilyKeyFile = path.join(homedir, '.gsd', 'tavily_api_key');
const hasTavilySearch = !!(process.env['TAVILY_API_KEY'] || fs.existsSync(tavilyKeyFile));
const refKeyFile = path.join(homedir, '.gsd', 'ref_api_key');
const hasRefSearch = !!(process.env['REF_API_KEY'] || fs.existsSync(refKeyFile));
const perplexityKeyFile = path.join(homedir, '.gsd', 'perplexity_api_key');
const hasPerplexity = !!(process.env['PERPLEXITY_API_KEY'] || fs.existsSync(perplexityKeyFile));
const jinaKeyFile = path.join(homedir, '.gsd', 'jina_api_key');
const hasJina = !!(process.env['JINA_API_KEY'] || fs.existsSync(jinaKeyFile));
// Load user-level defaults from ~/.gsd/defaults.json if available
const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json');
let userDefaults: Record<string, unknown> = {};
try {
if (fs.existsSync(globalDefaultsPath)) {
userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8')) as Record<string, unknown>;
// Migrate deprecated "depth" key to "granularity"
if ('depth' in userDefaults && !('granularity' in userDefaults)) {
const depthToGranularity: Record<string, string> = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
userDefaults['granularity'] = depthToGranularity[userDefaults['depth'] as string] || userDefaults['depth'];
delete userDefaults['depth'];
try {
platformWriteSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2));
} catch { /* intentionally empty */ }
}
}
} catch {
// Ignore malformed global defaults
}
const hardcoded: Record<string, unknown> = {
model_profile: CONFIG_DEFAULTS.model_profile,
commit_docs: CONFIG_DEFAULTS.commit_docs,
parallelization: CONFIG_DEFAULTS.parallelization,
search_gitignored: CONFIG_DEFAULTS.search_gitignored,
brave_search: hasBraveSearch,
firecrawl: hasFirecrawl,
exa_search: hasExaSearch,
tavily_search: hasTavilySearch,
ref_search: hasRefSearch,
perplexity: hasPerplexity,
jina: hasJina,
git: {
branching_strategy: CONFIG_DEFAULTS.branching_strategy,
create_tag: true,
phase_branch_template: CONFIG_DEFAULTS.phase_branch_template,
milestone_branch_template: CONFIG_DEFAULTS.milestone_branch_template,
quick_branch_template: CONFIG_DEFAULTS.quick_branch_template,
},
workflow: {
research: true,
plan_check: true,
verifier: true,
nyquist_validation: true,
auto_advance: false,
node_repair: true,
node_repair_budget: 2,
ui_phase: true,
ui_safety_gate: true,
ai_integration_phase: true,
api_coverage_gate: true,
human_verify_mode: 'end-of-phase',
context_guard_mode: 'warn',
text_mode: false,
compact_content: false,
research_before_questions: false,
discuss_mode: 'discuss',
skip_discuss: false,
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: CONFIG_DEFAULTS.post_planning_gaps,
security_enforcement: CONFIG_DEFAULTS.security_enforcement,
security_asvs_level: CONFIG_DEFAULTS.security_asvs_level,
security_block_on: CONFIG_DEFAULTS.security_block_on,
},
ship: {
pr_body_sections: [],
},
hooks: {
context_warnings: true,
},
project_code: null,
phase_naming: 'sequential',
agent_skills: {},
claude_md_path: './.claude/CLAUDE.md',
plan_review: {
source_grounding: true,
source_grounding_authority: 'grep',
},
};
const ud = userDefaults as Record<string, Record<string, unknown>>;
const ch = choices as Record<string, Record<string, unknown>>;
const hd = hardcoded as Record<string, Record<string, unknown>>;
// #2840: `runtime` is host-specific (written by the installer for whichever
// runtime's install ran last). On a machine with 2+ runtimes, it poisons every
// new project config — e.g. a Codex install's `runtime:"codex"` leaks into
// Claude Code projects, resolving agents to wrong model IDs. `resolve_model_ids`
// already has a per-install guard (#2297); `runtime` gets the same treatment
// by excluding it from the defaults spread. Projects detect the runtime from
// the install path / .gsd-runtime marker, not from a copied config key.
const safeDefaults = { ...userDefaults };
delete safeDefaults['runtime'];
// Three-level deep merge: hardcoded <- userDefaults <- choices
const config: Record<string, unknown> = {
...hardcoded,
...safeDefaults,
...choices,
git: {
...hd['git'],
...(ud['git'] || {}),
...(ch['git'] || {}),
},
workflow: {
...hd['workflow'],
...(ud['workflow'] || {}),
...(ch['workflow'] || {}),
},
ship: {
...hd['ship'],
...(ud['ship'] || {}),
...(ch['ship'] || {}),
},
hooks: {
...hd['hooks'],
...(ud['hooks'] || {}),
...(ch['hooks'] || {}),
},
agent_skills: {
...hd['agent_skills'],
...(ud['agent_skills'] || {}),
...(ch['agent_skills'] || {}),
},
plan_review: {
...hd['plan_review'],
...(ud['plan_review'] || {}),
...(ch['plan_review'] || {}),
},
};
validateShipPrBodySections((config['ship'] as Record<string, unknown>)['pr_body_sections']);
return config;
}
/**
* Command: create a fully-materialized .planning/config.json for a new project.
*
* Accepts user-chosen settings as a JSON string (the keys the user explicitly
* configured during /gsd:new-project). All remaining keys are filled from
* hardcoded defaults and optional ~/.gsd/defaults.json.
*
* Idempotent: if config.json already exists, returns { created: false }.
*/
function cmdConfigNewProject(cwd: string, choicesJson: string | undefined, raw: boolean): void {
const planningBase = planningDir(cwd);
const configPath = path.join(planningBase, 'config.json');
// Idempotent: don't overwrite existing config
if (fs.existsSync(configPath)) {
output({ created: false, reason: 'already_exists' }, raw, 'exists');
return;
}
// Parse user choices
let userChoices: Record<string, unknown> = {};
if (choicesJson && choicesJson.trim() !== '') {
try {
userChoices = JSON.parse(choicesJson) as Record<string, unknown>;
} catch (err) {
error('Invalid JSON for config-new-project: ' + (err as Error).message);
}
}
// Ensure .planning directory exists
try {
platformEnsureDir(planningBase);
} catch (err) {
error('Failed to create .planning directory: ' + (err as Error).message);
}
const config = buildNewProjectConfig(userChoices);
try {
platformWriteSync(configPath, JSON.stringify(config, null, 2));
output({ created: true, path: '.planning/config.json' }, raw, 'created');
} catch (err) {
error('Failed to write config.json: ' + (err as Error).message);
}
}
/**
* Ensures the config file exists (creates it if needed).
*
* Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
* the happy path. But note that `error()` will still `exit(1)` out of the process.
*/
function ensureConfigFile(cwd: string): { created: boolean; reason?: string; path?: string } | undefined {
const planningBase = planningDir(cwd);
const configPath = path.join(planningBase, 'config.json');
// Ensure .planning directory exists
try {
platformEnsureDir(planningBase);
} catch (err) {
error('Failed to create .planning directory: ' + (err as Error).message);
}
// Check if config already exists
if (fs.existsSync(configPath)) {
return { created: false, reason: 'already_exists' };
}
const config = buildNewProjectConfig({});
try {
platformWriteSync(configPath, JSON.stringify(config, null, 2));
return { created: true, path: '.planning/config.json' };
} catch (err) {
error('Failed to create config.json: ' + (err as Error).message);
}
}
/**
* Command to ensure the config file exists (creates it if needed).
*
* Note that this exits the process (via `output()`) even in the happy path; use
* `ensureConfigFile()` directly if you need to avoid this.
*/
function cmdConfigEnsureSection(cwd: string, raw: boolean): void {
const ensureConfigFileResult = ensureConfigFile(cwd);
if (ensureConfigFileResult && ensureConfigFileResult.created) {
output(ensureConfigFileResult, raw, 'created');
} else {
output(ensureConfigFileResult, raw, 'exists');
}
}
/**
* Shared helper: write a single key-path into an in-memory config object.
*
* Prototype-pollution guard: reject dangerous segments via inline literal
* comparisons on the exact key used to index `current`, immediately before
* each write. The inline comparison is the barrier CodeQL's
* js/prototype-pollution-utility query recognises — the previous Set-based
* pre-loop check was functionally correct but not traced through, so
* code-scanning alert #26 kept firing. Behaviour is unchanged from #663.
*
* Returns the previous value at the leaf key (undefined if absent).
* Never writes to disk — callers handle persistence.
* Calls error() (process.exit(1)) on prototype-pollution attempts.
*/
function _setNestedValue(
config: Record<string, unknown>,
keyPath: string,
parsedValue: unknown,
): unknown {
const keys = keyPath.split('.');
let current: Record<string, unknown> = config;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const existingChild = current[key];
if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
const lastKey = keys[keys.length - 1];
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const previousValue = current[lastKey];
current[lastKey] = parsedValue;
return previousValue;
}
/**
* Deletes a value from the config object, allowing nested values via dot
* notation (e.g., "review.models.codex"). Mirrors `_setNestedValue`'s
* prototype-pollution guard on every path segment (including intermediates).
*
* Unlike `_setNestedValue`, this NEVER creates missing intermediate objects —
* if any segment along the path is missing (or not a plain, non-array
* object), the key doesn't exist and we return early without mutating
* `config` at all.
*
* Does not prune now-empty parent objects after deletion (matches the
* conservative, structure-preserving behaviour callers expect from a bare
* unset).
*
* Returns { previousValue, existed } — existed is false when the leaf key
* (or an intermediate segment) was never present.
* Calls error() (process.exit(1)) on prototype-pollution attempts.
*/
function _unsetNestedValue(
config: Record<string, unknown>,
keyPath: string,
): { previousValue: unknown; existed: boolean } {
const keys = keyPath.split('.');
let current: Record<string, unknown> = config;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const existingChild = current[key];
if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) {
// Path doesn't exist — nothing to unset, and we must not create it.
return { previousValue: undefined, existed: false };
}
current = existingChild as Record<string, unknown>;
}
const lastKey = keys[keys.length - 1];
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const existed = Object.prototype.hasOwnProperty.call(current, lastKey);
const previousValue = current[lastKey];
if (existed) {
delete current[lastKey];
}
return { previousValue, existed };
}
/**
* Deletes a key from the config file, allowing nested values via dot
* notation. Mirrors `setConfigValue`'s load/lock/write cycle.
*
* Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
* the happy path. But note that `error()` will still `exit(1)` out of the process.
*/
/**
* Loads `.planning/config.json` as a plain object, or `{}` if the file does
* not exist. A parse failure calls `error()` (process-exiting) rather than
* throwing, matching every caller's existing behavior.
*
* Single source for this load+parse step — `setConfigValue`,
* `unsetConfigValue`, `setConfigValues`, `previewConfigValue`, and
* `previewUnsetConfigValue` all delegate here instead of each repeating the
* same try/catch (CLAUDE.md's "Generative Fix Divergence" known-defect
* pattern: independently-guessed copies of the same logic can silently
* drift apart).
*/
function loadConfigJson(cwd: string): Record<string, unknown> {
const configPath = path.join(planningDir(cwd), 'config.json');
let config: Record<string, unknown> = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
}
} catch (err) {
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
return config;
}
function unsetConfigValue(cwd: string, keyPath: string): UnsetConfigValueResult {
const configPath = path.join(planningDir(cwd), 'config.json');
return withPlanningLock(cwd, () => {
const config = loadConfigJson(cwd);
const { previousValue, existed } = _unsetNestedValue(config, keyPath);
// Write back
try {
platformWriteSync(configPath, JSON.stringify(config, null, 2));
return { updated: existed, unset: true, key: keyPath, value: null, previousValue };
} catch (err) {
error('Failed to write config.json: ' + (err as Error).message);
}
}) as UnsetConfigValueResult;
}
/**
* Sets a value in the config file, allowing nested values via dot notation (e.g.,
* "workflow.research").
*
* Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
* the happy path. But note that `error()` will still `exit(1)` out of the process.
*/
function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): SetConfigValueResult {
const configPath = path.join(planningDir(cwd), 'config.json');
return withPlanningLock(cwd, () => {
const config = loadConfigJson(cwd);
const previousValue = _setNestedValue(config, keyPath, parsedValue);
// Write back
try {
platformWriteSync(configPath, JSON.stringify(config, null, 2));
return { updated: true, key: keyPath, value: parsedValue, previousValue };
} catch (err) {
error('Failed to write config.json: ' + (err as Error).message);
}
}) as SetConfigValueResult;
}
/**
* #4444: read-only preview counterpart to `setConfigValue` — loads config
* exactly like the real setter and reuses `_setNestedValue` (the SAME
* traversal/creation logic, including its prototype-pollution guards) on a
* throwaway in-memory copy that is NEVER written back to disk. This is what
* makes the dry-run preview provably identical to what the real write would
* compute, rather than a second, hand-maintained traversal that could drift
* from the real one.
*/
function previewConfigValue(cwd: string, keyPath: string, parsedValue: unknown): { key: string; value: unknown; previousValue: unknown } {
const config = loadConfigJson(cwd);
const previousValue = _setNestedValue(config, keyPath, parsedValue);
return { key: keyPath, value: parsedValue, previousValue };
}
/**
* #4444: read-only preview counterpart to `unsetConfigValue` — same pattern
* as `previewConfigValue`, reusing `_unsetNestedValue` on a throwaway copy.
*/
function previewUnsetConfigValue(cwd: string, keyPath: string): { key: string; value: null; previousValue: unknown; existed: boolean } {
const config = loadConfigJson(cwd);
const { previousValue, existed } = _unsetNestedValue(config, keyPath);
return { key: keyPath, value: null, previousValue, existed };
}
/**
* Batched sibling of setConfigValue: apply multiple key-path writes in a
* single load → set-all → write cycle inside ONE withPlanningLock call.
*
* Returns { updated: true, results: SetConfigValueResult[] } on success.
* An empty entries array is a no-op and returns { updated: false, results: [] }.
*
* Prototype-pollution guards are enforced per entry (identical inline-literal
* guards as setConfigValue — CodeQL barrier requirement).
*/
function setConfigValues(
cwd: string,
entries: Array<{ keyPath: string; value: unknown }>,
): { updated: boolean; results: SetConfigValueResult[] } {
if (entries.length === 0) {
return { updated: false, results: [] };
}
const configPath = path.join(planningDir(cwd), 'config.json');
return withPlanningLock(cwd, () => {
const config = loadConfigJson(cwd);
const results: SetConfigValueResult[] = [];
for (const entry of entries) {
const previousValue = _setNestedValue(config, entry.keyPath, entry.value);
results.push({ updated: true, key: entry.keyPath, value: entry.value, previousValue });
}
// Write back once for all entries
try {
platformWriteSync(configPath, JSON.stringify(config, null, 2));
return { updated: true, results };
} catch (err) {
error('Failed to write config.json: ' + (err as Error).message);
}
}) as { updated: boolean; results: SetConfigValueResult[] };
}
/**
* Type-safe enum guard for config-set string-enum keys.
*
* Rejects any parsedValue that is not a plain string AND a member of `allowed`.
* This closes the JSON-array coercion bypass: String(["val"]) === "val" satisfies
* a bare .includes(String(parsedValue)) check, but typeof parsedValue !== 'string'
* catches the array before the includes test.
*
* The `label` parameter is used verbatim in the error message so callers can
* preserve existing message text byte-for-byte.
*/
function assertEnumValue(parsedValue: unknown, rawVal: string, allowed: readonly string[], label: string): void {
if (typeof parsedValue !== 'string' || !allowed.includes(parsedValue)) {
error(`Invalid ${label} '${rawVal}'. Valid values: ${allowed.join(', ')}`);
}
}
/**
* Command to set a value in the config file, allowing nested values via dot notation (e.g.,
* "workflow.research").
*
* Note that this exits the process (via `output()`) even in the happy path; use `setConfigValue()`
* directly if you need to avoid this.
*/
interface ConfigSetOptions {
dryRun?: boolean;
}
function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | undefined, raw: boolean, options: ConfigSetOptions = {}): void {
const dryRun = options.dryRun === true;
if (!keyPath) {
error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
}
// #3593: reject the "key without value" form (e.g. `config-set
// model_profile` with args[2] === undefined). Without this guard the
// value passes through as undefined, the number/boolean/json branches
// all fall through, and the write either silently strips the key
// (JSON.stringify drops undefined values) or writes a corrupt entry.
// Typed reason so the negative-matrix test can assert on it instead
// of greppinng prose.
if (value === undefined) {
error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
}
// After the two error() guards above, keyPath and value are narrowed to string.
// TypeScript doesn't always infer never-return narrowing through error(), so we assert.
const kp = keyPath!;
const val = value!;
validateKnownConfigKeyPath(kp);
if (!isValidConfigKey(kp, cwd)) {
error(`Unknown config key: "${kp}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>, phase_commit_docs.<phase-id>`, ERROR_REASON.CONFIG_INVALID_KEY);
}
// Parse value (handle booleans, numbers, and JSON arrays/objects)
let parsedValue: unknown = val;
if (val === 'true') parsedValue = true;
else if (val === 'false') parsedValue = false;
else if (val === 'null') parsedValue = null;
// #1581: Number.isFinite (not !isNaN) so 'Infinity'/'-Infinity' are NOT
// coerced to non-finite numbers that JSON.stringify later renders as `null`
// (disk=null while the CLI echoed 'Infinity'). They fall through to the
// JSON branch (which rejects them) and stay strings, then per-key validators
// reject them with a non-zero exit.
else if (Number.isFinite(Number(val)) && val !== '') parsedValue = Number(val);
else if (typeof val === 'string' && (val.startsWith('[') || val.startsWith('{'))) {
try { parsedValue = JSON.parse(val); } catch { /* keep as string */ }
}
// #2046: a bare `null` unsets (deletes) the key — the documented "Clear" action.
// Short-circuits before every typed per-key validator so clearing a typed key
// (enum/boolean/number) removes it rather than being rejected. Deleting (not
// persisting JSON null) is the correct "clear": a persisted null is still a
// present, truthy-adjacent value that consumers must special-case — worst for
// secret keys where a leftover value can be passed as a real credential.
if (parsedValue === null) {
if (dryRun) {
const preview = previewUnsetConfigValue(cwd, kp);
if (isSecretKey(kp)) {
const maskedPrev = preview.previousValue === undefined
? undefined
: maskSecret(preview.previousValue as Parameters<typeof maskSecret>[0]);
output({ dry_run: true, would_unset: true, key: kp, value: null, previousValue: maskedPrev, masked: true }, raw, `${kp} unset (dry run)`);
return;
}
output({ dry_run: true, would_unset: true, key: kp, value: null, previousValue: preview.previousValue }, raw, `${kp} unset (dry run)`);
return;
}
const unsetResult = unsetConfigValue(cwd, kp);
if (isSecretKey(kp)) {
const maskedPrev = unsetResult.previousValue === undefined
? undefined
: maskSecret(unsetResult.previousValue as Parameters<typeof maskSecret>[0]);
output({ ...unsetResult, value: null, previousValue: maskedPrev, masked: true }, raw, `${kp} unset`);
return;
}
output(unsetResult, raw, `${kp} unset`);
return;
}
// #1581: project_code is an identifier string — never number-coerce it. A
// leading-zero code like '007' must persist verbatim (not collapse to 7).
if (kp === 'project_code') {
parsedValue = val;
}
const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
if (kp === 'context') assertEnumValue(parsedValue, val, VALID_CONTEXT_VALUES, 'context value');
if (kp === 'phase_id_convention') {
assertEnumValue(parsedValue, val, VALID_PHASE_ID_CONVENTIONS, 'phase_id_convention');
}
// Codebase drift detector (#2003)
const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
if (kp === 'workflow.drift_action') assertEnumValue(parsedValue, val, VALID_DRIFT_ACTIONS, 'workflow.drift_action');
if (kp === 'workflow.drift_threshold') {
if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) {
error(`Invalid workflow.drift_threshold '${val}'. Must be a positive integer.`);
}
}
// #1581: context_window must be a finite positive integer. 'Infinity' is no
// longer number-coerced (see the parse block above) so it reaches here as a
// string and is rejected; '0', negatives, and non-integers are also rejected.
if (kp === 'context_window') {
if (typeof parsedValue !== 'number' || !Number.isFinite(parsedValue) || !Number.isInteger(parsedValue) || parsedValue < 1) {
error(`Invalid context_window '${val}'. Must be a positive integer (token count).`, ERROR_REASON.USAGE);
}
}
// Smart-zone token budget (#2630, ADR-2629). Same shape as context_window:
// a positive integer token count. A POLICY default, not a benchmark constant.
// Number.isSafeInteger, NOT Number.isInteger: the read side
// (estimate-cli readSmartZoneBudget) accepts only safe integers, so an
// isInteger-only gate would let config-set 'succeed' on a value past 2^53
// that estimate-check then silently ignores in favour of the default.
// Accept and honour must agree.
if (kp === 'workflow.smart_zone_tokens') {
if (typeof parsedValue !== 'number' || !Number.isSafeInteger(parsedValue) || parsedValue < 1) {
error(`Invalid workflow.smart_zone_tokens '${val}'. Must be a positive integer (token count).`, ERROR_REASON.USAGE);
}
}
// Post-planning gap checker (#2493)
if (kp === 'workflow.post_planning_gaps') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid workflow.post_planning_gaps '${val}'. Must be a boolean (true or false).`);
}
}
// Compact Content mode gate (#4139)
if (kp === 'workflow.compact_content') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid workflow.compact_content '${val}'. Must be a boolean (true or false).`);
}
}
// Per-plan executor routing via agent_hint frontmatter (#1689)
if (kp === 'workflow.agent_hint_routing') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid workflow.agent_hint_routing '${val}'. Must be a boolean (true or false).`);
}
}
// #3086 — git.create_tag: boolean only
if (kp === 'git.create_tag') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid git.create_tag '${val}'. Must be a boolean (true or false).`);
}
}
if (kp === 'git.protected_branches') {
if (!isValidProtectedBranches(parsedValue)) {
error(`Invalid git.protected_branches '${val}'. Must be a non-empty array of non-empty branch names.`);
}
}
if (kp === 'ship.pr_body_sections') {
validateShipPrBodySections(parsedValue);
}
// Human verification checkpoint mode (#3309)
const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase'];
if (kp === 'workflow.human_verify_mode') assertEnumValue(parsedValue, val, VALID_HUMAN_VERIFY_MODES, 'workflow.human_verify_mode');
// Context exhaustion guard mode (#1452)
const VALID_CONTEXT_GUARD_MODES = ['auto', 'warn', 'off'];
if (kp === 'workflow.context_guard_mode') assertEnumValue(parsedValue, val, VALID_CONTEXT_GUARD_MODES, 'workflow.context_guard_mode');
// Context position enum validation (#2937)
const VALID_CONTEXT_POSITIONS = ['front', 'end'];
if (kp === 'statusline.context_position') assertEnumValue(parsedValue, val, VALID_CONTEXT_POSITIONS, 'statusline.context_position');
// statusline.show_context_tokens — boolean only
if (kp === 'statusline.show_context_tokens') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid statusline.show_context_tokens '${val}'. Must be a boolean (true or false).`);
}
}
// Statusline GSD-state format enum validation
const VALID_STATE_FORMATS = ['full', 'compact'];
if (kp === 'statusline.state_format') assertEnumValue(parsedValue, val, VALID_STATE_FORMATS, 'statusline.state_format');
// statusline.show_git — boolean only
if (kp === 'statusline.show_git') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid statusline.show_git '${val}'. Must be a boolean (true or false).`);
}
}
// Context-monitor fire-points (#4285) — a percentage of the context window
// REMAINING, so the domain is 0-100 and the hook compares them against
// `remaining_percentage`. Rejecting an out-of-domain value here keeps accept
// and honour in agreement ON THE DOMAIN: the hook falls back to its default
// for a value outside it, so reporting success would be a lie. That agreement
// is per-key and no wider — a value accepted here can still be superseded at
// read time by the hook's pair check, and a scoped write (GSD_PROJECT /
// GSD_WORKSTREAM) lands in a config the hook does not read at all. The PAIR
// (critical < warning) is deliberately NOT enforced here: config-set writes
// one key per call, so a two-step retune can be transiently inconsistent on
// disk and a check here would reject that intermediate write.
if (kp === 'hooks.context_warning_threshold' || kp === 'hooks.context_critical_threshold') {
if (typeof parsedValue !== 'number' || !Number.isFinite(parsedValue) || parsedValue < 0 || parsedValue > 100) {
error(`Invalid ${kp} '${val}'. Must be a number between 0 and 100 (percent of context window remaining).`);
}
// The two ENDPOINTS that are in range but can never form a valid pair are
// refused here rather than stored (#4285 review). `critical < warning` must
// hold at read time and BOTH sides are clamped to 0-100, so `warning: 0`
// has no legal partner (nothing is below 0) and `critical: 100` has none
// either (nothing above 100). Either one is silently discarded by the hook
// for EVERY value of the other key — verified: both resolve to the 35/25
// defaults against a present, absent, or extreme partner, while 0.001 and
// 99.999 are honoured.
//
// Storing a value the reader can never honour is exactly the
// accept-then-discard shape this codebase refuses elsewhere, so this fails
// at write time where the operator can see it. The pair itself is still NOT
// checked here — config-set writes one key per call, so a two-step retune
// is legitimately inconsistent on disk in between.
if (kp === 'hooks.context_warning_threshold' && parsedValue === 0) {
error(`Invalid ${kp} '${val}'. 0 is in range but unusable: the monitor requires `
+ `hooks.context_critical_threshold < hooks.context_warning_threshold, and no valid `
+ `critical value is below 0, so a warning of 0 would always fall back to the 35/25 `
+ `defaults. Use a value above 0.`);
}
if (kp === 'hooks.context_critical_threshold' && parsedValue === 100) {
error(`Invalid ${kp} '${val}'. 100 is in range but unusable: the monitor requires `
+ `hooks.context_critical_threshold < hooks.context_warning_threshold, and no valid `
+ `warning value is above 100, so a critical of 100 would always fall back to the `
+ `35/25 defaults. Use a value below 100.`);
}
}
// Fallow scope + profile enum validation (#3424)
const VALID_FALLOW_SCOPES = ['phase', 'repo'];
if (kp === 'code_quality.fallow.scope') assertEnumValue(parsedValue, val, VALID_FALLOW_SCOPES, 'code_quality.fallow.scope');
const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict'];
if (kp === 'code_quality.fallow.profile') assertEnumValue(parsedValue, val, VALID_FALLOW_PROFILES, 'code_quality.fallow.profile');
// plan_review.source_grounding (#22) — boolean only
if (kp === 'plan_review.source_grounding') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid plan_review.source_grounding '${val}'. Must be a boolean (true or false).`);
}
}
// plan_review.source_grounding_authority (#22) — enum
const VALID_SOURCE_GROUNDING_AUTHORITIES = ['grep', 'intel', 'treesitter', 'lsp', 'scip'];
if (kp === 'plan_review.source_grounding_authority') assertEnumValue(parsedValue, val, VALID_SOURCE_GROUNDING_AUTHORITIES, 'plan_review.source_grounding_authority');
// Generic capability-registry validation (#1628). Capability-owned keys declare
// their type/values in the registry but most lack a hardcoded guard, so out-of-
// domain values (including JSON array/object coercion) were stored silently.
const capDef = getCapabilityConfigSchema(cwd)[kp] as { type?: string; values?: unknown[] } | undefined;
if (capDef && typeof capDef.type === 'string') {
switch (capDef.type) {
case 'enum':
if (Array.isArray(capDef.values)) {
assertEnumValue(parsedValue, val, capDef.values.map((v) => String(v)), kp);
}
break;
case 'boolean':
if (typeof parsedValue !== 'boolean') {
error(`Invalid ${kp} '${val}'. Must be a boolean (true or false).`);
}
break;
case 'number':
if (typeof parsedValue !== 'number' || !Number.isFinite(parsedValue)) {
error(`Invalid ${kp} '${val}'. Must be a number.`);
}
break;
case 'string':
if (typeof parsedValue !== 'string') {
error(`Invalid ${kp} '${val}'. Must be a string.`);
}
break;
}
}
// Security — ASVS level range (#1628)
// Must be an integer in {1, 2, 3} (OWASP ASVS levels).
if (kp === 'workflow.security_asvs_level') {
if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1 || parsedValue > 3) {
error(`Invalid workflow.security_asvs_level '${val}'. Must be an integer 1, 2, or 3.`);
}
}
if (kp === 'review.default_reviewers') {
const normalized = normalizeConfiguredDefaultReviewers(parsedValue);
if (normalized.errors.length > 0) {
error(normalized.errors[0]);
}
parsedValue = normalized.values;
}
// #1517: validate review.reviewer_instances.<name>.<field> leaves at the
// invocation boundary (Postel/Kerckhoffs — strict at accept). The config
// schema dynamic pattern admits the path; this block validates the name + the
// field value so a misconfigured instance is rejected at config-set time, not
// silently at review time. Single-source validators live in
// review-reviewer-selection.cjs (INSTANCE_NAME_PATTERN, KNOWN_REVIEWER_SLUGS).
const instanceLeaf = kp.match(/^review\.reviewer_instances\.([a-zA-Z0-9_-]+)\.(cli|model|agent)$/);
if (instanceLeaf) {
const [, instanceName, field] = instanceLeaf;
if (!INSTANCE_NAME_PATTERN.test(instanceName)) {
error(`Invalid reviewer instance name '${instanceName}'. Must match ^[a-z0-9][a-z0-9-]*$.`);
}
if (KNOWN_REVIEWER_SLUGS.includes(instanceName)) {
error(`Reviewer instance name '${instanceName}' must not equal a built-in reviewer slug.`);
}
if (field === 'cli') {
if (typeof parsedValue !== 'string' || !KNOWN_REVIEWER_SLUGS.includes(parsedValue)) {
error(`Invalid reviewer_instances.${instanceName}.cli '${val}'. Must be a known reviewer adapter: ${KNOWN_REVIEWER_SLUGS.join(', ')}.`);
}
} else {
// model | agent — opaque pass-through strings (never interpolated into shell).
if (typeof parsedValue !== 'string') {
error(`Invalid reviewer_instances.${instanceName}.${field} '${val}'. Must be a string.`);
}
}
}
if (dryRun) {
const preview = previewConfigValue(cwd, kp, parsedValue);
if (isSecretKey(kp)) {
const masked = maskSecret(parsedValue as Parameters<typeof maskSecret>[0]);
const maskedPrev = preview.previousValue === undefined
? undefined
: maskSecret(preview.previousValue as Parameters<typeof maskSecret>[0]);
output({ dry_run: true, would_update: true, key: kp, value: masked, previousValue: maskedPrev, masked: true }, raw, `${kp}=${masked} (dry run)`);
return;
}
output({ dry_run: true, would_update: true, key: kp, value: parsedValue, previousValue: preview.previousValue }, raw, `${kp}=${String(parsedValue)} (dry run)`);
return;
}
const setConfigValueResult = setConfigValue(cwd, kp, parsedValue);
// Mask secrets in both JSON and text output. The plaintext is written
// to config.json (that's where secrets live on disk); the CLI output
// must never echo it. See lib/secrets.cjs.
if (isSecretKey(kp)) {
// parsedValue is unknown at this point; maskSecret accepts MaskableValue
const masked = maskSecret(parsedValue as Parameters<typeof maskSecret>[0]);
const maskedPrev = setConfigValueResult.previousValue === undefined
? undefined
: maskSecret(setConfigValueResult.previousValue as Parameters<typeof maskSecret>[0]);
const maskedResult = {
...setConfigValueResult,
value: masked,
previousValue: maskedPrev,
masked: true,
};
output(maskedResult, raw, `${kp}=${masked}`);
return;
}
output(setConfigValueResult, raw, `${kp}=${String(parsedValue)}`);
}
function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, defaultValue: unknown): void {
const configPath = path.join(planningDir(cwd), 'config.json');
const hasDefault = defaultValue !== undefined;
if (!keyPath) {
error('Usage: config-get <key.path> [--default <value>]');
}
// After the error() guard, keyPath is narrowed to string.
const kp = keyPath!;
let config: Record<string, unknown> = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
} else {
// #2702: when a workstream is active and has no config.json of its own, fall
// back to the project ROOT config first — a key the user configured at root is
// a real, present value and must inherit (per #1893: a present key wins over
// --default). Only when root also misses do --default / schema default apply.
// (When no workstream is active, resolveFromRootConfig is a no-op: same file.)
const rootVal = resolveFromRootConfig(cwd, kp);
if (rootVal.found) { emitResolvedDefault(kp, rootVal.value, raw); return; }
if (hasDefault) { emitResolvedDefault(kp, defaultValue, raw); return; }
const sd = resolveSchemaDefault(cwd, kp);
if (sd.found) { emitResolvedDefault(kp, sd.value, raw); return; }
error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
}
} catch (err) {
// ADR-3889: error() now throws ExitError (carries no message) instead of
// calling process.exit() directly. The message-sniffing check below
// (`.startsWith('No config.json')`) can never match an ExitError raised
// by the "no config.json" error() call above it — ExitError.message
// defaults to `process exit ${code}` when no message is passed — so
// without this unconditional guard that ExitError falls through and gets
// re-wrapped as a WRONG reason (CONFIG_PARSE_FAILED instead of
// CONFIG_NO_FILE) with a nonsense message, plus a duplicate stderr write.
if (err instanceof ExitError) throw err;
if ((err as Error).message.startsWith('No config.json')) throw err;
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
// Traverse dot-notation path (e.g., "workflow.auto_advance")
const keys = kp.split('.');
let current: unknown = config;
for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') {
// #2702: root-config inheritance before --default / schema default (see above).
const rootVal = resolveFromRootConfig(cwd, kp);
if (rootVal.found) { emitResolvedDefault(kp, rootVal.value, raw); return; }
if (hasDefault) { emitResolvedDefault(kp, defaultValue, raw); return; }
const sd = resolveSchemaDefault(cwd, kp);
if (sd.found) { emitResolvedDefault(kp, sd.value, raw); return; }
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
}
// Own-property gate: bracket access on a plain object walks the
// prototype chain, so an unqualified `current[key]` would resolve
// '__proto__' / 'constructor' / 'hasOwnProperty' (and other
// Object.prototype members) to their inherited values instead of
// correctly reporting them as absent. hasOwnProperty.call only
// returns true for a key JSON.parse actually assigned as data on
// this object (including a literal "__proto__" JSON key, which
// JSON.parse defines as an own data property, not the accessor) —
// never for something inherited from the prototype chain.
current = Object.prototype.hasOwnProperty.call(current, key)
? (current as Record<string, unknown>)[key]
: undefined;
}
if (current === undefined) {
// #2702: root-config inheritance before --default / schema default (see above).
const rootVal = resolveFromRootConfig(cwd, kp);
if (rootVal.found) { emitResolvedDefault(kp, rootVal.value, raw); return; }
if (hasDefault) { emitResolvedDefault(kp, defaultValue, raw); return; }
const sd = resolveSchemaDefault(cwd, kp);
if (sd.found) { emitResolvedDefault(kp, sd.value, raw); return; }
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
}
// Never echo plaintext for sensitive keys via config-get. Plaintext lives
// in config.json on disk; the CLI surface always shows the masked form.
if (isSecretKey(kp)) {
const masked = maskSecret(current as Parameters<typeof maskSecret>[0]);
output(masked, raw, masked);
return;
}
output(current, raw, String(current));
}
/**
* #2702: resolve a dot-notation key against the project ROOT config
* (`.planning/config.json`), ignoring any active workstream scope. Returns
* `{found:false}` when the root config is absent, unparseable, or does not
* contain the key. This is the inheritance rung `cmdConfigGet` was missing —
* when a workstream's own config doesn't set a key, the project root value
* must show through (workstream overrides root; it never fully replaces it),
* exactly as `loadConfigResolved`'s root+workstream merge already does for
* every other config consumer. No-op (found:false) when no workstream is
* active, because `planningDir === planningRoot` and the caller already read
* that file directly.
*/
function resolveFromRootConfig(cwd: string, kp: string): { found: boolean; value: unknown } {
// Only meaningful when a workstream is active (GSD_WORKSTREAM set) — that is what
// redirects planningDir away from root AND what loadConfigResolved gates root-reading
// on. Gating on `process.env.GSD_WORKSTREAM` (not on a planningDir !== planningRoot
// path inequality) avoids a false trigger under GSD_PROJECT alone, where planningDir
// diverges from planningRoot without a workstream and loadConfigResolved does NOT
// inherit root — matching the runtime's own `if (ws)` gate keeps the two surfaces
// from diverging on the project-scoped (non-workstream) case.
if (!resolveEnvWorkstream()) return { found: false, value: undefined };
const root = planningRoot(cwd);
const rootConfigPath = path.join(root, 'config.json');
let rootConfig: Record<string, unknown>;
try {
if (!fs.existsSync(rootConfigPath)) return { found: false, value: undefined };
rootConfig = JSON.parse(fs.readFileSync(rootConfigPath, 'utf-8')) as Record<string, unknown>;
} catch {
// Unparseable root config → don't inherit (do not let a corrupt root file
// change config-get's verdict). Fall through to schema default / error.
return { found: false, value: undefined };
}
let current: unknown = rootConfig;
for (const key of kp.split('.')) {
if (current === undefined || current === null || typeof current !== 'object') {
return { found: false, value: undefined };
}
current = Object.prototype.hasOwnProperty.call(current, key)
? (current as Record<string, unknown>)[key]
: undefined;
}
if (current === undefined) return { found: false, value: undefined };
return { found: true, value: current };
}
/**
* Command to set the model profile in the config file.
*
* Note that this exits the process (via `output()`) even in the happy path.
*/
function cmdConfigSetModelProfile(cwd: string, profile: string | undefined, raw: boolean): void {
if (!profile) {
error(`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`);
}
const normalizedProfile = profile!.toLowerCase().trim();
if (!VALID_PROFILES.includes(normalizedProfile)) {
error(`Invalid profile '${String(profile)}'. Valid profiles: ${VALID_PROFILES.join(', ')}`);
}
// Ensure config exists (create if needed)
ensureConfigFile(cwd);
// Set the model profile in the config
const { previousValue } = setConfigValue(cwd, 'model_profile', normalizedProfile);
const previousProfile = typeof previousValue === 'string' ? previousValue : 'balanced';
// Build result value / message and return
const agentToModelMap = getAgentToModelMapForProfile(normalizedProfile);
const result = {
updated: true,
profile: normalizedProfile,
previousProfile,
agentToModelMap,
};
const rawValue = getCmdConfigSetModelProfileResultMessage(
normalizedProfile,
previousProfile,
agentToModelMap
);
output(result, raw, rawValue);
}
/**
* Returns the message to display for the result of the `config-set-model-profile` command when
* displaying raw output.
*/
function getCmdConfigSetModelProfileResultMessage(
normalizedProfile: string,
previousProfile: string,
agentToModelMap: Record<string, string>
): string {
const agentToModelTable = formatAgentToModelMapAsTable(agentToModelMap);
const didChange = previousProfile !== normalizedProfile;
const paragraphs = didChange
? [
`✓ Model profile set to: ${normalizedProfile} (was: ${previousProfile})`,
'Agents will now use:',
agentToModelTable,
'Next spawned agents will use the new profile.',
]
: [
`✓ Model profile is already set to: ${normalizedProfile}`,
'Agents are using:',
agentToModelTable,
];
return paragraphs.join('\n\n');
}
/**
* Print the resolved config.json path (workstream-aware). Used by settings.md
* so the workflow writes/reads the correct file when a workstream is active (#2282).
*/
function cmdConfigPath(cwd: string, _raw: boolean, workstreamContext: WorkstreamContext | null = null): void {
// Always emit as plain text — a file path is used via shell substitution,
// never consumed as JSON. Passing raw=true forces plain-text output.
const configPath = workstreamContext && workstreamContext.configPath
? workstreamContext.configPath
: path.join(planningDir(cwd), 'config.json');
output(configPath, true, configPath);
}
/**
* Explicit on-disk migration of legacy config keys to canonical nested shape.
*
* Wraps the Configuration Module's migrateOnDisk() for the CLI surface. This
* is the Phase 2 acceptance-criteria deliverable for opt-in migration (#3536):
* users can run `gsd-tools migrate-config` to apply all four legacy-key
* migrations to their .planning/config.json without having to load any config
* implicitly via another command.
*
* Output: JSON object with { migrated, normalizations, wrote } or a human-readable
* summary when --raw is set. Exits 0 in all cases (including no-op).
*
* Note: migrateOnDisk() is synchronous; the original CJS used async for
* forward-compatibility but no await is needed. Dropped async per ADR-457 policy
* (caller uses `await` which is safe on a sync return value).
*/
function cmdMigrateConfig(cwd: string, raw: boolean): void {
const ws = resolveEnvWorkstream();
// #3749: resolve the migration target through the project-aware resolver so
// GSD_PROJECT scopes the write; migrateOnDisk itself cannot (see its
// configPathOverride note).
const scopedConfigPath = path.join(planningDir(cwd, ws || undefined), 'config.json');
const report = migrateOnDisk(cwd, ws || undefined, scopedConfigPath);
// #3760: deduplicated on (path, reason), so a repeated invocation stays quiet.
if (report.skipped.length > 0) {
warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_SECTION_NOT_OBJECT,
source: path.join(planningDir(cwd, ws || undefined), 'config.json'),
});
}
if (raw) {
// #3760: a refused migration is NOT an already-canonical config. Reporting
// "no legacy keys found" when a legacy key was found and declined would send
// the user away believing there is nothing to fix — and the thing to fix is
// the one thing only they can fix, by hand.
const declined = (report.skipped as Array<{ from: string; to: string; section: string; sectionType: string }>);
const declinedLines = declined.map(
s => ` ${s.from} → ${s.to} SKIPPED: '${s.section}' holds a ${s.sectionType}, not an object`,
);
if (!report.migrated && declined.length === 0) {
const msg = 'No legacy keys found — config is already canonical.';
output(msg, true, msg);
} else if (!report.migrated) {
const lines = [
'Not migrated — every legacy key found was left in place:',
...declinedLines,
'Fix the section by hand, then re-run. Nothing was written.',
].join('\n');
output(lines, true, lines);
} else {
const lines = [
`Migrated: ${String(report.wrote)}`,
...(report.normalizations as Array<{ from: string; to: string }>).map(n => ` ${n.from} → ${n.to}`),
...declinedLines,
].join('\n');
output(lines, true, lines);
}
} else {
// output() JSON.stringify's its first arg when raw=false; pass the report object.
output(report, false, report);
}
}
export = {
VALID_CONFIG_KEYS,
VALID_PHASE_ID_CONVENTIONS,
cmdConfigEnsureSection,
cmdConfigSet,
cmdConfigGet,
cmdConfigSetModelProfile,
cmdConfigNewProject,
cmdConfigPath,
cmdMigrateConfig,
// Exported for programmatic use by capability-writer and tests
setConfigValue,
setConfigValues,
isValidProtectedBranches,
};