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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
519
sdk/src/query/check-decision-coverage.test.ts
Normal file
519
sdk/src/query/check-decision-coverage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
554
sdk/src/query/check-decision-coverage.ts
Normal file
554
sdk/src/query/check-decision-coverage.ts
Normal 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 };
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
215
sdk/src/query/decisions.test.ts
Normal file
215
sdk/src/query/decisions.test.ts
Normal 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
192
sdk/src/query/decisions.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
169
tests/bug-2492-context-coverage-gate.test.cjs
Normal file
169
tests/bug-2492-context-coverage-gate.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user