* 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>
186 lines
5.8 KiB
TypeScript
186 lines
5.8 KiB
TypeScript
/**
|
|
* Config reader — loads `.planning/config.json` and merges with defaults.
|
|
*
|
|
* Mirrors the default structure from `get-shit-done/bin/lib/config.cjs`
|
|
* `buildNewProjectConfig()`.
|
|
*/
|
|
|
|
import { readFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { relPlanningPath } from './workstream-utils.js';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface GitConfig {
|
|
branching_strategy: string;
|
|
phase_branch_template: string;
|
|
milestone_branch_template: string;
|
|
quick_branch_template: string | null;
|
|
}
|
|
|
|
export interface WorkflowConfig {
|
|
research: boolean;
|
|
plan_check: boolean;
|
|
verifier: boolean;
|
|
nyquist_validation: boolean;
|
|
/** Mirrors gsd-tools flat `config.tdd_mode` (from `workflow.tdd_mode`). */
|
|
tdd_mode: boolean;
|
|
auto_advance: boolean;
|
|
node_repair: boolean;
|
|
node_repair_budget: number;
|
|
ui_phase: boolean;
|
|
ui_safety_gate: boolean;
|
|
text_mode: boolean;
|
|
research_before_questions: boolean;
|
|
discuss_mode: string;
|
|
skip_discuss: boolean;
|
|
/** Maximum self-discuss passes in auto/headless mode before forcing proceed. Default: 3. */
|
|
max_discuss_passes: number;
|
|
/** Subagent timeout in ms (matches `get-shit-done/bin/lib/core.cjs` default 300000). */
|
|
subagent_timeout: number;
|
|
/**
|
|
* Issue #2492. When true (default), enforces that every trackable decision in
|
|
* CONTEXT.md `<decisions>` is referenced by at least one plan (translation
|
|
* gate, blocking) and reports decisions not honored by shipped artifacts at
|
|
* verify-phase (validation gate, non-blocking). Set false to disable both.
|
|
*/
|
|
context_coverage_gate: boolean;
|
|
}
|
|
|
|
export interface HooksConfig {
|
|
context_warnings: boolean;
|
|
}
|
|
|
|
export interface GSDConfig {
|
|
model_profile: string;
|
|
commit_docs: boolean;
|
|
parallelization: boolean;
|
|
search_gitignored: boolean;
|
|
brave_search: boolean;
|
|
firecrawl: boolean;
|
|
exa_search: boolean;
|
|
git: GitConfig;
|
|
workflow: WorkflowConfig;
|
|
hooks: HooksConfig;
|
|
agent_skills: Record<string, unknown>;
|
|
/** Project slug for branch templates; mirrors gsd-tools `config.project_code`. */
|
|
project_code?: string | null;
|
|
/** Interactive vs headless; mirrors gsd-tools flat `config.mode`. */
|
|
mode?: string;
|
|
/** Internal auto-chain flag; mirrors gsd-tools `config._auto_chain_active`. */
|
|
_auto_chain_active?: boolean;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
|
|
export const CONFIG_DEFAULTS: GSDConfig = {
|
|
model_profile: 'balanced',
|
|
commit_docs: true,
|
|
parallelization: true,
|
|
search_gitignored: false,
|
|
brave_search: false,
|
|
firecrawl: false,
|
|
exa_search: false,
|
|
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,
|
|
tdd_mode: false,
|
|
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,
|
|
max_discuss_passes: 3,
|
|
subagent_timeout: 300000,
|
|
context_coverage_gate: true,
|
|
},
|
|
hooks: {
|
|
context_warnings: true,
|
|
},
|
|
agent_skills: {},
|
|
project_code: null,
|
|
mode: 'interactive',
|
|
_auto_chain_active: false,
|
|
};
|
|
|
|
// ─── Loader ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Load project config from `.planning/config.json`, merging with defaults.
|
|
* Returns full defaults when file is missing or empty.
|
|
* Throws on malformed JSON with a helpful error message.
|
|
*/
|
|
export async function loadConfig(projectDir: string, workstream?: string): Promise<GSDConfig> {
|
|
const configPath = join(projectDir, relPlanningPath(workstream), 'config.json');
|
|
const rootConfigPath = join(projectDir, '.planning', 'config.json');
|
|
|
|
let raw: string;
|
|
try {
|
|
raw = await readFile(configPath, 'utf-8');
|
|
} catch {
|
|
// If workstream config missing, fall back to root config
|
|
if (workstream) {
|
|
try {
|
|
raw = await readFile(rootConfigPath, 'utf-8');
|
|
} catch {
|
|
return structuredClone(CONFIG_DEFAULTS);
|
|
}
|
|
} else {
|
|
// File missing — normal for new projects
|
|
return structuredClone(CONFIG_DEFAULTS);
|
|
}
|
|
}
|
|
|
|
const trimmed = raw.trim();
|
|
if (trimmed === '') {
|
|
return structuredClone(CONFIG_DEFAULTS);
|
|
}
|
|
|
|
let parsed: Record<string, unknown>;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
|
|
}
|
|
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
throw new Error(`Config at ${configPath} must be a JSON object`);
|
|
}
|
|
|
|
// Three-level deep merge: defaults <- parsed
|
|
return {
|
|
...structuredClone(CONFIG_DEFAULTS),
|
|
...parsed,
|
|
git: {
|
|
...CONFIG_DEFAULTS.git,
|
|
...(parsed.git as Partial<GitConfig> ?? {}),
|
|
},
|
|
workflow: {
|
|
...CONFIG_DEFAULTS.workflow,
|
|
...(parsed.workflow as Partial<WorkflowConfig> ?? {}),
|
|
},
|
|
hooks: {
|
|
...CONFIG_DEFAULTS.hooks,
|
|
...(parsed.hooks as Partial<HooksConfig> ?? {}),
|
|
},
|
|
agent_skills: {
|
|
...CONFIG_DEFAULTS.agent_skills,
|
|
...(parsed.agent_skills as Record<string, unknown> ?? {}),
|
|
},
|
|
};
|
|
}
|