Files
msd-core/src/config.cts
Tom Boucher bf634b95c3 feat(#1213): Capability State Writer — write-side inverse of the resolver (#1225)
* feat(#1213): Capability State Writer — write-side inverse of the resolver

Adds src/capability-writer.cts (setCapabilityState + cmdCapabilitySet) and the
`gsd-tools capability set` subcommand: the write-side inverse of the capability
resolver (ADR-1213). One desired capability state projects onto the substrates —
`enabled` drives the runtime surface (canonical on/off), `gates` drive federated
config keys (hook granularity), install profile is a read-only floor — then
re-resolves and reports divergence (assert-and-report), so "off means off" holds
as a write-time invariant. Adds batched setConfigValues; routes gsd:settings
capability hook-gates through the writer. Docs: CLI-TOOLS reference, how-to,
ADR-1213, CONTEXT.md term.

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

* chore(#1213): add changeset for Capability State Writer (#1225)

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:51:17 -04:00

871 lines
34 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 core = require('./core.cjs');
const { output, error, ERROR_REASON, CONFIG_DEFAULTS } = core;
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, 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 } = configSchema;
import { isSecretKey, maskSecret } from './secrets.cjs';
import { normalizeConfiguredDefaultReviewers } from './review-reviewer-selection.cjs';
import { migrateOnDisk } from './configuration.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SetConfigValueResult {
updated: boolean;
key: string;
value: unknown;
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]*$/;
/**
* 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,
'git.create_tag': true,
};
// ─── 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);
}
}
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,
human_verify_mode: 'end-of-phase',
text_mode: 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>>;
// Three-level deep merge: hardcoded <- userDefaults <- choices
const config: Record<string, unknown> = {
...hardcoded,
...userDefaults,
...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;
}
/**
* 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, () => {
// Load existing config or start with empty object
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);
}
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;
}
/**
* 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, () => {
// Load existing config or start with empty object
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);
}
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[] };
}
/**
* 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.
*/
function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | undefined, raw: boolean): void {
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)) {
error(`Unknown config key: "${kp}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, 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 (!isNaN(Number(val)) && val !== '') parsedValue = Number(val);
else if (typeof val === 'string' && (val.startsWith('[') || val.startsWith('{'))) {
try { parsedValue = JSON.parse(val); } catch { /* keep as string */ }
}
const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
if (kp === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) {
error(`Invalid context value '${val}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`);
}
// Codebase drift detector (#2003)
const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
if (kp === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
error(`Invalid workflow.drift_action '${val}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`);
}
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.`);
}
}
// 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).`);
}
}
// #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 === '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' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
}
// Context position enum validation (#2937)
const VALID_CONTEXT_POSITIONS = ['front', 'end'];
if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
error(`Invalid statusline.context_position '${val}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`);
}
// Fallow scope + profile enum validation (#3424)
const VALID_FALLOW_SCOPES = ['phase', 'repo'];
if (kp === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) {
error(`Invalid code_quality.fallow.scope '${val}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`);
}
const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict'];
if (kp === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) {
error(`Invalid code_quality.fallow.profile '${val}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`);
}
// 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' && !VALID_SOURCE_GROUNDING_AUTHORITIES.includes(String(parsedValue))) {
error(`Invalid plan_review.source_grounding_authority '${val}'. Valid values: ${VALID_SOURCE_GROUNDING_AUTHORITIES.join(', ')}`);
}
if (kp === 'review.default_reviewers') {
const normalized = normalizeConfiguredDefaultReviewers(parsedValue);
if (normalized.errors.length > 0) {
error(normalized.errors[0]);
}
parsedValue = normalized.values;
}
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 if (hasDefault) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
output(defaultValue, raw, String(defaultValue));
return;
} else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def));
return;
} else {
error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
}
} catch (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') {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def));
return;
}
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
}
current = (current as Record<string, unknown>)[key];
}
if (current === undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; }
if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, kp)) {
const def = SCHEMA_DEFAULTS[kp];
output(def, raw, String(def));
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));
}
/**
* 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 = process.env['GSD_WORKSTREAM'] || null;
const report = migrateOnDisk(cwd, ws || undefined);
if (raw) {
if (!report.migrated) {
const msg = 'No legacy keys found — config is already canonical.';
output(msg, true, msg);
} else {
const lines = [
`Migrated: ${String(report.wrote)}`,
...(report.normalizations as Array<{ from: string; to: string }>).map(n => ` ${n.from} → ${n.to}`),
].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,
cmdConfigEnsureSection,
cmdConfigSet,
cmdConfigGet,
cmdConfigSetModelProfile,
cmdConfigNewProject,
cmdConfigPath,
cmdMigrateConfig,
// Exported for programmatic use by capability-writer and tests
setConfigValue,
setConfigValues,
};