feat(ai-integration): API-coverage verify:pre gate (#1562)

Full API Coverage by Default — Opt Out, Never Opt In. A phase that integrates
an external API/SDK/service can no longer seal without a decided coverage matrix.

- src/api-coverage.cts: deterministic detector (compound verb+noun signal +
  <Service> API/SDK surface; stopword-guarded; strips fenced code) + matrix
  parse/validate/render with field-length caps.
- check api-coverage.verify-pre: blocking seal-time gate; phase arg resolved as
  a token under .planning/phases/ only (traversal-neutralized); validates
  COVERAGE.md or blocks iff a strong integration signal is detected and no
  matrix exists; fail-closed when phases tree exists but phase unresolvable.
- capabilities/ai-integration: workflow.api_coverage_gate config key (default
  true), plan:pre contribution, blocking verify:pre gate. Data-driven.
- gsd-core/workflows/verify-work.md: generic verify:pre gate dispatch.
- Tests: detector FP/FN + matrix validation + fast-check bijection; gate e2e.
  Code+security review findings fixed (stopword FP, scope containment, pipe/cap
  rejection, prompt-injection message hygiene).
- Regenerated registry/matrix/loop-host-contract/goldens/baseline + docs.

Closes #1562
This commit is contained in:
Tom Boucher
2026-07-07 14:35:30 -04:00
parent cce405c9b7
commit 6addeccd19
40 changed files with 2404 additions and 54 deletions

View File

@@ -0,0 +1,466 @@
"use strict";
/**
* API-Coverage detector + matrix validator (#1562).
*
* The enforcement half of "Full API Coverage by Default — Opt Out, Never Opt In."
* When a phase integrates an external API/service/SDK, the planner must produce a
* coverage matrix (COVERAGE.md) enumerating the API's capability surface; every
* non-integrated capability is an explicit, reasoned opt-out. The seal-time gate
* (capabilities/ai-integration, verify:pre) consumes this module to (a) detect
* whether a phase integrates an external API and (b) validate the produced matrix.
*
* Design notes (rubber-duck'd):
* - DETERMINISTIC + TYPED IR. Both the "does this phase integrate an external
* API?" decision and the "is this matrix complete?" decision are pure
* functions returning typed IR, not LLM judgments — so the low-false-positive
* guarantee (acceptance criterion #4) and the completeness guarantee
* (acceptance #2) are testable. Mirrors assumption-delta.cts (#1561).
* - COMPOUND SIGNAL for low false positives. A bare word like "api" appears in
* countless non-integration phases ("the public API of UserController"). The
* detector requires an INTEGRATION VERB co-occurring with an EXTERNAL-API
* NOUN (or an explicit "<Service> API/SDK" phrase). Single weak tokens do not
* fire. This is the issue's "low false-positive trigger" made mechanical.
* - FENCED CODE BLOCKS ARE STRIPPED first (markdown-sectionizer seam) so a
* trigger term inside a code snippet does not fire.
* - THE DETECTOR IS A FALLBACK. The primary path is the plan:pre contribution
* prompting COVERAGE.md creation. The detector runs only when COVERAGE.md is
* ABSENT, to catch the "nobody decided" case (acceptance #1). Its precision
* therefore matters but is not the only line of defense.
* - MATRIX FORMAT. The matrix is a markdown table (human-editable, diff-friendly)
* with a header row `| capability | decision | reason |` and one row per
* capability. decision ∈ {INTEGRATE, OPT-OUT}. An OPT-OUT row MUST carry a
* non-empty reason. A fenced ```coverage JSON block is also accepted for
* machine-generated matrices. This dual shape is bijective (parse/render
* round-trip) and covered by a fast-check property test.
* - ADDITIVE-ONLY VOCABULARY (Hyrum's Law). Once shipped, the verb/noun sets
* are depended-upon interfaces; they only grow. Tunable via the `terms`
* parameter so teams can widen them without forking.
*
* Public API:
* detectApiIntegration(text, terms?) -> { detected, signals, terms }
* parseCoverageMatrix(text) -> { rows, errors, format }
* validateCoverageMatrix(text) -> { valid, errors, counts }
* renderCoverageMatrix(rows) -> string
* DEFAULT_API_COVERAGE_TERMS
*
* CLI:
* echo "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs [--json]
* exit 0 = integration detected, 1 = none, 2 = startup error
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_API_COVERAGE_TERMS = void 0;
exports.detectApiIntegration = detectApiIntegration;
exports.parseCoverageMatrix = parseCoverageMatrix;
exports.validateCoverageMatrix = validateCoverageMatrix;
exports.renderCoverageMatrix = renderCoverageMatrix;
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
/**
* Curated default trigger vocabulary. ADDITIVE-ONLY (Hyrum's Law). Tunable via
* the `terms` parameter.
*
* VERBS are deliberately conservative: common verbs like "add", "use", "call",
* "implement" are EXCLUDED because they appear in nearly every phase and would
* make the gate fire on prose that has nothing to do with an external API. The
* verbs kept all connote BRINGING IN an external surface.
*
* NOUNS name an external-API surface. Bare "client" is excluded — too ambiguous
* (client-side UI vs API client). "service" alone is excluded (internal
* services); a phase integrating an external service virtually always pairs it
* with "API"/"SDK"/"REST"/etc., which the compound verb+noun rule captures.
*/
exports.DEFAULT_API_COVERAGE_TERMS = {
verbs: [
'integrate',
'integrates',
'integrating',
'integration',
'wrap',
'wraps',
'wrapping',
'connect',
'connects',
'connecting',
'consume',
'consumes',
'consuming',
'wire',
'wires',
'wiring',
'onboard',
'onboarding',
'adopt',
'adopts',
'adopting',
],
nouns: [
'api',
'apis',
'sdk',
'sdks',
'rest',
'graphql',
'grpc',
'endpoint',
'endpoints',
'oauth',
'oauth2',
'webhook',
'webhooks',
'mcp',
],
};
/** Hardening caps for the tunable vocabulary (hostile `--terms` defense). */
const MAX_TERMS_PER_KIND = 200;
const MAX_TERM_LEN = 32;
/**
* Field-length caps for matrix cell values. Cell content flows from a
* semi-trusted COVERAGE.md into the gate `message` that the orchestrator LLM
* reads, so it is bounded to keep the prompt-injection surface small and to
* document the format contract (short, single-line prose — not paragraphs).
*/
const CAPABILITY_MAX_LEN = 80;
const REASON_MAX_LEN = 200;
function normalizeTerms(list) {
if (!Array.isArray(list))
return [];
const seen = new Set();
const out = [];
for (const raw of list) {
if (typeof raw !== 'string')
continue;
const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN);
if (!t || !/[a-z0-9]/.test(t))
continue;
if (seen.has(t))
continue;
seen.add(t);
out.push(t);
if (out.length >= MAX_TERMS_PER_KIND)
break;
}
return out;
}
function resolveTerms(terms) {
const merge = (key) => {
const t = terms && terms[key];
return Array.isArray(t) ? normalizeTerms(t) : [...exports.DEFAULT_API_COVERAGE_TERMS[key]];
};
return { verbs: merge('verbs'), nouns: merge('nouns') };
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function makeSnippet(line, anchor) {
const cleaned = line.replace(/\s+/g, ' ').trim();
if (cleaned.length <= 120)
return cleaned;
const idx = cleaned.toLowerCase().indexOf(anchor);
if (idx < 0)
return cleaned.slice(0, 120);
const start = Math.max(0, idx - 50);
const end = Math.min(cleaned.length, idx + anchor.length + 50);
const prefix = start > 0 ? '…' : '';
const suffix = end < cleaned.length ? '…' : '';
return `${prefix}${cleaned.slice(start, end)}${suffix}`;
}
/** `<Service> API` / `<Service> SDK` — a capitalized proper noun immediately
* followed by API/SDK. Strong signal on its own (no verb required).
*
* STOPWORDS guard against the false positive where an ordinary capitalized
* sentence starter ("The API …", "An SDK …", "Our REST …") matches the
* `[A-Z]\w+ API` shape. Those are common English, not a service name, so they
* are rejected before counting as a surface signal (acceptance #4 — low false
* positives). */
const SERVICE_SURFACE_API_RE = /\b([A-Z][A-Za-z0-9_-]{1,})\s+(API|SDK|REST|GraphQL)\b/;
const SERVICE_STOPWORDS = new Set([
'the', 'an', 'a', 'our', 'this', 'these', 'that', 'those', 'new', 'add',
'use', 'your', 'my', 'no', 'some', 'any', 'all', 'each', 'every', 'both',
'if', 'when', 'while', 'with', 'via', 'using', 'into', 'its', 'their',
'we', 'you', 'they', 'it',
]);
/**
* Detect whether phase-scope prose describes integrating an external API/SDK.
*
* Fires when EITHER:
* (a) a compound verb+noun signal co-occurs on the same line, OR
* (b) an explicit `<Service> API|SDK|REST|GraphQL` surface appears.
*
* Non-string inputs degrade to `{ detected: false }` without throwing.
*/
function detectApiIntegration(text, terms) {
const effective = resolveTerms(terms);
if (typeof text !== 'string') {
return { detected: false, signals: [], terms: effective };
}
const stripped = (0, markdown_sectionizer_cjs_1.stripFencedCode)(text.replace(/\r\n/g, '\n')).text;
if (stripped.trim().length === 0) {
return { detected: false, signals: [], terms: effective };
}
const signals = [];
const seen = new Set();
const lines = stripped.split('\n');
// (a) compound verb+noun on the same line.
if (effective.verbs.length > 0 && effective.nouns.length > 0) {
const verbRe = new RegExp('(^|[^a-zA-Z0-9])(' + effective.verbs.map(escapeRegex).join('|') + ')([^a-zA-Z0-9]|$)', 'gi');
const nounRe = new RegExp('(^|[^a-zA-Z0-9])(' + effective.nouns.map(escapeRegex).join('|') + ')([^a-zA-Z0-9]|$)', 'gi');
for (const line of lines) {
verbRe.lastIndex = 0;
nounRe.lastIndex = 0;
const vMatch = verbRe.exec(line);
if (!vMatch)
continue;
const nMatch = nounRe.exec(line);
if (!nMatch)
continue;
const verb = (vMatch[2] || '').toLowerCase();
const noun = (nMatch[2] || '').toLowerCase();
const key = `${verb}+${noun}`;
if (seen.has(key))
continue;
seen.add(key);
signals.push({ verb, noun, snippet: makeSnippet(line, noun) });
}
}
// (b) explicit <Service> API|SDK|REST|GraphQL surface.
for (const line of lines) {
SERVICE_SURFACE_API_RE.lastIndex = 0;
const m = SERVICE_SURFACE_API_RE.exec(line);
if (!m)
continue;
// Reject ordinary capitalized sentence starters ("The API …", "Our REST …").
if (SERVICE_STOPWORDS.has((m[1] || '').toLowerCase()))
continue;
const noun = (m[2] || '').toLowerCase();
const key = `surface+${noun}`;
if (seen.has(key))
continue;
seen.add(key);
signals.push({ verb: '(surface)', noun, snippet: makeSnippet(line, m[1]) });
}
return { detected: signals.length > 0, signals, terms: effective };
}
const VALID_DECISIONS = new Set(['INTEGRATE', 'OPT-OUT']);
/**
* Parse a coverage matrix from COVERAGE.md. Accepts two bijective formats:
*
* 1. Markdown table (canonical, human-editable):
* | capability | decision | reason |
* |---|---|---|
* | search | INTEGRATE | |
* | playlists | OPT-OUT | not needed yet |
*
* 2. Fenced ```coverage JSON block (machine-generated):
* ```coverage
* [ {"capability":"search","decision":"INTEGRATE","reason":""}, ... ]
* ```
*
* Rows are trimmed; decisions upper-cased; missing reason → "". Returns
* `{ rows: [], errors: [], format: 'none' }` for empty/non-matrix input.
*/
function parseCoverageMatrix(text) {
const out = { rows: [], errors: [], format: 'none' };
if (typeof text !== 'string')
return out;
const src = text.replace(/\r\n/g, '\n');
// (1) fenced ```coverage JSON block takes precedence if present.
// Case-insensitive info string (```coverage and ```Coverage are both legal CommonMark).
// allow-adhoc-markdown: extracting a NAMED ```coverage fence (extraction of one tagged block), not stripping all fences — stripFencedCode/extractTaggedBlocks do not cover named-fence extraction.
const fenceMatch = src.match(/```coverage\s*\n([\s\S]*?)\n```/i);
if (fenceMatch && fenceMatch[1]) {
out.format = 'json';
let parsed;
try {
parsed = JSON.parse(fenceMatch[1]);
}
catch {
out.errors.push('fenced ```coverage block is not valid JSON');
return out;
}
if (!Array.isArray(parsed)) {
out.errors.push('fenced ```coverage block must be a JSON array');
return out;
}
for (let i = 0; i < parsed.length; i++) {
const row = rowFromJson(parsed[i]);
if ('error' in row) {
out.errors.push(`row[${i}]: ${row.error}`);
continue;
}
out.rows.push(row);
}
return out;
}
// (2) markdown table — collect table rows whose decision column parses.
const lines = src.split('\n');
let sawHeader = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('|'))
continue;
const cells = trimmed.slice(1, trimmed.endsWith('|') ? -1 : trimmed.length).split('|');
if (cells.length < 2)
continue;
const cleaned = cells.map((c) => c.trim());
// skip separator rows (|---|---|); require ≥3 dashes so a literal "-" cell
// is not mistaken for a separator.
if (cleaned.every((c) => /^:?-{3,}:?$/.test(c)))
continue;
const decisionCell = (cleaned[1] || '').toUpperCase();
// header detection
if (!sawHeader && cleaned[0].toLowerCase() === 'capability') {
sawHeader = true;
out.format = 'table';
continue;
}
if (!VALID_DECISIONS.has(decisionCell)) {
// A row that otherwise looks like data (≥3 cells, non-empty capability)
// but carries a malformed decision is a real error, not a row to skip
// silently — otherwise a single typo'd row collapses the matrix to
// "empty" and the user sees a confusing message.
if (cleaned.length >= 3 && cleaned[0]) {
out.errors.push(`row: decision "${decisionCell}" not in {INTEGRATE, OPT-OUT}`);
}
continue;
}
if (out.format === 'none')
out.format = 'table';
// A coverage row has exactly 3 cells. Extra cells mean an unescaped pipe in
// a value silently corrupted the row — surface it rather than parse garbage.
if (cleaned.length > 3) {
out.errors.push(`row: ${cleaned.length} columns (expected 3 — unescaped pipe in a cell?)`);
}
out.rows.push({
capability: cleaned[0] || '',
decision: decisionCell,
reason: (cleaned[2] ?? '').trim(),
});
}
return out;
}
function rowFromJson(v) {
if (!v || typeof v !== 'object' || Array.isArray(v))
return { error: 'not an object' };
const o = v;
const capability = typeof o['capability'] === 'string' ? o['capability'].trim() : '';
if (!capability)
return { error: 'missing/empty "capability"' };
const dRaw = typeof o['decision'] === 'string' ? o['decision'].trim().toUpperCase() : '';
if (!VALID_DECISIONS.has(dRaw)) {
return { error: `decision "${dRaw}" not in {INTEGRATE, OPT-OUT}` };
}
const reason = typeof o['reason'] === 'string' ? o['reason'].trim() : '';
return { capability, decision: dRaw, reason };
}
/**
* Validate a parsed matrix. A matrix is valid when:
* - it is non-empty (acceptance #1: "enumerating the API surface"),
* - every capability name is non-empty,
* - every decision is INTEGRATE or OPT-OUT (enforced by parser, re-checked
* here for defense-in-depth),
* - every OPT-OUT row carries a non-empty reason (acceptance #2).
*
* Un-enumerated remainder is not representable in the format — the gate blocks
* when an integration is detected and NO matrix exists. This validator catches
* a malformed/partial matrix that does exist.
*/
function validateCoverageMatrix(text) {
const parsed = parseCoverageMatrix(text);
const errors = [...parsed.errors];
const rows = parsed.rows;
if (rows.length === 0) {
if (errors.length === 0)
errors.push('matrix is empty — no capabilities enumerated');
return { valid: false, errors, counts: { surface: 0, integrate: 0, optout: 0 } };
}
const seen = new Set();
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row.capability) {
errors.push(`row[${i}]: empty capability name`);
}
else {
// Format contract + prompt-injection bound: cell values must be short,
// single-line, pipe-free prose (the matrix is a markdown table whose
// content flows into the gate message). Pipes/newlines would corrupt the
// table and let a COVERAGE.md inject unbounded text into the seal message.
if (/[|\n\r]/.test(row.capability)) {
errors.push(`row[${i}]: capability contains a pipe or newline (unsupported in a table cell)`);
}
if (row.capability.length > CAPABILITY_MAX_LEN) {
errors.push(`row[${i}]: capability exceeds ${CAPABILITY_MAX_LEN} chars`);
}
}
if (row.reason && /[|\n\r]/.test(row.reason)) {
errors.push(`row[${i}]: reason contains a pipe or newline (unsupported in a table cell)`);
}
if (row.reason.length > REASON_MAX_LEN) {
errors.push(`row[${i}]: reason exceeds ${REASON_MAX_LEN} chars`);
}
const key = row.capability.toLowerCase();
if (key && seen.has(key))
errors.push(`row[${i}]: duplicate capability`);
if (key)
seen.add(key);
if (!VALID_DECISIONS.has(row.decision)) {
errors.push(`row[${i}]: decision not in {INTEGRATE, OPT-OUT}`);
}
if (row.decision === 'OPT-OUT' && !row.reason) {
errors.push(`row[${i}]: OPT-OUT missing reason`);
}
}
const counts = {
surface: rows.length,
integrate: rows.filter((r) => r.decision === 'INTEGRATE').length,
optout: rows.filter((r) => r.decision === 'OPT-OUT').length,
};
return { valid: errors.length === 0, errors, counts };
}
/** Render rows back to the canonical markdown-table format (bijective with parse). */
function renderCoverageMatrix(rows) {
const body = rows
.map((r) => `| ${r.capability} | ${r.decision} | ${r.reason} |`)
.join('\n');
return `| capability | decision | reason |\n|---|---|---|\n${body}`;
}
// ── CLI entry point ──────────────────────────────────────────────────────────
// Reads phase-scope text from STDIN (not argv) to avoid OS ARG_MAX limits.
// Invoked by workflow bash as: echo "$SCOPE" | node .../api-coverage.cjs [--json]
// Exit 0 = integration detected, 1 = none, 2 = startup error. Mirrors
// assumption-delta.cjs / ui-safety-gate.cjs.
if (require.main === module) {
const argv = process.argv.slice(2);
const wantJson = argv.includes('--json');
let termsOverride;
const verbsIdx = argv.indexOf('--verbs');
const verbsVal = verbsIdx !== -1 ? argv[verbsIdx + 1] : undefined;
const nounsIdx = argv.indexOf('--nouns');
const nounsVal = nounsIdx !== -1 ? argv[nounsIdx + 1] : undefined;
// A non-empty, non-flag value is an override. An EMPTY value ("") restores
// the curated defaults (does NOT silently zero the vocabulary).
const verbsOverride = typeof verbsVal === 'string' && verbsVal.length > 0 && !verbsVal.startsWith('-');
const nounsOverride = typeof nounsVal === 'string' && nounsVal.length > 0 && !nounsVal.startsWith('-');
if (verbsOverride || nounsOverride) {
termsOverride = {};
if (verbsOverride) {
termsOverride.verbs = verbsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
}
if (nounsOverride) {
termsOverride.nouns = nounsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
}
}
const chunks = [];
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', () => {
const input = chunks.join('');
const result = detectApiIntegration(input, termsOverride);
if (wantJson) {
process.stdout.write(JSON.stringify(result) + '\n');
}
process.exit(result.detected ? 0 : 1);
});
process.stdin.on('error', (err) => {
process.stderr.write(`ERROR: api-coverage.cjs stdin read failed: ${err.message}\n`);
process.exit(2);
});
}

View File

@@ -39,6 +39,11 @@ const capabilities = {
"type": "boolean",
"default": true,
"description": "Prompt for an AI-SPEC design contract before planning phases that involve AI systems."
},
"workflow.api_coverage_gate": {
"type": "boolean",
"default": true,
"description": "Require an explicit API-coverage decision (full-by-default, opt-out-not-opt-in) before a phase that integrates an external API/SDK/service can seal. At plan:pre the planner is prompted to enumerate the API surface into COVERAGE.md; at verify:pre a blocking gate fails the seal unless the matrix exists with every non-integrated capability an explicit, reasoned opt-out. Independent of ai_integration_phase (applies to any external-API integration, not only AI)."
}
},
"steps": [
@@ -57,8 +62,35 @@ const capabilities = {
"onError": "skip"
}
],
"contributions": [],
"gates": []
"contributions": [
{
"point": "plan:pre",
"into": "planner",
"fragment": {
"path": "fragments/api-coverage-plan-pre.md",
"inline": "# API Coverage Decision Checkpoint\n\n> Full API Coverage by Default — Opt Out, Never Opt In. Fires when a phase\n> integrates an external API / SDK / service. Most non-API phases will not fire\n> it — that is the point.\n\n## Why this exists\n\n\"We integrated the API\" too often silently means \"we integrated whatever the\nfirst use case exercised.\" Every un-built capability is then an invisible hole,\ndiscovered later by a user who reasonably expected it to work. The phase sealed\ngreen because its tasks completed; nobody decided the gaps were acceptable,\nbecause nobody enumerated them. This checkpoint makes the surface **visible and\ndecided** before the phase can seal.\n\n## Detect whether this phase integrates an external API\n\nThe detector is a deterministic scan over the phase scope. It strips fenced\ncode blocks first, so a trigger term inside a code snippet does not fire. It\nreturns a typed result: `{ detected, signals[], terms }`. Run it on the phase\nscope (the concatenation of this phase's ROADMAP section + the PLAN body):\n\n```bash\nSCOPE=\"$(cat \"${PHASE_DIR}\"/*-PLAN.md 2>/dev/null) $(gsd_run query roadmap.get-phase \"${PHASE}\" 2>/dev/null || true)\"\nAPI_COVERAGE_JSON=$(printf '%s' \"$SCOPE\" | node gsd-core/bin/lib/api-coverage.cjs --json 2>/dev/null || echo '{\"detected\":false,\"signals\":[]}')\n```\n\nRead `API_COVERAGE_JSON.detected`. Act on it only — do **not** pattern-match the\nprose yourself.\n\n**If `detected` is `false`:** this phase does not integrate an external API. Skip\nthe checkpoint entirely and continue planning. Do not raise it with the user.\n\n**If `detected` is `true`:** an external-API integration is in scope. You MUST\nproduce a **coverage matrix** before the plan is finalized.\n\n## Produce the coverage matrix\n\nEnumerate the external API's full **capability surface** — the verb/endpoint/method\nlist (e.g. for a music service: `search`, `play`, `pause`, `skip`, `set_volume`,\n`get_playlist`, `create_playlist`, `add_to_playlist`, …). For each capability\nrecord a decision, starting from **full coverage** as the default:\n\n| capability | decision | reason |\n|---|---|---|\n| `<capability-id>` | `INTEGRATE` \\| `OPT-OUT` | `<one-line reason if OPT-OUT>` |\n\nRules:\n\n- **`INTEGRATE` is the default.** Every capability starts as INTEGRATE; the\n matrix is the *subtraction record*.\n- **Every `OPT-OUT` MUST carry a one-line reason** (`not needed`, `not needed\n yet`, `explicitly out of scope`, …). An opt-out without a reason is an\n un-decided hole — the exact failure mode this gate exists to close.\n- **A second integration against the same need** (e.g. a second platform for the\n same capability) starts from the **same full-coverage baseline** as the first.\n Do not carry over the first integration's opt-outs silently — re-decide each\n capability for the new surface, so a first-class/fallback asymmetry cannot\n accumulate.\n\nWrite the matrix to `${PHASE_DIR}/COVERAGE.md` (canonical markdown-table form):\n\n```markdown\n# API Coverage — <service>\n\n> Full coverage by default. Opt-outs are explicit, reasoned decisions.\n\n| capability | decision | reason |\n|---|---|---|\n| search | INTEGRATE | |\n| playlists | INTEGRATE | |\n| skip | OPT-OUT | not needed yet — tracked for follow-up phase |\n```\n\nA fenced ` ```coverage ` JSON block is also accepted for machine-generated\nmatrices; the markdown table is preferred (human-editable, diff-friendly).\n\n## The seal-time gate\n\nThis checkpoint is enforced. At `verify:pre` the `api-coverage.verify-pre` gate\nruns `check api-coverage.verify-pre <phase-dir>`:\n\n- If `COVERAGE.md` exists, it is validated — every row needs a valid decision and\n every `OPT-OUT` a reason. A malformed/partial matrix **blocks the seal**.\n- If `COVERAGE.md` is absent, the detector runs again over the phase scope. If a\n strong external-API-integration signal is found, the seal is **blocked** until a\n matrix is produced. If no signal is found, the phase is treated as a non-API\n phase and the seal proceeds.\n\nSo: an API-integrating phase cannot seal without a decided matrix. Produce it at\nplan time; do not leave it for seal time.\n\n## Tuning the vocabulary (optional)\n\nThe trigger vocabulary is a curated, additive-only set in\n`gsd-core/bin/lib/api-coverage.cjs` (`DEFAULT_API_COVERAGE_TERMS`). To widen it\nfor a project, override at the call site:\n\n```bash\nprintf '%s' \"$SCOPE\" | node gsd-core/bin/lib/api-coverage.cjs --json \\\n --verbs integrate,wrap,connect,embed --nouns api,sdk,rest,grpc,webhook,plugin\n```\n\nThe whole checkpoint is toggleable via `workflow.api_coverage_gate` in\n`.planning/config.json`.\n"
},
"produces": [
"COVERAGE.md"
],
"consumes": [
"CONTEXT.md"
],
"when": "workflow.api_coverage_gate",
"onError": "skip"
}
],
"gates": [
{
"point": "verify:pre",
"check": {
"query": "api-coverage.verify-pre"
},
"when": "workflow.api_coverage_gate",
"blocking": true,
"onError": "halt"
}
]
},
"antigravity": {
"id": "antigravity",
@@ -2832,6 +2864,23 @@ const byLoopPoint = {
}
],
"contributions": [
{
"capId": "ai-integration",
"point": "plan:pre",
"into": "planner",
"fragment": {
"path": "fragments/api-coverage-plan-pre.md",
"inline": "# API Coverage Decision Checkpoint\n\n> Full API Coverage by Default — Opt Out, Never Opt In. Fires when a phase\n> integrates an external API / SDK / service. Most non-API phases will not fire\n> it — that is the point.\n\n## Why this exists\n\n\"We integrated the API\" too often silently means \"we integrated whatever the\nfirst use case exercised.\" Every un-built capability is then an invisible hole,\ndiscovered later by a user who reasonably expected it to work. The phase sealed\ngreen because its tasks completed; nobody decided the gaps were acceptable,\nbecause nobody enumerated them. This checkpoint makes the surface **visible and\ndecided** before the phase can seal.\n\n## Detect whether this phase integrates an external API\n\nThe detector is a deterministic scan over the phase scope. It strips fenced\ncode blocks first, so a trigger term inside a code snippet does not fire. It\nreturns a typed result: `{ detected, signals[], terms }`. Run it on the phase\nscope (the concatenation of this phase's ROADMAP section + the PLAN body):\n\n```bash\nSCOPE=\"$(cat \"${PHASE_DIR}\"/*-PLAN.md 2>/dev/null) $(gsd_run query roadmap.get-phase \"${PHASE}\" 2>/dev/null || true)\"\nAPI_COVERAGE_JSON=$(printf '%s' \"$SCOPE\" | node gsd-core/bin/lib/api-coverage.cjs --json 2>/dev/null || echo '{\"detected\":false,\"signals\":[]}')\n```\n\nRead `API_COVERAGE_JSON.detected`. Act on it only — do **not** pattern-match the\nprose yourself.\n\n**If `detected` is `false`:** this phase does not integrate an external API. Skip\nthe checkpoint entirely and continue planning. Do not raise it with the user.\n\n**If `detected` is `true`:** an external-API integration is in scope. You MUST\nproduce a **coverage matrix** before the plan is finalized.\n\n## Produce the coverage matrix\n\nEnumerate the external API's full **capability surface** — the verb/endpoint/method\nlist (e.g. for a music service: `search`, `play`, `pause`, `skip`, `set_volume`,\n`get_playlist`, `create_playlist`, `add_to_playlist`, …). For each capability\nrecord a decision, starting from **full coverage** as the default:\n\n| capability | decision | reason |\n|---|---|---|\n| `<capability-id>` | `INTEGRATE` \\| `OPT-OUT` | `<one-line reason if OPT-OUT>` |\n\nRules:\n\n- **`INTEGRATE` is the default.** Every capability starts as INTEGRATE; the\n matrix is the *subtraction record*.\n- **Every `OPT-OUT` MUST carry a one-line reason** (`not needed`, `not needed\n yet`, `explicitly out of scope`, …). An opt-out without a reason is an\n un-decided hole — the exact failure mode this gate exists to close.\n- **A second integration against the same need** (e.g. a second platform for the\n same capability) starts from the **same full-coverage baseline** as the first.\n Do not carry over the first integration's opt-outs silently — re-decide each\n capability for the new surface, so a first-class/fallback asymmetry cannot\n accumulate.\n\nWrite the matrix to `${PHASE_DIR}/COVERAGE.md` (canonical markdown-table form):\n\n```markdown\n# API Coverage — <service>\n\n> Full coverage by default. Opt-outs are explicit, reasoned decisions.\n\n| capability | decision | reason |\n|---|---|---|\n| search | INTEGRATE | |\n| playlists | INTEGRATE | |\n| skip | OPT-OUT | not needed yet — tracked for follow-up phase |\n```\n\nA fenced ` ```coverage ` JSON block is also accepted for machine-generated\nmatrices; the markdown table is preferred (human-editable, diff-friendly).\n\n## The seal-time gate\n\nThis checkpoint is enforced. At `verify:pre` the `api-coverage.verify-pre` gate\nruns `check api-coverage.verify-pre <phase-dir>`:\n\n- If `COVERAGE.md` exists, it is validated — every row needs a valid decision and\n every `OPT-OUT` a reason. A malformed/partial matrix **blocks the seal**.\n- If `COVERAGE.md` is absent, the detector runs again over the phase scope. If a\n strong external-API-integration signal is found, the seal is **blocked** until a\n matrix is produced. If no signal is found, the phase is treated as a non-API\n phase and the seal proceeds.\n\nSo: an API-integrating phase cannot seal without a decided matrix. Produce it at\nplan time; do not leave it for seal time.\n\n## Tuning the vocabulary (optional)\n\nThe trigger vocabulary is a curated, additive-only set in\n`gsd-core/bin/lib/api-coverage.cjs` (`DEFAULT_API_COVERAGE_TERMS`). To widen it\nfor a project, override at the call site:\n\n```bash\nprintf '%s' \"$SCOPE\" | node gsd-core/bin/lib/api-coverage.cjs --json \\\n --verbs integrate,wrap,connect,embed --nouns api,sdk,rest,grpc,webhook,plugin\n```\n\nThe whole checkpoint is toggleable via `workflow.api_coverage_gate` in\n`.planning/config.json`.\n"
},
"produces": [
"COVERAGE.md"
],
"consumes": [
"CONTEXT.md"
],
"when": "workflow.api_coverage_gate",
"onError": "skip"
},
{
"capId": "assumption-delta",
"point": "plan:pre",
@@ -3101,7 +3150,18 @@ const byLoopPoint = {
"verify:pre": {
"steps": [],
"contributions": [],
"gates": []
"gates": [
{
"capId": "ai-integration",
"point": "verify:pre",
"check": {
"query": "api-coverage.verify-pre"
},
"when": "workflow.api_coverage_gate",
"blocking": true,
"onError": "halt"
}
]
},
"verify:post": {
"steps": [
@@ -3211,6 +3271,7 @@ const byLoopPoint = {
const configKeys = {
"workflow.ai_integration_phase": "ai-integration",
"workflow.api_coverage_gate": "ai-integration",
"workflow.assumption_delta": "assumption-delta",
"claude_orchestration.enabled": "claude-orchestration",
"claude_orchestration.execution_backend": "claude-orchestration",
@@ -3260,6 +3321,12 @@ const configSchema = {
"default": true,
"description": "Prompt for an AI-SPEC design contract before planning phases that involve AI systems."
},
"workflow.api_coverage_gate": {
"owner": "ai-integration",
"type": "boolean",
"default": true,
"description": "Require an explicit API-coverage decision (full-by-default, opt-out-not-opt-in) before a phase that integrates an external API/SDK/service can seal. At plan:pre the planner is prompted to enumerate the API surface into COVERAGE.md; at verify:pre a blocking gate fails the seal unless the matrix exists with every non-integrated capability an explicit, reasoned opt-out. Independent of ai_integration_phase (applies to any external-API integration, not only AI)."
},
"workflow.assumption_delta": {
"owner": "assumption-delta",
"type": "boolean",

View File

@@ -0,0 +1,104 @@
# API Coverage Gate (Full Coverage by Default — Opt Out, Never Opt In)
> Reference for the `api-coverage` gate on the `ai-integration` capability (#1562).
> Config key: `workflow.api_coverage_gate` (default `true`). Gate point: `verify:pre`.
## The problem this closes
"We integrated the API" too often silently means "we integrated whatever the
first use case exercised." Every un-built capability is then an invisible hole,
discovered later by a user who reasonably expected it to work. The phase sealed
green because its tasks completed — nobody *decided* the gaps were acceptable,
because nobody *enumerated* them.
This gate makes the API surface **visible and decided** before the phase can
seal. Full coverage is the default starting position; the coverage matrix is the
*subtraction record*. Every gap is an explicit, reasoned opt-out rather than a
surprise.
## When it fires
The gate runs at `verify:pre` (before `/gsd:verify-work` begins UAT). A phase is
treated as an external-API integration when **either**:
1. a `COVERAGE.md` matrix is present in the phase directory (the planner produced
one at `plan:pre`), **or**
2. the phase scope shows a strong external-API-integration signal (an integration
verb co-occurring with an external-API noun, or an explicit `<Service>
API|SDK|REST|GraphQL` surface) and no matrix yet exists.
Non-API phases (refactors, bug fixes, internal-only work, features that merely
*mention* an existing internal API) do **not** fire the gate — the trigger
requires a compound signal, so a bare word like "api" in "the public API of
UserController" is intentionally ignored.
## The two touch points
1. **Plan time (`plan:pre`).** A contribution to the planner prompts it to run
the deterministic detector over the phase scope and, when an integration is
detected, produce `COVERAGE.md`. See
`capabilities/ai-integration/fragments/api-coverage-plan-pre.md`.
2. **Seal time (`verify:pre`).** The blocking `api-coverage.verify-pre` gate
runs `check api-coverage.verify-pre <phase-dir>` and blocks unless a valid
matrix exists (or no integration is detected).
## The coverage matrix format
Canonical form — a markdown table (human-editable, diff-friendly):
```markdown
# API Coverage — <service>
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
| capability | decision | reason |
|---|---|---|
| search | INTEGRATE | |
| playlists | INTEGRATE | |
| skip | OPT-OUT | not needed yet — tracked for follow-up phase |
```
- **`INTEGRATE` is the default.** Every capability starts as INTEGRATE.
- **Every `OPT-OUT` MUST carry a one-line reason** (`not needed`, `not needed
yet`, `explicitly out of scope`, …). An opt-out without a reason is an
un-decided hole — the exact failure mode this gate exists to close.
- A fenced ` ```coverage ` JSON block (`[{"capability":…,"decision":…,"reason":…}]`)
is also accepted for machine-generated matrices.
Rules enforced at seal time: the matrix must be non-empty; every capability name
must be non-empty and unique; every decision must be `INTEGRATE` or `OPT-OUT`;
every `OPT-OUT` must have a reason. Violations block the seal with a precise
error.
## A second integration against the same need
A second platform for an existing capability (e.g. adding YouTube alongside
Spotify for media playback) starts from the **same full-coverage baseline** as
the first. Do not carry over the first integration's opt-outs silently —
re-decide each capability for the new surface, so a first-class/fallback
asymmetry cannot accumulate into a later user-facing bug.
## The matrix persists
`COVERAGE.md` is a phase artifact. A future phase that extends the same
integration starts from the recorded surface and decisions rather than from
zero — the matrix is the durable subtraction record.
## Tuning
- **Disable entirely:** set `workflow.api_coverage_gate: false` in
`.planning/config.json` (the gate unregisters from `verify:pre`).
- **Widen the trigger vocabulary:** the detector accepts `--verbs` / `--nouns`
overrides (see `capabilities/ai-integration/fragments/api-coverage-plan-pre.md`).
The default vocabulary is additive-only.
## Detector CLI
```bash
echo "$PHASE_SCOPE" | node gsd-core/bin/lib/api-coverage.cjs --json
# exit 0 = integration detected, 1 = none, 2 = startup error
```
The detector is a pure function (`detectApiIntegration` → `{ detected, signals,
terms }`) shared by the plan-time prompt and the seal-time gate, so the
low-false-positive guarantee is testable rather than a judgment call.

View File

@@ -256,6 +256,7 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research":
| `workflow.node_repair` | boolean | `true` | `true`, `false` | Attempt automatic repair of failed plan nodes |
| `workflow.node_repair_budget` | number | `2` | Any positive integer | Max repair retries per failed node |
| `workflow.ai_integration_phase` | boolean | `true` | `true`, `false` | Run /gsd:ai-integration-phase before planning AI system phases |
| `workflow.api_coverage_gate` | boolean | `true` | `true`, `false` | Require an explicit API-coverage decision (full-by-default, opt-out-not-opt-in) before a phase that integrates an external API/SDK/service can seal. At plan:pre prompts a COVERAGE.md matrix; at verify:pre a blocking gate fails the seal unless the matrix exists with every non-integrated capability an explicit, reasoned opt-out (#1562) |
| `workflow.ui_phase` | boolean | `true` | `true`, `false` | Generate UI-SPEC.md for frontend phases |
| `workflow.ui_safety_gate` | boolean | `true` | `true`, `false` | Require safety gate approval for UI changes |
| `workflow.text_mode` | boolean | `false` | `true`, `false` | Use plain-text numbered lists instead of AskUserQuestion menus |

View File

@@ -58,6 +58,44 @@ MVP_MODE=$(gsd_run query phase.mvp-mode "${phase_number}" ${GSD_WS} --pick activ
```
</step>
<step name="verify_pre_hooks">
**Verify:pre gate dispatch.** Before verification begins, dispatch every active
gate hook registered at the `verify:pre` loop extension point. Each gate is
data-driven — resolved from the capability registry, not hardcoded here.
```bash
VERIFY_PRE_HOOKS_JSON=$(gsd_run loop render-hooks verify:pre --raw)
PHASE_DIR=$(printf '%s' "$INIT" | jq -r '.phase_dir // empty')
```
Resolve active gate hooks from `VERIFY_PRE_HOOKS_JSON` where `kind == "gate"`.
For each active gate hook, run its declared check (a `check.query` gate runs
`gsd_run check ${hook.check.query} "${PHASE_DIR}" --raw`; a `predicate` gate
runs `gsd_run check predicate --predicate '<hook.check.predicate as JSON>' --phase-dir "${PHASE_DIR}" --raw`):
```bash
GATE_RESULT=$(gsd_run check "${hook_check_query}" "${PHASE_DIR}" --raw)
GATE_BLOCK=$(printf '%s' "$GATE_RESULT" | jq -r '.block // false' 2>/dev/null || echo "false")
```
**Two-step gate contract (same as execute:wave:post / execute:post):**
- **Step 1 — command failure:** if the `gsd_run check ...` invocation itself
fails (non-zero exit, no JSON), route by the gate's `onError`. An `onError:
halt` gate HALTs; an `onError: skip` gate logs a warning and continues.
- **Step 2 — block evaluation:** parse `GATE_RESULT.block`. For a **blocking
gate** (`hook.blocking == true`) with `block == true`: HALT — do not begin UAT,
present the gate's `message`, and tell the user what artifact resolves it. For
a **non-blocking gate** with a non-empty `message`: print
`⚠ {hook.capId} advisory: {GATE_RESULT.message}` and continue. For any gate
with `block == false`: continue silently.
Example — the `ai-integration` capability's `api-coverage.verify-pre` gate
(when `workflow.api_coverage_gate` is on) blocks here if the phase integrates an
external API without a decided COVERAGE.md matrix. Present its `message` and
point the user at producing COVERAGE.md before re-running verification.
</step>
<step name="check_active_session">
**First: Check for active UAT sessions**