/** * Config — Planning config CRUD operations */ const fs = require('fs'); const path = require('path'); const { output, error, ERROR_REASON, CONFIG_DEFAULTS } = require('./core.cjs'); const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs'); const { planningDir, withPlanningLock } = require('./planning-workspace.cjs'); const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable, } = require('./model-profiles.cjs'); const { VALID_CONFIG_KEYS, isValidConfigKey } = require('./config-schema.cjs'); const { isSecretKey, maskSecret } = require('./secrets.cjs'); const { normalizeConfiguredDefaultReviewers } = require('./review-reviewer-selection.cjs'); const CONFIG_KEY_SUGGESTIONS = { '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.', '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]*$/; function validateKnownConfigKeyPath(keyPath) { 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) { if (!Array.isArray(value)) { error('Invalid ship.pr_body_sections value. Expected a JSON array of section objects.'); } value.forEach((section, index) => { const prefix = `Invalid ship.pr_body_sections[${index}]`; if (!section || typeof section !== 'object' || Array.isArray(section)) { error(`${prefix}. Expected an object.`); } const unknownKeys = Object.keys(section).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key)); if (unknownKeys.length > 0) { error(`${prefix}. Unknown field(s): ${unknownKeys.join(', ')}.`); } if (typeof section.heading !== 'string' || section.heading.trim() === '') { error(`${prefix}. heading must be a non-empty string.`); } if (/[\r\n]/.test(section.heading)) { error(`${prefix}. heading must be a single line.`); } if ('enabled' in section && typeof section.enabled !== 'boolean') { error(`${prefix}. enabled must be true or false.`); } for (const field of ['source', 'fallback', 'template']) { if (field in section && typeof section[field] !== 'string') { error(`${prefix}. ${field} must be a string.`); } } const hasContent = ['source', 'fallback', 'template'].some((field) => { return typeof section[field] === 'string' && section[field].trim() !== ''; }); if (!hasContent) { error(`${prefix}. Provide at least one of source, fallback, or template.`); } if (typeof section.source === 'string' && section.source.trim() !== '') { const selectors = section.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 section.template === 'string') { const tokens = section.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]}}.`); } } } }); } /** * 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) { const choices = userChoices || {}; const homedir = require('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)); // Load user-level defaults from ~/.gsd/defaults.json if available const globalDefaultsPath = path.join(homedir, '.gsd', 'defaults.json'); let userDefaults = {}; try { if (fs.existsSync(globalDefaultsPath)) { userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8')); // Migrate deprecated "depth" key to "granularity" if ('depth' in userDefaults && !('granularity' in userDefaults)) { const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' }; userDefaults.granularity = depthToGranularity[userDefaults.depth] || userDefaults.depth; delete userDefaults.depth; try { platformWriteSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2)); } catch { /* intentionally empty */ } } } } catch { // Ignore malformed global defaults } const hardcoded = { 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, 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, tdd_mode: false, 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.md', }; // Three-level deep merge: hardcoded <- userDefaults <- choices const config = { ...hardcoded, ...userDefaults, ...choices, git: { ...hardcoded.git, ...(userDefaults.git || {}), ...(choices.git || {}), }, workflow: { ...hardcoded.workflow, ...(userDefaults.workflow || {}), ...(choices.workflow || {}), }, ship: { ...hardcoded.ship, ...(userDefaults.ship || {}), ...(choices.ship || {}), }, hooks: { ...hardcoded.hooks, ...(userDefaults.hooks || {}), ...(choices.hooks || {}), }, agent_skills: { ...hardcoded.agent_skills, ...(userDefaults.agent_skills || {}), ...(choices.agent_skills || {}), }, }; validateShipPrBodySections(config.ship.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, choicesJson, raw) { 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 = {}; if (choicesJson && choicesJson.trim() !== '') { try { userChoices = JSON.parse(choicesJson); } catch (err) { error('Invalid JSON for config-new-project: ' + err.message); } } // Ensure .planning directory exists try { platformEnsureDir(planningBase); } catch (err) { error('Failed to create .planning directory: ' + err.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.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) { 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.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.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, raw) { const ensureConfigFileResult = ensureConfigFile(cwd); if (ensureConfigFileResult.created) { output(ensureConfigFileResult, raw, 'created'); } else { output(ensureConfigFileResult, raw, 'exists'); } } /** * 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, keyPath, parsedValue) { const configPath = path.join(planningDir(cwd), 'config.json'); return withPlanningLock(cwd, () => { // Load existing config or start with empty object let config = {}; try { if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } } catch (err) { error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED); } // Set nested value using dot notation (e.g., "workflow.research") const keys = keyPath.split('.'); let current = config; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (current[key] === undefined || typeof current[key] !== 'object') { current[key] = {}; } current = current[key]; } const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting current[keys[keys.length - 1]] = 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.message); } }); } /** * 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, keyPath, value, raw) { if (!keyPath) { error('Usage: config-set ', 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 ', ERROR_REASON.USAGE); } validateKnownConfigKeyPath(keyPath); if (!isValidConfigKey(keyPath)) { error(`Unknown config key: "${keyPath}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills., features.`, ERROR_REASON.CONFIG_INVALID_KEY); } // Parse value (handle booleans, numbers, and JSON arrays/objects) let parsedValue = value; if (value === 'true') parsedValue = true; else if (value === 'false') parsedValue = false; else if (!isNaN(value) && value !== '') parsedValue = Number(value); else if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) { try { parsedValue = JSON.parse(value); } catch { /* keep as string */ } } const VALID_CONTEXT_VALUES = ['dev', 'research', 'review']; if (keyPath === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) { error(`Invalid context value '${value}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`); } // Codebase drift detector (#2003) const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap']; if (keyPath === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) { error(`Invalid workflow.drift_action '${value}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`); } if (keyPath === 'workflow.drift_threshold') { if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) { error(`Invalid workflow.drift_threshold '${value}'. Must be a positive integer.`); } } // Post-planning gap checker (#2493) if (keyPath === 'workflow.post_planning_gaps') { if (typeof parsedValue !== 'boolean') { error(`Invalid workflow.post_planning_gaps '${value}'. Must be a boolean (true or false).`); } } // #3086 — git.create_tag: boolean only if (keyPath === 'git.create_tag') { if (typeof parsedValue !== 'boolean') { error(`Invalid git.create_tag '${value}'. Must be a boolean (true or false).`); } } if (keyPath === 'ship.pr_body_sections') { validateShipPrBodySections(parsedValue); } // Human verification checkpoint mode (#3309) const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase']; if (keyPath === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) { error(`Invalid workflow.human_verify_mode '${value}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`); } // Context position enum validation (#2937) const VALID_CONTEXT_POSITIONS = ['front', 'end']; if (keyPath === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) { error(`Invalid statusline.context_position '${value}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`); } // Fallow scope + profile enum validation (#3424) const VALID_FALLOW_SCOPES = ['phase', 'repo']; if (keyPath === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) { error(`Invalid code_quality.fallow.scope '${value}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`); } const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict']; if (keyPath === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) { error(`Invalid code_quality.fallow.profile '${value}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`); } if (keyPath === 'review.default_reviewers') { const normalized = normalizeConfiguredDefaultReviewers(parsedValue); if (normalized.errors.length > 0) { error(normalized.errors[0]); } parsedValue = normalized.values; } const setConfigValueResult = setConfigValue(cwd, keyPath, 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(keyPath)) { const masked = maskSecret(parsedValue); const maskedPrev = setConfigValueResult.previousValue === undefined ? undefined : maskSecret(setConfigValueResult.previousValue); const maskedResult = { ...setConfigValueResult, value: masked, previousValue: maskedPrev, masked: true, }; output(maskedResult, raw, `${keyPath}=${masked}`); return; } output(setConfigValueResult, raw, `${keyPath}=${parsedValue}`); } /** * 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 = { 'context_window': 200000, 'executor.stall_detect_interval_minutes': 5, 'executor.stall_threshold_minutes': 10, 'git.create_tag': true, }; function cmdConfigGet(cwd, keyPath, raw, defaultValue) { const configPath = path.join(planningDir(cwd), 'config.json'); const hasDefault = defaultValue !== undefined; if (!keyPath) { error('Usage: config-get [--default ]'); } let config = {}; try { if (fs.existsSync(configPath)) { config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } else if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; } else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) { const def = SCHEMA_DEFAULTS[keyPath]; output(def, raw, String(def)); return; } else { error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE); } } catch (err) { if (err.message.startsWith('No config.json')) throw err; error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED); } // Traverse dot-notation path (e.g., "workflow.auto_advance") const keys = keyPath.split('.'); let current = config; for (const key of keys) { if (current === undefined || current === null || typeof current !== 'object') { if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; } if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) { const def = SCHEMA_DEFAULTS[keyPath]; output(def, raw, String(def)); return; } error(`Key not found: ${keyPath}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND); } current = current[key]; } if (current === undefined) { if (hasDefault) { output(defaultValue, raw, String(defaultValue)); return; } if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) { const def = SCHEMA_DEFAULTS[keyPath]; output(def, raw, String(def)); return; } error(`Key not found: ${keyPath}`, 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(keyPath)) { const masked = maskSecret(current); 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, profile, raw) { if (!profile) { error(`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`); } const normalizedProfile = profile.toLowerCase().trim(); if (!VALID_PROFILES.includes(normalizedProfile)) { error(`Invalid profile '${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, raw); const previousProfile = 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, previousProfile, agentToModelMap ) { 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) { // 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 = path.join(planningDir(cwd), 'config.json'); output(configPath, true, configPath); } module.exports = { VALID_CONFIG_KEYS, cmdConfigEnsureSection, cmdConfigSet, cmdConfigGet, cmdConfigSetModelProfile, cmdConfigNewProject, cmdConfigPath, };