Files
msd-core/sdk/src/query/config-mutation.ts
Tom Boucher f30da8326a feat: add gates ensuring discuss-phase decisions are translated to plans and verified (closes #2492) (#2611)
* feat(#2492): add gates ensuring discuss-phase decisions are translated and verified

Two gates close the loop between CONTEXT.md `<decisions>` and downstream
work, fixing #2492:

- Plan-phase **translation gate** (BLOCKING). After requirements
  coverage, refuses to mark a phase planned when a trackable decision
  is not cited (by id `D-NN` or by 6+-word phrase) in any plan's
  `must_haves`, `truths`, or body. Failure message names each missed
  decision with id, category, text, and remediation paths.

- Verify-phase **validation gate** (NON-BLOCKING). Searches plans,
  SUMMARY.md, files modified, and recent commit subjects for each
  trackable decision. Misses are written to VERIFICATION.md as a
  warning section but do not change verification status. Asymmetry is
  deliberate — fuzzy-match miss should not fail an otherwise green
  phase.

Shared helper `parseDecisions()` lives in `sdk/src/query/decisions.ts`
so #2493 can consume the same parser.

Decisions opt out of both gates via `### Claude's Discretion` heading
or `[informational]` / `[folded]` / `[deferred]` tags.

Both gates skip silently when `workflow.context_coverage_gate=false`
(default `true`).

Closes #2492

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

* fix(#2492): make plan-phase decision gate actually block (review F1, F8, F9, F10, F15)

- F1: replace `${context_path}` with `${CONTEXT_PATH}` in the plan-phase
  gate snippet so the BLOCKING gate receives a non-empty path. The
  variable was defined in Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" ...)`)
  and the gate snippet referenced the lowercase form, leaving the gate to
  run with an empty path argument and silently skip.
- F15: wrap the SDK call with `jq -e '.data.passed == true' || exit 1` so
  failure halts the workflow instead of being printed and ignored. The
  verify-phase counterpart deliberately keeps no exit-1 (non-blocking by
  design) and now carries an inline note documenting the asymmetry.
- F10: tag the JSON example fence as `json` and the options-list fence as
  `text` (MD040).
- F8/F9: anchor the heading-presence test regexes to `^## 13[a-z]?\\.` so
  prose substrings like "Requirements Coverage Gate" mentioned in body
  text cannot satisfy the assertion. Added two new regression tests
  (variable-name match, exit-1 guard) so a future revert is caught.

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

* fix(#2492): tighten decision-coverage gates against false positives and config drift (review F3,F4,F5,F6,F7,F16,F18,F19)

- F3: forward `workstream` arg through both gate handlers so workstream-scoped
  `workflow.context_coverage_gate=false` actually skips. Added negative test
  that creates a workstream config disabling the gate while the root config
  has it enabled and asserts the workstream call is skipped.
- F4: restrict the plan-phase haystack to designated sections — front-matter
  `must_haves` / `truths` / `objective` plus body sections under headings
  matching `must_haves|truths|tasks|objective`. HTML comments and fenced
  code blocks are stripped before extraction so a commented-out citation or
  a literal example never counts as coverage. Verify-phase keeps the broader
  artifact-wide haystack by design (non-blocking).
- F5: reject decisions with fewer than 6 normalized words from soft-matching
  (previously only rejected when the resulting phrase was under 12 chars
  AFTER slicing — too lenient). Short decisions now require an explicit
  `D-NN` citation, with regression tests for the boundary.
- F6: walk every `*-SUMMARY.md` independently and use `matchAll` with the
  `/g` flag so multiple `files_modified:` blocks across multiple summaries
  are all aggregated. Previously only the first block in the concatenated
  string was parsed, silently dropping later plans' files.
- F7: validate every `files_modified` path stays inside `projectDir` after
  resolution (rejects absolute paths, `../` traversal). Cap each file read
  at 256 KB. Skipped paths emit a stderr warning naming the entry.
- F16: validate `workflow.context_coverage_gate` is boolean in
  `loadGateConfig`; warn loudly on numeric or other-shaped values and
  default to ON. Mirrors the schema-vs-loadConfig validation gap from
  #2609.
- F18: bump verify-phase `git log -n` cap from 50 to 200 so longer-running
  phases are not undercounted. Documented as a precision-vs-recall tradeoff
  appropriate for a non-blocking gate.
- F19: tighten `QueryResult` / `QueryHandler` to be parameterized
  (`<T = unknown>`). Drops the `as unknown as Record<string, unknown>`
  casts in the gate handlers and surfaces shape mismatches at compile time
  for callers that pass a typed `data` value.

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

* fix(#2492): harden decisions parser and verify-phase glob (review F11,F12,F13,F14,F17,F20)

- F11: strip fenced code blocks from CONTEXT.md before searching for
  `<decisions>` so an example block inside ``` ``` is not mis-parsed.
- F12: accept tab-indented continuation lines (previously required a leading
  space) so decisions split with `\t` continue cleanly.
- F13: parse EVERY `<decisions>` block in the file via `matchAll`, not just
  the first. CONTEXT.md may legitimately carry more than one block.
- F14: `decisions.parse` handler now resolves a relative path against
  `projectDir` — symmetric with the gate handlers — and still accepts
  absolute paths.
- F17: replace `ls "${PHASE_DIR}"/*-CONTEXT.md | head -1` in verify-phase.md
  with a glob loop (ShellCheck SC2012 fix). Also avoids spawning an extra
  subprocess and survives filenames with whitespace.
- F20: extend the unicode quote-stripping in the discretion-heading match
  to cover U+2018/2019/201A/201B and the U+201C-F double-quote variants
  plus backtick, so any rendering of "Claude's Discretion" collapses to
  the same key.

Each fix has a regression test in `decisions.test.ts`.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:26:53 -04:00

501 lines
18 KiB
TypeScript

/**
* Config mutation handlers — write operations for .planning/config.json.
*
* Ported from get-shit-done/bin/lib/config.cjs.
* Provides config-set (with key validation and value coercion),
* config-set-model-profile, config-new-project, and config-ensure-section.
*
* @example
* ```typescript
* import { configSet, configNewProject } from './config-mutation.js';
*
* await configSet(['model_profile', 'quality'], '/project');
* // { data: { updated: true, key: 'model_profile', value: 'quality', previousValue: 'balanced' } }
*
* await configNewProject([], '/project');
* // { data: { created: true, path: '.planning/config.json' } }
* ```
*/
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { VALID_PROFILES, getAgentToModelMapForProfile } from './config-query.js';
import { planningPaths } from './helpers.js';
import { acquireStateLock, releaseStateLock } from './state-mutation.js';
import type { QueryHandler } from './utils.js';
/**
* Write config JSON atomically via temp file + rename to prevent
* partial writes on process interruption.
*/
async function atomicWriteConfig(configPath: string, config: Record<string, unknown>): Promise<void> {
const tmpPath = configPath + '.tmp.' + process.pid;
const content = JSON.stringify(config, null, 2) + '\n';
try {
await writeFile(tmpPath, content, 'utf-8');
await rename(tmpPath, configPath);
} catch {
// D5: Rename-failure fallback — clean up temp, fall back to direct write
try { await unlink(tmpPath); } catch { /* already gone */ }
await writeFile(configPath, content, 'utf-8');
}
}
// ─── VALID_CONFIG_KEYS ────────────────────────────────────────────────────
/**
* Allowlist of valid config key paths.
*
* Ported from config.cjs lines 14-37.
* Dynamic patterns (agent_skills.*, features.*) are handled
* separately in isValidConfigKey.
*/
const VALID_CONFIG_KEYS = new Set([
'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile',
'search_gitignored', 'brave_search', 'firecrawl', 'exa_search',
'workflow.research', 'workflow.plan_check', 'workflow.verifier',
'workflow.nyquist_validation', 'workflow.ui_phase', 'workflow.ui_safety_gate',
'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget',
'workflow.text_mode',
'workflow.research_before_questions',
'workflow.discuss_mode',
'workflow.skip_discuss',
'workflow.ui_review',
'workflow.max_discuss_passes',
'workflow.use_worktrees',
'workflow.code_review',
'workflow.code_review_depth',
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template',
'git.milestone_branch_template', 'git.quick_branch_template',
'planning.commit_docs', 'planning.search_gitignored',
'workflow.subagent_timeout',
'workflow.context_coverage_gate',
'hooks.context_warnings',
'hooks.workflow_guard',
'features.thinking_partner',
'features.global_learnings',
'learnings.max_inject',
'context',
'project_code', 'phase_naming',
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
'response_language',
]);
// ─── CONFIG_KEY_SUGGESTIONS (D9 — match CJS config.cjs:57-67) ────────────
/**
* Curated typo correction map for known config key mistakes.
* Checked before the general LCP fallback for more precise suggestions.
*/
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': 'workflow.code_review',
'workflow.code_review_level': 'workflow.code_review_depth',
'workflow.review_depth': 'workflow.code_review_depth',
};
// ─── isValidConfigKey ─────────────────────────────────────────────────────
/**
* Check whether a config key path is valid.
*
* Supports exact matches from VALID_CONFIG_KEYS plus dynamic patterns
* like `agent_skills.<agent-type>` and `features.<feature_name>`.
* Uses curated CONFIG_KEY_SUGGESTIONS before LCP fallback for typo correction.
*
* @param keyPath - Dot-notation config key path
* @returns Object with valid flag and optional suggestion for typos
*/
export function isValidConfigKey(keyPath: string): { valid: boolean; suggestion?: string } {
if (VALID_CONFIG_KEYS.has(keyPath)) return { valid: true };
// Dynamic patterns: agent_skills.<agent-type>
if (/^agent_skills\.[a-zA-Z0-9_-]+$/.test(keyPath)) return { valid: true };
// Dynamic patterns: features.<feature_name>
if (/^features\.[a-zA-Z0-9_]+$/.test(keyPath)) return { valid: true };
// D9: Check curated suggestions before LCP fallback
if (CONFIG_KEY_SUGGESTIONS[keyPath]) {
return { valid: false, suggestion: CONFIG_KEY_SUGGESTIONS[keyPath] };
}
// Find closest suggestion using longest common prefix
const keys = [...VALID_CONFIG_KEYS];
let bestMatch = '';
let bestScore = 0;
for (const candidate of keys) {
let shared = 0;
const maxLen = Math.min(keyPath.length, candidate.length);
for (let i = 0; i < maxLen; i++) {
if (keyPath[i] === candidate[i]) shared++;
else break;
}
if (shared > bestScore) {
bestScore = shared;
bestMatch = candidate;
}
}
return { valid: false, suggestion: bestScore > 2 ? bestMatch : undefined };
}
// ─── parseConfigValue ─────────────────────────────────────────────────────
/**
* Coerce a CLI string value to its native type.
*
* Ported from config.cjs lines 344-351.
*
* @param value - String value from CLI
* @returns Coerced value: boolean, number, parsed JSON, or original string
*/
export function parseConfigValue(value: string): unknown {
if (value === 'true') return true;
if (value === 'false') return false;
if (value !== '' && !isNaN(Number(value))) return Number(value);
if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) {
try { return JSON.parse(value); } catch { /* keep as string */ }
}
return value;
}
// ─── setConfigValue ───────────────────────────────────────────────────────
/**
* Set a value at a dot-notation path in a config object.
*
* Creates nested objects as needed along the path.
*
* @param obj - Config object to mutate
* @param dotPath - Dot-notation key path (e.g., 'workflow.auto_advance')
* @param value - Value to set
*/
function getValueAtPath(obj: Record<string, unknown>, dotPath: string): unknown {
const keys = dotPath.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
function setConfigValue(obj: Record<string, unknown>, dotPath: string, value: unknown): void {
const keys = dotPath.split('.');
let current: Record<string, unknown> = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (current[key] === undefined || typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
current[keys[keys.length - 1]] = value;
}
// ─── configSet ────────────────────────────────────────────────────────────
/**
* Write a validated key-value pair to config.json.
*
* Validates key against VALID_CONFIG_KEYS allowlist, coerces value
* from CLI string to native type, and writes config.json.
*
* @param args - args[0]=key, args[1]=value
* @param projectDir - Project root directory
* @returns QueryResult matching gsd-tools `config-set` JSON: `{ updated, key, value, previousValue }`
* @throws GSDError with Validation if key is invalid or args missing
*/
export const configSet: QueryHandler = async (args, projectDir, _workstream) => {
const keyPath = args[0];
const rawValue = args[1];
if (!keyPath) {
throw new GSDError('Usage: config-set <key.path> <value>', ErrorClassification.Validation);
}
const validation = isValidConfigKey(keyPath);
if (!validation.valid) {
const suggestion = validation.suggestion ? `. Did you mean: ${validation.suggestion}?` : '';
throw new GSDError(
`Unknown config key: "${keyPath}"${suggestion}`,
ErrorClassification.Validation,
);
}
const parsedValue = rawValue !== undefined ? parseConfigValue(rawValue) : rawValue;
// D8: Context value validation (match CJS config.cjs:357-359)
const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
if (keyPath === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) {
throw new GSDError(
`Invalid context value '${rawValue}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`,
ErrorClassification.Validation,
);
}
// D6: Lock protection for read-modify-write (match CJS config.cjs:296)
const paths = planningPaths(projectDir);
const lockPath = await acquireStateLock(paths.config);
let previousValue: unknown;
try {
let config: Record<string, unknown> = {};
try {
const raw = await readFile(paths.config, 'utf-8');
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
// Start with empty config if file doesn't exist or is malformed
}
previousValue = getValueAtPath(config, keyPath);
setConfigValue(config, keyPath, parsedValue);
await atomicWriteConfig(paths.config, config);
} finally {
await releaseStateLock(lockPath);
}
// Match CJS JSON: `JSON.stringify` omits keys whose value is `undefined`
const data: Record<string, unknown> = {
updated: true,
key: keyPath,
value: parsedValue,
};
if (previousValue !== undefined) {
data.previousValue = previousValue;
}
return { data };
};
// ─── configSetModelProfile ────────────────────────────────────────────────
/**
* Validate and set the model profile in config.json.
*
* @param args - args[0]=profileName
* @param projectDir - Project root directory
* @returns QueryResult with { set: true, profile, agents }
* @throws GSDError with Validation if profile is invalid
*/
export const configSetModelProfile: QueryHandler = async (args, projectDir, _workstream) => {
const profileName = args[0];
if (!profileName) {
throw new GSDError(
`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`,
ErrorClassification.Validation,
);
}
const normalized = profileName.toLowerCase().trim();
if (!VALID_PROFILES.includes(normalized)) {
throw new GSDError(
`Invalid profile '${profileName}'. Valid profiles: ${VALID_PROFILES.join(', ')}`,
ErrorClassification.Validation,
);
}
// D6: Lock protection for read-modify-write
const paths = planningPaths(projectDir);
const lockPath = await acquireStateLock(paths.config);
let previousProfile = 'balanced';
try {
let config: Record<string, unknown> = {};
try {
const raw = await readFile(paths.config, 'utf-8');
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
// Start with empty config
}
const prev =
typeof config.model_profile === 'string' ? config.model_profile.toLowerCase().trim() : '';
previousProfile = VALID_PROFILES.includes(prev) ? prev : 'balanced';
config.model_profile = normalized;
await atomicWriteConfig(paths.config, config);
} finally {
await releaseStateLock(lockPath);
}
const agentToModelMap = getAgentToModelMapForProfile(normalized);
return {
data: {
updated: true,
profile: normalized,
previousProfile,
agentToModelMap,
},
};
};
// ─── configNewProject ─────────────────────────────────────────────────────
/**
* Create config.json with defaults and optional user choices.
*
* Idempotent: if config.json already exists, returns { created: false }.
* Detects API key availability from environment variables.
*
* @param args - args[0]=optional JSON string of user choices
* @param projectDir - Project root directory
* @returns QueryResult with { created: true, path } or { created: false, reason }
*/
export const configNewProject: QueryHandler = async (args, projectDir, _workstream) => {
const paths = planningPaths(projectDir);
// Idempotent: don't overwrite existing config
if (existsSync(paths.config)) {
return { data: { created: false, reason: 'already_exists' } };
}
// Parse user choices
let userChoices: Record<string, unknown> = {};
if (args[0] && args[0].trim() !== '') {
try {
userChoices = JSON.parse(args[0]) as Record<string, unknown>;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new GSDError(`Invalid JSON for config-new-project: ${msg}`, ErrorClassification.Validation);
}
}
// Ensure .planning directory exists
const planningDir = paths.planning;
if (!existsSync(planningDir)) {
await mkdir(planningDir, { recursive: true });
}
// D11: Load global defaults from ~/.gsd/defaults.json if present
const homeDir = homedir();
let globalDefaults: Record<string, unknown> = {};
try {
const defaultsPath = join(homeDir, '.gsd', 'defaults.json');
const defaultsRaw = await readFile(defaultsPath, 'utf-8');
globalDefaults = JSON.parse(defaultsRaw) as Record<string, unknown>;
} catch {
// No global defaults — continue with hardcoded defaults only
}
// Detect API key availability (boolean only, never store keys)
const hasBraveSearch = !!(process.env.BRAVE_API_KEY || existsSync(join(homeDir, '.gsd', 'brave_api_key')));
const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || existsSync(join(homeDir, '.gsd', 'firecrawl_api_key')));
const hasExaSearch = !!(process.env.EXA_API_KEY || existsSync(join(homeDir, '.gsd', 'exa_api_key')));
// Build default config
const defaults: Record<string, unknown> = {
model_profile: 'balanced',
commit_docs: false,
parallelization: 1,
search_gitignored: false,
brave_search: hasBraveSearch,
firecrawl: hasFirecrawl,
exa_search: hasExaSearch,
git: {
branching_strategy: 'none',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
milestone_branch_template: 'gsd/{milestone}-{slug}',
quick_branch_template: null,
},
workflow: {
research: true,
plan_check: true,
verifier: true,
nyquist_validation: true,
auto_advance: false,
node_repair: true,
node_repair_budget: 2,
ui_phase: true,
ui_safety_gate: true,
text_mode: false,
research_before_questions: false,
discuss_mode: 'discuss',
skip_discuss: false,
code_review: true,
code_review_depth: 'standard',
},
hooks: {
context_warnings: true,
},
project_code: null,
phase_naming: 'sequential',
agent_skills: {},
features: {},
};
// Deep merge: hardcoded <- globalDefaults <- userChoices (D11)
const config: Record<string, unknown> = {
...defaults,
...globalDefaults,
...userChoices,
git: {
...(defaults.git as Record<string, unknown>),
...((userChoices.git as Record<string, unknown>) || {}),
},
workflow: {
...(defaults.workflow as Record<string, unknown>),
...((userChoices.workflow as Record<string, unknown>) || {}),
},
hooks: {
...(defaults.hooks as Record<string, unknown>),
...((userChoices.hooks as Record<string, unknown>) || {}),
},
agent_skills: {
...((defaults.agent_skills as Record<string, unknown>) || {}),
...((userChoices.agent_skills as Record<string, unknown>) || {}),
},
features: {
...((defaults.features as Record<string, unknown>) || {}),
...((userChoices.features as Record<string, unknown>) || {}),
},
};
await atomicWriteConfig(paths.config, config);
return { data: { created: true, path: paths.config } };
};
// ─── configEnsureSection ──────────────────────────────────────────────────
/**
* Idempotently ensure a top-level section exists in config.json.
*
* If the section key doesn't exist, creates it as an empty object.
* If it already exists, preserves its contents.
*
* @param args - args[0]=sectionName
* @param projectDir - Project root directory
* @returns QueryResult with { ensured: true, section }
*/
export const configEnsureSection: QueryHandler = async (args, projectDir, _workstream) => {
const sectionName = args[0];
if (!sectionName) {
throw new GSDError('Usage: config-ensure-section <section>', ErrorClassification.Validation);
}
const paths = planningPaths(projectDir);
let config: Record<string, unknown> = {};
try {
const raw = await readFile(paths.config, 'utf-8');
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
// Start with empty config
}
if (!(sectionName in config)) {
config[sectionName] = {};
}
await atomicWriteConfig(paths.config, config);
return { data: { ensured: true, section: sectionName } };
};