diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3b663afbfb..ce95e968c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -411,7 +411,9 @@ plan-phase ├── Research gate (blocks if RESEARCH.md has unresolved open questions) ├── Phase Researcher → RESEARCH.md ├── Planner (with reachability check) → PLAN.md files - └── Plan Checker → Verify loop (max 3x) + ├── Plan Checker → Verify loop (max 3x) + ├── Requirements coverage gate (REQ-IDs → plans) + └── Decision coverage gate (CONTEXT.md `` → plans, BLOCKING — #2492) │ ▼ state planned-phase → STATE.md (Planned/Ready to execute) @@ -422,6 +424,7 @@ execute-phase (context reduction: truncated prompts, cache-friendly ordering) ├── Executor per plan → code + atomic commits ├── SUMMARY.md per plan └── Verifier → VERIFICATION.md + └── Decision coverage gate (CONTEXT.md decisions → shipped artifacts, NON-BLOCKING — #2492) │ ▼ verify-work → UAT.md (user acceptance testing) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e7fee21860..abf912f08d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -454,6 +454,60 @@ These keys live under `workflow.*` — that is where the workflows and installer --- +## Decision Coverage Gates (`workflow.context_coverage_gate`) + +When `discuss-phase` writes implementation decisions into CONTEXT.md +``, two gates ensure those decisions survive the trip into +plans and shipped code (issue #2492). + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `workflow.context_coverage_gate` | boolean | `true` | Toggle for both decision-coverage gates. When `false`, both the plan-phase translation gate and the verify-phase validation gate skip silently. | + +### What the gates do + +**Plan-phase translation gate (BLOCKING).** Runs immediately after the +existing requirements coverage gate, before plans are committed. For each +trackable decision in ``, it checks that the decision id +(`D-NN`) or its text appears in at least one plan's `must_haves`, +`truths`, or body. A miss surfaces the missing decision by id and refuses +to mark the phase planned. + +**Verify-phase validation gate (NON-BLOCKING).** Runs alongside the other +verify steps. Searches every shipped artifact (PLAN.md, SUMMARY.md, files +modified, recent commit subjects) for each trackable decision. Misses are +written to VERIFICATION.md as a warning section but do **not** flip the +overall verification status. The asymmetry is deliberate — by verify time +the work is done, and a fuzzy substring miss should not fail an otherwise +green phase. + +### How to write decisions the gates accept + +The discuss-phase template already produces `D-NN`-numbered decisions. +The gate is happiest when: + +1. Every plan that implements a decision **cites the id** somewhere — + `must_haves.truths: ["D-12: bit offsets exposed"]` or a `D-12:` mention + in the plan body. Strict id match is the cheapest, deterministic path. +2. Soft phrase matching is a fallback for paraphrases — if a 6+-word slice + of the decision text appears verbatim in a plan/summary, it counts. + +### Opt-outs + +A decision is **not** subject to the gates when any of the following +apply: + +- It lives under the `### Claude's Discretion` heading inside ``. +- It is tagged `[informational]`, `[folded]`, or `[deferred]` in its + bullet (e.g., `- **D-08 [informational]:** Naming style for internal + helpers`). + +Use these escape hatches when a decision genuinely doesn't need plan +coverage — implementation discretion, future ideas captured for the +record, or items already deferred to a later phase. + +--- + ## Review Settings Configure per-CLI model selection for `/gsd-review`. When set, overrides the CLI's default model for that reviewer. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index f9e43a9f54..c09146f9f8 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -179,6 +179,47 @@ By default, `/gsd-discuss-phase` asks open-ended questions about your implementa See [docs/workflow-discuss-mode.md](workflow-discuss-mode.md) for the full discuss-mode reference. +### Decision Coverage Gates + +The discuss-phase captures implementation decisions in CONTEXT.md under a +`` block as numbered bullets (`- **D-01:** …`). Two gates — added +for issue #2492 — ensure those decisions survive into plans and shipped +code. + +**Plan-phase translation gate (blocking).** After planning, GSD refuses to +mark the phase planned until every trackable decision appears in at least +one plan's `must_haves`, `truths`, or body. The gate names each missed +decision by id (`D-07: …`) so you know exactly what to add, move, or +reclassify. + +**Verify-phase validation gate (non-blocking).** During verification, GSD +searches plans, SUMMARY.md, modified files, and recent commit messages for +each trackable decision. Misses are logged to VERIFICATION.md as a warning +section; verification status is unchanged. The asymmetry is deliberate — +the blocking gate is cheap at plan time but hostile at verify time. + +**Writing decisions the gate can match.** Two match modes: + +1. **Strict id match (recommended).** Cite the decision id anywhere in a + plan that implements it — `must_haves.truths: ["D-12: bit offsets + exposed"]`, a bullet in the plan body, a frontmatter comment. This is + deterministic and unambiguous. +2. **Soft phrase match (fallback).** If a 6+-word slice of the decision + text appears verbatim in any plan or shipped artifact, it counts. This + forgives paraphrasing but is less reliable. + +**Opting a decision out.** If a decision genuinely should not be tracked — +an implementation-discretion note, an informational capture, a decision +already deferred — mark it one of these ways: + +- Move it under the `### Claude's Discretion` heading inside ``. +- Tag it in its bullet: `- **D-08 [informational]:** …`, + `- **D-09 [folded]:** …`, `- **D-10 [deferred]:** …`. + +**Disabling the gates.** Set +`workflow.context_coverage_gate: false` in `.planning/config.json` (or via +`/gsd-settings`) to skip both gates silently. Default is `true`. + --- ## UI Design Contract diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index 3e602dbb07..16a40e3c45 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -1310,6 +1310,72 @@ Options: If `TEXT_MODE` is true, present as a plain-text numbered list (options already shown in the block above). Otherwise use AskUserQuestion to present the options. +## 13a. Decision Coverage Gate + +After the requirements coverage gate passes, verify that every trackable +decision captured by discuss-phase in CONTEXT.md `` is referenced +by at least one plan. This is the **translation gate** from issue #2492 — +its job is to refuse to mark a phase planned when a discuss-phase decision +silently dropped on the way into the plans. + +**Skip if** `workflow.context_coverage_gate` is explicitly set to `false` +(absent key = enabled). Also skip if no CONTEXT.md exists for this phase +(nothing to translate) or if its `` block is empty. + +```bash +GATE_CFG=$(gsd-sdk query config-get workflow.context_coverage_gate 2>/dev/null || echo "true") +if [ "$GATE_CFG" != "false" ]; then + GATE_RESULT=$(gsd-sdk query check.decision-coverage-plan "${PHASE_DIR}" "${CONTEXT_PATH}") + # BLOCKING: refuse to mark phase planned when a trackable decision is uncovered. + # `passed: true` covers both real-pass and skipped cases (gate disabled / no CONTEXT.md / + # no trackable decisions). Verify-phase counterpart deliberately omits this exit-1 — that + # gate is non-blocking by design (review finding F15). + echo "$GATE_RESULT" | jq -e '.data.passed == true' >/dev/null || { + echo "$GATE_RESULT" | jq -r '.data.message' + exit 1 + } +fi +``` + +The handler returns JSON: +```json +{ + "passed": true, + "skipped": false, + "total": 2, + "covered": 2, + "uncovered": [ { "id": "D-01", "text": "...", "category": "..." } ], + "message": "..." +} +``` + +**If `passed` is true (or `skipped` is true):** Display +`✓ Decision coverage: {M}/{N} CONTEXT.md decisions covered by plans` (or +`(skipped — gate disabled)` / `(skipped — no decisions)`) and proceed to +step 13b. + +**If `passed` is false:** Display the handler's `message` block. It already +names each uncovered decision (`D-NN | category | text`) and tells the user +what to do — cite the id in a relevant plan's `must_haves` / `truths`, or +move the decision under `### Claude's Discretion` / tag it `[informational]` +if it should not be tracked. Then offer: + +```text +Options: +1. Re-plan to cover missing decisions (recommended) +2. Edit CONTEXT.md to mark dropped decisions as [informational] / Discretion +3. Proceed anyway — accept the coverage gap +``` + +If `TEXT_MODE` is true, present as a plain-text numbered list. Otherwise use +AskUserQuestion. Selecting "Proceed anyway" continues to step 13b but +records the override in STATE.md so verify-phase can re-surface it. + +**Why this gate blocks:** failing here is cheap. The plans are the contract +between discuss-phase and execute-phase; if a decision isn't visible in any +plan, no executor will implement it. Catching that now beats discovering it +after thousands of dollars of execution. + ## 13b. Record Planning Completion in STATE.md After plans pass all gates, record that planning is complete so STATE.md reflects the new phase status: diff --git a/get-shit-done/workflows/verify-phase.md b/get-shit-done/workflows/verify-phase.md index e5b0c71dee..04d051496b 100644 --- a/get-shit-done/workflows/verify-phase.md +++ b/get-shit-done/workflows/verify-phase.md @@ -183,6 +183,57 @@ grep -E "Phase ${PHASE_NUM}" .planning/REQUIREMENTS.md 2>/dev/null || true For each requirement: parse description → identify supporting truths/artifacts → status: ✓ SATISFIED / ✗ BLOCKED / ? NEEDS HUMAN. + +**Decision coverage validation gate (issue #2492).** + +After requirements coverage, also check that each trackable CONTEXT.md +`` entry shows up somewhere in the shipped artifacts (plans, +SUMMARY.md, files modified by the phase, or recent commit subjects on the +phase branch). + +This gate is **non-blocking / warning only** by deliberate asymmetry with +the plan-phase translation gate. The plan-phase gate already blocked at +translation time, so by the time verification runs every decision has +either been translated or explicitly deferred. This gate's job is to +surface decisions that *were* translated but vanished during execution — +that's a soft signal because "honors a decision" is a fuzzy substring +heuristic, and we don't want a paraphrase miss to fail an otherwise good +phase. + +**Skip if** `workflow.context_coverage_gate` is explicitly set to `false` +(absent key = enabled). Also skip cleanly when CONTEXT.md is missing or has +no `` block. + +```bash +GATE_CFG=$(gsd-sdk query config-get workflow.context_coverage_gate 2>/dev/null || echo "true") +if [ "$GATE_CFG" != "false" ]; then + # Discover the phase CONTEXT.md via glob expansion rather than `ls | head` + # (review F17 / ShellCheck SC2012). Globs preserve filenames containing + # spaces and avoid an extra subprocess. + CONTEXT_PATH="" + for f in "${PHASE_DIR}"/*-CONTEXT.md; do + [ -e "$f" ] && CONTEXT_PATH="$f" && break + done + DECISION_RESULT=$(gsd-sdk query check.decision-coverage-verify "${PHASE_DIR}" "${CONTEXT_PATH}") +fi +``` + +The handler returns JSON `{ skipped, blocking: false, total, honored, +not_honored: [...], message }`. + +**Reporting:** Append the handler's `message` (a `### Decision Coverage` +section) to VERIFICATION.md regardless of outcome — even when all +decisions are honored, recording the count helps reviewers spot drift over +time. Set `decision_coverage` in the verification result to +`{honored, total, not_honored: [...]}` so downstream tooling can read it. + +**Status impact:** none. The decision gate does NOT influence the +`gaps_found` / `human_needed` / `passed` decision tree in +`determine_status`. Its findings are warnings the user reviews and may act +on by re-opening the phase or by acknowledging the decision was abandoned +intentionally. + + **Run the project's test suite and CLI commands to verify behavior, not just structure.** @@ -479,6 +530,7 @@ Orchestrator routes: `passed` → update_roadmap | `gaps_found` → create/execu - [ ] All artifacts checked at all three levels - [ ] All key links verified - [ ] Requirements coverage assessed (if applicable) +- [ ] CONTEXT.md decisions checked against shipped artifacts (#2492 — non-blocking) - [ ] Anti-patterns scanned and categorized - [ ] Test quality audited (disabled tests, circular patterns, assertion strength, provenance) - [ ] Human verification items identified diff --git a/sdk/src/config.ts b/sdk/src/config.ts index e61b8666de..acc27cda21 100644 --- a/sdk/src/config.ts +++ b/sdk/src/config.ts @@ -38,6 +38,13 @@ export interface WorkflowConfig { 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 `` 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 { @@ -98,6 +105,7 @@ export const CONFIG_DEFAULTS: GSDConfig = { skip_discuss: false, max_discuss_passes: 3, subagent_timeout: 300000, + context_coverage_gate: true, }, hooks: { context_warnings: true, diff --git a/sdk/src/query/check-decision-coverage.test.ts b/sdk/src/query/check-decision-coverage.test.ts new file mode 100644 index 0000000000..a44af3f444 --- /dev/null +++ b/sdk/src/query/check-decision-coverage.test.ts @@ -0,0 +1,519 @@ +/** + * Decision-coverage gate tests for issue #2492. + * + * Two gates, two semantics: + * + * - `check.decision-coverage-plan` — translation gate, BLOCKING. + * Each trackable CONTEXT.md decision must appear (by id or text) in at + * least one PLAN.md `must_haves` / `truths` / body. + * + * - `check.decision-coverage-verify` — validation gate, NON-BLOCKING. + * Each trackable decision should appear in shipped artifacts (PLANs, + * SUMMARY.md, files_modified, recent commit messages). Missing items + * are reported as warnings only. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + checkDecisionCoveragePlan, + checkDecisionCoverageVerify, +} from './check-decision-coverage.js'; + +let tmp: string; +let phaseDir: string; +let contextPath: string; + +async function setupPhase(decisionsBlock: string, plans: Record, summary?: string) { + await mkdir(phaseDir, { recursive: true }); + await writeFile(contextPath, `# Phase 17 Context\n\n${decisionsBlock}\n`, 'utf-8'); + for (const [name, content] of Object.entries(plans)) { + await writeFile(join(phaseDir, name), content, 'utf-8'); + } + if (summary !== undefined) { + await writeFile(join(phaseDir, '17-SUMMARY.md'), summary, 'utf-8'); + } +} + +function planFile(mustHavesYaml: string, body = ''): string { + return `--- +phase: 17 +plan: 1 +type: implementation +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true +must_haves: +${mustHavesYaml} +--- +${body} +`; +} + +beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'gsd-deccov-')); + phaseDir = join(tmp, '.planning', 'phases', '17-foo'); + contextPath = join(phaseDir, '17-CONTEXT.md'); +}); + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); +}); + +describe('checkDecisionCoveragePlan — translation gate (#2492)', () => { + it('passes when every trackable decision is cited by id in a plan', async () => { + await setupPhase( + ` +### Cat +- **D-01:** Use bit offsets +- **D-02:** Display TArray element type +`, + { + '17-01-PLAN.md': planFile( + ` truths: + - "D-01: bit offsets are exposed via API" + artifacts: [] + key_links: []`, + // D-02 cited under a designated `## tasks` heading (review F4). + '## tasks\n- Implements D-02: TArray display logic.\n', + ), + }, + ); + + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.passed).toBe(true); + expect(result.data.uncovered).toEqual([]); + expect(result.data.total).toBe(2); + expect(result.data.covered).toBe(2); + }); + + it('fails when a decision is not covered by any plan and names it', async () => { + await setupPhase( + ` +### Cat +- **D-01:** Use bit offsets, not byte offsets +- **D-99:** A decision nobody bothered to plan +`, + { + '17-01-PLAN.md': planFile( + ` truths: + - "D-01: bit offsets are exposed" + artifacts: [] + key_links: []`, + ), + }, + ); + + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.passed).toBe(false); + expect(result.data.uncovered.map((u: { id: string }) => u.id)).toEqual(['D-99']); + expect(result.data.message).toMatch(/D-99/); + }); + + it('honors `truths` AND `must_haves` body bullets', async () => { + await setupPhase( + ` +### Cat +- **D-01:** First decision +- **D-02:** Second decision +`, + { + '17-01-PLAN.md': planFile( + ` truths: + - "D-01 honored" + artifacts: [] + key_links: []`, + '## must_haves\n- D-02: also honored in body\n', + ), + }, + ); + + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.passed).toBe(true); + }); + + it('skips when context_coverage_gate is disabled in config', async () => { + await setupPhase( + ` +### Cat +- **D-01:** Anything +- **D-02:** Anything else +`, + { '17-01-PLAN.md': planFile(` truths: []\n artifacts: []\n key_links: []`) }, + ); + await mkdir(join(tmp, '.planning'), { recursive: true }); + await writeFile( + join(tmp, '.planning', 'config.json'), + JSON.stringify({ workflow: { context_coverage_gate: false } }), + 'utf-8', + ); + + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.skipped).toBe(true); + expect(result.data.passed).toBe(true); + }); + + it('skips cleanly when CONTEXT.md is missing', async () => { + await mkdir(phaseDir, { recursive: true }); + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.skipped).toBe(true); + expect(result.data.reason).toMatch(/CONTEXT/); + }); + + it('skips cleanly when block is missing', async () => { + await mkdir(phaseDir, { recursive: true }); + await writeFile(contextPath, '# Phase 17\n\nNo decisions block here.\n', 'utf-8'); + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.skipped).toBe(true); + }); + + it('does not flag non-trackable decisions (Discretion / informational / folded)', async () => { + await setupPhase( + ` +### Cat +- **D-01:** trackable +- **D-02 [informational]:** opt-out +- **D-03 [folded]:** opt-out + +### Claude's Discretion +- **D-99:** never tracked +`, + { + '17-01-PLAN.md': planFile( + ` truths: + - "D-01" + artifacts: [] + key_links: []`, + ), + }, + ); + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.passed).toBe(true); + expect(result.data.total).toBe(1); // only D-01 is trackable + }); +}); + +describe('checkDecisionCoverageVerify — validation gate (#2492)', () => { + it('reports honored decisions when ID appears in shipped artifacts', async () => { + await setupPhase( + ` +### Cat +- **D-05:** Validate input +`, + { '17-01-PLAN.md': planFile(` truths: ["D-05"]\n artifacts: []\n key_links: []`) }, + '## Summary\nImplemented D-05.\nfiles_modified: []\n', + ); + + const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp); + expect(result.data.honored).toBe(1); + expect(result.data.not_honored).toEqual([]); + expect(result.data.blocking).toBe(false); + }); + + it('reports decisions not honored when ID appears nowhere', async () => { + await setupPhase( + ` +### Cat +- **D-50:** Add metrics endpoint +`, + { '17-01-PLAN.md': planFile(` truths: []\n artifacts: []\n key_links: []`) }, + '## Summary\nDid other things.\n', + ); + + const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp); + expect(result.data.honored).toBe(0); + expect(result.data.not_honored.map((u: { id: string }) => u.id)).toEqual(['D-50']); + expect(result.data.blocking).toBe(false); // non-blocking by spec + expect(result.data.message).toMatch(/D-50/); + }); + + it('skips when context_coverage_gate is disabled', async () => { + await setupPhase( + ` +### Cat +- **D-50:** anything +`, + { '17-01-PLAN.md': planFile(` truths: []\n artifacts: []\n key_links: []`) }, + ); + await mkdir(join(tmp, '.planning'), { recursive: true }); + await writeFile( + join(tmp, '.planning', 'config.json'), + JSON.stringify({ workflow: { context_coverage_gate: false } }), + 'utf-8', + ); + const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp); + expect(result.data.skipped).toBe(true); + expect(result.data.blocking).toBe(false); + }); + + it('skips cleanly when CONTEXT.md is missing', async () => { + await mkdir(phaseDir, { recursive: true }); + const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp); + expect(result.data.skipped).toBe(true); + }); +}); + +// ─── Adversarial-review regression tests ────────────────────────────────── + +describe('translation gate haystack restriction (review F4)', () => { + it('does NOT count a D-NN citation buried in an HTML comment', async () => { + await setupPhase( + ` +### Cat +- **D-77:** A trackable decision worth six or more words long +`, + { + '17-01-PLAN.md': planFile( + ` truths: []\n artifacts: []\n key_links: []`, + '\nNothing else mentions the decision.', + ), + }, + ); + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.passed).toBe(false); + expect(result.data.uncovered.map((u: { id: string }) => u.id)).toContain('D-77'); + }); + + it('does NOT count a D-NN citation buried in a fenced code example', async () => { + await setupPhase( + ` +### Cat +- **D-78:** A trackable decision worth six or more words long +`, + { + '17-01-PLAN.md': planFile( + ` truths: []\n artifacts: []\n key_links: []`, + '## Design notes\n\n```text\nExample: D-78 should appear here\n```\n', + ), + }, + ); + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.passed).toBe(false); + expect(result.data.uncovered.map((u: { id: string }) => u.id)).toContain('D-78'); + }); + + it('counts a citation in front-matter `must_haves`', async () => { + await setupPhase( + ` +### Cat +- **D-79:** Trackable decision text long enough to soft-match. +`, + { + '17-01-PLAN.md': `--- +phase: 17 +plan: 1 +must_haves: + - "D-79 must be honored" +truths: [] +artifacts: [] +key_links: [] +--- +`, + }, + ); + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.passed).toBe(true); + }); + + it('counts a citation in front-matter `truths`', async () => { + await setupPhase( + ` +### Cat +- **D-80:** Trackable decision text long enough to soft-match. +`, + { + '17-01-PLAN.md': planFile(` truths: ["D-80 honored"]\n artifacts: []\n key_links: []`), + }, + ); + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.passed).toBe(true); + }); +}); + +describe('soft-phrase length gating (review F5)', () => { + it('flags a sub-6-word decision when only the body paraphrases — id citation is required', async () => { + await setupPhase( + // 4 words → cannot soft-match; user must cite the id. + ` +### Cat +- **D-81:** Use bit offsets always +`, + { + '17-01-PLAN.md': planFile( + ` truths: ["something else"]\n artifacts: []\n key_links: []`, + // No D-81 citation, paraphrase only. + '## tasks\n- Use bit offsets in storage layer\n', + ), + }, + ); + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.passed).toBe(false); + expect(result.data.uncovered.map((u: { id: string }) => u.id)).toEqual(['D-81']); + }); + + it('still passes a sub-6-word decision when the id is cited', async () => { + await setupPhase( + ` +### Cat +- **D-82:** Disable cache +`, + { + '17-01-PLAN.md': planFile(` truths: ["D-82"]\n artifacts: []\n key_links: []`), + }, + ); + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(result.data.passed).toBe(true); + }); +}); + +describe('verify-phase summary parsing (review F6, F7)', () => { + it('reads files_modified from EVERY summary, not just the first', async () => { + await mkdir(phaseDir, { recursive: true }); + await writeFile( + contextPath, + `# Phase 17 Context + + +### Cat +- **D-83:** A long-enough trackable decision text for soft matching honored elsewhere. + +`, + 'utf-8', + ); + await writeFile( + join(phaseDir, '17-01-PLAN.md'), + planFile(` truths: []\n artifacts: []\n key_links: []`), + 'utf-8', + ); + // Summary 01 — no files_modified mentioning D-83. + await writeFile( + join(phaseDir, '17-01-SUMMARY.md'), + 'files_modified:\n - "src/unrelated.ts"\n', + 'utf-8', + ); + // Summary 02 — files_modified entry whose content mentions D-83. + await writeFile( + join(phaseDir, '17-02-SUMMARY.md'), + 'files_modified:\n - "src/keeper.ts"\n', + 'utf-8', + ); + await mkdir(join(tmp, 'src'), { recursive: true }); + await writeFile(join(tmp, 'src', 'unrelated.ts'), '// nothing relevant\n', 'utf-8'); + await writeFile(join(tmp, 'src', 'keeper.ts'), '// honors D-83 in code\n', 'utf-8'); + const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp); + // If only the first SUMMARY were parsed, D-83 would be missing. + expect(result.data.honored).toBe(1); + expect(result.data.not_honored).toEqual([]); + }); + + it('rejects absolute files_modified paths outside projectDir (path traversal guard)', async () => { + await mkdir(phaseDir, { recursive: true }); + await writeFile( + contextPath, + `# Phase 17 + + +### Cat +- **D-84:** A trackable decision text spanning enough words to soft-match. + +`, + 'utf-8', + ); + await writeFile( + join(phaseDir, '17-01-PLAN.md'), + planFile(` truths: []\n artifacts: []\n key_links: []`), + 'utf-8', + ); + // Summary points at /etc/passwd and a parent-traversal path. Both must be skipped. + await writeFile( + join(phaseDir, '17-01-SUMMARY.md'), + 'files_modified:\n - "/etc/passwd"\n - "../../../etc/hostname"\n', + 'utf-8', + ); + const result = await checkDecisionCoverageVerify([phaseDir, contextPath], tmp); + // Should not honor D-84 from those files (and should not throw). + expect(result.data.honored).toBe(0); + expect(result.data.not_honored.map((u: { id: string }) => u.id)).toEqual(['D-84']); + }); +}); + +describe('workstream-aware config (review F3)', () => { + it('honors workstream-scoped context_coverage_gate=false', async () => { + await setupPhase( + ` +### Cat +- **D-85:** A trackable decision long enough to potentially soft match. +`, + { '17-01-PLAN.md': planFile(` truths: []\n artifacts: []\n key_links: []`) }, + ); + // Root config does NOT disable the gate. + await mkdir(join(tmp, '.planning'), { recursive: true }); + await writeFile( + join(tmp, '.planning', 'config.json'), + JSON.stringify({ workflow: { context_coverage_gate: true } }), + 'utf-8', + ); + // Workstream config DOES disable it. + await mkdir(join(tmp, '.planning', 'workstreams', 'feat-x'), { recursive: true }); + await writeFile( + join(tmp, '.planning', 'workstreams', 'feat-x', 'config.json'), + JSON.stringify({ workflow: { context_coverage_gate: false } }), + 'utf-8', + ); + + // Without workstream → enabled → would fail + const rootResult = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + expect(rootResult.data.skipped).toBe(false); + expect(rootResult.data.passed).toBe(false); + + // With workstream → workstream config disables → skipped + const wsResult = await checkDecisionCoveragePlan( + [phaseDir, contextPath], + tmp, + 'feat-x', + ); + expect(wsResult.data.skipped).toBe(true); + expect(wsResult.data.passed).toBe(true); + + // Same for verify + const wsVerify = await checkDecisionCoverageVerify( + [phaseDir, contextPath], + tmp, + 'feat-x', + ); + expect(wsVerify.data.skipped).toBe(true); + }); +}); + +describe('config-type validation (review F16)', () => { + it('warns and defaults to ON when context_coverage_gate is a number', async () => { + await setupPhase( + ` +### Cat +- **D-86:** A trackable decision text long enough to soft-match. +`, + { '17-01-PLAN.md': planFile(` truths: []\n artifacts: []\n key_links: []`) }, + ); + await mkdir(join(tmp, '.planning'), { recursive: true }); + await writeFile( + join(tmp, '.planning', 'config.json'), + JSON.stringify({ workflow: { context_coverage_gate: 1 } }), + 'utf-8', + ); + + const warnings: string[] = []; + const origWarn = console.warn; + console.warn = (msg: string) => warnings.push(String(msg)); + try { + const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp); + // Defaulted to ON → not skipped, runs the gate (and fails with uncovered D-86). + expect(result.data.skipped).toBe(false); + expect(result.data.passed).toBe(false); + } finally { + console.warn = origWarn; + } + expect(warnings.some((w) => /context_coverage_gate.*invalid type/.test(w))).toBe(true); + }); +}); diff --git a/sdk/src/query/check-decision-coverage.ts b/sdk/src/query/check-decision-coverage.ts new file mode 100644 index 0000000000..f34eb839a4 --- /dev/null +++ b/sdk/src/query/check-decision-coverage.ts @@ -0,0 +1,554 @@ +/** + * Decision-coverage gates — issue #2492. + * + * Two handlers, two semantics: + * + * - `check.decision-coverage-plan` — translation gate, BLOCKING. + * Plan-phase calls this after the existing requirements coverage gate. + * Each trackable CONTEXT.md decision must appear (by id or normalized + * phrase) in at least one PLAN.md `must_haves` / `truths` block or in + * the plan body. A miss returns `passed: false` with a clear message + * naming the missed decision; the workflow surfaces this to the user + * and refuses to mark the phase planned. + * + * - `check.decision-coverage-verify` — validation gate, NON-BLOCKING. + * Verify-phase calls this. Each trackable decision is searched in the + * phase's shipped artifacts (PLAN.md, SUMMARY.md, files_modified, recent + * commit subjects). Misses are reported but do NOT change verification + * status. Rationale: by verification time the work is done; a fuzzy + * "honored" check is a soft signal, not a blocker. + * + * Both gates short-circuit when `workflow.context_coverage_gate` is `false`. + * + * Match strategy (used by both gates): + * 1. Strict id match — `D-NN` appears verbatim somewhere in the searched + * text. This is the path users should aim for. + * 2. Soft phrase match — a normalized 6+-word slice of the decision text + * appears as a substring. Catches plans/summaries that paraphrase but + * forget the id. + */ + +import { readdir, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, isAbsolute } from 'node:path'; +import { execFile as execFileCb } from 'node:child_process'; +import { promisify } from 'node:util'; +import { loadConfig } from '../config.js'; +import { parseDecisions, type ParsedDecision } from './decisions.js'; +import type { QueryHandler } from './utils.js'; + +const execFile = promisify(execFileCb); + +interface GateUncoveredItem { + id: string; + text: string; + category: string; +} + +interface PlanGateData { + passed: boolean; + skipped: boolean; + reason?: string; + total: number; + covered: number; + uncovered: GateUncoveredItem[]; + message: string; +} + +interface VerifyGateData { + skipped: boolean; + blocking: false; + reason?: string; + total: number; + honored: number; + not_honored: GateUncoveredItem[]; + message: string; +} + +function normalizePhrase(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Minimum normalized words a decision must have to be soft-matchable. */ +const SOFT_PHRASE_MIN_WORDS = 6; + +/** + * Build a soft-match phrase: the first 6 normalized words. Six is empirically + * long enough to avoid collisions with common English fragments and short + * enough to survive minor rewordings. + * + * Returns an empty string when the decision text has fewer than + * SOFT_PHRASE_MIN_WORDS words — such decisions are effectively id-only and + * callers must rely on a `D-NN` citation (review F5). + */ +function softPhrase(text: string): string { + const words = normalizePhrase(text).split(' ').filter(Boolean); + if (words.length < SOFT_PHRASE_MIN_WORDS) return ''; + return words.slice(0, SOFT_PHRASE_MIN_WORDS).join(' '); +} + +/** True when a decision is too short to soft-match — caller must cite by id. */ +function requiresIdCitation(decision: ParsedDecision): boolean { + const wordCount = normalizePhrase(decision.text).split(' ').filter(Boolean).length; + return wordCount < SOFT_PHRASE_MIN_WORDS; +} + +/** True when decision text or id appears in `haystack`. */ +function decisionMentioned(haystack: string, decision: ParsedDecision): boolean { + if (!haystack) return false; + const idRe = new RegExp(`\\b${decision.id}\\b`); + if (idRe.test(haystack)) return true; + const phrase = softPhrase(decision.text); + if (!phrase) return false; // too short to soft-match — id citation required + return normalizePhrase(haystack).includes(phrase); +} + +async function readIfExists(path: string): Promise { + try { + return await readFile(path, 'utf-8'); + } catch { + return ''; + } +} + +async function loadPlanContents(phaseDir: string): Promise { + if (!existsSync(phaseDir)) return []; + let entries: string[] = []; + try { + entries = await readdir(phaseDir); + } catch { + return []; + } + const planFiles = entries.filter((e) => /-PLAN\.md$/.test(e)); + const out: string[] = []; + for (const f of planFiles) { + out.push(await readIfExists(join(phaseDir, f))); + } + return out; +} + +/** + * One plan reduced to the sections the BLOCKING translation gate searches. + * + * The plan-phase gate refuses to honor a decision mention buried in a code + * fence, an HTML comment, or arbitrary prose elsewhere on the page. The user + * must put a `D-NN` citation (or a 6+-word phrase) in a designated section + * so they have an unambiguous way to make a decision deliberately uncovered. + * + * Designated sections (review F4): + * - Front-matter `must_haves` block (YAML) + * - Front-matter `truths` block (YAML) + * - Front-matter `objective` field + * - Body section under a heading whose text contains "must_haves", + * "truths", "tasks", or "objective" (case-insensitive) + * + * HTML comments (``) and fenced code blocks are stripped before + * extraction so neither a commented-out citation nor a literal example + * counts as coverage. + */ +interface PlanSections { + /** Concatenation of all designated section text, with HTML comments and code fences stripped. */ + designated: string; +} + +const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i; + +/** Strip HTML comments AND fenced code blocks from `text`. */ +function stripCommentsAndFences(text: string): string { + return text + .replace(//g, ' ') + .replace(/```[\s\S]*?```/g, ' ') + .replace(/~~~[\s\S]*?~~~/g, ' '); +} + +/** Extract a YAML block scalar (key followed by indented continuation lines). */ +function extractYamlBlock(frontmatter: string, key: string): string { + const re = new RegExp(`^${key}\\s*:(.*)$`, 'm'); + const match = frontmatter.match(re); + if (!match) return ''; + const startIdx = (match.index ?? 0) + match[0].length; + const sameLine = match[1] ?? ''; + const rest = frontmatter.slice(startIdx + 1).split(/\r?\n/); + const block: string[] = [sameLine]; + for (const line of rest) { + // Stop at a non-indented, non-empty line (next top-level key) or end of frontmatter. + if (line === '' || /^\s/.test(line)) { + block.push(line); + } else { + break; + } + } + return block.join('\n'); +} + +function extractPlanSections(planContent: string): PlanSections { + if (!planContent) return { designated: '' }; + const cleaned = stripCommentsAndFences(planContent); + + // Split front-matter from body. + const fmMatch = cleaned.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + const frontmatter = fmMatch ? fmMatch[1] : ''; + const body = fmMatch ? fmMatch[2] : cleaned; + + const fmParts: string[] = []; + for (const key of ['must_haves', 'truths', 'objective']) { + const block = extractYamlBlock(frontmatter, key); + if (block) fmParts.push(block); + } + + // Body sections under designated headings (must_haves, truths, tasks, objective). + const bodyLines = body.split(/\r?\n/); + const bodyParts: string[] = []; + let inDesignated = false; + for (const line of bodyLines) { + const heading = /^#{1,6}\s+/.test(line); + if (heading) { + inDesignated = DESIGNATED_HEADINGS_RE.test(line); + if (inDesignated) bodyParts.push(line); + continue; + } + if (inDesignated) bodyParts.push(line); + } + + return { designated: [...fmParts, bodyParts.join('\n')].join('\n\n') }; +} + +async function loadPlanSections(phaseDir: string): Promise { + const contents = await loadPlanContents(phaseDir); + return contents.map(extractPlanSections); +} + +/** True when a decision is mentioned in any plan's designated sections. */ +function planSectionsMention(planSections: PlanSections[], decision: ParsedDecision): boolean { + for (const p of planSections) { + if (decisionMentioned(p.designated, decision)) return true; + } + return false; +} + +async function loadGateConfig(projectDir: string, workstream?: string): Promise { + try { + const cfg = await loadConfig(projectDir, workstream); + const wf = (cfg.workflow ?? {}) as Record; + const v = wf.context_coverage_gate; + if (typeof v === 'boolean') return v; + // Tolerate stringified booleans coming from environment-variable-style configs, + // but warn loudly on numeric / other-shaped values so silent type drift surfaces. + // Schema-vs-loadConfig validation gap (review F16, mirror of #2609). + if (typeof v === 'string') { + const lower = v.toLowerCase(); + if (lower === 'false' || lower === 'true') return lower !== 'false'; + console.warn( + `[gsd] workflow.context_coverage_gate is a string "${v}" — expected boolean. Defaulting to ON.`, + ); + return true; + } + if (v !== undefined && v !== null) { + console.warn( + `[gsd] workflow.context_coverage_gate has invalid type ${typeof v} (value: ${JSON.stringify(v)}); expected boolean. Defaulting to ON.`, + ); + } + return true; // default ON + } catch { + return true; + } +} + +function resolvePath(p: string, projectDir: string): string { + return isAbsolute(p) ? p : join(projectDir, p); +} + +function buildPlanMessage(uncovered: GateUncoveredItem[]): string { + if (uncovered.length === 0) return 'All trackable CONTEXT.md decisions are covered by plans.'; + const lines = [ + `## ⚠ Decision Coverage Gap`, + ``, + `${uncovered.length} CONTEXT.md decision(s) are not covered by any plan:`, + ``, + ]; + for (const u of uncovered) { + lines.push(`- **${u.id}** (${u.category || 'uncategorized'}): ${u.text}`); + } + lines.push(''); + lines.push( + 'Resolve by citing `D-NN:` in a relevant plan\'s `must_haves`/`truths` (or body),', + ); + lines.push( + 'OR move the decision to `### Claude\'s Discretion` / tag it `[informational]` if it should not be tracked.', + ); + return lines.join('\n'); +} + +function buildVerifyMessage(notHonored: GateUncoveredItem[]): string { + if (notHonored.length === 0) + return 'All trackable CONTEXT.md decisions are honored by shipped artifacts.'; + const lines = [ + `### Decision Coverage (warning)`, + ``, + `${notHonored.length} decision(s) not found in shipped artifacts:`, + ``, + ]; + for (const u of notHonored) { + lines.push(`- **${u.id}** (${u.category || 'uncategorized'}): ${u.text}`); + } + lines.push(''); + lines.push('This is a soft warning — verification status is unchanged.'); + return lines.join('\n'); +} + +// ─── Plan-phase gate ────────────────────────────────────────────────────── + +export const checkDecisionCoveragePlan: QueryHandler = async (args, projectDir, workstream) => { + const phaseDir = args[0] ? resolvePath(args[0], projectDir) : ''; + const contextPath = args[1] ? resolvePath(args[1], projectDir) : ''; + + const enabled = await loadGateConfig(projectDir, workstream); + if (!enabled) { + const data: PlanGateData = { + passed: true, + skipped: true, + reason: 'workflow.context_coverage_gate is false', + total: 0, + covered: 0, + uncovered: [], + message: 'Decision coverage gate disabled by config.', + }; + return { data }; + } + + if (!contextPath || !existsSync(contextPath)) { + const data: PlanGateData = { + passed: true, + skipped: true, + reason: 'CONTEXT.md missing', + total: 0, + covered: 0, + uncovered: [], + message: 'No CONTEXT.md — nothing to check.', + }; + return { data }; + } + + const contextRaw = await readIfExists(contextPath); + const decisions = parseDecisions(contextRaw).filter((d) => d.trackable); + if (decisions.length === 0) { + const data: PlanGateData = { + passed: true, + skipped: true, + reason: 'no trackable decisions', + total: 0, + covered: 0, + uncovered: [], + message: 'No trackable decisions in CONTEXT.md.', + }; + return { data }; + } + + const planSections = await loadPlanSections(phaseDir); + + const uncovered: GateUncoveredItem[] = []; + let covered = 0; + for (const d of decisions) { + if (planSectionsMention(planSections, d)) { + covered++; + } else { + uncovered.push({ id: d.id, text: d.text, category: d.category }); + } + } + + const passed = uncovered.length === 0; + const data: PlanGateData = { + passed, + skipped: false, + total: decisions.length, + covered, + uncovered, + message: buildPlanMessage(uncovered), + }; + return { data }; +}; + +// ─── Verify-phase gate ──────────────────────────────────────────────────── + +/** + * Recent commit subjects + bodies, capped at 200 to span typical phase boundaries + * even on busy repos. The non-blocking verify gate trades precision for recall — + * a few extra commits in the haystack only inflate "honored" counts harmlessly, + * while too few commits could cause false misses on long-running phases (review F18). + */ +async function recentCommitMessages(projectDir: string, limit = 200): Promise { + try { + const { stdout } = await execFile('git', ['log', `-n`, String(limit), '--pretty=%s%n%b'], { + cwd: projectDir, + maxBuffer: 4 * 1024 * 1024, + }); + return stdout; + } catch { + return ''; + } +} + +/** Per-file size cap when slurping modified-file contents into the verify haystack. */ +const MAX_MODIFIED_FILE_BYTES = 256 * 1024; + +/** Read a file and truncate to MAX_MODIFIED_FILE_BYTES; returns '' on error. */ +async function readBoundedFile(absPath: string): Promise { + try { + const raw = await readFile(absPath, 'utf-8'); + return raw.length > MAX_MODIFIED_FILE_BYTES ? raw.slice(0, MAX_MODIFIED_FILE_BYTES) : raw; + } catch { + return ''; + } +} + +/** + * True when `candidatePath` (after resolution) is contained within `rootDir`. + * Rejects absolute paths outside the root, `..` traversal, and any input + * whose canonical form escapes the project boundary (review F7). + * + * Note: this is a lexical check. Symlink targets are NOT resolved here — we + * intentionally do not follow links, so a symlink inside the project pointing + * outside is not de-referenced (we read the link's target only if it resolves + * within projectDir). For full symlink hardening callers should run on a + * trusted SUMMARY.md. + */ +function isInsideRoot(candidatePath: string, rootDir: string): boolean { + const root = isAbsolute(rootDir) ? rootDir : join(process.cwd(), rootDir); + const target = isAbsolute(candidatePath) ? candidatePath : join(root, candidatePath); + // Normalize both via path.resolve-equivalent (join handles `..`). + const normalizedRoot = root.endsWith('/') ? root : root + '/'; + const normalizedTarget = target; + return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot); +} + +async function readModifiedFilesContent(projectDir: string, summaries: string[]): Promise { + // Walk EVERY summary independently and aggregate file paths. The previous + // implementation matched only the first `files_modified:` block in a + // concatenated string — when two summaries shipped in one phase the second + // plan's files were silently dropped (review F6). + const out: string[] = []; + let total = 0; + for (const summary of summaries) { + if (!summary) continue; + // /g so multiple `files_modified:` blocks in a single summary are also captured. + const blockMatches = summary.matchAll(/files_modified:\s*\n((?:[ \t]*-\s+.+\n?)+)/g); + for (const blockMatch of blockMatches) { + const block = blockMatch[1] ?? ''; + const files = [...block.matchAll(/-\s+(.+)/g)].map((m) => + m[1].trim().replace(/^["']|["']$/g, ''), + ); + for (const f of files) { + if (!f) continue; + if (total >= 50) break; // cap total files across all summaries + // Reject absolute paths AND any relative path that escapes projectDir. + if (!isInsideRoot(f, projectDir)) { + console.warn( + `[gsd] decision-coverage: skipping files_modified entry "${f}" — outside project root`, + ); + continue; + } + out.push(await readBoundedFile(resolvePath(f, projectDir))); + total++; + } + if (total >= 50) break; + } + if (total >= 50) break; + } + return out.join('\n\n'); +} + +export const checkDecisionCoverageVerify: QueryHandler = async (args, projectDir, workstream) => { + const phaseDir = args[0] ? resolvePath(args[0], projectDir) : ''; + const contextPath = args[1] ? resolvePath(args[1], projectDir) : ''; + + const enabled = await loadGateConfig(projectDir, workstream); + if (!enabled) { + const data: VerifyGateData = { + skipped: true, + blocking: false, + reason: 'workflow.context_coverage_gate is false', + total: 0, + honored: 0, + not_honored: [], + message: 'Decision coverage gate disabled by config.', + }; + return { data }; + } + + if (!contextPath || !existsSync(contextPath)) { + const data: VerifyGateData = { + skipped: true, + blocking: false, + reason: 'CONTEXT.md missing', + total: 0, + honored: 0, + not_honored: [], + message: 'No CONTEXT.md — nothing to check.', + }; + return { data }; + } + + const contextRaw = await readIfExists(contextPath); + const decisions = parseDecisions(contextRaw).filter((d) => d.trackable); + if (decisions.length === 0) { + const data: VerifyGateData = { + skipped: true, + blocking: false, + reason: 'no trackable decisions', + total: 0, + honored: 0, + not_honored: [], + message: 'No trackable decisions in CONTEXT.md.', + }; + return { data }; + } + + // Verify-phase haystack is intentionally broad — this gate is non-blocking and looks + // for honored decisions across all phase artifacts, not just plan front-matter sections. + const planContents = await loadPlanContents(phaseDir); + // Read all *-SUMMARY.md files in phaseDir, capped to keep the haystack bounded. + const summaryParts: string[] = []; + let summaryContent = ''; + if (existsSync(phaseDir)) { + try { + const entries = await readdir(phaseDir); + for (const e of entries.filter((x) => /-SUMMARY\.md$/.test(x))) { + summaryParts.push(await readIfExists(join(phaseDir, e))); + } + } catch { + /* ignore */ + } + } + summaryContent = summaryParts.join('\n\n'); + + const filesModifiedContent = await readModifiedFilesContent(projectDir, summaryParts); + const commits = await recentCommitMessages(projectDir); + + const haystack = [planContents.join('\n\n'), summaryContent, filesModifiedContent, commits].join( + '\n\n', + ); + + const notHonored: GateUncoveredItem[] = []; + let honored = 0; + for (const d of decisions) { + if (decisionMentioned(haystack, d)) { + honored++; + } else { + notHonored.push({ id: d.id, text: d.text, category: d.category }); + } + } + + const data: VerifyGateData = { + skipped: false, + blocking: false, + total: decisions.length, + honored, + not_honored: notHonored, + message: buildVerifyMessage(notHonored), + }; + return { data }; +}; diff --git a/sdk/src/query/config-gates.ts b/sdk/src/query/config-gates.ts index 6b16cbc654..256f06bb88 100644 --- a/sdk/src/query/config-gates.ts +++ b/sdk/src/query/config-gates.ts @@ -63,6 +63,7 @@ export const checkConfigGates: QueryHandler = async (args, projectDir) => { verifier: workflowBool(wf.verifier, true), plan_check: workflowBool(planCheckFlag, true), subagent_timeout: wf.subagent_timeout ?? CONFIG_DEFAULTS.workflow.subagent_timeout, + context_coverage_gate: workflowBool(wf.context_coverage_gate, true), }; return { data }; diff --git a/sdk/src/query/config-mutation.test.ts b/sdk/src/query/config-mutation.test.ts index 5db18cadbe..e84aaf6337 100644 --- a/sdk/src/query/config-mutation.test.ts +++ b/sdk/src/query/config-mutation.test.ts @@ -34,6 +34,13 @@ describe('isValidConfigKey', () => { expect(isValidConfigKey('workflow.auto_advance').valid).toBe(true); }); + it('accepts workflow.context_coverage_gate (#2492)', async () => { + const { isValidConfigKey, parseConfigValue } = await import('./config-mutation.js'); + expect(isValidConfigKey('workflow.context_coverage_gate').valid).toBe(true); + expect(parseConfigValue('true')).toBe(true); + expect(parseConfigValue('false')).toBe(false); + }); + it('accepts wildcard agent_skills.* patterns', async () => { const { isValidConfigKey } = await import('./config-mutation.js'); expect(isValidConfigKey('agent_skills.gsd-planner').valid).toBe(true); diff --git a/sdk/src/query/config-mutation.ts b/sdk/src/query/config-mutation.ts index de54e5fc83..5892a79156 100644 --- a/sdk/src/query/config-mutation.ts +++ b/sdk/src/query/config-mutation.ts @@ -72,6 +72,7 @@ const VALID_CONFIG_KEYS = new Set([ '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', diff --git a/sdk/src/query/decisions.test.ts b/sdk/src/query/decisions.test.ts new file mode 100644 index 0000000000..ca7b5637e4 --- /dev/null +++ b/sdk/src/query/decisions.test.ts @@ -0,0 +1,215 @@ +/** + * Unit tests for CONTEXT.md `` parser. + * + * Decision format (from `discuss-phase.md` lines 1035–1048): + * + * + * ## Implementation Decisions + * + * ### Category A + * - **D-01:** First decision text + * - **D-02 [folded]:** Second decision text + * + * ### Claude's Discretion + * - free-form, never tracked + * + * ### Folded Todos + * - **D-03 [folded]:** ... + * + * + * Issue #2492. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { parseDecisions } from './decisions.js'; + +const MINIMAL = `# Phase 17 Context + + +## Implementation Decisions + +### API Surface +- **D-01:** Use bit offsets, not byte offsets +- **D-02:** Display TArray element type alongside count + +### Storage +- **D-03 [informational]:** Backing store is on disk +- **D-04:** Persist via SQLite WAL mode + +### Claude's Discretion +- Naming of internal helpers is up to the implementer +- **D-99:** This should be ignored — it lives under Discretion + +### Folded Todos +- **D-05 [folded]:** Add a CLI flag for verbose mode + +`; + +describe('parseDecisions (#2492)', () => { + it('extracts D-NN decisions with id, text, and category', () => { + const decisions = parseDecisions(MINIMAL); + const ids = decisions.map((d) => d.id); + expect(ids).toContain('D-01'); + expect(ids).toContain('D-02'); + expect(ids).toContain('D-04'); + const d01 = decisions.find((d) => d.id === 'D-01'); + expect(d01?.text).toBe('Use bit offsets, not byte offsets'); + expect(d01?.category).toBe('API Surface'); + }); + + it('captures bracketed tags', () => { + const decisions = parseDecisions(MINIMAL); + const d05 = decisions.find((d) => d.id === 'D-05'); + expect(d05?.tags).toContain('folded'); + const d03 = decisions.find((d) => d.id === 'D-03'); + expect(d03?.tags).toContain('informational'); + }); + + it('marks Claude\'s Discretion entries as non-trackable', () => { + const decisions = parseDecisions(MINIMAL); + const d99 = decisions.find((d) => d.id === 'D-99'); + expect(d99).toBeDefined(); + expect(d99?.trackable).toBe(false); + // And it must NOT appear in the trackable filter + const trackableIds = decisions.filter((d) => d.trackable).map((d) => d.id); + expect(trackableIds).not.toContain('D-99'); + }); + + it('marks [informational] entries as opt-out (excluded from trackable by default)', () => { + const trackable = parseDecisions(MINIMAL).filter((d) => d.trackable); + const ids = trackable.map((d) => d.id); + expect(ids).toContain('D-01'); + expect(ids).toContain('D-02'); + expect(ids).toContain('D-04'); + expect(ids).not.toContain('D-03'); // [informational] tag + expect(ids).not.toContain('D-05'); // [folded] tag — not user-facing decision + }); + + it('returns empty array when CONTEXT.md has no block', () => { + expect(parseDecisions('# Phase 1\n\nNo decisions here.\n')).toEqual([]); + }); + + it('returns empty array when content is empty', () => { + expect(parseDecisions('')).toEqual([]); + }); + + it('returns empty array when block is empty', () => { + expect(parseDecisions('\n')).toEqual([]); + }); + + it('does not crash on malformed bullet lines', () => { + const malformed = ` +- not a decision (no D-NN) +- **D-bogus:** wrong id format +- **D-7:** single digit allowed +- **D-10:** ten +`; + const decisions = parseDecisions(malformed); + const ids = decisions.map((d) => d.id); + expect(ids).toContain('D-7'); + expect(ids).toContain('D-10'); + expect(ids).not.toContain('D-bogus'); + }); + + it('preserves multi-line decision text continuations', () => { + const multi = ` +### Cat +- **D-01:** First line + continues here +- **D-02:** Second +`; + const decisions = parseDecisions(multi); + const d01 = decisions.find((d) => d.id === 'D-01'); + expect(d01?.text).toMatch(/First line/); + }); + + // ─── Adversarial-review regressions ──────────────────────────────────── + + it('ignores `` blocks inside fenced code (review F11)', () => { + const content = `# Doc + +\`\`\` + +### Example +- **D-99:** Should not be parsed + +\`\`\` + + +### Real +- **D-01:** Real decision text long enough to soft match +`; + const decisions = parseDecisions(content); + const ids = decisions.map((d) => d.id); + expect(ids).toContain('D-01'); + expect(ids).not.toContain('D-99'); + }); + + it('captures continuation lines indented with TABS (review F12)', () => { + const content = '\n### Cat\n- **D-07:** First line\n\tcontinued via tab\n'; + const decisions = parseDecisions(content); + const d07 = decisions.find((d) => d.id === 'D-07'); + expect(d07?.text).toMatch(/continued via tab/); + }); + + it('parses ALL `` blocks, not just the first (review F13)', () => { + const content = ` +### One +- **D-01:** First batch + + +Some prose. + + +### Two +- **D-02:** Second batch +`; + const ids = parseDecisions(content).map((d) => d.id); + expect(ids).toContain('D-01'); + expect(ids).toContain('D-02'); + }); + + it('treats curly-quote variants of "Claude\u2019s Discretion" as non-trackable (review F20)', () => { + // U+201B (single high-reversed-9 quotation mark) — uncommon but legal unicode. + const content = + '\n### Claude\u201Bs Discretion\n- **D-50:** Should be non-trackable\n'; + const decisions = parseDecisions(content); + const d50 = decisions.find((d) => d.id === 'D-50'); + expect(d50?.trackable).toBe(false); + }); +}); + +// ─── decisions.parse query handler ──────────────────────────────────────── + +import { decisionsParse } from './decisions.js'; +import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +describe('decisionsParse handler (review F14 — accepts relative path via projectDir)', () => { + let tmp: string; + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'gsd-decparse-')); + }); + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('resolves a relative file path against projectDir', async () => { + await mkdir(join(tmp, '.planning', 'phases', '17'), { recursive: true }); + await writeFile( + join(tmp, '.planning', 'phases', '17', '17-CONTEXT.md'), + '\n### Cat\n- **D-01:** Hello\n', + 'utf-8', + ); + const result = await decisionsParse(['.planning/phases/17/17-CONTEXT.md'], tmp); + expect((result.data as { trackable: number }).trackable).toBe(1); + expect((result.data as { missing: boolean }).missing).toBe(false); + }); + + it('still accepts an absolute path', async () => { + const abs = join(tmp, 'CONTEXT.md'); + await writeFile(abs, '\n### Cat\n- **D-02:** Bye\n', 'utf-8'); + const result = await decisionsParse([abs], tmp); + expect((result.data as { trackable: number }).trackable).toBe(1); + }); +}); diff --git a/sdk/src/query/decisions.ts b/sdk/src/query/decisions.ts new file mode 100644 index 0000000000..b8edda27df --- /dev/null +++ b/sdk/src/query/decisions.ts @@ -0,0 +1,192 @@ +/** + * CONTEXT.md `` parser — shared helper for issue #2492 (decision + * coverage gates) and #2493 (post-planning gap checker). + * + * Decision format (produced by `discuss-phase.md`): + * + * + * ## Implementation Decisions + * + * ### Category Heading + * - **D-01:** Decision text + * - **D-02 [tag1, tag2]:** Tagged decision + * + * ### Claude's Discretion + * - free-form, never tracked + * + * + * A decision is "trackable" when: + * - it has a valid D-NN id + * - it is NOT under the "Claude's Discretion" category + * - it is NOT tagged `informational` or `folded` + * + * Trackable decisions are the ones the plan-phase translation gate and the + * verify-phase validation gate enforce. + */ + +import { readFile } from 'node:fs/promises'; +import { isAbsolute, join } from 'node:path'; +import type { QueryHandler } from './utils.js'; + +export interface ParsedDecision { + /** Stable id: `D-01`, `D-7`, `D-42`. */ + id: string; + /** Body text (everything after `**D-NN[ tags]:**` up to next bullet/blank). */ + text: string; + /** Most recent `### ` heading inside the decisions block. */ + category: string; + /** Bracketed tags from `**D-NN [tag1, tag2]:**`. Lower-cased. */ + tags: string[]; + /** + * False when under "Claude's Discretion" or tagged `informational` / + * `folded`. Trackable decisions are subject to the coverage gates. + */ + trackable: boolean; +} + +const DISCRETION_HEADINGS = new Set([ + "claude's discretion", + 'claudes discretion', + 'claude discretion', +]); + +const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']); + +/** + * Strip fenced code blocks from `content` so example `` snippets + * inside ```` ``` ```` do not pollute the parser (review F11). + */ +function stripFencedCode(content: string): string { + return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' '); +} + +/** + * Extract the inner text of EVERY `...` block in + * order, concatenated by `\n\n`. Returns null when no block is present. + * + * CONTEXT.md may legitimately contain more than one block (for example, a + * "current decisions" block plus a "carry-over from prior phase" block); + * dropping all-but-the-first silently lost the second batch (review F13). + */ +function extractDecisionsBlock(content: string): string | null { + const cleaned = stripFencedCode(content); + const matches = [...cleaned.matchAll(/([\s\S]*?)<\/decisions>/g)]; + if (matches.length === 0) return null; + return matches.map((m) => m[1]).join('\n\n'); +} + +/** + * Parse trackable decisions from CONTEXT.md content. + * + * Returns ALL D-NN decisions found inside `` (including + * non-trackable ones, with `trackable: false`). Callers that only want the + * gate-enforced decisions should filter `.filter(d => d.trackable)`. + */ +export function parseDecisions(content: string): ParsedDecision[] { + if (!content || typeof content !== 'string') return []; + const block = extractDecisionsBlock(content); + if (block === null) return []; + + const lines = block.split(/\r?\n/); + const out: ParsedDecision[] = []; + let category = ''; + let inDiscretion = false; + + // Bullet line: `- **D-NN[ [tags]]:** text` + const bulletRe = /^\s*-\s+\*\*D-(\d+)(?:\s*\[([^\]]+)\])?\s*:\*\*\s*(.*)$/; + + let current: ParsedDecision | null = null; + + const flush = () => { + if (current) { + current.text = current.text.trim(); + out.push(current); + current = null; + } + }; + + for (const line of lines) { + const trimmed = line.trim(); + + // Track category headings (`### Heading`) + const headingMatch = trimmed.match(/^###\s+(.+?)\s*$/); + if (headingMatch) { + flush(); + category = headingMatch[1]; + // Strip the full unicode-quote family so any rendering of "Claude's + // Discretion" (ASCII apostrophe, curly U+2019, U+2018, U+201A, U+201B, + // double-quote variants U+201C/D/E/F, etc.) collapses to the same key + // (review F20). + const normalized = category + .toLowerCase() + .replace(/[\u2018\u2019\u201A\u201B\u201C\u201D\u201E\u201F'"`]/g, '') + .trim(); + inDiscretion = DISCRETION_HEADINGS.has(normalized); + continue; + } + + const bulletMatch = line.match(bulletRe); + if (bulletMatch) { + flush(); + const id = `D-${bulletMatch[1]}`; + const tags = bulletMatch[2] + ? bulletMatch[2] + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter(Boolean) + : []; + const trackable = + !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t)); + current = { id, text: bulletMatch[3], category, tags, trackable }; + continue; + } + + // Continuation line for current decision (indented with space OR tab, + // non-bullet, non-empty) — tab indentation must work too (review F12). + if (current && trimmed !== '' && !trimmed.startsWith('-') && /^[ \t]/.test(line)) { + current.text += ' ' + trimmed; + continue; + } + + // Blank line or unrelated content terminates the current decision + if (trimmed === '') { + flush(); + } + } + flush(); + + return out; +} + +// ─── Query handler ──────────────────────────────────────────────────────── + +/** + * `decisions.parse ` — parse CONTEXT.md and return decisions array. + * + * Used by workflow shell snippets that need to enumerate decisions without + * spawning a full Node process. Accepts either an absolute path or a path + * relative to `projectDir` — symmetric with the gate handlers (review F14). + */ +export const decisionsParse: QueryHandler = async (args, projectDir) => { + const filePath = args[0]; + if (!filePath) { + return { data: { decisions: [], trackable: 0, total: 0, missing: true } }; + } + const resolved = isAbsolute(filePath) ? filePath : join(projectDir, filePath); + let raw = ''; + try { + raw = await readFile(resolved, 'utf-8'); + } catch { + return { data: { decisions: [], trackable: 0, total: 0, missing: true } }; + } + const decisions = parseDecisions(raw); + const trackable = decisions.filter((d) => d.trackable); + return { + data: { + decisions, + trackable: trackable.length, + total: decisions.length, + missing: false, + }, + }; +}; diff --git a/sdk/src/query/index.ts b/sdk/src/query/index.ts index c9c27203bf..a109d94878 100644 --- a/sdk/src/query/index.ts +++ b/sdk/src/query/index.ts @@ -39,6 +39,8 @@ import { import { commit, checkCommit } from './commit.js'; import { templateFill, templateSelect } from './template.js'; import { verifyPlanStructure, verifyPhaseCompleteness, verifyArtifacts, verifyCommits, verifyReferences, verifySummary, verifyPathExists } from './verify.js'; +import { decisionsParse } from './decisions.js'; +import { checkDecisionCoveragePlan, checkDecisionCoverageVerify } from './check-decision-coverage.js'; import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents } from './validate.js'; import { phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete, @@ -359,6 +361,14 @@ export function createRegistry( registry.register('verify-path-exists', verifyPathExists); registry.register('verify.path-exists', verifyPathExists); registry.register('verify path-exists', verifyPathExists); + + // Decision coverage gates (issue #2492) + registry.register('decisions.parse', decisionsParse); + registry.register('decisions parse', decisionsParse); + registry.register('check.decision-coverage-plan', checkDecisionCoveragePlan); + registry.register('check decision-coverage-plan', checkDecisionCoveragePlan); + registry.register('check.decision-coverage-verify', checkDecisionCoverageVerify); + registry.register('check decision-coverage-verify', checkDecisionCoverageVerify); registry.register('validate.consistency', validateConsistency); registry.register('validate consistency', validateConsistency); registry.register('validate.health', validateHealth); diff --git a/sdk/src/query/utils.ts b/sdk/src/query/utils.ts index 99468562e4..5781e9adba 100644 --- a/sdk/src/query/utils.ts +++ b/sdk/src/query/utils.ts @@ -22,12 +22,16 @@ import { GSDError, ErrorClassification } from '../errors.js'; // ─── Types ────────────────────────────────────────────────────────────────── /** Structured result returned by all query handlers. */ -export interface QueryResult { - data: unknown; +export interface QueryResult { + data: T; } /** Signature for a query handler function. */ -export type QueryHandler = (args: string[], projectDir: string, workstream?: string) => Promise; +export type QueryHandler = ( + args: string[], + projectDir: string, + workstream?: string, +) => Promise>; // ─── generateSlug ─────────────────────────────────────────────────────────── diff --git a/tests/bug-2492-context-coverage-gate.test.cjs b/tests/bug-2492-context-coverage-gate.test.cjs new file mode 100644 index 0000000000..d06fcaf8ff --- /dev/null +++ b/tests/bug-2492-context-coverage-gate.test.cjs @@ -0,0 +1,169 @@ +/** + * Bug #2492: Add gates to ensure discuss-phase decisions are translated to + * plans (plan-phase, BLOCKING) and verified against shipped artifacts + * (verify-phase, NON-BLOCKING). + * + * These workflow files are loaded as prompts by the corresponding subagents. + * The tests below verify that the prompt text contains the gate steps and + * the config-toggle skip clauses — losing them silently would regress the + * fix. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const PLAN_PHASE = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'plan-phase.md'); +const VERIFY_PHASE = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'verify-phase.md'); +const CONFIG_TS = path.join(__dirname, '..', 'sdk', 'src', 'config.ts'); +const CONFIG_MUTATION_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-mutation.ts'); +const CONFIG_GATES_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-gates.ts'); +const QUERY_INDEX_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'index.ts'); + +describe('plan-phase decision-coverage gate (#2492)', () => { + const md = fs.readFileSync(PLAN_PHASE, 'utf-8'); + + test('contains a Decision Coverage Gate step', () => { + assert.ok( + /Decision Coverage Gate/i.test(md), + 'plan-phase.md must define a Decision Coverage Gate step', + ); + }); + + test('invokes the check.decision-coverage-plan handler', () => { + assert.ok( + md.includes('check.decision-coverage-plan'), + 'plan-phase.md must call gsd-sdk query check.decision-coverage-plan', + ); + }); + + test('mentions workflow.context_coverage_gate skip clause', () => { + assert.ok( + md.includes('workflow.context_coverage_gate'), + 'plan-phase.md must reference workflow.context_coverage_gate to allow skipping', + ); + }); + + test('decision gate appears AFTER the existing Requirements Coverage Gate', () => { + // Anchored heading regexes — avoid prose-substring traps (review F8/F9). + const reqIdx = md.search(/^## 13[a-z]?\.\s+Requirements Coverage Gate/m); + const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m); + assert.ok(reqIdx !== -1, 'Requirements Coverage Gate heading must exist as ## 13[a-z]?.'); + assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.'); + assert.ok(decIdx > reqIdx, 'Decision gate must run after Requirements gate'); + }); + + test('decision gate appears BEFORE plans are committed', () => { + const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m); + const commitIdx = md.search(/^## 13[a-z]?\.\s+Commit Plans/m); + assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.'); + assert.ok(commitIdx !== -1, 'Commit Plans heading must exist as ## 13[a-z]?.'); + assert.ok(decIdx < commitIdx, 'Decision gate must run before commit so failures block the commit'); + }); + + test('plan-phase Decision Coverage Gate uses CONTEXT_PATH variable defined in INIT extraction (review F1)', () => { + // The CONTEXT_PATH bash variable is defined at Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" context_path)`). + // The plan-phase gate snippet must reference the same casing — `${CONTEXT_PATH}` — not `${context_path}`, + // otherwise the BLOCKING gate is invoked with an empty path and silently skips. + const defIdx = md.indexOf('CONTEXT_PATH=$(_gsd_field "$INIT" context_path)'); + assert.ok(defIdx !== -1, 'CONTEXT_PATH must be defined from INIT JSON'); + + const gateIdx = md.indexOf('check.decision-coverage-plan'); + assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist'); + + // Slice the surrounding gate snippet (~600 chars) and verify variable casing matches the definition. + const snippet = md.slice(Math.max(0, gateIdx - 200), gateIdx + 400); + assert.ok( + snippet.includes('${CONTEXT_PATH}'), + 'Gate snippet must reference ${CONTEXT_PATH} (uppercase) to match the variable defined in Step 4', + ); + assert.ok( + !snippet.includes('${context_path}'), + 'Gate snippet must NOT reference ${context_path} (lowercase) — that name is undefined in shell scope', + ); + }); + + test('plan-phase blocking gate exits non-zero on failure (review F15)', () => { + // The gate is documented as BLOCKING. To actually block, the shell snippet must + // exit with non-zero status when `passed` is false. Without exit-1 the workflow + // continues silently past the failure. + const gateIdx = md.indexOf('check.decision-coverage-plan'); + assert.ok(gateIdx !== -1); + const snippet = md.slice(gateIdx, gateIdx + 800); + // Accept either an inline `|| exit 1` or a `|| { ...; exit 1; }` group. + const hasJqGuard = /jq[^\n]*passed\s*==\s*true/.test(snippet); + const hasExitOne = /\|\|\s*(?:exit\s+1|\{[\s\S]{0,200}?exit\s+1)/.test(snippet); + assert.ok( + hasJqGuard && hasExitOne, + 'plan-phase gate must guard with `jq -e .passed == true || exit 1` (or `|| { ...; exit 1; }`) to actually block', + ); + }); +}); + +describe('verify-phase decision-coverage gate (#2492)', () => { + const md = fs.readFileSync(VERIFY_PHASE, 'utf-8'); + + test('contains a verify_decisions step', () => { + assert.ok( + /verify_decisions/.test(md), + 'verify-phase.md must define a verify_decisions step', + ); + }); + + test('invokes the check.decision-coverage-verify handler', () => { + assert.ok( + md.includes('check.decision-coverage-verify'), + 'verify-phase.md must call gsd-sdk query check.decision-coverage-verify', + ); + }); + + test('declares the decision gate as non-blocking / warning only', () => { + const lower = md.toLowerCase(); + assert.ok( + lower.includes('non-blocking') || lower.includes('warning only') || lower.includes('not block'), + 'verify-phase.md must declare the decision gate is non-blocking', + ); + }); + + test('mentions workflow.context_coverage_gate skip clause', () => { + assert.ok( + md.includes('workflow.context_coverage_gate'), + 'verify-phase.md must reference workflow.context_coverage_gate to allow skipping', + ); + }); +}); + +describe('SDK wiring for #2492 gates', () => { + test('config.ts WorkflowConfig has context_coverage_gate key', () => { + const c = fs.readFileSync(CONFIG_TS, 'utf-8'); + assert.ok(c.includes('context_coverage_gate'), 'WorkflowConfig must declare context_coverage_gate'); + assert.ok( + /context_coverage_gate:\s*true/.test(c), + 'CONFIG_DEFAULTS.workflow.context_coverage_gate must default to true', + ); + }); + + test('config-mutation.ts VALID_CONFIG_KEYS allows workflow.context_coverage_gate', () => { + const c = fs.readFileSync(CONFIG_MUTATION_TS, 'utf-8'); + assert.ok( + c.includes("'workflow.context_coverage_gate'"), + 'workflow.context_coverage_gate must be in VALID_CONFIG_KEYS', + ); + }); + + test('config-gates.ts surfaces context_coverage_gate', () => { + const c = fs.readFileSync(CONFIG_GATES_TS, 'utf-8'); + assert.ok( + c.includes('context_coverage_gate'), + 'check.config-gates must expose context_coverage_gate to workflows', + ); + }); + + test('query index.ts registers the new handlers', () => { + const c = fs.readFileSync(QUERY_INDEX_TS, 'utf-8'); + assert.ok(c.includes('check.decision-coverage-plan'), 'check.decision-coverage-plan handler must be registered'); + assert.ok(c.includes('check.decision-coverage-verify'), 'check.decision-coverage-verify handler must be registered'); + assert.ok(c.includes('decisions.parse'), 'decisions.parse handler must be registered'); + }); +});