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>
This commit is contained in:
Tom Boucher
2026-04-23 00:26:53 -04:00
committed by GitHub
parent 1a3d953767
commit f30da8326a
16 changed files with 1900 additions and 4 deletions

View File

@@ -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 `<decisions>` → 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)

View File

@@ -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
`<decisions>`, 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 `<decisions>`, 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 `<decisions>`.
- 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.

View File

@@ -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
`<decisions>` 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 `<decisions>`.
- 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

View File

@@ -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 `<decisions>` 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 `<decisions>` 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:

View File

@@ -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.
</step>
<step name="verify_decisions">
**Decision coverage validation gate (issue #2492).**
After requirements coverage, also check that each trackable CONTEXT.md
`<decisions>` 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 `<decisions>` 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.
</step>
<step name="behavioral_verification">
**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

View File

@@ -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 `<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 {
@@ -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,

View File

@@ -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<string, string>, 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(
`<decisions>
### Cat
- **D-01:** Use bit offsets
- **D-02:** Display TArray element type
</decisions>`,
{
'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(
`<decisions>
### Cat
- **D-01:** Use bit offsets, not byte offsets
- **D-99:** A decision nobody bothered to plan
</decisions>`,
{
'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(
`<decisions>
### Cat
- **D-01:** First decision
- **D-02:** Second decision
</decisions>`,
{
'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(
`<decisions>
### Cat
- **D-01:** Anything
- **D-02:** Anything else
</decisions>`,
{ '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 <decisions> 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(
`<decisions>
### Cat
- **D-01:** trackable
- **D-02 [informational]:** opt-out
- **D-03 [folded]:** opt-out
### Claude's Discretion
- **D-99:** never tracked
</decisions>`,
{
'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(
`<decisions>
### Cat
- **D-05:** Validate input
</decisions>`,
{ '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(
`<decisions>
### Cat
- **D-50:** Add metrics endpoint
</decisions>`,
{ '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(
`<decisions>
### Cat
- **D-50:** anything
</decisions>`,
{ '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(
`<decisions>
### Cat
- **D-77:** A trackable decision worth six or more words long
</decisions>`,
{
'17-01-PLAN.md': planFile(
` truths: []\n artifacts: []\n key_links: []`,
'<!-- D-77 was here -->\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(
`<decisions>
### Cat
- **D-78:** A trackable decision worth six or more words long
</decisions>`,
{
'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(
`<decisions>
### Cat
- **D-79:** Trackable decision text long enough to soft-match.
</decisions>`,
{
'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(
`<decisions>
### Cat
- **D-80:** Trackable decision text long enough to soft-match.
</decisions>`,
{
'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.
`<decisions>
### Cat
- **D-81:** Use bit offsets always
</decisions>`,
{
'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(
`<decisions>
### Cat
- **D-82:** Disable cache
</decisions>`,
{
'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
<decisions>
### Cat
- **D-83:** A long-enough trackable decision text for soft matching honored elsewhere.
</decisions>
`,
'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
<decisions>
### Cat
- **D-84:** A trackable decision text spanning enough words to soft-match.
</decisions>
`,
'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(
`<decisions>
### Cat
- **D-85:** A trackable decision long enough to potentially soft match.
</decisions>`,
{ '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(
`<decisions>
### Cat
- **D-86:** A trackable decision text long enough to soft-match.
</decisions>`,
{ '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);
});
});

View File

@@ -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<string> {
try {
return await readFile(path, 'utf-8');
} catch {
return '';
}
}
async function loadPlanContents(phaseDir: string): Promise<string[]> {
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(/<!--[\s\S]*?-->/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<PlanSections[]> {
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<boolean> {
try {
const cfg = await loadConfig(projectDir, workstream);
const wf = (cfg.workflow ?? {}) as Record<string, unknown>;
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<string> {
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<string> {
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<string> {
// 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 };
};

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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',

View File

@@ -0,0 +1,215 @@
/**
* Unit tests for CONTEXT.md `<decisions>` parser.
*
* Decision format (from `discuss-phase.md` lines 1035–1048):
*
* <decisions>
* ## 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]:** ...
* </decisions>
*
* Issue #2492.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { parseDecisions } from './decisions.js';
const MINIMAL = `# Phase 17 Context
<decisions>
## 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
</decisions>
`;
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 <decisions> 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 <decisions> block is empty', () => {
expect(parseDecisions('<decisions>\n</decisions>')).toEqual([]);
});
it('does not crash on malformed bullet lines', () => {
const malformed = `<decisions>
- not a decision (no D-NN)
- **D-bogus:** wrong id format
- **D-7:** single digit allowed
- **D-10:** ten
</decisions>`;
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 = `<decisions>
### Cat
- **D-01:** First line
continues here
- **D-02:** Second
</decisions>`;
const decisions = parseDecisions(multi);
const d01 = decisions.find((d) => d.id === 'D-01');
expect(d01?.text).toMatch(/First line/);
});
// ─── Adversarial-review regressions ────────────────────────────────────
it('ignores `<decisions>` blocks inside fenced code (review F11)', () => {
const content = `# Doc
\`\`\`
<decisions>
### Example
- **D-99:** Should not be parsed
</decisions>
\`\`\`
<decisions>
### Real
- **D-01:** Real decision text long enough to soft match
</decisions>`;
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 = '<decisions>\n### Cat\n- **D-07:** First line\n\tcontinued via tab\n</decisions>';
const decisions = parseDecisions(content);
const d07 = decisions.find((d) => d.id === 'D-07');
expect(d07?.text).toMatch(/continued via tab/);
});
it('parses ALL `<decisions>` blocks, not just the first (review F13)', () => {
const content = `<decisions>
### One
- **D-01:** First batch
</decisions>
Some prose.
<decisions>
### Two
- **D-02:** Second batch
</decisions>`;
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 =
'<decisions>\n### Claude\u201Bs Discretion\n- **D-50:** Should be non-trackable\n</decisions>';
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'),
'<decisions>\n### Cat\n- **D-01:** Hello\n</decisions>',
'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, '<decisions>\n### Cat\n- **D-02:** Bye\n</decisions>', 'utf-8');
const result = await decisionsParse([abs], tmp);
expect((result.data as { trackable: number }).trackable).toBe(1);
});
});

192
sdk/src/query/decisions.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* CONTEXT.md `<decisions>` parser — shared helper for issue #2492 (decision
* coverage gates) and #2493 (post-planning gap checker).
*
* Decision format (produced by `discuss-phase.md`):
*
* <decisions>
* ## Implementation Decisions
*
* ### Category Heading
* - **D-01:** Decision text
* - **D-02 [tag1, tag2]:** Tagged decision
*
* ### Claude's Discretion
* - free-form, never tracked
* </decisions>
*
* 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 `<decisions>` 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 `<decisions>...</decisions>` 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(/<decisions>([\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 `<decisions>` (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 <path>` — 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,
},
};
};

View File

@@ -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);

View File

@@ -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<T = unknown> {
data: T;
}
/** Signature for a query handler function. */
export type QueryHandler = (args: string[], projectDir: string, workstream?: string) => Promise<QueryResult>;
export type QueryHandler<T = unknown> = (
args: string[],
projectDir: string,
workstream?: string,
) => Promise<QueryResult<T>>;
// ─── generateSlug ───────────────────────────────────────────────────────────

View File

@@ -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');
});
});