Spaces:
Runtime error
Runtime error
Aryan Mishra
feat: initial commit - Multilingual ABSA project setup with 6 phases, 36 requirements
a6b96c2 | ; | |
| /** | |
| * 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. | |
| */ | |
| var __importDefault = (this && this.__importDefault) || function (mod) { | |
| return (mod && mod.__esModule) ? mod : { "default": mod }; | |
| }; | |
| const node_fs_1 = __importDefault(require("node:fs")); | |
| const node_path_1 = __importDefault(require("node:path")); | |
| const node_os_1 = __importDefault(require("node:os")); | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const io = require("./io.cjs"); | |
| const { output, error, ERROR_REASON } = io; | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const configLoader = require("./config-loader.cjs"); | |
| const { CONFIG_DEFAULTS } = configLoader; | |
| const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs"); | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const planningWorkspace = require("./planning-workspace.cjs"); | |
| const { planningDir, withPlanningLock } = planningWorkspace; | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const modelProfiles = require("./model-profiles.cjs"); | |
| const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles; | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const configSchema = require("./config-schema.cjs"); | |
| const { VALID_CONFIG_KEYS, isValidConfigKey } = configSchema; | |
| const secrets_cjs_1 = require("./secrets.cjs"); | |
| const review_reviewer_selection_cjs_1 = require("./review-reviewer-selection.cjs"); | |
| const configuration_cjs_1 = require("./configuration.cjs"); | |
| // ─── Constants ──────────────────────────────────────────────────────────────── | |
| 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.<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 = { | |
| 'context_window': 200000, | |
| 'executor.stall_detect_interval_minutes': 5, | |
| 'executor.stall_threshold_minutes': 10, | |
| 'git.create_tag': true, | |
| }; | |
| // ─── Validation helpers ─────────────────────────────────────────────────────── | |
| 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 sectionObj = section; | |
| 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'])) { | |
| 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) { | |
| const choices = userChoices || {}; | |
| const homedir = node_os_1.default.homedir(); | |
| // Detect API key availability | |
| const braveKeyFile = node_path_1.default.join(homedir, '.gsd', 'brave_api_key'); | |
| const hasBraveSearch = !!(process.env['BRAVE_API_KEY'] || node_fs_1.default.existsSync(braveKeyFile)); | |
| const firecrawlKeyFile = node_path_1.default.join(homedir, '.gsd', 'firecrawl_api_key'); | |
| const hasFirecrawl = !!(process.env['FIRECRAWL_API_KEY'] || node_fs_1.default.existsSync(firecrawlKeyFile)); | |
| const exaKeyFile = node_path_1.default.join(homedir, '.gsd', 'exa_api_key'); | |
| const hasExaSearch = !!(process.env['EXA_API_KEY'] || node_fs_1.default.existsSync(exaKeyFile)); | |
| const tavilyKeyFile = node_path_1.default.join(homedir, '.gsd', 'tavily_api_key'); | |
| const hasTavilySearch = !!(process.env['TAVILY_API_KEY'] || node_fs_1.default.existsSync(tavilyKeyFile)); | |
| const refKeyFile = node_path_1.default.join(homedir, '.gsd', 'ref_api_key'); | |
| const hasRefSearch = !!(process.env['REF_API_KEY'] || node_fs_1.default.existsSync(refKeyFile)); | |
| const perplexityKeyFile = node_path_1.default.join(homedir, '.gsd', 'perplexity_api_key'); | |
| const hasPerplexity = !!(process.env['PERPLEXITY_API_KEY'] || node_fs_1.default.existsSync(perplexityKeyFile)); | |
| const jinaKeyFile = node_path_1.default.join(homedir, '.gsd', 'jina_api_key'); | |
| const hasJina = !!(process.env['JINA_API_KEY'] || node_fs_1.default.existsSync(jinaKeyFile)); | |
| // Load user-level defaults from ~/.gsd/defaults.json if available | |
| const globalDefaultsPath = node_path_1.default.join(homedir, '.gsd', 'defaults.json'); | |
| let userDefaults = {}; | |
| try { | |
| if (node_fs_1.default.existsSync(globalDefaultsPath)) { | |
| userDefaults = JSON.parse(node_fs_1.default.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 { | |
| (0, shell_command_projection_cjs_1.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, | |
| 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; | |
| const ch = choices; | |
| const hd = hardcoded; | |
| // Three-level deep merge: hardcoded <- userDefaults <- choices | |
| const config = { | |
| ...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']['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 = node_path_1.default.join(planningBase, 'config.json'); | |
| // Idempotent: don't overwrite existing config | |
| if (node_fs_1.default.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 { | |
| (0, shell_command_projection_cjs_1.platformEnsureDir)(planningBase); | |
| } | |
| catch (err) { | |
| error('Failed to create .planning directory: ' + err.message); | |
| } | |
| const config = buildNewProjectConfig(userChoices); | |
| try { | |
| (0, shell_command_projection_cjs_1.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 = node_path_1.default.join(planningBase, 'config.json'); | |
| // Ensure .planning directory exists | |
| try { | |
| (0, shell_command_projection_cjs_1.platformEnsureDir)(planningBase); | |
| } | |
| catch (err) { | |
| error('Failed to create .planning directory: ' + err.message); | |
| } | |
| // Check if config already exists | |
| if (node_fs_1.default.existsSync(configPath)) { | |
| return { created: false, reason: 'already_exists' }; | |
| } | |
| const config = buildNewProjectConfig({}); | |
| try { | |
| (0, shell_command_projection_cjs_1.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 && 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, keyPath, parsedValue) { | |
| const keys = keyPath.split('.'); | |
| let current = 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]; | |
| } | |
| 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, keyPath, parsedValue) { | |
| const configPath = node_path_1.default.join(planningDir(cwd), 'config.json'); | |
| return withPlanningLock(cwd, () => { | |
| // Load existing config or start with empty object | |
| let config = {}; | |
| try { | |
| if (node_fs_1.default.existsSync(configPath)) { | |
| config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8')); | |
| } | |
| } | |
| catch (err) { | |
| error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED); | |
| } | |
| const previousValue = _setNestedValue(config, keyPath, parsedValue); | |
| // Write back | |
| try { | |
| (0, shell_command_projection_cjs_1.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); | |
| } | |
| }); | |
| } | |
| /** | |
| * 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, entries) { | |
| if (entries.length === 0) { | |
| return { updated: false, results: [] }; | |
| } | |
| const configPath = node_path_1.default.join(planningDir(cwd), 'config.json'); | |
| return withPlanningLock(cwd, () => { | |
| // Load existing config or start with empty object | |
| let config = {}; | |
| try { | |
| if (node_fs_1.default.existsSync(configPath)) { | |
| config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8')); | |
| } | |
| } | |
| catch (err) { | |
| error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED); | |
| } | |
| const results = []; | |
| 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 { | |
| (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2)); | |
| return { updated: true, results }; | |
| } | |
| 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 <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 = 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 = (0, review_reviewer_selection_cjs_1.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 ((0, secrets_cjs_1.isSecretKey)(kp)) { | |
| // parsedValue is unknown at this point; maskSecret accepts MaskableValue | |
| const masked = (0, secrets_cjs_1.maskSecret)(parsedValue); | |
| const maskedPrev = setConfigValueResult.previousValue === undefined | |
| ? undefined | |
| : (0, secrets_cjs_1.maskSecret)(setConfigValueResult.previousValue); | |
| const maskedResult = { | |
| ...setConfigValueResult, | |
| value: masked, | |
| previousValue: maskedPrev, | |
| masked: true, | |
| }; | |
| output(maskedResult, raw, `${kp}=${masked}`); | |
| return; | |
| } | |
| output(setConfigValueResult, raw, `${kp}=${String(parsedValue)}`); | |
| } | |
| function cmdConfigGet(cwd, keyPath, raw, defaultValue) { | |
| const configPath = node_path_1.default.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 = {}; | |
| try { | |
| if (node_fs_1.default.existsSync(configPath)) { | |
| config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8')); | |
| } | |
| 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.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 = kp.split('.'); | |
| let current = 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[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 ((0, secrets_cjs_1.isSecretKey)(kp)) { | |
| const masked = (0, secrets_cjs_1.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 '${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, 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, _raw, workstreamContext = null) { | |
| // 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 | |
| : node_path_1.default.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, raw) { | |
| const ws = process.env['GSD_WORKSTREAM'] || null; | |
| const report = (0, configuration_cjs_1.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.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); | |
| } | |
| } | |
| module.exports = { | |
| VALID_CONFIG_KEYS, | |
| cmdConfigEnsureSection, | |
| cmdConfigSet, | |
| cmdConfigGet, | |
| cmdConfigSetModelProfile, | |
| cmdConfigNewProject, | |
| cmdConfigPath, | |
| cmdMigrateConfig, | |
| // Exported for programmatic use by capability-writer and tests | |
| setConfigValue, | |
| setConfigValues, | |
| }; | |