From 6addeccd190b70b5f979bbca163c2ed8cc79c8df Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 7 Jul 2026 14:35:30 -0400 Subject: [PATCH 1/3] feat(ai-integration): API-coverage verify:pre gate (#1562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + 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 --- .changeset/1562-api-coverage-gate.md | 5 + capabilities/ai-integration/capability.json | 35 +- .../fragments/api-coverage-plan-pre.md | 105 ++++ docs/CONFIGURATION.md | 1 + docs/INVENTORY-MANIFEST.json | 2 + docs/INVENTORY.md | 2 + docs/reference/capability-matrix.md | 2 +- eslint.config.mjs | 1 + gsd-core/bin/lib/api-coverage.cjs | 466 ++++++++++++++++ gsd-core/bin/lib/capability-registry.cjs | 73 ++- gsd-core/references/api-coverage.md | 104 ++++ gsd-core/references/planning-config.md | 1 + gsd-core/workflows/verify-work.md | 38 ++ src/api-coverage.cts | 514 ++++++++++++++++++ src/check-command-router.cts | 284 +++++++++- src/config-loader.cts | 1 + src/config.cts | 1 + tests/api-coverage-gate-e2e.test.cjs | 273 ++++++++++ tests/api-coverage.test.cjs | 410 ++++++++++++++ tests/config-field-docs.test.cjs | 1 + .../golden-install-parity/antigravity.json | 5 +- .../golden-install-parity/augment.json | 5 +- .../golden-install-parity/claude.json | 5 +- .../fixtures/golden-install-parity/cline.json | 5 +- .../golden-install-parity/codebuddy.json | 5 +- .../fixtures/golden-install-parity/codex.json | 5 +- .../golden-install-parity/copilot.json | 5 +- .../golden-install-parity/cursor.json | 5 +- .../golden-install-parity/hermes.json | 5 +- .../fixtures/golden-install-parity/kilo.json | 5 +- .../fixtures/golden-install-parity/kimi.json | 5 +- .../golden-install-parity/opencode.json | 5 +- .../fixtures/golden-install-parity/qwen.json | 5 +- .../fixtures/golden-install-parity/trae.json | 5 +- .../golden-install-parity/windsurf.json | 5 +- .../fixtures/golden-install-parity/zcode.json | 5 +- ...e-2045-third-party-skills-surface.test.cjs | 2 +- tests/loop-hooks-empty-points-e2e.test.cjs | 54 +- tests/plan-pre-hook-e2e.test.cjs | 1 + tests/workflow-size-baseline.json | 2 +- 40 files changed, 2404 insertions(+), 54 deletions(-) create mode 100644 .changeset/1562-api-coverage-gate.md create mode 100644 capabilities/ai-integration/fragments/api-coverage-plan-pre.md create mode 100644 gsd-core/bin/lib/api-coverage.cjs create mode 100644 gsd-core/references/api-coverage.md create mode 100644 src/api-coverage.cts create mode 100644 tests/api-coverage-gate-e2e.test.cjs create mode 100644 tests/api-coverage.test.cjs diff --git a/.changeset/1562-api-coverage-gate.md b/.changeset/1562-api-coverage-gate.md new file mode 100644 index 000000000..9abfb1f52 --- /dev/null +++ b/.changeset/1562-api-coverage-gate.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2065 +--- +**Phases that integrate an external API/SDK/service can no longer seal without a decided coverage matrix** — a new `api-coverage` gate on the `ai-integration` capability blocks `/gsd:verify-work` until the phase produces a `COVERAGE.md` enumerating the API's full capability surface, with every non-integrated capability an explicit, reasoned opt-out. Full coverage is the default; the matrix is the subtraction record, so "we integrated the API" can no longer silently mean "we integrated whatever the first use case exercised." Toggleable via `workflow.api_coverage_gate` (on by default). (#1562) diff --git a/capabilities/ai-integration/capability.json b/capabilities/ai-integration/capability.json index 998933a2d..8e5147267 100644 --- a/capabilities/ai-integration/capability.json +++ b/capabilities/ai-integration/capability.json @@ -30,6 +30,11 @@ "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": [ @@ -48,6 +53,32 @@ "onError": "skip" } ], - "contributions": [], - "gates": [] + "contributions": [ + { + "point": "plan:pre", + "into": "planner", + "fragment": { + "path": "fragments/api-coverage-plan-pre.md" + }, + "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" + } + ] } diff --git a/capabilities/ai-integration/fragments/api-coverage-plan-pre.md b/capabilities/ai-integration/fragments/api-coverage-plan-pre.md new file mode 100644 index 000000000..ff280890a --- /dev/null +++ b/capabilities/ai-integration/fragments/api-coverage-plan-pre.md @@ -0,0 +1,105 @@ +# API Coverage Decision Checkpoint + +> Full API Coverage by Default — Opt Out, Never Opt In. Fires when a phase +> integrates an external API / SDK / service. Most non-API phases will not fire +> it — that is the point. + +## Why this exists + +"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 checkpoint makes the surface **visible and +decided** before the phase can seal. + +## Detect whether this phase integrates an external API + +The detector is a deterministic scan over the phase scope. It strips fenced +code blocks first, so a trigger term inside a code snippet does not fire. It +returns a typed result: `{ detected, signals[], terms }`. Run it on the phase +scope (the concatenation of this phase's ROADMAP section + the PLAN body): + +```bash +SCOPE="$(cat "${PHASE_DIR}"/*-PLAN.md 2>/dev/null) $(gsd_run query roadmap.get-phase "${PHASE}" 2>/dev/null || true)" +API_COVERAGE_JSON=$(printf '%s' "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs --json 2>/dev/null || echo '{"detected":false,"signals":[]}') +``` + +Read `API_COVERAGE_JSON.detected`. Act on it only — do **not** pattern-match the +prose yourself. + +**If `detected` is `false`:** this phase does not integrate an external API. Skip +the checkpoint entirely and continue planning. Do not raise it with the user. + +**If `detected` is `true`:** an external-API integration is in scope. You MUST +produce a **coverage matrix** before the plan is finalized. + +## Produce the coverage matrix + +Enumerate the external API's full **capability surface** — the verb/endpoint/method +list (e.g. for a music service: `search`, `play`, `pause`, `skip`, `set_volume`, +`get_playlist`, `create_playlist`, `add_to_playlist`, …). For each capability +record a decision, starting from **full coverage** as the default: + +| capability | decision | reason | +|---|---|---| +| `` | `INTEGRATE` \| `OPT-OUT` | `` | + +Rules: + +- **`INTEGRATE` is the default.** Every capability starts as INTEGRATE; the + matrix is the *subtraction record*. +- **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 second integration against the same need** (e.g. a second platform for the + same capability) 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. + +Write the matrix to `${PHASE_DIR}/COVERAGE.md` (canonical markdown-table form): + +```markdown +# API Coverage — + +> 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 | +``` + +A fenced ` ```coverage ` JSON block is also accepted for machine-generated +matrices; the markdown table is preferred (human-editable, diff-friendly). + +## The seal-time gate + +This checkpoint is enforced. At `verify:pre` the `api-coverage.verify-pre` gate +runs `check api-coverage.verify-pre `: + +- If `COVERAGE.md` exists, it is validated — every row needs a valid decision and + every `OPT-OUT` a reason. A malformed/partial matrix **blocks the seal**. +- If `COVERAGE.md` is absent, the detector runs again over the phase scope. If a + strong external-API-integration signal is found, the seal is **blocked** until a + matrix is produced. If no signal is found, the phase is treated as a non-API + phase and the seal proceeds. + +So: an API-integrating phase cannot seal without a decided matrix. Produce it at +plan time; do not leave it for seal time. + +## Tuning the vocabulary (optional) + +The trigger vocabulary is a curated, additive-only set in +`gsd-core/bin/lib/api-coverage.cjs` (`DEFAULT_API_COVERAGE_TERMS`). To widen it +for a project, override at the call site: + +```bash +printf '%s' "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs --json \ + --verbs integrate,wrap,connect,embed --nouns api,sdk,rest,grpc,webhook,plugin +``` + +The whole checkpoint is toggleable via `workflow.api_coverage_gate` in +`.planning/config.json`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f1c4357a8..d754a4887 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -325,6 +325,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin | `workflow.cross_ai_timeout` | number | `300` | Timeout in seconds for cross-AI execution commands. Prevents runaway external processes. Added in v1.36 | | `workflow.test_gate_timeout` | number | `600` | Wall-clock timeout (seconds) for a verification test gate; a watch-mode runner (vitest/jest) that never exits is aborted after this budget instead of hanging the orchestrator (#1857) | | `workflow.ai_integration_phase` | boolean | `true` | Enable the `/gsd-ai-integration-phase` command. When `false`, the command exits with a configuration gate message | +| `workflow.api_coverage_gate` | boolean | `true` | Require an explicit API-coverage decision before a phase that integrates an external API/SDK/service can seal. At `plan:pre` the planner is prompted to produce a `COVERAGE.md` matrix (full coverage by default, every opt-out reasoned); at `verify:pre` a blocking gate fails the seal unless the matrix is complete. Independent of `ai_integration_phase` (#1562) | | `workflow.auto_prune_state` | boolean | `false` | When `true`, automatically prune stale entries from STATE.md at phase boundaries instead of prompting | | `workflow.pattern_mapper` | boolean | `true` | Run the `gsd-pattern-mapper` agent between research and planning to map new files to existing codebase analogs | | `workflow.subagent_timeout` | number | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes) | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 8a5fd93fa..d3d251353 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -207,6 +207,7 @@ "agent-skills-bootstrap.md", "ai-evals.md", "ai-frameworks.md", + "api-coverage.md", "artifact-types.md", "autonomous-smart-discuss.md", "checkpoints.md", @@ -292,6 +293,7 @@ "adr-parser.cjs", "agent-command-router.cjs", "agent-install-check.cjs", + "api-coverage.cjs", "artifacts.cjs", "assumption-delta.cjs", "audit-command-router.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 8743b4305..5dfbe8688 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -331,6 +331,7 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum | `autonomous-smart-discuss.md` | Smart-discuss logic for autonomous mode. | | `ios-scaffold.md` | iOS application scaffolding patterns. | | `ai-evals.md` | AI evaluation design reference for `/gsd-ai-integration-phase`. | +| `api-coverage.md` | API-coverage gate reference (full-coverage-by-default) for the `ai-integration` capability's `verify:pre` blocking gate (#1562) — matrix format, trigger, tuning, detector CLI. | | `ai-frameworks.md` | AI framework decision-matrix reference for `gsd-framework-selector`. | | `executor-examples.md` | Worked examples for the gsd-executor agent. | | `doc-conflict-engine.md` | Shared conflict-detection contract for ingest/import workflows. | @@ -397,6 +398,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `active-workstream-store.cjs` | Workstream source precedence and selection (CLI `--ws` > `GSD_WORKSTREAM` env > stored pointer); name validation and environment propagation | | `adr-parser.cjs` | ADR decision parser for plan-phase ingest express path; normalizes section synonyms, parses status/decision/scope fences, and enforces status rejection gates | | `agent-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools agent` | +| `api-coverage.cjs` | API-coverage detector + matrix validator (#1562) — pure `detectApiIntegration` (compound verb+noun signal + ` API/SDK` surface; strips fenced code) and `validateCoverageMatrix`/`parseCoverageMatrix`/`renderCoverageMatrix` for the COVERAGE.md artifact; STDIN CLI (`echo "$SCOPE" \| node .../api-coverage.cjs [--json]`, exit 0=detected/1=none/2=error); consumed by the `ai-integration` capability's `plan:pre` contribution and blocking `verify:pre` gate (`check api-coverage.verify-pre`) | | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | diff --git a/docs/reference/capability-matrix.md b/docs/reference/capability-matrix.md index c74755eac..6cb34f4a9 100644 --- a/docs/reference/capability-matrix.md +++ b/docs/reference/capability-matrix.md @@ -52,7 +52,7 @@ points. | id | role | tier | engines.gsd | extension points | hook kinds | source | |---|---|---|---|---|---|---| -| `ai-integration` | feature | full | `>=1.6.0` | `plan:pre` | step | first-party | +| `ai-integration` | feature | full | `>=1.6.0` | `plan:pre`, `verify:pre` | step, contribution, gate | first-party | | `assumption-delta` | feature | full | `>=1.6.0` | `plan:pre` | contribution | first-party | | `audit` | feature | full | `>=1.6.0` | — | — | first-party | | `claude-orchestration` | feature | full | `>=1.7.0` | `plan:post`, `execute:wave:post` | contribution | first-party | diff --git a/eslint.config.mjs b/eslint.config.mjs index 493b9a219..148104840 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -80,6 +80,7 @@ export default tseslint.config( 'gsd-core/bin/lib/prohibition-enforcement.cjs', 'gsd-core/bin/lib/code-review-flags.cjs', 'gsd-core/bin/lib/context-utilization.cjs', + 'gsd-core/bin/lib/api-coverage.cjs', 'gsd-core/bin/lib/artifacts.cjs', 'gsd-core/bin/lib/assumption-delta.cjs', 'gsd-core/bin/lib/state-transition.cjs', diff --git a/gsd-core/bin/lib/api-coverage.cjs b/gsd-core/bin/lib/api-coverage.cjs new file mode 100644 index 000000000..c51f9b6d7 --- /dev/null +++ b/gsd-core/bin/lib/api-coverage.cjs @@ -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 " 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}`; +} +/** ` API` / ` 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 ` 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 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); + }); +} diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 23acba5ba..106f56433 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -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| `` | `INTEGRATE` \\| `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 — \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 `:\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| `` | `INTEGRATE` \\| `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 — \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 `:\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", diff --git a/gsd-core/references/api-coverage.md b/gsd-core/references/api-coverage.md new file mode 100644 index 000000000..f5d238f9f --- /dev/null +++ b/gsd-core/references/api-coverage.md @@ -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 ` + 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 ` 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 — + +> 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. diff --git a/gsd-core/references/planning-config.md b/gsd-core/references/planning-config.md index fc24fed3e..23de7a258 100644 --- a/gsd-core/references/planning-config.md +++ b/gsd-core/references/planning-config.md @@ -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 | diff --git a/gsd-core/workflows/verify-work.md b/gsd-core/workflows/verify-work.md index 7bacf8577..c3ac9528a 100644 --- a/gsd-core/workflows/verify-work.md +++ b/gsd-core/workflows/verify-work.md @@ -58,6 +58,44 @@ MVP_MODE=$(gsd_run query phase.mvp-mode "${phase_number}" ${GSD_WS} --pick activ ``` + +**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 '' --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. + + **First: Check for active UAT sessions** diff --git a/src/api-coverage.cts b/src/api-coverage.cts new file mode 100644 index 000000000..ab0b43d91 --- /dev/null +++ b/src/api-coverage.cts @@ -0,0 +1,514 @@ +/** + * 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 " 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 + */ + +import { stripFencedCode } from './markdown-sectionizer.cjs'; + +// ─── Integration-signal vocabulary ──────────────────────────────────────────── + +export interface ApiCoverageTermSet { + verbs: string[]; + nouns: string[]; +} + +export interface ApiCoverageSignal { + verb: string; + noun: string; + snippet: string; +} + +export interface ApiCoverageDetectionResult { + detected: boolean; + signals: ApiCoverageSignal[]; + terms: ApiCoverageTermSet; +} + +/** + * 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. + */ +export const DEFAULT_API_COVERAGE_TERMS: Readonly = { + 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: unknown): string[] { + if (!Array.isArray(list)) return []; + const seen = new Set(); + const out: string[] = []; + 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?: Partial): ApiCoverageTermSet { + const merge = (key: 'verbs' | 'nouns'): string[] => { + const t = terms && terms[key]; + return Array.isArray(t) ? normalizeTerms(t) : [...DEFAULT_API_COVERAGE_TERMS[key]]; + }; + return { verbs: merge('verbs'), nouns: merge('nouns') }; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function makeSnippet(line: string, anchor: string): string { + 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}`; +} + +/** ` API` / ` 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 ` API|SDK|REST|GraphQL` surface appears. + * + * Non-string inputs degrade to `{ detected: false }` without throwing. + */ +export function detectApiIntegration( + text: unknown, + terms?: Partial, +): ApiCoverageDetectionResult { + const effective = resolveTerms(terms); + if (typeof text !== 'string') { + return { detected: false, signals: [], terms: effective }; + } + + const stripped = stripFencedCode(text.replace(/\r\n/g, '\n')).text; + if (stripped.trim().length === 0) { + return { detected: false, signals: [], terms: effective }; + } + + const signals: ApiCoverageSignal[] = []; + 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 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 }; +} + +// ─── Coverage matrix parse / validate / render ──────────────────────────────── + +export type CoverageDecision = 'INTEGRATE' | 'OPT-OUT'; + +export interface CoverageRow { + capability: string; + decision: CoverageDecision; + reason: string; +} + +export interface CoverageParseResult { + rows: CoverageRow[]; + errors: string[]; + format: 'table' | 'json' | 'none'; +} + +export interface CoverageValidationResult { + valid: boolean; + errors: string[]; + counts: { surface: number; integrate: number; optout: number }; +} + +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. + */ +export function parseCoverageMatrix(text: unknown): CoverageParseResult { + const out: CoverageParseResult = { 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: unknown; + 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 as CoverageDecision)) { + // 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 as CoverageDecision, + reason: (cleaned[2] ?? '').trim(), + }); + } + return out; +} + +function rowFromJson(v: unknown): CoverageRow | { error: string } { + if (!v || typeof v !== 'object' || Array.isArray(v)) return { error: 'not an object' }; + const o = v as Record; + 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 as CoverageDecision)) { + return { error: `decision "${dRaw}" not in {INTEGRATE, OPT-OUT}` }; + } + const reason = typeof o['reason'] === 'string' ? o['reason'].trim() : ''; + return { capability, decision: dRaw as CoverageDecision, 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. + */ +export function validateCoverageMatrix(text: unknown): CoverageValidationResult { + 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). */ +export function renderCoverageMatrix(rows: readonly CoverageRow[]): string { + 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: Partial | undefined; + 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: string[] = []; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk: string) => 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: Error) => { + process.stderr.write(`ERROR: api-coverage.cjs stdin read failed: ${err.message}\n`); + process.exit(2); + }); +} diff --git a/src/check-command-router.cts b/src/check-command-router.cts index c3201b502..3a712bb14 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -35,6 +35,9 @@ import { routeProhibitionEnforcement } from './prohibition-enforcement.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import gatePredicateEval = require('./gate-predicate-evaluator.cjs'); const { evaluatePredicate } = gatePredicateEval; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import apiCoverageMod = require('./api-coverage.cjs'); +const { detectApiIntegration, validateCoverageMatrix } = apiCoverageMod; import { execTool } from './shell-command-projection.cjs'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -986,6 +989,279 @@ function cmdCheckPredicate(projectDir: string, args: string[], raw: boolean): vo output(result, raw, undefined); } +// ─── api-coverage-verify-pre ────────────────────────────────────────────────── + +/** + * api-coverage.verify-pre: BLOCKING seal-time gate for the ai-integration + * capability (#1562). Enforces "Full API Coverage by Default — Opt Out, Never + * Opt In." A phase that integrates an external API/SDK/service may not seal + * until a COVERAGE.md matrix enumerates the surface and every non-integrated + * capability is an explicit, reasoned opt-out. + * + * Contract (two touch points composed into one check): + * 1. If COVERAGE.md exists in the phase dir → validate it (acceptance #2). + * Block on any validation error (empty matrix, OPT-OUT without reason, + * duplicate/empty capability). + * 2. If COVERAGE.md is absent → run detectApiIntegration over the phase scope + * (PLAN.md body, then ROADMAP phase section as fallback). If a strong + * external-API-integration signal is detected → BLOCK ("integration + * detected without coverage matrix"). If no signal → PASS (treat as a + * non-API phase; acceptance #4 — low false positives). + * + * The detector is the FALLBACK for the "nobody decided / forgot the matrix" + * case; the primary path is the plan:pre contribution prompting COVERAGE.md. + * + * Args: check api-coverage.verify-pre + * Emits the uniform gate contract: { block, passed, message, ...details }. + */ +function cmdApiCoverageVerifyPre(projectDir: string, args: string[], raw: boolean): void { + const phaseArg = typeof args[2] === 'string' ? args[2] : ''; + if (!phaseArg) { + error( + 'api-coverage.verify-pre requires a phase argument: check api-coverage.verify-pre ', + ERROR_REASON.SDK_MISSING_ARG, + ); + return; + } + + const pDir = planningDir(projectDir); + const phasesRoot = path.join(pDir, 'phases'); + + // SECURITY (path traversal): the phase argument is taken ONLY as a phase + // token — its basename — and resolved by findPhaseInternal strictly under + // .planning/phases/ (or a milestone archive). The raw arg is never used as a + // path, so `..`, absolute paths, and arbitrary directories cannot reach a + // file read. Mirrors cmdVerifySchemaDrift's token-match approach. + let token = phaseArg.replace(/\\/g, '/').split('/').filter(Boolean).pop() || ''; + // A token like ".." or "." carries no phase identity → unresolvable. + if (token === '.' || token === '..') token = ''; + + // Not a GSD project (no phases tree at all) → fail-open: nothing to gate. + if (!fs.existsSync(phasesRoot)) { + output( + { + block: false, + passed: true, + coverage_present: false, + detected: false, + message: 'api-coverage: no .planning/phases directory; gate skipped (not a GSD project layout)', + }, + raw, + undefined, + ); + return; + } + + // Resolve the phase dir under the contained phases root. + let resolvedDir: string | null = null; + let phaseNumber = ''; + if (token) { + const found = findPhaseInternal(projectDir, token); + if (found && found.directory) { + resolvedDir = found.directory; + phaseNumber = found.phase_number || ''; + } + } + + if (!resolvedDir) { + // The phases tree EXISTS but THIS phase could not be resolved. For a + // BLOCKING gate, fail-closed: a missing phase dir must not silently bypass + // the coverage requirement. (Distinguished from "no .planning at all" + // above, which is a genuine non-GSD-project → pass.) + output( + { + block: true, + passed: false, + coverage_present: false, + detected: false, + phase_lookup_failed: true, + message: + `api-coverage: could not resolve phase "${phaseArg}" under .planning/phases/. ` + + 'Resolve the phase directory (or produce COVERAGE.md) before sealing.', + }, + raw, + undefined, + ); + return; + } + + // Defense-in-depth: the resolved dir must be inside the phases root (or a + // milestone archive under .planning/milestones). + const milestonesRoot = path.join(pDir, 'milestones'); + if (!isInsideRoot(resolvedDir, phasesRoot) && !isInsideRoot(resolvedDir, milestonesRoot)) { + output( + { + block: true, + passed: false, + coverage_present: false, + detected: false, + message: 'api-coverage: resolved phase dir escapes .planning/ — refusing to evaluate', + }, + raw, + undefined, + ); + return; + } + + // (1) locate COVERAGE.md — prefer the exact name, then a single *-COVERAGE.md. + let coverageFile = ''; + let suffixed: string[] = []; + try { + const entries = fs.readdirSync(resolvedDir, { withFileTypes: true }); + const files = entries.filter((e) => e.isFile()).map((e) => e.name); + const exact = files.find((f) => /^COVERAGE\.md$/i.test(f)); + if (exact) { + coverageFile = exact; + } else { + suffixed = files.filter((f) => /-COVERAGE\.md$/i.test(f)).sort(); + if (suffixed.length === 1) coverageFile = suffixed[0]; + } + } catch { + // readdir failure → treat as no matrix readable; fall through to detection. + } + + if (coverageFile) { + let matrixText: string; + try { + matrixText = fs.readFileSync(path.join(resolvedDir, coverageFile), 'utf8'); + } catch { + // COVERAGE.md exists but is unreadable (EACCES/EIO/encoding). Fail-closed + // with a useful message rather than a raw throw. + output( + { + block: true, + passed: false, + coverage_present: true, + message: `api-coverage: COVERAGE.md exists but is unreadable — fix file permissions/encoding before sealing`, + }, + raw, + undefined, + ); + return; + } + const v = validateCoverageMatrix(matrixText); + if (v.valid) { + output( + { + block: false, + passed: true, + coverage_present: true, + matrix: coverageFile, + counts: v.counts, + message: `api-coverage: matrix present (${v.counts.surface} capabilities, ${v.counts.optout} opt-out)`, + }, + raw, + undefined, + ); + return; + } + // Fixed-template message (no raw cell content echoed into the LLM-facing + // message). The structured `errors` array is safe (row-indexed, no cell + // values) and travels as data for tooling that wants detail. + output( + { + block: true, + passed: false, + coverage_present: true, + matrix: coverageFile, + error_count: v.errors.length, + errors: v.errors, + message: `api-coverage: COVERAGE.md has ${v.errors.length} problem(s) — fix the matrix (every capability INTEGRATE or OPT-OUT with a reason) before sealing`, + }, + raw, + undefined, + ); + return; + } + if (suffixed.length > 1) { + output( + { + block: true, + passed: false, + coverage_present: false, + message: `api-coverage: multiple *-COVERAGE.md files found (${suffixed.length}) — consolidate into one COVERAGE.md before sealing`, + }, + raw, + undefined, + ); + return; + } + + // (2) no matrix — detect whether this phase integrates an external API. + const scopeText = readPhaseScope(projectDir, resolvedDir, phaseNumber); + const detection = detectApiIntegration(scopeText); + if (detection.detected) { + // Surface only verb/noun (typed, bounded) — NOT raw prose snippets — so the + // gate output cannot relay injected PLAN.md instructions to the orchestrator. + const signals = detection.signals.map((s) => ({ verb: s.verb, noun: s.noun })); + output( + { + block: true, + passed: false, + coverage_present: false, + detected: true, + signals, + message: + 'api-coverage: external-API integration detected without a coverage matrix. ' + + 'Produce COVERAGE.md enumerating the API surface (every capability INTEGRATE or ' + + 'OPT-OUT with a reason) before sealing. Full coverage is the default.', + }, + raw, + undefined, + ); + return; + } + + output( + { + block: false, + passed: true, + coverage_present: false, + detected: false, + message: 'api-coverage: no external-API integration detected; coverage matrix not required', + }, + raw, + undefined, + ); +} + +/** + * Read the phase-scope text used for API-integration detection. Uses the + * resolved plan files (PLAN.md bodies — the planner's own words about what the + * phase does) and, as a fallback, ONLY THIS PHASE'S ROADMAP section (not the + * whole roadmap, which would cross-contaminate sibling phases). Strips nothing + * here — detectApiIntegration strips fenced code itself. + */ +function readPhaseScope(projectDir: string, phaseDir: string, phaseNumber: string): string { + const chunks: string[] = []; + try { + const entries = fs.readdirSync(phaseDir, { withFileTypes: true }); + const plans = entries + .filter((e) => e.isFile() && /-PLAN\.md$/i.test(e.name)) + .map((e) => e.name) + .sort(); + for (const p of plans) { + chunks.push(fs.readFileSync(path.join(phaseDir, p), 'utf8')); + } + } catch { + // ignore — fall through to roadmap + } + if (chunks.join('').trim().length > 0) return chunks.join('\n\n'); + + // Fallback: ONLY this phase's ROADMAP section (not the whole file, which + // would pollute detection with sibling-phase prose). Best-effort; absence or + // an unresolvable section is non-fatal (detector returns not-detected). + if (phaseNumber) { + try { + const section = getRoadmapPhaseWithFallback(projectDir, phaseNumber); + if (section) return section; + } catch { + // ignore + } + } + return ''; +} + function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void { // Normalize dots to hyphens in the subcommand so both forms are accepted. // This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates) @@ -1015,6 +1291,12 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void { cmdGapAnalysisPlanPost(cwd, args, raw); return; } + if (subcommand === 'api-coverage-verify-pre') { + // ai-integration capability blocking gate at verify:pre (#1562). Dot-to- + // hyphen normalization means query "api-coverage.verify-pre" routes here. + cmdApiCoverageVerifyPre(cwd, args, raw); + return; + } if (subcommand === 'tdd-review-checkpoint') { cmdTddReviewCheckpoint(cwd, args, raw); return; @@ -1055,7 +1337,7 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void { routeProhibitionEnforcement(args, raw); return; } - error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND); + error('Unknown check subcommand. Available: api-coverage-verify-pre, auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND); } export = { diff --git a/src/config-loader.cts b/src/config-loader.cts index 5f5e79ec1..2384f60a2 100644 --- a/src/config-loader.cts +++ b/src/config-loader.cts @@ -108,6 +108,7 @@ const CONFIG_DEFAULTS = { verifier: _getNestedConfigDefault('workflow', 'verifier'), nyquist_validation: _getNestedConfigDefault('workflow', 'nyquist_validation'), ai_integration_phase: _getNestedConfigDefault('workflow', 'ai_integration_phase'), + api_coverage_gate: _getNestedConfigDefault('workflow', 'api_coverage_gate'), parallelization: _getConfigDefault('parallelization'), brave_search: _getConfigDefault('brave_search'), firecrawl: _getConfigDefault('firecrawl'), diff --git a/src/config.cts b/src/config.cts index 0e54cabaa..a0989f78c 100644 --- a/src/config.cts +++ b/src/config.cts @@ -246,6 +246,7 @@ function buildNewProjectConfig(userChoices: Record): Record t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1')); + try { + const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...argv], { + cwd, + encoding: 'utf-8', + env: { ...process.env, ...TEST_ENV_BASE }, + timeout: 60000, + }); + return { success: true, output: stdout.trim(), exitCode: 0, error: '' }; + } catch (err) { + return { + success: false, + output: err.stdout?.toString().trim() || '', + error: err.stderr?.toString().trim() || err.message, + exitCode: err.status ?? 1, + }; + } +} + +function makeProject(workflow) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-apicov-')); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ workflow }), + 'utf8' + ); + return tmpDir; +} + +function makePhaseDir(projectDir, phaseSlug) { + const dir = path.join(projectDir, '.planning', 'phases', phaseSlug); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function writePlan(phaseDir, planFile, body) { + fs.writeFileSync(path.join(phaseDir, planFile), body, 'utf8'); +} + +function writeCoverage(phaseDir, body) { + fs.writeFileSync(path.join(phaseDir, 'COVERAGE.md'), body, 'utf8'); +} + +function verifyPreHooks(cwd) { + const result = runTools('loop render-hooks verify:pre --raw', cwd); + assert.ok(result.success, `render-hooks verify:pre should succeed. stderr: ${result.error}`); + const envelope = JSON.parse(result.output); + assert.strictEqual(envelope.point, 'verify:pre', 'point field must be verify:pre'); + assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array'); + return envelope; +} + +function findCap(envelope, capId) { + return envelope.activeHooks.find((h) => h.capId === capId) || null; +} + +function runGate(cwd, phaseDir) { + return runTools(['check', 'api-coverage.verify-pre', phaseDir, '--raw'], cwd); +} + +// ─── Capability wiring: data-driven activation (acceptance #5) ─────────────── + +describe('api-coverage verify:pre gate — capability wiring (#1562 acceptance #5)', () => { + let tmpDir; + afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } }); + + test('gate is ACTIVE when workflow.api_coverage_gate is true', () => { + tmpDir = makeProject({ api_coverage_gate: true }); + const env = verifyPreHooks(tmpDir); + const hook = findCap(env, 'ai-integration'); + assert.ok(hook, 'ai-integration gate must register at verify:pre when enabled'); + assert.strictEqual(hook.kind, 'gate'); + assert.strictEqual(hook.blocking, true); + assert.strictEqual(hook.check.query, 'api-coverage.verify-pre'); + }); + + test('gate is ABSENT when workflow.api_coverage_gate is false', () => { + tmpDir = makeProject({ api_coverage_gate: false }); + const env = verifyPreHooks(tmpDir); + assert.strictEqual(findCap(env, 'ai-integration'), null, 'gate must not register when disabled'); + }); + + test('gate is ACTIVE by default when the key is absent (opt-out, not opt-in)', () => { + tmpDir = makeProject({}); + const env = verifyPreHooks(tmpDir); + assert.ok(findCap(env, 'ai-integration'), 'gate must default ON (full-coverage-by-default)'); + }); +}); + +// ─── Seal contract: block / pass (acceptance #1, #2, #4, #6) ────────────────── + +describe('api-coverage.verify-pre — seal contract (#1562 acceptance #1,#2,#4,#6)', () => { + let tmpDir; + let phaseDir; + afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } }); + + function fresh() { + tmpDir = makeProject({ api_coverage_gate: true }); + phaseDir = makePhaseDir(tmpDir, '01-pay'); + return phaseDir; + } + + test('#1 API phase without a matrix → BLOCKS the seal', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API for payment processing.'); + const r = runGate(tmpDir, phaseDir); + assert.ok(r.success, `gate should succeed (JSON). stderr: ${r.error}`); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, true, 'must block when API integration has no matrix'); + assert.strictEqual(j.detected, true); + assert.strictEqual(j.coverage_present, false); + }); + + test('#4 non-API phase without a matrix → does NOT block', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nRefactor the auth helper to use bcrypt.'); + const r = runGate(tmpDir, phaseDir); + assert.ok(r.success, `gate should succeed. stderr: ${r.error}`); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, false, 'must not block a non-API phase'); + assert.strictEqual(j.detected, false); + }); + + test('#1/#6 API phase WITH a valid matrix → passes (matrix persists on disk)', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API for payment processing.'); + writeCoverage( + phaseDir, + '| capability | decision | reason |\n|---|---|---|\n' + + '| charge | INTEGRATE | |\n| refund | OPT-OUT | not needed yet |\n' + ); + const r = runGate(tmpDir, phaseDir); + assert.ok(r.success, `gate should succeed. stderr: ${r.error}`); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, false); + assert.strictEqual(j.coverage_present, true); + assert.strictEqual(j.counts.surface, 2); + assert.strictEqual(j.counts.optout, 1); + }); + + test('#2 OPT-OUT without a reason → BLOCKS (un-decided hole)', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.'); + writeCoverage( + phaseDir, + '| capability | decision | reason |\n|---|---|---|\n| refund | OPT-OUT | |\n' + ); + const r = runGate(tmpDir, phaseDir); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, true, 'opt-out without reason must block'); + assert.ok(j.errors.some((e) => /missing reason/i.test(e))); + }); + + test('#2 empty matrix → BLOCKS (surface must be enumerated)', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.'); + writeCoverage(phaseDir, '| capability | decision | reason |\n|---|---|---|\n'); + const r = runGate(tmpDir, phaseDir); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, true); + assert.ok(j.errors.some((e) => /empty/i.test(e))); + }); + + test('#3 a second platform with full-coverage baseline is accepted (no asymmetry)', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nAdd a YouTube SDK as a second media platform.'); + // Full-coverage baseline for the second platform: every capability decided. + writeCoverage( + phaseDir, + '| capability | decision | reason |\n|---|---|---|\n' + + '| search | INTEGRATE | |\n| playlists | INTEGRATE | |\n| skip | INTEGRATE | |\n' + ); + const r = runGate(tmpDir, phaseDir); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, false, 'a fully-decided second platform seals clean'); + assert.strictEqual(j.counts.surface, 3); + }); + + test('JSON-fenced matrix is accepted (machine-generated form)', () => { + fresh(); + writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.'); + writeCoverage( + phaseDir, + '```coverage\n[{"capability":"charge","decision":"INTEGRATE","reason":""}]\n```\n' + ); + const r = runGate(tmpDir, phaseDir); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, false); + assert.strictEqual(j.counts.surface, 1); + }); + + // ── Security (#1562 security review S1/S2): the phase arg is taken only as a + // token resolved under .planning/phases/. Traversal / unresolvable args must + // NOT read files outside the phase dir, and — since the phases tree exists — + // must fail CLOSED (a blocking gate must not silently bypass on a bad arg). + test('path-traversal arg is contained and fails CLOSED (phases tree exists)', () => { + fresh(); // creates .planning/phases/01-pay + const r = runTools(['check', 'api-coverage.verify-pre', '../../etc', '--raw'], tmpDir); + assert.ok(r.success, `gate should succeed (JSON). stderr: ${r.error}`); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, true, 'unresolvable phase under an existing phases tree must block'); + assert.strictEqual(j.phase_lookup_failed, true); + }); + + test('no .planning/phases at all → fail-open (genuine non-GSD project)', () => { + // A project with .planning/config.json but no phases directory. + const noPhases = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-apicov-nophase-')); + try { + fs.mkdirSync(path.join(noPhases, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(noPhases, '.planning', 'config.json'), + JSON.stringify({ workflow: { api_coverage_gate: true } }), + 'utf8' + ); + const r = runTools(['check', 'api-coverage.verify-pre', '01-pay', '--raw'], noPhases); + assert.ok(r.success); + const j = JSON.parse(r.output); + assert.strictEqual(j.block, false, 'no phases tree → pass (not a GSD project)'); + } finally { + cleanup(noPhases); + } + }); +}); diff --git a/tests/api-coverage.test.cjs b/tests/api-coverage.test.cjs new file mode 100644 index 000000000..9bff9ab45 --- /dev/null +++ b/tests/api-coverage.test.cjs @@ -0,0 +1,410 @@ +/** + * Tests for the API-coverage detector + matrix validator (#1562). + * + * Two pure functions under test, both returning typed IR (asserted, never + * text-matched on prose): + * - detectApiIntegration(text) -> { detected, signals, terms } + * - validateCoverageMatrix(text) -> { valid, errors, counts } + * + * Acceptance-criterion mapping: + * #2 (opt-out needs reason; un-enumerated blocks) → validateCoverageMatrix suite + * #4 (non-API phases unaffected, low false-positive) → false-positive suite + * Matrix parse/render bijectivity → fast-check property + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); +const fc = require('fast-check'); + +const MODULE_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'api-coverage.cjs'); + +describe('detectApiIntegration — pure detector (#1562)', () => { + let mod; + try { + mod = require(MODULE_PATH); + } catch (err) { + throw new Error( + `Could not require ${MODULE_PATH}. Run "npm run build:lib" first. Underlying: ${err.message}` + ); + } + const { detectApiIntegration, DEFAULT_API_COVERAGE_TERMS } = mod; + + test('result shape — always carries detected, signals[], terms', () => { + const r = detectApiIntegration('refactor the login function'); + assert.strictEqual(r.detected, false); + assert(Array.isArray(r.signals)); + assert.strictEqual(r.signals.length, 0); + assert.ok(r.terms && Array.isArray(r.terms.verbs)); + assert.ok(Array.isArray(r.terms.nouns)); + }); + + test('non-string input degrades to {detected:false} without throwing', () => { + assert.strictEqual(detectApiIntegration(undefined).detected, false); + assert.strictEqual(detectApiIntegration(null).detected, false); + assert.strictEqual(detectApiIntegration(42).detected, false); + assert.strictEqual(detectApiIntegration({}).detected, false); + }); + + test('empty / whitespace-only input does not fire', () => { + assert.strictEqual(detectApiIntegration('').detected, false); + assert.strictEqual(detectApiIntegration(' \n\t ').detected, false); + }); + + test('terms echo is the effective set actually used', () => { + const r = detectApiIntegration('nothing relevant here'); + assert.deepStrictEqual(r.terms.verbs, [...DEFAULT_API_COVERAGE_TERMS.verbs]); + assert.deepStrictEqual(r.terms.nouns, [...DEFAULT_API_COVERAGE_TERMS.nouns]); + }); + + test('terms override — explicit verbs+nouns replace defaults', () => { + const r = detectApiIntegration('xyzzy the frobninator', { verbs: ['xyzzy'], nouns: ['frobninator'] }); + assert.deepStrictEqual(r.terms.verbs, ['xyzzy']); + assert.deepStrictEqual(r.terms.nouns, ['frobninator']); + assert.strictEqual(r.detected, true); + }); + + // ── Positive: compound verb+noun (acceptance #1 trigger) ───────────────── + for (const scope of [ + 'Integrate the Stripe API for payment processing', + 'Wrap the GitHub GraphQL API for issue triage', + 'Connect to the SendGrid REST endpoint for transactional email', + 'Consume the billing service over gRPC', + 'Wire up the Slack webhook for deploy notifications', + 'Onboard the Twilio SDK for SMS', + 'integrate oauth for login', + ]) { + test(`POSITIVE fires on: "${scope}"`, () => { + const r = detectApiIntegration(scope); + assert.strictEqual(r.detected, true, `expected detection for: ${scope}`); + assert.ok(r.signals.length > 0); + assert.ok(r.signals.every((s) => s.snippet.length > 0)); + }); + } + + // ── Positive: explicit API|SDK surface (no verb needed) ──────── + for (const scope of ['Add a Spotify API client', 'Ship the Notion SDK helper']) { + test(`POSITIVE (surface) fires on: "${scope}"`, () => { + const r = detectApiIntegration(scope); + assert.strictEqual(r.detected, true); + assert.ok(r.signals.some((s) => s.verb === '(surface)')); + }); + } + + // ── Negative: false-positive guards (acceptance #4 — the crux) ─────────── + // Each of these is a phase that is NOT an external-API integration. The + // detector must stay silent. The "public API of UserController" case is the + // canonical FP trap: "api" is present but there is no integration verb. + for (const [label, scope] of [ + ['internal API mention', 'The public API of the UserController should accept pagination params'], + ['refactor', 'Refactor the authentication module to use bcrypt'], + ['feature toggle', 'Add a dark mode toggle to the settings page'], + ['bug fix', 'Fix the off-by-one error in the pagination helper'], + ['docs', 'Update the README to document the config options'], + ['internal client code', 'Add a client-side helper to debounce input'], + ['bare noun no verb', 'We expose a REST-ish JSON shape already'], + ['bare verb no noun', 'We will integrate the new design system tokens'], + ]) { + test(`NEGATIVE does not fire (${label}): "${scope}"`, () => { + const r = detectApiIntegration(scope); + assert.strictEqual(r.detected, false, `unexpected detection for [${label}]: ${scope}`); + }); + } + + test('fenced code blocks are stripped — trigger inside a code fence does not fire', () => { + const scope = [ + 'Refactor the helpers.', + '', + '```bash', + '# integrate the Stripe API (example command in docs)', + '```', + '', + 'No integration in this phase.', + ].join('\n'); + assert.strictEqual(detectApiIntegration(scope).detected, false); + }); + + // ── H1 fix (#1562 code review): capitalized sentence starters must not fire + // the API/SDK surface rule. These are common English, not services. + for (const [label, scope] of [ + ['The API', 'The API documentation needs updating.'], + ['An SDK', 'An SDK is already present in the repo.'], + ['Our REST', 'Our REST endpoints return JSON.'], + ['This GraphQL', 'This GraphQL schema is internal.'], + ['New API', 'New API surface was added by the refactor.'], + ]) { + test(`NEGATIVE (stopword) does not fire (${label}): "${scope}"`, () => { + assert.strictEqual(detectApiIntegration(scope).detected, false); + }); + } + + test('compound signal fires when verb and noun are on the same line only', () => { + const sameLine = 'Phase A integrates things.\nLater we mention an api.'; + const splitLine = 'Phase A integrates things.\nLater we mention an api here too.'; + assert.strictEqual(detectApiIntegration(sameLine).detected, false); + assert.strictEqual(detectApiIntegration(splitLine).detected, false); + assert.strictEqual(detectApiIntegration('integrates the api').detected, true); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Matrix parse / validate / render +// ────────────────────────────────────────────────────────────────────────────── + +describe('coverage matrix — parse / validate (#1562 acceptance #2)', () => { + let mod; + try { + mod = require(MODULE_PATH); + } catch (err) { + throw new Error(`Could not require ${MODULE_PATH}. Run "npm run build:lib". Underlying: ${err.message}`); + } + const { parseCoverageMatrix, validateCoverageMatrix, renderCoverageMatrix } = mod; + + test('parse markdown table — header + 2 rows', () => { + const md = [ + '| capability | decision | reason |', + '|---|---|---|', + '| search | INTEGRATE | |', + '| playlists | OPT-OUT | not needed yet |', + ].join('\n'); + const p = parseCoverageMatrix(md); + assert.strictEqual(p.format, 'table'); + assert.strictEqual(p.rows.length, 2); + assert.strictEqual(p.rows[0].capability, 'search'); + assert.strictEqual(p.rows[0].decision, 'INTEGRATE'); + assert.strictEqual(p.rows[1].decision, 'OPT-OUT'); + assert.strictEqual(p.rows[1].reason, 'not needed yet'); + assert.strictEqual(p.errors.length, 0); + }); + + test('parse fenced ```coverage JSON block', () => { + const md = [ + 'Some prose.', + '', + '```coverage', + '[{"capability":"search","decision":"INTEGRATE","reason":""},', + ' {"capability":"skip","decision":"OPT-OUT","reason":"out of scope"}]', + '```', + ].join('\n'); + const p = parseCoverageMatrix(md); + assert.strictEqual(p.format, 'json'); + assert.strictEqual(p.rows.length, 2); + assert.strictEqual(p.errors.length, 0); + }); + + test('parse empty / non-matrix input → format none, no rows, no errors', () => { + const p = parseCoverageMatrix('# Notes\n\nNothing here.'); + assert.strictEqual(p.format, 'none'); + assert.strictEqual(p.rows.length, 0); + assert.strictEqual(p.errors.length, 0); + }); + + test('parse non-string → empty result, no throw', () => { + const p = parseCoverageMatrix(undefined); + assert.strictEqual(p.rows.length, 0); + }); + + test('parse rejects malformed fenced JSON with an error', () => { + const md = '```coverage\n{not json}\n```'; + const p = parseCoverageMatrix(md); + assert.strictEqual(p.format, 'json'); + assert.ok(p.errors.length > 0); + assert.strictEqual(p.rows.length, 0); + }); + + // ── validate: boundaries 0 / 1 / 2 rows (limit-1, limit, limit+1) ──────── + test('validate — empty matrix is invalid (acceptance #1: surface must be enumerated)', () => { + const v = validateCoverageMatrix('| capability | decision | reason |\n|---|---|---|'); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /empty/i.test(e))); + assert.strictEqual(v.counts.surface, 0); + }); + + test('validate — single INTEGRATE row is valid (limit)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| search | INTEGRATE | |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, true); + assert.strictEqual(v.counts.surface, 1); + assert.strictEqual(v.counts.integrate, 1); + assert.strictEqual(v.counts.optout, 0); + }); + + test('validate — two rows valid (limit+1)', () => { + const md = [ + '| capability | decision | reason |', + '|---|---|---|', + '| search | INTEGRATE | |', + '| playlists | OPT-OUT | not needed |', + ].join('\n'); + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, true); + assert.strictEqual(v.counts.surface, 2); + assert.strictEqual(v.counts.optout, 1); + }); + + // ── acceptance #2: every OPT-OUT must carry a reason ───────────────────── + test('validate — OPT-OUT without reason is INVALID (acceptance #2)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| skip | OPT-OUT | |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /missing reason/i.test(e))); + }); + + test('validate — OPT-OUT with one-char reason is valid (boundary)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| skip | OPT-OUT | x |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, true); + }); + + test('validate — duplicate capability is invalid (matrix is a set of decisions)', () => { + const md = [ + '| capability | decision | reason |', + '|---|---|---|', + '| search | INTEGRATE | |', + '| Search | OPT-OUT | dup |', + ].join('\n'); + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /duplicate/i.test(e))); + }); + + test('validate — empty capability name is invalid', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| | INTEGRATE | |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /empty capability/i.test(e))); + }); + + // ── review-fix coverage: parser robustness (#1562 code review M1/M2/L1/L2) ── + test('validate — invalid decision cell in a table row is an error, not silently dropped (M1)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| x | INTEGRAT | |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /not in \{INTEGRATE, OPT-OUT\}/i.test(e))); + }); + + test('validate — a pipe in a cell adds extra columns → invalid (M2)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| x | OPT-OUT | a|b |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /columns|pipe/i.test(e))); + }); + + test('validate — a pipe in a JSON-fence reason → invalid (M2)', () => { + const md = '```coverage\n[{"capability":"x","decision":"OPT-OUT","reason":"a|b"}]\n```'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + }); + + test('validate — capability over the length cap → invalid (S3 bound)', () => { + const longCap = 'x'.repeat(81); + const md = `| capability | decision | reason |\n|---|---|---|\n| ${longCap} | INTEGRATE | |`; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.valid, false); + assert.ok(v.errors.some((e) => /exceeds/i.test(e))); + }); + + test('parse — case-insensitive ```Coverage fence is accepted (L1)', () => { + const md = '```Coverage\n[{"capability":"a","decision":"INTEGRATE","reason":""}]\n```'; + const p = parseCoverageMatrix(md); + assert.strictEqual(p.format, 'json'); + assert.strictEqual(p.rows.length, 1); + }); + + test('parse — a single-dash cell is not mistaken for a separator (L2)', () => { + const md = '| capability | decision | reason |\n|---|---|---|\n| - | INTEGRATE | |'; + const v = validateCoverageMatrix(md); + assert.strictEqual(v.counts.surface, 1); + assert.ok(v.valid, 'a capability named "-" is legal'); + }); + + // ── render round-trip (manual) ─────────────────────────────────────────── + test('render → parse round-trips a valid matrix', () => { + const rows = [ + { capability: 'search', decision: 'INTEGRATE', reason: '' }, + { capability: 'playlists', decision: 'OPT-OUT', reason: 'not needed yet' }, + ]; + const rendered = renderCoverageMatrix(rows); + const v = validateCoverageMatrix(rendered); + assert.strictEqual(v.valid, true); + assert.strictEqual(v.counts.surface, 2); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Property test: parse/render bijectivity (RULESET.TESTS property-based) +// ────────────────────────────────────────────────────────────────────────────── + +describe('coverage matrix — parse/render bijection (fast-check)', () => { + let mod; + try { + mod = require(MODULE_PATH); + } catch (err) { + throw new Error(`Could not require ${MODULE_PATH}. Run "npm run build:lib". Underlying: ${err.message}`); + } + const { renderCoverageMatrix, validateCoverageMatrix } = mod; + + test('any valid row set renders and re-validates to the same counts', () => { + const capabilityGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,14}$/); + const rowGen = fc.record({ + capability: capabilityGen, + decision: fc.constantFrom('INTEGRATE', 'OPT-OUT'), + // Reasons are short prose (e.g. "not needed yet"). The matrix is a + // markdown table, so cell text is format-safe: no pipes / newlines. + reason: fc.stringMatching(/^[a-z0-9 ,.\-!?]{0,20}$/), + }); + // OPT-OUT rows must carry a non-empty reason for the round-trip to validate. + const validRowGen = rowGen.map((r) => + r.decision === 'OPT-OUT' && r.reason.trim() === '' + ? { ...r, reason: 'because' } + : { ...r, reason: r.reason.trim() } + ); + const matrixGen = fc.uniqueArray(validRowGen, { + minLength: 1, + maxLength: 8, + selector: (r) => r.capability.toLowerCase(), + }); + fc.assert( + fc.property(matrixGen, (rows) => { + const rendered = renderCoverageMatrix(rows); + const v = validateCoverageMatrix(rendered); + // Injected reason guarantees validity; any invalid result is a parser bug. + assert.strictEqual(v.valid, true); + assert.strictEqual(v.counts.surface, rows.length); + return true; + }), + { numRuns: 100 } + ); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// CLI entry point (STDIN → exit codes mirror grep, like assumption-delta) +// ────────────────────────────────────────────────────────────────────────────── + +describe('api-coverage CLI — STDIN + exit codes', () => { + const CLI = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'api-coverage.cjs'); + + function runCli(stdin) { + const r = spawnSync(process.execPath, [CLI, '--json'], { + input: stdin, + encoding: 'utf-8', + timeout: 15000, + }); + return { exitCode: r.status, stdout: r.stdout, stderr: r.stderr }; + } + + test('exit 0 + JSON IR when integration detected', () => { + const r = runCli('Integrate the Stripe API for payments'); + assert.strictEqual(r.exitCode, 0); + const body = JSON.parse(r.stdout); + assert.strictEqual(body.detected, true); + assert.ok(Array.isArray(body.signals)); + }); + + test('exit 1 when no integration', () => { + const r = runCli('Refactor the login helper'); + assert.strictEqual(r.exitCode, 1); + }); +}); diff --git a/tests/config-field-docs.test.cjs b/tests/config-field-docs.test.cjs index 99936372f..9ff819c05 100644 --- a/tests/config-field-docs.test.cjs +++ b/tests/config-field-docs.test.cjs @@ -81,6 +81,7 @@ describe('config-field-docs', () => { verifier: 'workflow.verifier', nyquist_validation: 'workflow.nyquist_validation', ai_integration_phase: 'workflow.ai_integration_phase', + api_coverage_gate: 'workflow.api_coverage_gate', text_mode: 'workflow.text_mode', subagent_timeout: 'workflow.subagent_timeout', branching_strategy: 'git.branching_strategy', diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index cb9ae5c8d..e6c8252c3 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -52,6 +52,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "51b5d0ba5b1e98d9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "e176817364a7cbf4", "gsd-core/references/autonomous-smart-discuss.md": "efd80aca449032ad", "gsd-core/references/checkpoints.md": "2de680837faa9752", @@ -108,7 +109,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "b0e55e9d585f90c1", + "gsd-core/references/planning-config.md": "2e2f418328e52f6a", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -308,7 +309,7 @@ "gsd-core/workflows/update.md": "2c58df5e21c41c31", "gsd-core/workflows/validate-phase.md": "6c0ab739d15709fa", "gsd-core/workflows/verify-phase.md": "0eefbb6bb1b0bed6", - "gsd-core/workflows/verify-work.md": "be699ed7920f61b0", + "gsd-core/workflows/verify-work.md": "59276d94999ee022", "hooks/gsd-check-update-worker.js": "fa301e6366270d5f", "hooks/gsd-check-update.js": "4617a98bf529e4c3", "hooks/gsd-config-reload.js": "96546e0e8bb47904", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index d4f58026c..7785d1737 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "66264d41dfd9154a", "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "eb168188abd00101", + "gsd-core/references/planning-config.md": "ac409835e8260a3e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "fd160e13f8b7e83c", "gsd-core/workflows/validate-phase.md": "2c6d7671fcaabcaa", "gsd-core/workflows/verify-phase.md": "22f18492581f1da5", - "gsd-core/workflows/verify-work.md": "34e980a6950cd83c", + "gsd-core/workflows/verify-work.md": "63b3f680d8f0a6f3", "hooks/gsd-check-update-worker.js": "cc1ef5f840f9dfc9", "hooks/gsd-check-update.js": "7b3a7983d5f1f5d3", "hooks/gsd-config-reload.js": "96546e0e8bb47904", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 9127cacfc..2be2052f2 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -51,6 +51,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", @@ -107,7 +108,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "99c46b7d318adf1a", + "gsd-core/references/planning-config.md": "b025429fc72f9285", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -307,7 +308,7 @@ "gsd-core/workflows/update.md": "f9e7d8a760d0d3c8", "gsd-core/workflows/validate-phase.md": "2ac231dc541441c2", "gsd-core/workflows/verify-phase.md": "e0957e153788a222", - "gsd-core/workflows/verify-work.md": "efe57bdbbb3af03f", + "gsd-core/workflows/verify-work.md": "c8ffee621e7319de", "hooks/gsd-check-update-worker.js": "a530efdb5fdc0da3", "hooks/gsd-check-update.js": "25cde66a12d6b886", "hooks/gsd-config-reload.js": "96546e0e8bb47904", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index bd83660d1..f3841bb20 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -55,6 +55,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "59f782556e4e611f", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "66264d41dfd9154a", "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "9a7ba3a17ece1698", @@ -111,7 +112,7 @@ "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "a0dc2dddb953b22c", + "gsd-core/references/planning-config.md": "5a40b2934db6a321", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -311,7 +312,7 @@ "gsd-core/workflows/update.md": "165beec33490bd28", "gsd-core/workflows/validate-phase.md": "5b4ae14c87859bd2", "gsd-core/workflows/verify-phase.md": "a4f918c92c927268", - "gsd-core/workflows/verify-work.md": "6fa216623778c541", + "gsd-core/workflows/verify-work.md": "3fb282f2bce34a79", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 71d7569c7..df78da0b6 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "66264d41dfd9154a", "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "eb168188abd00101", + "gsd-core/references/planning-config.md": "ac409835e8260a3e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "5ff1f77222977648", "gsd-core/workflows/validate-phase.md": "2c6d7671fcaabcaa", "gsd-core/workflows/verify-phase.md": "22f18492581f1da5", - "gsd-core/workflows/verify-work.md": "34e980a6950cd83c", + "gsd-core/workflows/verify-work.md": "63b3f680d8f0a6f3", "hooks/gsd-check-update-worker.js": "bdc9324a2f080ddd", "hooks/gsd-check-update.js": "b7669f605631e506", "hooks/gsd-config-reload.js": "96546e0e8bb47904", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index ad2b46d1f..a6e01217e 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -87,6 +87,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "51b5d0ba5b1e98d9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/api-coverage.md": "524382216a8e713f", "gsd-core/references/artifact-types.md": "3218cafb0c92dc32", "gsd-core/references/autonomous-smart-discuss.md": "4156025334411073", "gsd-core/references/checkpoints.md": "9feb961f644afa96", @@ -143,7 +144,7 @@ "gsd-core/references/planner-reviews.md": "7889bfa28e82156b", "gsd-core/references/planner-revision.md": "2ebf1a714d1ec4bf", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "40d4abb1ed0f3723", + "gsd-core/references/planning-config.md": "b01856d7a63e73a2", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -343,7 +344,7 @@ "gsd-core/workflows/update.md": "5c35c0ec0f462ea6", "gsd-core/workflows/validate-phase.md": "020201a41049679f", "gsd-core/workflows/verify-phase.md": "2e12c3cb97a9122a", - "gsd-core/workflows/verify-work.md": "69e27f6f419d0bba", + "gsd-core/workflows/verify-work.md": "5b348e73fc0d0829", "hooks/gsd-check-update.js": "ef48957eb6ac6a10", "hooks/gsd-context-monitor.js": "76fecaaa2babd6c1", "scripts/changeset/README.md": "86ff89331dfd94b2", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 0e194d4b8..060f81cd9 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -53,6 +53,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "51b5d0ba5b1e98d9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "f992de8b2b1a4420", "gsd-core/references/autonomous-smart-discuss.md": "efd80aca449032ad", "gsd-core/references/checkpoints.md": "c70b323dcb1583d5", @@ -109,7 +110,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "fd81dd276828eab4", + "gsd-core/references/planning-config.md": "309a566a0e13e43e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -309,7 +310,7 @@ "gsd-core/workflows/update.md": "f444a7cfcd246cfb", "gsd-core/workflows/validate-phase.md": "2f705775a4b76d42", "gsd-core/workflows/verify-phase.md": "5c72780e34214e27", - "gsd-core/workflows/verify-work.md": "58e9b1b16f773b53", + "gsd-core/workflows/verify-work.md": "c966971a3cbd1d71", "hooks/gsd-session.json": "0a462834f2a28fee", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index dcca3bbc3..c618c1846 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "3d62d178004db5cc", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "273b371c5751f35a", "gsd-core/references/checkpoints.md": "3001eeccb319781b", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "8af05de88771e9f4", + "gsd-core/references/planning-config.md": "a3f1b4aca291db59", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "23e294ba707c3580", "gsd-core/workflows/validate-phase.md": "2df0c6e298a5f249", "gsd-core/workflows/verify-phase.md": "e0957e153788a222", - "gsd-core/workflows/verify-work.md": "145596b2542c457a", + "gsd-core/workflows/verify-work.md": "e7e7e900c4874490", "hooks/gsd-cursor-post-tool.js": "019d503aee8b4a3f", "hooks/gsd-cursor-session-start.js": "c6e04ed597ea7020", "scripts/changeset/README.md": "86ff89331dfd94b2", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index a76235b8d..f8013837a 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -52,6 +52,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "892f086e846cd8e4", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "db8a7425ed808e24", @@ -108,7 +109,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "1358a14bded944f5", + "gsd-core/references/planning-config.md": "831d626c214d92e4", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -308,7 +309,7 @@ "gsd-core/workflows/update.md": "7499bb4cb2a3ce6f", "gsd-core/workflows/validate-phase.md": "75e8971d3981d06b", "gsd-core/workflows/verify-phase.md": "5c8d1305b47fbef4", - "gsd-core/workflows/verify-work.md": "e56e07475d5eb51e", + "gsd-core/workflows/verify-work.md": "7a9c9541d2d73fdc", "hooks/gsd-check-update-worker.js": "7989cc2bedd1138d", "hooks/gsd-check-update.js": "25f5ad726f76fc11", "hooks/gsd-config-reload.js": "880b696458e85e9b", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 62d6de451..f3596be07 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "51b5d0ba5b1e98d9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "3986d58011bf9006", "gsd-core/references/checkpoints.md": "9feb961f644afa96", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "aad463ddda23dbaf", + "gsd-core/references/planning-config.md": "37ab69107a2f7dc6", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "07dc2fba78ad1865", "gsd-core/workflows/validate-phase.md": "557e3251e3b9349a", "gsd-core/workflows/verify-phase.md": "e0957e153788a222", - "gsd-core/workflows/verify-work.md": "b68ac37f6301a3b5", + "gsd-core/workflows/verify-work.md": "9fc717845828c6e3", "kilo.json": "13151e97ff23c1aa", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 795b3c198..2b3f8fa6b 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -88,6 +88,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "66264d41dfd9154a", "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", @@ -144,7 +145,7 @@ "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "eb168188abd00101", + "gsd-core/references/planning-config.md": "ac409835e8260a3e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -344,7 +345,7 @@ "gsd-core/workflows/update.md": "6718e0632bba26ca", "gsd-core/workflows/validate-phase.md": "2c6d7671fcaabcaa", "gsd-core/workflows/verify-phase.md": "22f18492581f1da5", - "gsd-core/workflows/verify-work.md": "34e980a6950cd83c", + "gsd-core/workflows/verify-work.md": "63b3f680d8f0a6f3", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 1da46755a..ed0c3e395 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "51b5d0ba5b1e98d9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "425dd69c629230e7", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "218c55caf8aff6df", "gsd-core/references/autonomous-smart-discuss.md": "3986d58011bf9006", "gsd-core/references/checkpoints.md": "9feb961f644afa96", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "aad463ddda23dbaf", + "gsd-core/references/planning-config.md": "37ab69107a2f7dc6", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "71b6cd852f38b4bc", "gsd-core/workflows/validate-phase.md": "abcdbc1b56780565", "gsd-core/workflows/verify-phase.md": "126be1d026900102", - "gsd-core/workflows/verify-work.md": "f0d205568abfaf74", + "gsd-core/workflows/verify-work.md": "ae07b3fa8b6f864e", "hooks/gsd-check-update-worker.js": "385fb7c67810baf6", "hooks/gsd-check-update.js": "4549451414ffa7d7", "hooks/gsd-config-reload.js": "96546e0e8bb47904", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index bf6e3e38e..6f24ee22f 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -52,6 +52,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "be09755fed7ad856", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "046171320f816346", @@ -108,7 +109,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "0037314fe38ca55c", + "gsd-core/references/planning-config.md": "4c6de9b6d66aca73", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -308,7 +309,7 @@ "gsd-core/workflows/update.md": "b34cb866152d4de3", "gsd-core/workflows/validate-phase.md": "e989cbaa4228564c", "gsd-core/workflows/verify-phase.md": "0dc52b9e629a5f5a", - "gsd-core/workflows/verify-work.md": "b5afb65fdf311301", + "gsd-core/workflows/verify-work.md": "3355ddc14f052fff", "hooks/gsd-check-update-worker.js": "4bb354044e0dff91", "hooks/gsd-check-update.js": "d2065cb3e725a42a", "hooks/gsd-config-reload.js": "4f52b8a0120bb1b8", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 5aecb31d3..7c07b71ab 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -52,6 +52,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "18d1ff7c7fa0bab1", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "4b8c645ffa695067", @@ -108,7 +109,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "e47cdb2337e10dac", + "gsd-core/references/planning-config.md": "a686c1363ae626c5", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -308,7 +309,7 @@ "gsd-core/workflows/update.md": "1f935251fca1f276", "gsd-core/workflows/validate-phase.md": "1c0ebe56d96a14d1", "gsd-core/workflows/verify-phase.md": "a151ed36eb51813b", - "gsd-core/workflows/verify-work.md": "d3a0970205acc6a5", + "gsd-core/workflows/verify-work.md": "dde2a42a56c27c4e", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 71f434e76..4993ab62d 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -52,6 +52,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "849977da771f2b06", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "205a43c5fa7c221c", "gsd-core/references/artifact-types.md": "8bd01fd75a2ba70e", "gsd-core/references/autonomous-smart-discuss.md": "273b371c5751f35a", "gsd-core/references/checkpoints.md": "808e4fcaa2fda15c", @@ -108,7 +109,7 @@ "gsd-core/references/planner-reviews.md": "da39eace09a10743", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "aafbfc62a3bb84b1", + "gsd-core/references/planning-config.md": "1fc70920778d7c8e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -308,7 +309,7 @@ "gsd-core/workflows/update.md": "79aaf4b8f1f83045", "gsd-core/workflows/validate-phase.md": "2db47bf5547d7b9d", "gsd-core/workflows/verify-phase.md": "f961cdb3ef03ff05", - "gsd-core/workflows/verify-work.md": "5ad63a5edfb6acac", + "gsd-core/workflows/verify-work.md": "cce8ae44b30957f1", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 7d18f6001..5c044e9eb 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -123,6 +123,7 @@ "gsd-core/references/agent-skills-bootstrap.md": "5ab875054b1adda9", "gsd-core/references/ai-evals.md": "b5afa786b938671e", "gsd-core/references/ai-frameworks.md": "f827de93dde124eb", + "gsd-core/references/api-coverage.md": "66264d41dfd9154a", "gsd-core/references/artifact-types.md": "a6d2e1f9453ffbf5", "gsd-core/references/autonomous-smart-discuss.md": "2fc710cde0ec7785", "gsd-core/references/checkpoints.md": "6aa620c6ca38bdf0", @@ -179,7 +180,7 @@ "gsd-core/references/planner-reviews.md": "dda0193a0fbd4947", "gsd-core/references/planner-revision.md": "86ba8a511f081f05", "gsd-core/references/planner-source-audit.md": "7de5bdb07232ce0b", - "gsd-core/references/planning-config.md": "eb168188abd00101", + "gsd-core/references/planning-config.md": "ac409835e8260a3e", "gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json": "f10df472f2846cc6", "gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json": "31e8a781eeffe020", "gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json": "70a532a7cc1b6ae8", @@ -379,7 +380,7 @@ "gsd-core/workflows/update.md": "dc580dee13f881a6", "gsd-core/workflows/validate-phase.md": "2c6d7671fcaabcaa", "gsd-core/workflows/verify-phase.md": "22f18492581f1da5", - "gsd-core/workflows/verify-work.md": "34e980a6950cd83c", + "gsd-core/workflows/verify-work.md": "63b3f680d8f0a6f3", "scripts/changeset/README.md": "86ff89331dfd94b2", "scripts/changeset/cli.cjs": "68f92a344b199271", "scripts/changeset/github-release-notes.cjs": "795677f0c009b132", diff --git a/tests/issue-2045-third-party-skills-surface.test.cjs b/tests/issue-2045-third-party-skills-surface.test.cjs index 09917e620..3e5ccd1cb 100644 --- a/tests/issue-2045-third-party-skills-surface.test.cjs +++ b/tests/issue-2045-third-party-skills-surface.test.cjs @@ -29,7 +29,7 @@ * AC5: first-party caps unaffected; writer still rejects truly-unknown ids. */ -const { describe, test, before, after } = require('node:test'); +const { describe, test, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); diff --git a/tests/loop-hooks-empty-points-e2e.test.cjs b/tests/loop-hooks-empty-points-e2e.test.cjs index 7099c999b..6c28f4f27 100644 --- a/tests/loop-hooks-empty-points-e2e.test.cjs +++ b/tests/loop-hooks-empty-points-e2e.test.cjs @@ -5,7 +5,10 @@ * Hook points tested: discuss:pre, discuss:post, execute:pre, execute:wave:pre, * verify:pre, ship:post * - * All 6 points have zero hooks in the real registry by design. + * 5 of these points have zero hooks in the real registry by design. + * verify:pre graduated out of the empty set in #1562 (it now carries the + * ai-integration `api-coverage.verify-pre` blocking gate); SECTION 5 pins the + * new contract + retains synthetic extension-point-mechanics coverage. * Tests pin: exact envelope shape, placeholder string contract (Hyrum's Law), * resolver-filter mechanics (schema default / config-override / capabilityStatesById), * CLI contract (missing-arg, invalid-point), and Postel-leniency (malformed config). @@ -481,8 +484,13 @@ describe('execute:wave:pre — real registry empty-resolution + synthetic mechan // ───────────────────────────────────────────────────────────────────────────── // SECTION 5: verify:pre // ───────────────────────────────────────────────────────────────────────────── +// NOTE: verify:pre was an empty extension point until #1562 added the +// ai-integration `api-coverage.verify-pre` blocking gate (default-on, opt-out +// via workflow.api_coverage_gate=false). The real-registry assertions below pin +// the NEW contract; the synthetic BVA tests retain extension-point-mechanics +// coverage. Empty-point coverage for the other 5 points is unaffected. -describe('verify:pre — real registry empty-resolution + synthetic extension-point readiness', () => { +describe('verify:pre — real registry carries the api-coverage gate (#1562); synthetic mechanics', () => { let tmpEmptyProjectDir; let tmpProjectDirAllOn; before(() => { @@ -494,29 +502,47 @@ describe('verify:pre — real registry empty-resolution + synthetic extension-po cleanup(tmpProjectDirAllOn); }); - it('[empty-resolution] verify:pre with real registry and no config yields empty activeHooks and exact placeholder (Gall\'s Law)', () => { + it('[default-on] verify:pre with real registry and no config yields the api-coverage blocking gate (full-coverage-by-default)', () => { const resolved = resolveLoopHooks({ point: 'verify:pre', registry: realRegistry, config: {} }); - assert.strictEqual(resolved.activeHooks.length, 0); - assert.strictEqual(renderLoopHooks(resolved), '_No active hooks at verify:pre._'); + const gate = resolved.activeHooks.find((h) => h.capId === 'ai-integration' && h.kind === 'gate'); + assert.ok(gate, 'ai-integration api-coverage gate must register at verify:pre by default'); + assert.strictEqual(gate.blocking, true); + assert.strictEqual(gate.check.query, 'api-coverage.verify-pre'); }); - it('[happy] verify:pre E2E subprocess returns well-formed 3-key JSON envelope with empty activeHooks (Hyrum\'s Law contract pin)', () => { + it('[happy] verify:pre E2E subprocess returns a well-formed envelope carrying the gate (Hyrum\'s Law contract pin)', () => { const result = spawnGsd(['loop', 'render-hooks', 'verify:pre', '--cwd', tmpEmptyProjectDir, '--raw'], tmpEmptyProjectDir); assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`); const envelope = JSON.parse(result.stdout.trim()); assert.strictEqual(envelope.point, 'verify:pre'); - assert.deepEqual(envelope.activeHooks, []); - assert.strictEqual(envelope.rendered, '_No active hooks at verify:pre._'); + assert.ok(Array.isArray(envelope.activeHooks)); + assert.ok(envelope.activeHooks.some((h) => h.capId === 'ai-integration' && h.kind === 'gate')); assert.deepEqual(Object.keys(envelope).sort(), ['activeHooks', 'point', 'rendered']); }); - it('[negative] verify:pre with all capability config keys set to true still yields empty activeHooks — no leakage from other points', () => { + it('[opt-out] verify:pre with workflow.api_coverage_gate=false yields NO ai-integration gate — config opt-out empties the point', () => { + const resolved = resolveLoopHooks({ + point: 'verify:pre', + registry: realRegistry, + config: { workflow: { api_coverage_gate: false } }, + }); + assert.strictEqual( + resolved.activeHooks.find((h) => h.capId === 'ai-integration'), + undefined, + 'opting out api_coverage_gate must remove the gate from verify:pre' + ); + }); + + it('[negative] verify:pre with unrelated capability keys on does not bleed non-verify:pre hooks into the point', () => { const resolved = resolveLoopHooks({ point: 'verify:pre', registry: realRegistry, config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } }, }); - assert.strictEqual(resolved.activeHooks.length, 0, 'UI and other capabilities must not bleed through to verify:pre'); + // The only verify:pre hook is the api-coverage gate; UI/other hooks must not bleed in. + for (const h of resolved.activeHooks) { + assert.notStrictEqual(h.capId, 'ui', 'UI capability must not bleed through to verify:pre'); + } }); it('[bva] Synthetic step at verify:pre with configSchema default=true fires correctly — extension point readiness', () => { @@ -547,7 +573,10 @@ describe('verify:pre — real registry empty-resolution + synthetic extension-po const result = spawnGsd(['loop', 'render-hooks', 'verify:pre', '--cwd', malformedDir, '--raw'], malformedDir); assert.strictEqual(result.status, 0, `must not crash on malformed config. stderr: ${result.stderr}`); const envelope = JSON.parse(result.stdout.trim()); - assert.deepEqual(envelope.activeHooks, []); + // Malformed config degrades to defaults (gate default-on); the contract + // here is leniency (exit 0, well-formed envelope), not emptiness. + assert.strictEqual(envelope.point, 'verify:pre'); + assert.ok(Array.isArray(envelope.activeHooks)); } finally { cleanup(malformedDir); } @@ -720,13 +749,12 @@ describe('CLI contract — missing/invalid point argument (shared across all 6 e // SECTION 8: Parametric empty-point sweep across all 6 points (E2E regression guard) // ───────────────────────────────────────────────────────────────────────────── -describe('Parametric E2E sweep — 5 empty points return correct envelope shape via real registry (ship:post excluded — mempalace registers 1 step there)', () => { +describe('Parametric E2E sweep — 4 empty points return correct envelope shape via real registry (ship:post excluded — mempalace registers 1 step there; verify:pre excluded since #1562 added the api-coverage gate)', () => { const EMPTY_POINTS = [ 'discuss:pre', 'discuss:post', 'execute:pre', 'execute:wave:pre', - 'verify:pre', ]; for (const point of EMPTY_POINTS) { diff --git a/tests/plan-pre-hook-e2e.test.cjs b/tests/plan-pre-hook-e2e.test.cjs index 41b02833f..0e0d75600 100644 --- a/tests/plan-pre-hook-e2e.test.cjs +++ b/tests/plan-pre-hook-e2e.test.cjs @@ -246,6 +246,7 @@ describe('plan:pre all-off — empty resolution', () => { tmpDir = makeProject({ workflow: { ai_integration_phase: false, + api_coverage_gate: false, tdd_mode: false, security_enforcement: false, ui_phase: false, diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 945f3c59f..d389e7147 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -89,5 +89,5 @@ "update.md": 20914, "validate-phase.md": 10789, "verify-phase.md": 40923, - "verify-work.md": 38267 + "verify-work.md": 40247 } From 448330025323afb40ea45759d484e149ec1a08f8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 7 Jul 2026 20:28:15 -0400 Subject: [PATCH 2/3] fix(#2072): thread resolved model into routed-agent spawns (assumptions-analyzer, code-reviewer, code-fixer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit model_overrides / models. were silently inert for gsd-assumptions-analyzer, gsd-code-reviewer, and gsd-code-fixer on Claude Code: resolveModelInternal honors them, but the workflows spawned these agents with no model= param, so the resolved value never reached the Agent tool and the agents inherited the session model — no warning. Fix — thread each agent's resolved model at every spawn site (the established plan-phase pattern; the architecture-consistent Claude mechanism, since 13 other agents already thread their model): - discuss-phase-assumptions.md: `resolve-model gsd-assumptions-analyzer --raw` → ANALYZER_MODEL, threaded. - code-review.md + code-review-fix.md (re-review): `resolve-model gsd-code-reviewer --raw` → REVIEWER_MODEL, threaded. - code-review-fix.md (both fixer spawns): `resolve-model gsd-code-fixer --raw` → FIXER_MODEL, threaded (same silently-inert bug, same file — folded in per review). - quick.md review step: was reusing `{executor_model}` for gsd-code-reviewer (so the reviewer's own override was ignored); init.quick now resolves `reviewer_model` (gsd-code-reviewer) and the spawn threads it. resolve-model --raw returns the bare model string (resolve-execution --raw would return effort — wrong). The resolver maps these agents to phaseType discuss / verification / execution, so models. apply too. Scope: the three agents reachable from the two issue-named workflows + quick.md. The wider systemic class (other agents in UNTOUCHED workflows with the same pattern) stays documented on the issue for a maintainer-scoped structural decision (thread-at-source vs embed-at-install like #2256), not widened here. Docs: the stale "discuss — reserved, no subagent today" model-profile tables now list gsd-assumptions-analyzer and the verification row includes gsd-code-reviewer, across the English docs, the shipped gsd-core/references/model-profiles.md reference, and the ja-JP / zh-CN / ko-KR / pt-BR locale mirrors. Tests: - tests/model-resolver.test.cjs: #2072 acceptance — model_overrides and models.discuss/verification/execution resolve for all three agents. - tests/model-routing-spawn-threading.test.cjs: every spawn of the three agents threads a resolved model (fails pre-fix); a header-precise parity guard fails the suite if a new un-threaded spawn of any of them regresses. All 16 golden-install-parity fixtures + the workflow size baseline regenerated for the changed shipped files (4 workflows + the reference doc); bin/lib is excluded from parity. Co-Authored-By: Claude Opus 4.8 --- ...2-thread-model-into-routed-agent-spawns.md | 5 + docs/CONFIGURATION.md | 4 +- docs/FEATURES.md | 4 +- docs/how-to/configure-model-profiles.md | 5 +- docs/ja-JP/FEATURES.md | 4 +- docs/ja-JP/how-to/configure-model-profiles.md | 5 +- docs/ko-KR/how-to/configure-model-profiles.md | 5 +- docs/pt-BR/CONFIGURATION.md | 4 +- docs/pt-BR/how-to/configure-model-profiles.md | 5 +- docs/zh-CN/CONFIGURATION.md | 4 +- docs/zh-CN/FEATURES.md | 4 +- docs/zh-CN/how-to/configure-model-profiles.md | 5 +- gsd-core/references/model-profiles.md | 4 +- gsd-core/workflows/code-review-fix.md | 10 +- gsd-core/workflows/code-review.md | 5 +- .../workflows/discuss-phase-assumptions.md | 5 +- gsd-core/workflows/quick.md | 4 +- src/init.cts | 3 + .../golden-install-parity/antigravity.json | 10 +- .../golden-install-parity/augment.json | 10 +- .../golden-install-parity/claude.json | 10 +- .../fixtures/golden-install-parity/cline.json | 10 +- .../golden-install-parity/codebuddy.json | 10 +- .../fixtures/golden-install-parity/codex.json | 10 +- .../golden-install-parity/copilot.json | 10 +- .../golden-install-parity/cursor.json | 10 +- .../golden-install-parity/hermes.json | 10 +- .../fixtures/golden-install-parity/kilo.json | 10 +- .../fixtures/golden-install-parity/kimi.json | 10 +- .../golden-install-parity/opencode.json | 10 +- .../fixtures/golden-install-parity/qwen.json | 10 +- .../fixtures/golden-install-parity/trae.json | 10 +- .../golden-install-parity/windsurf.json | 10 +- .../fixtures/golden-install-parity/zcode.json | 10 +- tests/model-resolver.test.cjs | 30 ++++ tests/model-routing-spawn-threading.test.cjs | 150 ++++++++++++++++++ tests/workflow-size-baseline.json | 8 +- 37 files changed, 318 insertions(+), 115 deletions(-) create mode 100644 .changeset/2072-thread-model-into-routed-agent-spawns.md create mode 100644 tests/model-routing-spawn-threading.test.cjs diff --git a/.changeset/2072-thread-model-into-routed-agent-spawns.md b/.changeset/2072-thread-model-into-routed-agent-spawns.md new file mode 100644 index 000000000..a74b21ff9 --- /dev/null +++ b/.changeset/2072-thread-model-into-routed-agent-spawns.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2074 +--- +**`model_overrides` and per-phase-type models now actually apply to the assumptions-analyzer, code-reviewer, and code-fixer agents on Claude Code.** Previously `model_overrides["gsd-code-reviewer"]` / `["gsd-assumptions-analyzer"]` / `["gsd-code-fixer"]` (and `models.verification` / `models.discuss` / `models.execution`) were accepted and resolved but silently dropped — the workflows spawned these agents with no model, so they inherited the session model and the configured routing never took effect (no warning). Every spawn now threads its resolved model: `discuss-phase-assumptions`, `code-review`, and `code-review-fix` (both the re-review and the two fixer spawns) resolve it inline, and `quick`'s review step uses the code-reviewer's own resolved model instead of the executor's. The stale "`discuss` — reserved, no subagent" model-profile docs are corrected to list `gsd-assumptions-analyzer`, and the `verification` row now includes `gsd-code-reviewer`. (#2074) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f1c4357a8..c8db5326a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1114,10 +1114,10 @@ for the change to take effect. See issue #2256. | Phase type | Agents | |---|---| | `planning` | `gsd-planner`, `gsd-roadmapper`, `gsd-pattern-mapper` | -| `discuss` | (reserved — no subagent today) | +| `discuss` | `gsd-assumptions-analyzer` | | `research` | `gsd-phase-researcher`, `gsd-project-researcher`, `gsd-research-synthesizer`, `gsd-codebase-mapper`, `gsd-ui-researcher` | | `execution` | `gsd-executor`, `gsd-debugger`, `gsd-doc-writer` | -| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier` | +| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier`, `gsd-code-reviewer` | | `completion` | (reserved — no subagent today) | `discuss` and `completion` are accepted by the schema for forward compatibility; setting them today is a no-op until a subagent maps to them. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 1a80bd7c3..2a3bcf584 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -2751,10 +2751,10 @@ Users who run a memory / knowledge-base MCP server (for example, ExoCortex-style | Slot | Agents assigned | |------|-----------------| | `planning` | `gsd-planner`, `gsd-roadmapper`, `gsd-pattern-mapper` | -| `discuss` | (reserved for future subagent) | +| `discuss` | `gsd-assumptions-analyzer` | | `research` | `gsd-phase-researcher`, `gsd-project-researcher`, `gsd-research-synthesizer`, `gsd-codebase-mapper`, `gsd-ui-researcher` | | `execution` | `gsd-executor`, `gsd-debugger`, `gsd-doc-writer` | -| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier` | +| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier`, `gsd-code-reviewer` | | `completion` | (reserved for future subagent) | **Accepted values:** `"opus"` / `"sonnet"` / `"haiku"` / `"inherit"` diff --git a/docs/how-to/configure-model-profiles.md b/docs/how-to/configure-model-profiles.md index 153611526..8d575d2f5 100644 --- a/docs/how-to/configure-model-profiles.md +++ b/docs/how-to/configure-model-profiles.md @@ -90,8 +90,9 @@ Phase types and their agents: | `planning` | `gsd-planner`, `gsd-roadmapper`, `gsd-pattern-mapper` | | `research` | `gsd-phase-researcher`, `gsd-project-researcher`, `gsd-research-synthesizer`, `gsd-codebase-mapper`, `gsd-ui-researcher` | | `execution` | `gsd-executor`, `gsd-debugger`, `gsd-doc-writer` | -| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier` | -| `discuss`, `completion` | Reserved — no subagent today; accepted by schema for forward compatibility | +| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier`, `gsd-code-reviewer` | +| `discuss` | `gsd-assumptions-analyzer` | +| `completion` | Reserved — no subagent today; accepted by schema for forward compatibility | The `models` block accepts tier aliases only (`opus`, `sonnet`, `haiku`, `inherit`). For a fully-qualified model ID, use `model_overrides` per agent instead. diff --git a/docs/ja-JP/FEATURES.md b/docs/ja-JP/FEATURES.md index 31c1da45c..0e65982f6 100644 --- a/docs/ja-JP/FEATURES.md +++ b/docs/ja-JP/FEATURES.md @@ -2676,10 +2676,10 @@ capture_thought({ | スロット | 割り当てられたエージェント | |---------|----------------------| | `planning` | `gsd-planner`、`gsd-roadmapper`、`gsd-pattern-mapper` | -| `discuss` | (将来のサブエージェント用に予約) | +| `discuss` | `gsd-assumptions-analyzer` | | `research` | `gsd-phase-researcher`、`gsd-project-researcher`、`gsd-research-synthesizer`、`gsd-codebase-mapper`、`gsd-ui-researcher` | | `execution` | `gsd-executor`、`gsd-debugger`、`gsd-doc-writer` | -| `verification` | `gsd-verifier`、`gsd-plan-checker`、`gsd-integration-checker`、`gsd-nyquist-auditor`、`gsd-ui-checker`、`gsd-ui-auditor`、`gsd-doc-verifier` | +| `verification` | `gsd-verifier`、`gsd-plan-checker`、`gsd-integration-checker`、`gsd-nyquist-auditor`、`gsd-ui-checker`、`gsd-ui-auditor`、`gsd-doc-verifier`、`gsd-code-reviewer` | | `completion` | (将来のサブエージェント用に予約) | **受け入れられる値:** `"opus"` / `"sonnet"` / `"haiku"` / `"inherit"` diff --git a/docs/ja-JP/how-to/configure-model-profiles.md b/docs/ja-JP/how-to/configure-model-profiles.md index 2cb243c82..bd59d3f3b 100644 --- a/docs/ja-JP/how-to/configure-model-profiles.md +++ b/docs/ja-JP/how-to/configure-model-profiles.md @@ -88,8 +88,9 @@ npx @opengsd/gsd-core@latest --codex --global # または --opencode、--kilo | `planning` | `gsd-planner`、`gsd-roadmapper`、`gsd-pattern-mapper` | | `research` | `gsd-phase-researcher`、`gsd-project-researcher`、`gsd-research-synthesizer`、`gsd-codebase-mapper`、`gsd-ui-researcher` | | `execution` | `gsd-executor`、`gsd-debugger`、`gsd-doc-writer` | -| `verification` | `gsd-verifier`、`gsd-plan-checker`、`gsd-integration-checker`、`gsd-nyquist-auditor`、`gsd-ui-checker`、`gsd-ui-auditor`、`gsd-doc-verifier` | -| `discuss`、`completion` | 予約済み — 現在はサブエージェントなし。スキーマの前方互換性のために受け入れられます | +| `verification` | `gsd-verifier`、`gsd-plan-checker`、`gsd-integration-checker`、`gsd-nyquist-auditor`、`gsd-ui-checker`、`gsd-ui-auditor`、`gsd-doc-verifier`、`gsd-code-reviewer` | +| `discuss` | `gsd-assumptions-analyzer` | +| `completion` | 予約済み — 現在はサブエージェントなし。スキーマの前方互換性のために受け入れられます | `models` ブロックはティアエイリアス(`opus`、`sonnet`、`haiku`、`inherit`)のみを受け入れます。特定のエージェントに完全修飾のモデル ID を指定するには `model_overrides` を使用してください。 diff --git a/docs/ko-KR/how-to/configure-model-profiles.md b/docs/ko-KR/how-to/configure-model-profiles.md index c87349c3f..e76d3758c 100644 --- a/docs/ko-KR/how-to/configure-model-profiles.md +++ b/docs/ko-KR/how-to/configure-model-profiles.md @@ -88,8 +88,9 @@ npx @opengsd/gsd-core@latest --codex --global # 또는 --opencode, --kilo 등 | `planning` | `gsd-planner`, `gsd-roadmapper`, `gsd-pattern-mapper` | | `research` | `gsd-phase-researcher`, `gsd-project-researcher`, `gsd-research-synthesizer`, `gsd-codebase-mapper`, `gsd-ui-researcher` | | `execution` | `gsd-executor`, `gsd-debugger`, `gsd-doc-writer` | -| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier` | -| `discuss`, `completion` | 예약됨 — 현재 서브에이전트 없음; 향후 호환성을 위해 스키마에서 허용 | +| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier`, `gsd-code-reviewer` | +| `discuss` | `gsd-assumptions-analyzer` | +| `completion` | 예약됨 — 현재 서브에이전트 없음; 향후 호환성을 위해 스키마에서 허용 | `models` 블록은 티어 별칭만 허용합니다(`opus`, `sonnet`, `haiku`, `inherit`). 완전히 정규화된 모델 ID는 에이전트별 `model_overrides`를 사용하세요. diff --git a/docs/pt-BR/CONFIGURATION.md b/docs/pt-BR/CONFIGURATION.md index 9510927f6..fda6c8b64 100644 --- a/docs/pt-BR/CONFIGURATION.md +++ b/docs/pt-BR/CONFIGURATION.md @@ -836,10 +836,10 @@ para que a alteração entre em vigor. Consulte a issue #2256. | Tipo de fase | Agentes | |---|---| | `planning` | `gsd-planner`, `gsd-roadmapper`, `gsd-pattern-mapper` | -| `discuss` | (reservado — sem subagente atualmente) | +| `discuss` | `gsd-assumptions-analyzer` | | `research` | `gsd-phase-researcher`, `gsd-project-researcher`, `gsd-research-synthesizer`, `gsd-codebase-mapper`, `gsd-ui-researcher` | | `execution` | `gsd-executor`, `gsd-debugger`, `gsd-doc-writer` | -| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier` | +| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier`, `gsd-code-reviewer` | | `completion` | (reservado — sem subagente atualmente) | `discuss` e `completion` são aceitos pelo esquema para compatibilidade futura; defini-los hoje é um no-op até que um subagente seja mapeado para eles. diff --git a/docs/pt-BR/how-to/configure-model-profiles.md b/docs/pt-BR/how-to/configure-model-profiles.md index 66465a740..4f04d6b43 100644 --- a/docs/pt-BR/how-to/configure-model-profiles.md +++ b/docs/pt-BR/how-to/configure-model-profiles.md @@ -88,8 +88,9 @@ Tipos de fase e seus agentes: | `planning` | `gsd-planner`, `gsd-roadmapper`, `gsd-pattern-mapper` | | `research` | `gsd-phase-researcher`, `gsd-project-researcher`, `gsd-research-synthesizer`, `gsd-codebase-mapper`, `gsd-ui-researcher` | | `execution` | `gsd-executor`, `gsd-debugger`, `gsd-doc-writer` | -| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier` | -| `discuss`, `completion` | Reservado — nenhum subagente hoje; aceito pelo esquema para compatibilidade futura | +| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier`, `gsd-code-reviewer` | +| `discuss` | `gsd-assumptions-analyzer` | +| `completion` | Reservado — nenhum subagente hoje; aceito pelo esquema para compatibilidade futura | O bloco `models` aceita apenas aliases de nível (`opus`, `sonnet`, `haiku`, `inherit`). Para um ID de modelo totalmente qualificado, use `model_overrides` por agente. diff --git a/docs/zh-CN/CONFIGURATION.md b/docs/zh-CN/CONFIGURATION.md index 2b49cf333..a43d6ba14 100644 --- a/docs/zh-CN/CONFIGURATION.md +++ b/docs/zh-CN/CONFIGURATION.md @@ -805,10 +805,10 @@ gsd-tools query config-set features.thinking_partner false | 阶段类型 | Agents | |---|---| | `planning` | `gsd-planner`, `gsd-roadmapper`, `gsd-pattern-mapper` | -| `discuss` | (保留——当前无 subagent) | +| `discuss` | `gsd-assumptions-analyzer` | | `research` | `gsd-phase-researcher`, `gsd-project-researcher`, `gsd-research-synthesizer`, `gsd-codebase-mapper`, `gsd-ui-researcher` | | `execution` | `gsd-executor`, `gsd-debugger`, `gsd-doc-writer` | -| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier` | +| `verification` | `gsd-verifier`, `gsd-plan-checker`, `gsd-integration-checker`, `gsd-nyquist-auditor`, `gsd-ui-checker`, `gsd-ui-auditor`, `gsd-doc-verifier`, `gsd-code-reviewer` | | `completion` | (保留——当前无 subagent) | `discuss` 和 `completion` 被 schema 接受以保持前向兼容性;今天设置它们是无操作,直到某个 subagent 映射到它们为止。 diff --git a/docs/zh-CN/FEATURES.md b/docs/zh-CN/FEATURES.md index 80953d3af..3d9e87e08 100644 --- a/docs/zh-CN/FEATURES.md +++ b/docs/zh-CN/FEATURES.md @@ -2692,10 +2692,10 @@ capture_thought({ | 槽位 | 分配的智能体 | |------|-----------------| | `planning` | `gsd-planner`、`gsd-roadmapper`、`gsd-pattern-mapper` | -| `discuss` | (为未来子智能体保留) | +| `discuss` | `gsd-assumptions-analyzer` | | `research` | `gsd-phase-researcher`、`gsd-project-researcher`、`gsd-research-synthesizer`、`gsd-codebase-mapper`、`gsd-ui-researcher` | | `execution` | `gsd-executor`、`gsd-debugger`、`gsd-doc-writer` | -| `verification` | `gsd-verifier`、`gsd-plan-checker`、`gsd-integration-checker`、`gsd-nyquist-auditor`、`gsd-ui-checker`、`gsd-ui-auditor`、`gsd-doc-verifier` | +| `verification` | `gsd-verifier`、`gsd-plan-checker`、`gsd-integration-checker`、`gsd-nyquist-auditor`、`gsd-ui-checker`、`gsd-ui-auditor`、`gsd-doc-verifier`、`gsd-code-reviewer` | | `completion` | (为未来子智能体保留) | **接受的值:** `"opus"` / `"sonnet"` / `"haiku"` / `"inherit"` diff --git a/docs/zh-CN/how-to/configure-model-profiles.md b/docs/zh-CN/how-to/configure-model-profiles.md index f3e790985..2bb89ae40 100644 --- a/docs/zh-CN/how-to/configure-model-profiles.md +++ b/docs/zh-CN/how-to/configure-model-profiles.md @@ -88,8 +88,9 @@ npx @opengsd/gsd-core@latest --codex --global # or --opencode, --kilo, etc. | `planning` | `gsd-planner`、`gsd-roadmapper`、`gsd-pattern-mapper` | | `research` | `gsd-phase-researcher`、`gsd-project-researcher`、`gsd-research-synthesizer`、`gsd-codebase-mapper`、`gsd-ui-researcher` | | `execution` | `gsd-executor`、`gsd-debugger`、`gsd-doc-writer` | -| `verification` | `gsd-verifier`、`gsd-plan-checker`、`gsd-integration-checker`、`gsd-nyquist-auditor`、`gsd-ui-checker`、`gsd-ui-auditor`、`gsd-doc-verifier` | -| `discuss`、`completion` | 保留——目前无子代理;已被模式接受以备向后兼容 | +| `verification` | `gsd-verifier`、`gsd-plan-checker`、`gsd-integration-checker`、`gsd-nyquist-auditor`、`gsd-ui-checker`、`gsd-ui-auditor`、`gsd-doc-verifier`、`gsd-code-reviewer` | +| `discuss` | `gsd-assumptions-analyzer` | +| `completion` | 保留——目前无子代理;已被模式接受以备向后兼容 | `models` 块仅接受层级别名(`opus`、`sonnet`、`haiku`、`inherit`)。如需使用完全限定的模型 ID,请改用按代理设置的 `model_overrides`。 diff --git a/gsd-core/references/model-profiles.md b/gsd-core/references/model-profiles.md index 52e3f7a8d..7cdefdbc8 100644 --- a/gsd-core/references/model-profiles.md +++ b/gsd-core/references/model-profiles.md @@ -45,10 +45,10 @@ Model profiles control which Claude model each GSD agent uses. This allows balan | Phase type | Agents | |---|---| | `planning` | gsd-planner, gsd-roadmapper, gsd-pattern-mapper | -| `discuss` | (reserved — no subagent today) | +| `discuss` | `gsd-assumptions-analyzer` | | `research` | gsd-phase-researcher, gsd-project-researcher, gsd-research-synthesizer, gsd-codebase-mapper, gsd-ui-researcher | | `execution` | gsd-executor, gsd-debugger, gsd-doc-writer | -| `verification` | gsd-verifier, gsd-plan-checker, gsd-integration-checker, gsd-nyquist-auditor, gsd-ui-checker, gsd-ui-auditor, gsd-doc-verifier | +| `verification` | gsd-verifier, gsd-plan-checker, gsd-integration-checker, gsd-nyquist-auditor, gsd-ui-checker, gsd-ui-auditor, gsd-doc-verifier, gsd-code-reviewer | | `completion` | (reserved — no subagent today) | ### Resolution precedence (highest to lowest) diff --git a/gsd-core/workflows/code-review-fix.md b/gsd-core/workflows/code-review-fix.md index 9a14680b6..833be2013 100644 --- a/gsd-core/workflows/code-review-fix.md +++ b/gsd-core/workflows/code-review-fix.md @@ -23,6 +23,10 @@ INIT=$(gsd_run query init.phase-op "${PHASE_ARG}") if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi AGENT_SKILLS_FIXER=$(gsd_run query agent-skills gsd-code-fixer) AGENT_SKILLS_REVIEWER=$(gsd_run query agent-skills gsd-code-reviewer) +# #2072: resolve the routed models so model_overrides / models. are honored +# (gsd-code-reviewer → "verification", gsd-code-fixer → "execution"); thread them below. +REVIEWER_MODEL=$(gsd_run query resolve-model gsd-code-reviewer --raw) +FIXER_MODEL=$(gsd_run query resolve-model gsd-code-fixer --raw) ``` Parse from init JSON: `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `padded_phase`, `commit_docs`. @@ -194,7 +198,7 @@ echo "Fix scope: ${FIX_SCOPE}" Use Agent() to spawn agent: ```text -Agent(subagent_type="gsd-code-fixer", prompt=" +Agent(subagent_type="gsd-code-fixer", model="{FIXER_MODEL}", prompt=" ${REVIEW_PATH} @@ -277,7 +281,7 @@ if [ "$AUTO_MODE" = "true" ]; then # Spawn gsd-code-reviewer agent to re-review (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze) # (This overwrites REVIEW_PATH with latest review state) - Agent(subagent_type="gsd-code-reviewer", prompt=" + Agent(subagent_type="gsd-code-reviewer", model="{REVIEWER_MODEL}", prompt=" depth: ${REVIEW_DEPTH} phase_dir: ${PHASE_DIR} @@ -311,7 +315,7 @@ ${AGENT_SKILLS_REVIEWER}") # Still has issues — spawn fixer again (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze) echo "Issues remain. Applying fixes for iteration ${ITERATION}..." - Agent(subagent_type="gsd-code-fixer", prompt=" + Agent(subagent_type="gsd-code-fixer", model="{FIXER_MODEL}", prompt=" ${REVIEW_PATH} diff --git a/gsd-core/workflows/code-review.md b/gsd-core/workflows/code-review.md index 0a257473f..a559cd74a 100644 --- a/gsd-core/workflows/code-review.md +++ b/gsd-core/workflows/code-review.md @@ -22,6 +22,9 @@ PHASE_ARG="${1}" INIT=$(gsd_run query init.phase-op "${PHASE_ARG}") if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi AGENT_SKILLS_REVIEWER=$(gsd_run query agent-skills gsd-code-reviewer) +# #2072: resolve the routed model so model_overrides / models.verification are honored +# (the resolver maps gsd-code-reviewer → phaseType "verification"); thread it below. +REVIEWER_MODEL=$(gsd_run query resolve-model gsd-code-reviewer --raw) ``` Parse from init JSON: `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `padded_phase`, `commit_docs`. @@ -482,7 +485,7 @@ Spawn the gsd-code-reviewer agent: Print: `◆ Spawning code reviewer... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)` ``` -Agent(subagent_type="gsd-code-reviewer", prompt=" +Agent(subagent_type="gsd-code-reviewer", model="{REVIEWER_MODEL}", prompt=" ${FILES_TO_READ} diff --git a/gsd-core/workflows/discuss-phase-assumptions.md b/gsd-core/workflows/discuss-phase-assumptions.md index 80c6666ae..6aa600c70 100644 --- a/gsd-core/workflows/discuss-phase-assumptions.md +++ b/gsd-core/workflows/discuss-phase-assumptions.md @@ -68,6 +68,9 @@ _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-pars INIT=$(gsd_run query init.phase-op "${PHASE}") if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi AGENT_SKILLS_ANALYZER=$(gsd_run query agent-skills gsd-assumptions-analyzer) +# #2072: resolve the routed model so model_overrides / models.discuss are honored +# (the resolver maps gsd-assumptions-analyzer → phaseType "discuss"); thread it below. +ANALYZER_MODEL=$(gsd_run query resolve-model gsd-assumptions-analyzer --raw) ``` Parse JSON for: `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, @@ -255,7 +258,7 @@ If no USER-PROFILE.md: calibration_tier = "standard" **Spawn Explore subagent** (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)**:** ``` -Agent(subagent_type="gsd-assumptions-analyzer", prompt=""" +Agent(subagent_type="gsd-assumptions-analyzer", model="{ANALYZER_MODEL}", prompt=""" Analyze the codebase for Phase {PHASE}: {phase_name}. Phase goal: {roadmap_description} diff --git a/gsd-core/workflows/quick.md b/gsd-core/workflows/quick.md index fc9f98756..b3ec7b140 100644 --- a/gsd-core/workflows/quick.md +++ b/gsd-core/workflows/quick.md @@ -134,7 +134,7 @@ AGENT_SKILLS_CHECKER=$(gsd_run query agent-skills gsd-plan-checker) AGENT_SKILLS_VERIFIER=$(gsd_run query agent-skills gsd-verifier) ``` -Parse JSON for: `planner_model`, `executor_model`, `checker_model`, `verifier_model`, `commit_docs`, `branch_name`, `quick_id`, `slug`, `date`, `timestamp`, `quick_dir`, `task_dir`, `roadmap_exists`, `planning_exists`. +Parse JSON for: `planner_model`, `executor_model`, `checker_model`, `verifier_model`, `reviewer_model`, `commit_docs`, `branch_name`, `quick_id`, `slug`, `date`, `timestamp`, `quick_dir`, `task_dir`, `roadmap_exists`, `planning_exists`. ```bash USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true") @@ -868,7 +868,7 @@ Agent( Output: ${QUICK_DIR}/${quick_id}-REVIEW.md Depth: quick", subagent_type="gsd-code-reviewer", - model="{executor_model}" + model="{reviewer_model}" ) ``` diff --git a/src/init.cts b/src/init.cts index 19d8e6f1c..5c3ac1724 100644 --- a/src/init.cts +++ b/src/init.cts @@ -758,6 +758,9 @@ function cmdInitQuick(cwd: string, description: string | undefined, raw: boolean executor_model: resolveModelInternal(cwd, 'gsd-executor'), checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), verifier_model: resolveModelInternal(cwd, 'gsd-verifier'), + // #2072: the quick review step spawns gsd-code-reviewer; resolve its own model + // so model_overrides / models.verification apply (was reusing executor_model). + reviewer_model: resolveModelInternal(cwd, 'gsd-code-reviewer'), commit_docs: config.commit_docs, branch_name: quickBranchName, diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index cb9ae5c8d..f685c1249 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -93,7 +93,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "de81380316d8a37f", - "gsd-core/references/model-profiles.md": "d6ed560db6357ad2", + "gsd-core/references/model-profiles.md": "6568ca29b6ee00d8", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "2459c40bfc8a0ea8", @@ -206,13 +206,13 @@ "gsd-core/workflows/autonomous.md": "603ce2019835f00e", "gsd-core/workflows/check-todos.md": "5a62092df800ad7c", "gsd-core/workflows/cleanup.md": "12c8fd85d3010fe6", - "gsd-core/workflows/code-review-fix.md": "5738cb929c995008", - "gsd-core/workflows/code-review.md": "d454365f1704d0bc", + "gsd-core/workflows/code-review-fix.md": "60640e633b0a124b", + "gsd-core/workflows/code-review.md": "5c40505c01871153", "gsd-core/workflows/complete-milestone.md": "aaf272074acec69d", "gsd-core/workflows/debug.md": "68f1ddc74886ebe5", "gsd-core/workflows/diagnose-issues.md": "c8c41993c277363c", "gsd-core/workflows/discovery-phase.md": "3de990caffdde4f8", - "gsd-core/workflows/discuss-phase-assumptions.md": "42210a0cbe1bac70", + "gsd-core/workflows/discuss-phase-assumptions.md": "8581fd77def7ce84", "gsd-core/workflows/discuss-phase-power.md": "5c0d1ca6abda0383", "gsd-core/workflows/discuss-phase.md": "c0a977154470b7f5", "gsd-core/workflows/discuss-phase/modes/advisor.md": "ab0c68941386998b", @@ -278,7 +278,7 @@ "gsd-core/workflows/pr-branch.md": "dc5598ae8accdecd", "gsd-core/workflows/profile-user.md": "355af92ac285567f", "gsd-core/workflows/progress.md": "79a11ce798082054", - "gsd-core/workflows/quick.md": "35582887917ef938", + "gsd-core/workflows/quick.md": "7108075f69e88e7c", "gsd-core/workflows/reapply-patches.md": "4dcd6117d0a507ca", "gsd-core/workflows/remove-phase.md": "23b9eb0858a2535e", "gsd-core/workflows/remove-workspace.md": "d0bd7e0601138798", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index d4f58026c..fad055290 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -164,7 +164,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "8484ad9799b7f680", + "gsd-core/references/model-profiles.md": "e067ad3df6770db1", "gsd-core/references/mvp-concepts.md": "72f7e8b1f8ae7118", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -277,13 +277,13 @@ "gsd-core/workflows/autonomous.md": "7e5683728ef33707", "gsd-core/workflows/check-todos.md": "bdeaf43f9c61e0cc", "gsd-core/workflows/cleanup.md": "0daa2f2720c111f1", - "gsd-core/workflows/code-review-fix.md": "c323f7098b671bd6", - "gsd-core/workflows/code-review.md": "ad450c6ef8459dad", + "gsd-core/workflows/code-review-fix.md": "2e113d1f4350a075", + "gsd-core/workflows/code-review.md": "334c90c401f291f8", "gsd-core/workflows/complete-milestone.md": "c1f91b77f4ace7f2", "gsd-core/workflows/debug.md": "849d7e5b9c12dcae", "gsd-core/workflows/diagnose-issues.md": "6cc3900891dfb927", "gsd-core/workflows/discovery-phase.md": "3ba7cfb89fb1e761", - "gsd-core/workflows/discuss-phase-assumptions.md": "1e229f6d21831d60", + "gsd-core/workflows/discuss-phase-assumptions.md": "f5b765d33eba4f88", "gsd-core/workflows/discuss-phase-power.md": "0841f7dc6e9a054a", "gsd-core/workflows/discuss-phase.md": "5c5e8d8c2c9d95aa", "gsd-core/workflows/discuss-phase/modes/advisor.md": "db488d74d080c653", @@ -349,7 +349,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "14263db831230142", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "5790ceb09aa685be", + "gsd-core/workflows/quick.md": "069fe37d083a94a3", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 9127cacfc..c88ceed97 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -92,7 +92,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "90e2c9bc577278fd", + "gsd-core/references/model-profiles.md": "c249163663bbea53", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -205,13 +205,13 @@ "gsd-core/workflows/autonomous.md": "722397c04272dfaa", "gsd-core/workflows/check-todos.md": "6c2a43d1d3e86589", "gsd-core/workflows/cleanup.md": "0daa2f2720c111f1", - "gsd-core/workflows/code-review-fix.md": "adb62c695b39ef61", - "gsd-core/workflows/code-review.md": "6c40b95e799907d0", + "gsd-core/workflows/code-review-fix.md": "722416b71b31c5fc", + "gsd-core/workflows/code-review.md": "506412604f767adc", "gsd-core/workflows/complete-milestone.md": "dcf1182398efb1ca", "gsd-core/workflows/debug.md": "a9397fd35cca2240", "gsd-core/workflows/diagnose-issues.md": "75ffc381ac3059ff", "gsd-core/workflows/discovery-phase.md": "6161c60d752d0058", - "gsd-core/workflows/discuss-phase-assumptions.md": "46876f83547db40a", + "gsd-core/workflows/discuss-phase-assumptions.md": "a8cd1db094fefd35", "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", "gsd-core/workflows/discuss-phase.md": "df56c8cd170b2150", "gsd-core/workflows/discuss-phase/modes/advisor.md": "db488d74d080c653", @@ -277,7 +277,7 @@ "gsd-core/workflows/pr-branch.md": "ab157cd8e49621dd", "gsd-core/workflows/profile-user.md": "ff3820a27731ceb8", "gsd-core/workflows/progress.md": "bd1ecf9207331bda", - "gsd-core/workflows/quick.md": "da83b1a15b7f1bf7", + "gsd-core/workflows/quick.md": "3363bdfefd403686", "gsd-core/workflows/reapply-patches.md": "ba9406b60f2c4041", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "f3ab3a88a7e9e1ed", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index bd83660d1..d98c9c048 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -96,7 +96,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "eb144b83e31196b8", + "gsd-core/references/model-profiles.md": "1794ad3d9854129e", "gsd-core/references/mvp-concepts.md": "72f7e8b1f8ae7118", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -209,13 +209,13 @@ "gsd-core/workflows/autonomous.md": "cc5217b1b2238a4c", "gsd-core/workflows/check-todos.md": "32fdf33f5dc8bdde", "gsd-core/workflows/cleanup.md": "b0ebfc48792b407b", - "gsd-core/workflows/code-review-fix.md": "b946fe7bcb303852", - "gsd-core/workflows/code-review.md": "0b24efcfa71a2ed2", + "gsd-core/workflows/code-review-fix.md": "e4549af672e74e6f", + "gsd-core/workflows/code-review.md": "a65e3e869508f89e", "gsd-core/workflows/complete-milestone.md": "c0808127038a8f86", "gsd-core/workflows/debug.md": "f42e8e3cbc298694", "gsd-core/workflows/diagnose-issues.md": "e616d0d730328d68", "gsd-core/workflows/discovery-phase.md": "3ba7cfb89fb1e761", - "gsd-core/workflows/discuss-phase-assumptions.md": "89987fd183e0d552", + "gsd-core/workflows/discuss-phase-assumptions.md": "9efcb2ef6a9245b3", "gsd-core/workflows/discuss-phase-power.md": "0841f7dc6e9a054a", "gsd-core/workflows/discuss-phase.md": "a290d5ee19607bb7", "gsd-core/workflows/discuss-phase/modes/advisor.md": "250de9aae90daebc", @@ -281,7 +281,7 @@ "gsd-core/workflows/pr-branch.md": "9923878a4f6a2d91", "gsd-core/workflows/profile-user.md": "26f74db0a7fcd268", "gsd-core/workflows/progress.md": "9f326d63afb4b76b", - "gsd-core/workflows/quick.md": "68a9dfcd53f0859f", + "gsd-core/workflows/quick.md": "2afc046e94daad0a", "gsd-core/workflows/reapply-patches.md": "eb4272145a117904", "gsd-core/workflows/remove-phase.md": "e336350f8113a328", "gsd-core/workflows/remove-workspace.md": "e685dfbd736dfd90", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 71d7569c7..31296cf05 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -164,7 +164,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "8484ad9799b7f680", + "gsd-core/references/model-profiles.md": "e067ad3df6770db1", "gsd-core/references/mvp-concepts.md": "72f7e8b1f8ae7118", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -277,13 +277,13 @@ "gsd-core/workflows/autonomous.md": "7e5683728ef33707", "gsd-core/workflows/check-todos.md": "bdeaf43f9c61e0cc", "gsd-core/workflows/cleanup.md": "0daa2f2720c111f1", - "gsd-core/workflows/code-review-fix.md": "c323f7098b671bd6", - "gsd-core/workflows/code-review.md": "ad450c6ef8459dad", + "gsd-core/workflows/code-review-fix.md": "2e113d1f4350a075", + "gsd-core/workflows/code-review.md": "334c90c401f291f8", "gsd-core/workflows/complete-milestone.md": "c1f91b77f4ace7f2", "gsd-core/workflows/debug.md": "849d7e5b9c12dcae", "gsd-core/workflows/diagnose-issues.md": "77d98ac07c4a26ff", "gsd-core/workflows/discovery-phase.md": "3ba7cfb89fb1e761", - "gsd-core/workflows/discuss-phase-assumptions.md": "1e229f6d21831d60", + "gsd-core/workflows/discuss-phase-assumptions.md": "f5b765d33eba4f88", "gsd-core/workflows/discuss-phase-power.md": "0841f7dc6e9a054a", "gsd-core/workflows/discuss-phase.md": "02a5381c9a2173be", "gsd-core/workflows/discuss-phase/modes/advisor.md": "db488d74d080c653", @@ -349,7 +349,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "4fa910d15dea5695", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "9033dbe58443af36", + "gsd-core/workflows/quick.md": "cc530299ba461539", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index ad2b46d1f..b80bb7cd9 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -128,7 +128,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "86e81f17c2f23fff", + "gsd-core/references/model-profiles.md": "bd90862f68007f2e", "gsd-core/references/mvp-concepts.md": "23201c8118fb074a", "gsd-core/references/phase-argument-parsing.md": "531176f66da49c98", "gsd-core/references/planner-antipatterns.md": "2459c40bfc8a0ea8", @@ -241,13 +241,13 @@ "gsd-core/workflows/autonomous.md": "92f08626d4aea668", "gsd-core/workflows/check-todos.md": "b2b103e8638e760a", "gsd-core/workflows/cleanup.md": "5d48d64664222a3e", - "gsd-core/workflows/code-review-fix.md": "3ff5f7eac2269ca3", - "gsd-core/workflows/code-review.md": "82858ab037b9176e", + "gsd-core/workflows/code-review-fix.md": "ae7f9c6b39a23c12", + "gsd-core/workflows/code-review.md": "eadada9e0a89adf2", "gsd-core/workflows/complete-milestone.md": "017df7443bd08da5", "gsd-core/workflows/debug.md": "cc7b2d2fd4307a78", "gsd-core/workflows/diagnose-issues.md": "e38bb21d06dff077", "gsd-core/workflows/discovery-phase.md": "71a4b78ff876a854", - "gsd-core/workflows/discuss-phase-assumptions.md": "f23abfe76623ed63", + "gsd-core/workflows/discuss-phase-assumptions.md": "0d936ec25299917c", "gsd-core/workflows/discuss-phase-power.md": "3f8b83dc6be2e9d5", "gsd-core/workflows/discuss-phase.md": "7c9df6656b81fda9", "gsd-core/workflows/discuss-phase/modes/advisor.md": "db488d74d080c653", @@ -313,7 +313,7 @@ "gsd-core/workflows/pr-branch.md": "d13e1cc81de40896", "gsd-core/workflows/profile-user.md": "05828c8cc61ef384", "gsd-core/workflows/progress.md": "7bedc431bb55edb8", - "gsd-core/workflows/quick.md": "3e7686705da2af19", + "gsd-core/workflows/quick.md": "62b9e138dc70b479", "gsd-core/workflows/reapply-patches.md": "26297b84736e66a4", "gsd-core/workflows/remove-phase.md": "9ee0fddd11a0d9d4", "gsd-core/workflows/remove-workspace.md": "19d7465aaa50cb62", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 0e194d4b8..f26647d5c 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -94,7 +94,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "1d392e37a746742a", - "gsd-core/references/model-profiles.md": "d6ed560db6357ad2", + "gsd-core/references/model-profiles.md": "6568ca29b6ee00d8", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "2459c40bfc8a0ea8", @@ -207,13 +207,13 @@ "gsd-core/workflows/autonomous.md": "dd972ddde9663295", "gsd-core/workflows/check-todos.md": "be9b50b5f28d7504", "gsd-core/workflows/cleanup.md": "8233b05ebf011bec", - "gsd-core/workflows/code-review-fix.md": "ab2b467b41522c10", - "gsd-core/workflows/code-review.md": "1bbbe61f45fccf4c", + "gsd-core/workflows/code-review-fix.md": "fda53892ae4b17fc", + "gsd-core/workflows/code-review.md": "f1c045ec4d33abc8", "gsd-core/workflows/complete-milestone.md": "470cf39261400ee2", "gsd-core/workflows/debug.md": "36cb536be452d79e", "gsd-core/workflows/diagnose-issues.md": "42acbe2a43fc886e", "gsd-core/workflows/discovery-phase.md": "8e99da61fb2b7074", - "gsd-core/workflows/discuss-phase-assumptions.md": "ab2d26b194805ac1", + "gsd-core/workflows/discuss-phase-assumptions.md": "ab0c432b84038681", "gsd-core/workflows/discuss-phase-power.md": "5c0d1ca6abda0383", "gsd-core/workflows/discuss-phase.md": "31a79ea11c1cdd5f", "gsd-core/workflows/discuss-phase/modes/advisor.md": "654cdaf1138d5e27", @@ -279,7 +279,7 @@ "gsd-core/workflows/pr-branch.md": "2833905f119b5722", "gsd-core/workflows/profile-user.md": "5cc032206c99ef71", "gsd-core/workflows/progress.md": "6b9a84a43dc55af5", - "gsd-core/workflows/quick.md": "7ddfe17f048541ec", + "gsd-core/workflows/quick.md": "eb68ea8748546331", "gsd-core/workflows/reapply-patches.md": "8fd59e24b486f180", "gsd-core/workflows/remove-phase.md": "e262654e319d1bc4", "gsd-core/workflows/remove-workspace.md": "ceddfeef5f2d6754", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index dcca3bbc3..0f089726c 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -164,7 +164,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "f4c013e700c52b08", + "gsd-core/references/model-profiles.md": "5452b2e19e19f77e", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e562947d1bf5c5a4", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -277,13 +277,13 @@ "gsd-core/workflows/autonomous.md": "4b20eda9a0b587ce", "gsd-core/workflows/check-todos.md": "1a67337d1630848f", "gsd-core/workflows/cleanup.md": "db47963f103c2748", - "gsd-core/workflows/code-review-fix.md": "49e7f024c551b3a5", - "gsd-core/workflows/code-review.md": "6a9fd2996a6b600e", + "gsd-core/workflows/code-review-fix.md": "c57af378033b1b58", + "gsd-core/workflows/code-review.md": "32c37bcbec8b8698", "gsd-core/workflows/complete-milestone.md": "1400a4856f592f3e", "gsd-core/workflows/debug.md": "a73d8018986131ba", "gsd-core/workflows/diagnose-issues.md": "cd582747131726e3", "gsd-core/workflows/discovery-phase.md": "7dcf150998559c11", - "gsd-core/workflows/discuss-phase-assumptions.md": "373788c6b7eab272", + "gsd-core/workflows/discuss-phase-assumptions.md": "c25c6a6c633d71b6", "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", "gsd-core/workflows/discuss-phase.md": "14688ff19ce2184c", "gsd-core/workflows/discuss-phase/modes/advisor.md": "30612a2de153cb5b", @@ -349,7 +349,7 @@ "gsd-core/workflows/pr-branch.md": "c67d90c65da47168", "gsd-core/workflows/profile-user.md": "8c943983241260b5", "gsd-core/workflows/progress.md": "65aabee5e8a6dd82", - "gsd-core/workflows/quick.md": "762256cf6d177c06", + "gsd-core/workflows/quick.md": "32a71f62041c8510", "gsd-core/workflows/reapply-patches.md": "ba9406b60f2c4041", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "433affcd1a200826", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index a76235b8d..d929d8b20 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -93,7 +93,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "8472d453a325cc1b", + "gsd-core/references/model-profiles.md": "6012c3b53473f04f", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -206,13 +206,13 @@ "gsd-core/workflows/autonomous.md": "dd572ca89862e5ec", "gsd-core/workflows/check-todos.md": "ea9a303c48a5d752", "gsd-core/workflows/cleanup.md": "6c488059fc152a49", - "gsd-core/workflows/code-review-fix.md": "04b143a963067236", - "gsd-core/workflows/code-review.md": "ff28724fc216972c", + "gsd-core/workflows/code-review-fix.md": "e829d3baf9901b54", + "gsd-core/workflows/code-review.md": "50a05ab8957bd05f", "gsd-core/workflows/complete-milestone.md": "f1866541148dc291", "gsd-core/workflows/debug.md": "15cf6999e85dcc8c", "gsd-core/workflows/diagnose-issues.md": "16f2d2a85335641f", "gsd-core/workflows/discovery-phase.md": "6161c60d752d0058", - "gsd-core/workflows/discuss-phase-assumptions.md": "db60ec68b59a40eb", + "gsd-core/workflows/discuss-phase-assumptions.md": "3a1e215890d2b3f4", "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", "gsd-core/workflows/discuss-phase.md": "0aa052ae4ee70bf6", "gsd-core/workflows/discuss-phase/modes/advisor.md": "536e2fa842f1bc95", @@ -278,7 +278,7 @@ "gsd-core/workflows/pr-branch.md": "ecabd55e4eabf229", "gsd-core/workflows/profile-user.md": "de5030437226cf2c", "gsd-core/workflows/progress.md": "f18db000584d9cb1", - "gsd-core/workflows/quick.md": "5044ce4e7512e174", + "gsd-core/workflows/quick.md": "e7a395b9e7786b56", "gsd-core/workflows/reapply-patches.md": "158083a310859594", "gsd-core/workflows/remove-phase.md": "fce799aae3ab2715", "gsd-core/workflows/remove-workspace.md": "8facde381657dd71", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 62d6de451..f9fab28cb 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -164,7 +164,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "d6ed560db6357ad2", + "gsd-core/references/model-profiles.md": "6568ca29b6ee00d8", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "2459c40bfc8a0ea8", @@ -277,13 +277,13 @@ "gsd-core/workflows/autonomous.md": "73f429f7472c9949", "gsd-core/workflows/check-todos.md": "ed4b4eb12be222d7", "gsd-core/workflows/cleanup.md": "c5f1bf186395d67d", - "gsd-core/workflows/code-review-fix.md": "adb62c695b39ef61", - "gsd-core/workflows/code-review.md": "6c40b95e799907d0", + "gsd-core/workflows/code-review-fix.md": "722416b71b31c5fc", + "gsd-core/workflows/code-review.md": "506412604f767adc", "gsd-core/workflows/complete-milestone.md": "59753bf44d4300da", "gsd-core/workflows/debug.md": "a72d830ac3df74aa", "gsd-core/workflows/diagnose-issues.md": "210b5b313e8a559a", "gsd-core/workflows/discovery-phase.md": "ca7b2be46e59e862", - "gsd-core/workflows/discuss-phase-assumptions.md": "36f7edcb666745f2", + "gsd-core/workflows/discuss-phase-assumptions.md": "3ef1df313e715387", "gsd-core/workflows/discuss-phase-power.md": "5c0d1ca6abda0383", "gsd-core/workflows/discuss-phase.md": "bd4b26ba168bb51f", "gsd-core/workflows/discuss-phase/modes/advisor.md": "f8ae26ba4e07672b", @@ -349,7 +349,7 @@ "gsd-core/workflows/pr-branch.md": "ab157cd8e49621dd", "gsd-core/workflows/profile-user.md": "203ebe3f8f3876a8", "gsd-core/workflows/progress.md": "9381c59676ccb937", - "gsd-core/workflows/quick.md": "fd7461e5a92fe450", + "gsd-core/workflows/quick.md": "e71c92cf0463a461", "gsd-core/workflows/reapply-patches.md": "becf9728cdb124c4", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "fc83f362a2d0a1b7", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 795b3c198..bdc538a2e 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -129,7 +129,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "8484ad9799b7f680", + "gsd-core/references/model-profiles.md": "e067ad3df6770db1", "gsd-core/references/mvp-concepts.md": "72f7e8b1f8ae7118", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -242,13 +242,13 @@ "gsd-core/workflows/autonomous.md": "7e5683728ef33707", "gsd-core/workflows/check-todos.md": "bdeaf43f9c61e0cc", "gsd-core/workflows/cleanup.md": "0daa2f2720c111f1", - "gsd-core/workflows/code-review-fix.md": "c323f7098b671bd6", - "gsd-core/workflows/code-review.md": "ad450c6ef8459dad", + "gsd-core/workflows/code-review-fix.md": "2e113d1f4350a075", + "gsd-core/workflows/code-review.md": "334c90c401f291f8", "gsd-core/workflows/complete-milestone.md": "c1f91b77f4ace7f2", "gsd-core/workflows/debug.md": "849d7e5b9c12dcae", "gsd-core/workflows/diagnose-issues.md": "67c058fc7ae6026b", "gsd-core/workflows/discovery-phase.md": "3ba7cfb89fb1e761", - "gsd-core/workflows/discuss-phase-assumptions.md": "1e229f6d21831d60", + "gsd-core/workflows/discuss-phase-assumptions.md": "f5b765d33eba4f88", "gsd-core/workflows/discuss-phase-power.md": "0841f7dc6e9a054a", "gsd-core/workflows/discuss-phase.md": "0c0465ba668adb33", "gsd-core/workflows/discuss-phase/modes/advisor.md": "db488d74d080c653", @@ -314,7 +314,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "5abfae83739fa978", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "e265e01cfe117215", + "gsd-core/workflows/quick.md": "09d88dd1c0b3e03a", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 1da46755a..7fdd79045 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -164,7 +164,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "858c16730df68ac2", - "gsd-core/references/model-profiles.md": "d6ed560db6357ad2", + "gsd-core/references/model-profiles.md": "6568ca29b6ee00d8", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "2459c40bfc8a0ea8", @@ -277,13 +277,13 @@ "gsd-core/workflows/autonomous.md": "6cb0c014f5f56965", "gsd-core/workflows/check-todos.md": "6526b64ee88c7d2e", "gsd-core/workflows/cleanup.md": "94d771d26f62dc86", - "gsd-core/workflows/code-review-fix.md": "5d97b960da4fef73", - "gsd-core/workflows/code-review.md": "aec8ef4de1829607", + "gsd-core/workflows/code-review-fix.md": "adb9388bb157610c", + "gsd-core/workflows/code-review.md": "ae6bcbd1575aeec4", "gsd-core/workflows/complete-milestone.md": "614299b2c08e66c3", "gsd-core/workflows/debug.md": "9d4a8afc8d36be93", "gsd-core/workflows/diagnose-issues.md": "2971c699d52f1b85", "gsd-core/workflows/discovery-phase.md": "724408336596c50c", - "gsd-core/workflows/discuss-phase-assumptions.md": "310b08dc31710cbc", + "gsd-core/workflows/discuss-phase-assumptions.md": "92e43cd12200c610", "gsd-core/workflows/discuss-phase-power.md": "5c0d1ca6abda0383", "gsd-core/workflows/discuss-phase.md": "3a62a8d4c374c69f", "gsd-core/workflows/discuss-phase/modes/advisor.md": "83ca3ea4fac785af", @@ -349,7 +349,7 @@ "gsd-core/workflows/pr-branch.md": "929b7cb0c99c7b9e", "gsd-core/workflows/profile-user.md": "248d59a31948e0ed", "gsd-core/workflows/progress.md": "8fc3404087f50b95", - "gsd-core/workflows/quick.md": "10cc8ddd4bc3f0ee", + "gsd-core/workflows/quick.md": "9bdccb7c6a5530c9", "gsd-core/workflows/reapply-patches.md": "a0e9b53f90abceb2", "gsd-core/workflows/remove-phase.md": "dea4661e8f89596f", "gsd-core/workflows/remove-workspace.md": "446847e71aa52504", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index bf6e3e38e..3e6f677fa 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -93,7 +93,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "cc1efc2942164744", + "gsd-core/references/model-profiles.md": "0b7e06ed2e4abac8", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -206,13 +206,13 @@ "gsd-core/workflows/autonomous.md": "3014ff90115f0daf", "gsd-core/workflows/check-todos.md": "ec8c22920f6b2df1", "gsd-core/workflows/cleanup.md": "6a18b165752e3087", - "gsd-core/workflows/code-review-fix.md": "e14afce87cf0d420", - "gsd-core/workflows/code-review.md": "d6d385b6dadae5a0", + "gsd-core/workflows/code-review-fix.md": "f3725ae9d685bed2", + "gsd-core/workflows/code-review.md": "29125604bed2c467", "gsd-core/workflows/complete-milestone.md": "40085d32b15805c8", "gsd-core/workflows/debug.md": "c23d067580b09f63", "gsd-core/workflows/diagnose-issues.md": "652ae26975f82242", "gsd-core/workflows/discovery-phase.md": "6161c60d752d0058", - "gsd-core/workflows/discuss-phase-assumptions.md": "3865afc23e9cd46a", + "gsd-core/workflows/discuss-phase-assumptions.md": "18712f78bb960ec8", "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", "gsd-core/workflows/discuss-phase.md": "8644b72386a51241", "gsd-core/workflows/discuss-phase/modes/advisor.md": "3102430dfad9c012", @@ -278,7 +278,7 @@ "gsd-core/workflows/pr-branch.md": "cef0f65b16d500b4", "gsd-core/workflows/profile-user.md": "263c0693563d98da", "gsd-core/workflows/progress.md": "3b1b2142a74af85c", - "gsd-core/workflows/quick.md": "0a9a7dacc73f8e53", + "gsd-core/workflows/quick.md": "bb9a4a145a7edc36", "gsd-core/workflows/reapply-patches.md": "de0ee8acfe7245b2", "gsd-core/workflows/remove-phase.md": "e8ae4fbbfac700f0", "gsd-core/workflows/remove-workspace.md": "4ac64de862dc650e", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 5aecb31d3..f166276e6 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -93,7 +93,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "41b0af6f5f77af81", + "gsd-core/references/model-profiles.md": "b4527b0f255d193f", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e562947d1bf5c5a4", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -206,13 +206,13 @@ "gsd-core/workflows/autonomous.md": "c482645c5d1ead46", "gsd-core/workflows/check-todos.md": "0dda8236355e8c9c", "gsd-core/workflows/cleanup.md": "82f65f5214ecd748", - "gsd-core/workflows/code-review-fix.md": "959a65773b325ed2", - "gsd-core/workflows/code-review.md": "b31bbd7dbe5ed303", + "gsd-core/workflows/code-review-fix.md": "f2761f7f8c4a5674", + "gsd-core/workflows/code-review.md": "47663a2922756c5e", "gsd-core/workflows/complete-milestone.md": "6e918b72bd885426", "gsd-core/workflows/debug.md": "197d3642a6704de5", "gsd-core/workflows/diagnose-issues.md": "9274b11a3db98c65", "gsd-core/workflows/discovery-phase.md": "b32b6197b66c9a13", - "gsd-core/workflows/discuss-phase-assumptions.md": "3d05b08eae75c7bd", + "gsd-core/workflows/discuss-phase-assumptions.md": "e376b1cf29379df4", "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", "gsd-core/workflows/discuss-phase.md": "4509657816c5566a", "gsd-core/workflows/discuss-phase/modes/advisor.md": "c9cd7db62e76ebcb", @@ -278,7 +278,7 @@ "gsd-core/workflows/pr-branch.md": "79fd55b88ea2db9c", "gsd-core/workflows/profile-user.md": "672821e6b1266645", "gsd-core/workflows/progress.md": "94768f835b0b8908", - "gsd-core/workflows/quick.md": "9bbf907e39688638", + "gsd-core/workflows/quick.md": "a568015cccb53615", "gsd-core/workflows/reapply-patches.md": "21b38c374f19fd78", "gsd-core/workflows/remove-phase.md": "a46c2fe853bf4e86", "gsd-core/workflows/remove-workspace.md": "ae0e1c6d4438d663", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 71f434e76..1c471783e 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -93,7 +93,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "b3ac5dc094bce1dc", + "gsd-core/references/model-profiles.md": "09aa53e3f1764a41", "gsd-core/references/mvp-concepts.md": "3464783eaaef5c10", "gsd-core/references/phase-argument-parsing.md": "e562947d1bf5c5a4", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -206,13 +206,13 @@ "gsd-core/workflows/autonomous.md": "fe7bb7c978f305a2", "gsd-core/workflows/check-todos.md": "afc840fceb07bf99", "gsd-core/workflows/cleanup.md": "93c14107979a4428", - "gsd-core/workflows/code-review-fix.md": "9b2de93a6087774d", - "gsd-core/workflows/code-review.md": "2f0ebfb7a2e33764", + "gsd-core/workflows/code-review-fix.md": "b99b1f20bb27c291", + "gsd-core/workflows/code-review.md": "faa87faf07ae765a", "gsd-core/workflows/complete-milestone.md": "f463bf4e86ac26f6", "gsd-core/workflows/debug.md": "52243eb936a43150", "gsd-core/workflows/diagnose-issues.md": "447072aa72385271", "gsd-core/workflows/discovery-phase.md": "7dcf150998559c11", - "gsd-core/workflows/discuss-phase-assumptions.md": "f57000d5c5fb178a", + "gsd-core/workflows/discuss-phase-assumptions.md": "b091b3d3e580dd29", "gsd-core/workflows/discuss-phase-power.md": "290c0d83d783f9f6", "gsd-core/workflows/discuss-phase.md": "ce4e6abce9cb2d4e", "gsd-core/workflows/discuss-phase/modes/advisor.md": "cb49f485c4a1f09e", @@ -278,7 +278,7 @@ "gsd-core/workflows/pr-branch.md": "acd59f915d018ad4", "gsd-core/workflows/profile-user.md": "c4313672b81b5bcd", "gsd-core/workflows/progress.md": "18813a345bd2343a", - "gsd-core/workflows/quick.md": "3acd391cc0c3813a", + "gsd-core/workflows/quick.md": "b7813e1810c683e1", "gsd-core/workflows/reapply-patches.md": "d449a23d3acf6379", "gsd-core/workflows/remove-phase.md": "e7a6af429b36e77b", "gsd-core/workflows/remove-workspace.md": "b5e60fbb33b3e33a", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 7d18f6001..ec6c8dfc8 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -164,7 +164,7 @@ "gsd-core/references/loop-hook-dispatch.md": "32e5dfb4dba76987", "gsd-core/references/mandatory-initial-read.md": "fe59abce693717cf", "gsd-core/references/model-profile-resolution.md": "f32bb05102839767", - "gsd-core/references/model-profiles.md": "8484ad9799b7f680", + "gsd-core/references/model-profiles.md": "e067ad3df6770db1", "gsd-core/references/mvp-concepts.md": "72f7e8b1f8ae7118", "gsd-core/references/phase-argument-parsing.md": "e5bbb985f3bc3e34", "gsd-core/references/planner-antipatterns.md": "7ed54ec1e2cc54ac", @@ -277,13 +277,13 @@ "gsd-core/workflows/autonomous.md": "7e5683728ef33707", "gsd-core/workflows/check-todos.md": "bdeaf43f9c61e0cc", "gsd-core/workflows/cleanup.md": "0daa2f2720c111f1", - "gsd-core/workflows/code-review-fix.md": "c323f7098b671bd6", - "gsd-core/workflows/code-review.md": "ad450c6ef8459dad", + "gsd-core/workflows/code-review-fix.md": "2e113d1f4350a075", + "gsd-core/workflows/code-review.md": "334c90c401f291f8", "gsd-core/workflows/complete-milestone.md": "c1f91b77f4ace7f2", "gsd-core/workflows/debug.md": "849d7e5b9c12dcae", "gsd-core/workflows/diagnose-issues.md": "aa8d787db8f3c46c", "gsd-core/workflows/discovery-phase.md": "3ba7cfb89fb1e761", - "gsd-core/workflows/discuss-phase-assumptions.md": "1e229f6d21831d60", + "gsd-core/workflows/discuss-phase-assumptions.md": "f5b765d33eba4f88", "gsd-core/workflows/discuss-phase-power.md": "0841f7dc6e9a054a", "gsd-core/workflows/discuss-phase.md": "d0a67b6f77b4d339", "gsd-core/workflows/discuss-phase/modes/advisor.md": "db488d74d080c653", @@ -349,7 +349,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "e23bea0a69c0bb4b", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "5747c2d8a37affbb", + "gsd-core/workflows/quick.md": "12917fdc9a624a0c", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/model-resolver.test.cjs b/tests/model-resolver.test.cjs index 3e2d06ddf..b902d5149 100644 --- a/tests/model-resolver.test.cjs +++ b/tests/model-resolver.test.cjs @@ -120,6 +120,36 @@ describe('resolveModelInternal', () => { assert.ok(typeof model === 'string' && model.length > 0); }); + // #2072 acceptance: these two catalog agents' config MUST resolve — the bug was + // that the workflows never threaded the resolved value, not that the resolver + // ignored it. These assert the value the (now-threaded) spawns receive. + test('#2072: model_overrides applies to gsd-code-reviewer', () => { + writeConfig(tmpDir, { model_overrides: { 'gsd-code-reviewer': 'my-custom-model' } }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-code-reviewer'), 'my-custom-model'); + }); + + test('#2072: model_overrides applies to gsd-assumptions-analyzer', () => { + writeConfig(tmpDir, { model_overrides: { 'gsd-assumptions-analyzer': 'my-custom-model' } }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-assumptions-analyzer'), 'my-custom-model'); + }); + + test('#2072: models.verification tier applies to gsd-code-reviewer', () => { + writeConfig(tmpDir, { models: { verification: 'opus' } }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-code-reviewer'), 'opus'); + }); + + test('#2072: models.discuss tier applies to gsd-assumptions-analyzer', () => { + writeConfig(tmpDir, { models: { discuss: 'opus' } }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-assumptions-analyzer'), 'opus'); + }); + + test('#2072: model_overrides + models.execution apply to gsd-code-fixer', () => { + writeConfig(tmpDir, { model_overrides: { 'gsd-code-fixer': 'my-custom-model' } }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-code-fixer'), 'my-custom-model'); + writeConfig(tmpDir, { models: { execution: 'opus' } }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-code-fixer'), 'opus'); + }); + test('runtime non-claude + model_profile_overrides for runtime tier', () => { writeConfig(tmpDir, { runtime: 'codex', diff --git a/tests/model-routing-spawn-threading.test.cjs b/tests/model-routing-spawn-threading.test.cjs new file mode 100644 index 000000000..bfa6a2ebc --- /dev/null +++ b/tests/model-routing-spawn-threading.test.cjs @@ -0,0 +1,150 @@ +// allow-test-rule: source-text-is-the-product #2072 +// Workflow .md files ARE the deployed orchestration contract the runtime executes; +// asserting that a spawn threads a resolved model= is asserting the deployed contract. + +'use strict'; + +/** + * #2072 — model_overrides / models. were silently inert for + * gsd-assumptions-analyzer and gsd-code-reviewer on Claude: the resolver honored + * them, but the workflows spawned the agents with NO model= param, so the resolved + * value never reached the Agent tool and the agents inherited the session model. + * + * Fix contract: every spawn of these two catalog agents must thread a resolved + * model=, and the workflow must obtain that model (inline `resolve-model` for the + * single-agent workflows, or the `reviewer_model` field of the init.quick bundle). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WF = path.join(__dirname, '..', 'gsd-core', 'workflows'); +const read = (rel) => fs.readFileSync(path.join(WF, rel), 'utf-8'); + +// Every .md under gsd-core/workflows (incl. nested steps/ and modes/). +function allWorkflowMd() { + const out = []; + (function walk(dir, rel) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) walk(path.join(dir, e.name), path.join(rel, e.name)); + else if (e.name.endsWith('.md')) out.push(path.join(rel, e.name)); + } + })(WF, ''); + return out; +} + +// Return the full text of every `Agent( … )` call in `content`, tracking string +// state (triple- and single-double-quoted) and paren depth so a prompt body's own +// parens/quotes don't end the call early. Order-independent: a call's params are +// captured whether subagent_type= appears before or after the prompt. +function agentCalls(content) { + const calls = []; + const re = /Agent\(/g; + let m; + while ((m = re.exec(content)) !== null) { + let i = m.index + m[0].length; + let depth = 1; + let tq = false; // inside """ … """ + let sq = false; // inside " … " + while (i < content.length && depth > 0) { + if (tq) { + if (content.startsWith('"""', i)) { tq = false; i += 3; continue; } + i++; continue; + } + if (sq) { + if (content[i] === '\\') { i += 2; continue; } + if (content[i] === '"') { sq = false; } + i++; continue; + } + if (content.startsWith('"""', i)) { tq = true; i += 3; continue; } + if (content[i] === '"') { sq = true; i++; continue; } + if (content[i] === '(') { depth++; } + else if (content[i] === ')') { depth--; } + i++; + } + calls.push(content.slice(m.index, i)); + re.lastIndex = i; // don't re-scan inside this call + } + return calls; +} + +describe('#2072: routed-agent spawns thread the resolved model', () => { + test('discuss-phase-assumptions.md resolves + threads gsd-assumptions-analyzer model', () => { + const c = read('discuss-phase-assumptions.md'); + assert.match(c, /resolve-model gsd-assumptions-analyzer/, 'must resolve the routed model'); + assert.match( + c, + /subagent_type="gsd-assumptions-analyzer",\s*model="\{ANALYZER_MODEL\}"/, + 'spawn must thread the resolved model=', + ); + }); + + test('code-review.md resolves + threads gsd-code-reviewer model', () => { + const c = read('code-review.md'); + assert.match(c, /resolve-model gsd-code-reviewer/); + assert.match(c, /subagent_type="gsd-code-reviewer",\s*model="\{REVIEWER_MODEL\}"/); + }); + + test('code-review-fix.md re-review resolves + threads gsd-code-reviewer model', () => { + const c = read('code-review-fix.md'); + assert.match(c, /resolve-model gsd-code-reviewer/); + assert.match(c, /subagent_type="gsd-code-reviewer",\s*model="\{REVIEWER_MODEL\}"/); + }); + + test('quick.md review step threads gsd-code-reviewer own model (not executor_model)', () => { + const c = read('quick.md'); + // reviewer_model comes from the init.quick bundle; the spawn must use it. + assert.match(c, /subagent_type="gsd-code-reviewer",\s*\n\s*model="\{reviewer_model\}"/); + assert.doesNotMatch( + c, + /subagent_type="gsd-code-reviewer",\s*\n\s*model="\{executor_model\}"/, + 'reviewer must not reuse the executor model (own model_overrides would be ignored)', + ); + }); + + test('code-review-fix.md threads gsd-code-fixer model at both fixer spawns', () => { + const c = read('code-review-fix.md'); + assert.match(c, /resolve-model gsd-code-fixer/, 'must resolve the fixer model'); + const fixerSpawns = c.match(/subagent_type="gsd-code-fixer", model="\{FIXER_MODEL\}"/g) || []; + assert.strictEqual(fixerSpawns.length, 2, 'both gsd-code-fixer spawns must thread FIXER_MODEL'); + }); + + // Parity guard: EVERY spawn of the fixed routed agents across ALL workflows must + // carry a model= in its Agent(...) call, so a new silently-inert spawn cannot + // regress. Uses a quote/paren-aware scan of the WHOLE Agent(...) call, so it holds + // for single-line and multi-line calls, multiple spawns per file, and either param + // ordering (subagent_type= before OR after the prompt body). + test('no spawn of the fixed routed agents is missing a model= (parity guard)', () => { + const ROUTED = /subagent_type="(gsd-code-reviewer|gsd-assumptions-analyzer|gsd-code-fixer)"/; + const offenders = []; + for (const rel of allWorkflowMd()) { + for (const call of agentCalls(read(rel))) { + const routed = call.match(ROUTED); + if (routed && !/\bmodel\s*=/.test(call)) { + offenders.push(`${rel}: ${routed[1]} spawn missing model=`); + } + } + } + assert.deepEqual(offenders, [], `routed-agent spawn(s) missing model=:\n${offenders.join('\n')}`); + }); + + // The scanner itself must catch a prompt-first, un-threaded spawn (the exact blind + // spot a naive "params before prompt=" heuristic misses) — otherwise the guard + // above could pass vacuously. + test('parity guard detects a prompt-first spawn that omits model=', () => { + const synthetic = [ + 'Agent(', + ' prompt="""do the thing (with parens) and a " quote""",', + ' subagent_type="gsd-code-reviewer"', + ')', + ].join('\n'); + const [call] = agentCalls(synthetic); + assert.ok(/subagent_type="gsd-code-reviewer"/.test(call), 'scanner must capture the prompt-first subagent_type'); + assert.ok(!/\bmodel\s*=/.test(call), 'and correctly see that model= is absent'); + // A well-formed prompt-first spawn WITH model= must be accepted. + const ok = agentCalls(synthetic.replace(')', ' model="{reviewer_model}"\n)'))[0]; + assert.ok(/\bmodel\s*=/.test(ok)); + }); +}); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 945f3c59f..ac29869fe 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -11,13 +11,13 @@ "autonomous.md": 42474, "check-todos.md": 9475, "cleanup.md": 9941, - "code-review-fix.md": 23934, - "code-review.md": 31646, + "code-review-fix.md": 24320, + "code-review.md": 31916, "complete-milestone.md": 31071, "debug.md": 13549, "diagnose-issues.md": 12864, "discovery-phase.md": 8651, - "discuss-phase-assumptions.md": 27028, + "discuss-phase-assumptions.md": 27302, "discuss-phase-power.md": 11273, "discuss-phase.md": 31986, "do.md": 10353, @@ -59,7 +59,7 @@ "pr-branch.md": 15963, "profile-user.md": 21246, "progress.md": 30599, - "quick.md": 50452, + "quick.md": 50470, "reapply-patches.md": 20312, "remove-phase.md": 8513, "remove-workspace.md": 7551, From 015c3a7fdafd459e0b0b2938a01af690c16abd44 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 7 Jul 2026 22:09:37 -0400 Subject: [PATCH 3/3] fix(#2071): extract install-time effort resolvers so `effort sync` stops requiring the un-shipped bin/install.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gsd-tools effort sync` crashed in every installed runtime (e.g. ~/.claude/gsd-core/) with `Cannot find module '../../../bin/install.js'`: cmdEffortSync (src/commands.cts) required the package-root bin/install.js for its install-time effort resolvers, but the installer only copies the gsd-core/ subtree into a runtime home — bin/install.js is never present there. So `effort` config changes silently never reached installed agents without a full reinstall (exactly the gap #488 was meant to close). 4th instance of the recurring "runtime code under gsd-core/ requires a file outside the shipped subtree via ../../../" anti-pattern (#1223/#1920/#1383 were the prior three, all already mitigated). Fix (ADR-457 direction — extract, single source): move readGsdEffectiveEffortConfig + resolveInstallTimeEffort (with their _getGsdEffortCatalog + _readGsdConfigFile helpers) out of the hand-authored bin/install.js into a new src/install-effort-resolver.cts that compiles into the shipped gsd-core/bin/lib/install-effort-resolver.cjs. commands.cts now requires it as a sibling (`./install-effort-resolver.cjs`) — always present in the installed tree — instead of `../../../bin/install.js`. bin/install.js imports the same four symbols back from the new module (it still calls them + re-exports them), so there is one source of truth and no duplication/drift. The lazy manifest read is repointed from the package-root layout (`.., gsd-core, bin, shared`) to the bin/lib layout (`.., shared`). Scope note: this is one of four instances of the anti-pattern; the other three are already shipped/guarded. A build-time guard rejecting new cross-boundary requires whose target isn't in the installer copy manifest (to prevent instance #5) is recommended on the issue but kept out of this fix. Tests: tests/effort-sync-installed-runtime.test.cjs does a real minimal install into a temp home (the golden-parity helper) and runs the issue's exact repro (`gsd-tools effort sync --config-dir `), asserting no MODULE_NOT_FOUND for bin/install.js. Fail-first verified: against pristine next the same test throws `Cannot find module '../../../bin/install.js'` at cmdEffortSync; post-fix it syncs cleanly. New module registered in .gitignore (ADR-457), eslint ignores, docs/INVENTORY.md + INVENTORY-MANIFEST.json. bin/install.js is not shipped and the new module is under bin/lib (excluded from golden parity), so no golden fixtures change. Co-Authored-By: Claude Opus 4.8 --- .../2071-effort-sync-installed-runtime.md | 5 + .gitignore | 1 + bin/install.js | 207 +-------------- docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 1 + eslint.config.mjs | 1 + src/commands.cts | 6 +- src/install-effort-resolver.cts | 242 ++++++++++++++++++ tests/effort-sync-installed-runtime.test.cjs | 72 ++++++ 9 files changed, 339 insertions(+), 197 deletions(-) create mode 100644 .changeset/2071-effort-sync-installed-runtime.md create mode 100644 src/install-effort-resolver.cts create mode 100644 tests/effort-sync-installed-runtime.test.cjs diff --git a/.changeset/2071-effort-sync-installed-runtime.md b/.changeset/2071-effort-sync-installed-runtime.md new file mode 100644 index 000000000..2c9a0b663 --- /dev/null +++ b/.changeset/2071-effort-sync-installed-runtime.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2076 +--- +**`gsd-tools effort sync` no longer crashes in an installed runtime.** In any global install (e.g. `~/.claude/gsd-core/`), `effort sync` threw `Cannot find module '../../../bin/install.js'` — the command reached into the package-root `bin/install.js` for its install-time effort resolvers, but the installer only copies the `gsd-core/` subtree into a runtime home, so that file is never present there. As a result, `effort` config changes (`routing_tier_defaults` / `agent_overrides`) silently never reached installed agents without a full reinstall. The two resolvers (`readGsdEffectiveEffortConfig` + `resolveInstallTimeEffort`, with their helpers) are now extracted into a shipped `gsd-core/bin/lib/install-effort-resolver.cjs` that both `effort sync` and the installer import — a single source of truth that is always present in the installed tree. (#2076) diff --git a/.gitignore b/.gitignore index 7e954bbf4..a76a62d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ build/ /gsd-core/bin/lib/host-integration.cjs /gsd-core/bin/lib/host-integration-sdk.cjs /gsd-core/bin/lib/handshake-serialized.cjs +/gsd-core/bin/lib/install-effort-resolver.cjs /gsd-core/bin/lib/install-engine.cjs /gsd-core/bin/lib/embedding-adapter.cjs /gsd-core/bin/lib/adapter-declarative.cjs diff --git a/bin/install.js b/bin/install.js index bd148d748..6cf14cea7 100755 --- a/bin/install.js +++ b/bin/install.js @@ -273,60 +273,20 @@ const { } = require(path.join(_gsdLibDir, 'model-catalog.cjs')); const { resolveTierEntry: gsdResolveTierEntry, - EFFORT_SET: GSD_EFFORT_SET, } = require(path.join(_gsdLibDir, 'model-resolver.cjs')); -// #443 — model-catalog and config-defaults.manifest.json exports needed only -// by effort-resolution code paths (resolveInstallTimeEffort / -// generateCodexAgentToml / Claude .md effort injection). Loaded lazily the -// first time they are needed so that requiring install.js in test contexts that -// never trigger an install does NOT produce module-load-time side effects (the -// manifest read + hard throw) that could alter subprocess exit codes or stderr. -let _gsdEffortCatalogCache = null; -function _getGsdEffortCatalog() { - if (_gsdEffortCatalogCache) return _gsdEffortCatalogCache; - - const { AGENT_DEFAULT_TIERS, renderEffortForRuntime } = require(path.join(_gsdLibDir, 'model-catalog.cjs')); - - const manifestPath = path.join( - __dirname, - '..', - 'gsd-core', - 'bin', - 'shared', - 'config-defaults.manifest.json' - ); - let manifestData; - try { - manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); - } catch (_err) { - // Fail loudly — a missing manifest is a broken install, not a soft degradation. - throw new Error( - `gsd install: cannot load config-defaults.manifest.json at ${manifestPath}: ${_err.message}` - ); - } - - const tierDefaults = - (manifestData.effort && - manifestData.effort.routing_tier_defaults && - typeof manifestData.effort.routing_tier_defaults === 'object' && - !Array.isArray(manifestData.effort.routing_tier_defaults)) - ? manifestData.effort.routing_tier_defaults - : { light: 'low', standard: 'high', heavy: 'xhigh' }; // guard: unreachable if manifest is valid - - const effortDefault = - (manifestData.effort && typeof manifestData.effort.default === 'string') - ? manifestData.effort.default - : 'high'; // guard: unreachable if manifest is valid - - _gsdEffortCatalogCache = { - AGENT_DEFAULT_TIERS, - renderEffortForRuntime, - EFFORT_MANIFEST_TIER_DEFAULTS: tierDefaults, - EFFORT_MANIFEST_DEFAULT: effortDefault, - }; - return _gsdEffortCatalogCache; -} +// #2071 — install-time effort resolution (readGsdEffectiveEffortConfig / +// resolveInstallTimeEffort, plus their _getGsdEffortCatalog + _readGsdConfigFile +// helpers) was extracted into the shipped gsd-core/bin/lib/install-effort-resolver.cjs +// so `gsd-tools effort sync` can require it from the installed runtime instead of this +// package-root bin/install.js, which the installer never copies (#2071 crash). The +// installer imports it back here — single source of truth for both surfaces. +const { + readGsdEffectiveEffortConfig, + resolveInstallTimeEffort, + _getGsdEffortCatalog, + _readGsdConfigFile, +} = require(path.join(_gsdLibDir, 'install-effort-resolver.cjs')); const { MINIMAL_SKILL_ALLOWLIST, @@ -1071,126 +1031,6 @@ function readGsdEffectiveModelOverrides(targetDir = null) { return { ...(global || {}), ...(projectOverrides || {}) }; } -/** - * #443 — Read the merged `effort` config block for install-time effort resolution. - * - * Probes the same config sources as readGsdRuntimeProfileResolver (per-project - * `.planning/config.json` wins over `~/.gsd/defaults.json`) but extracts the - * `effort` object instead of the model-profile fields. - * - * Returns the merged `effort` object or null when neither source defines one. - * The caller can pass this to resolveInstallTimeEffort() which is pure and - * requires no filesystem access beyond what this helper already performs. - * - * @param {string|null} targetDir Runtime install root (walks up to find .planning/). - * @returns {object|null} - */ -function readGsdEffectiveEffortConfig(targetDir = null) { - const homeDefaults = _readGsdConfigFile( - path.join(os.homedir(), '.gsd', 'defaults.json'), - '~/.gsd/defaults.json' - ); - - let projectConfig = null; - if (targetDir) { - let probeDir = path.resolve(targetDir); - for (let depth = 0; depth < 8; depth += 1) { - const candidate = path.join(probeDir, '.planning', 'config.json'); - if (fs.existsSync(candidate)) { - projectConfig = _readGsdConfigFile(candidate, '.planning/config.json'); - break; - } - const parent = path.dirname(probeDir); - if (parent === probeDir) break; - probeDir = parent; - } - } - - const homeEffort = (homeDefaults && homeDefaults.effort && typeof homeDefaults.effort === 'object' && !Array.isArray(homeDefaults.effort)) - ? homeDefaults.effort - : null; - const projectEffort = (projectConfig && projectConfig.effort && typeof projectConfig.effort === 'object' && !Array.isArray(projectConfig.effort)) - ? projectConfig.effort - : null; - - if (!homeEffort && !projectEffort) return null; - - // Per-project wins on conflict within each sub-field. Merge field-by-field so - // a project config that only sets agent_overrides still inherits global - // routing_tier_defaults and default. - return { - ...(homeEffort || {}), - ...(projectEffort || {}), - // Deep-merge agent_overrides (project wins per-key) - agent_overrides: { - ...((homeEffort && homeEffort.agent_overrides) || {}), - ...((projectEffort && projectEffort.agent_overrides) || {}), - }, - }; -} - - -/** - * #443 — Resolve install-time effort for a given agent, using the same - * precedence chain as resolveEffortInternal() in core.cjs, but operating - * on a pre-loaded effortCfg object (no loadConfig side-effects at install). - * - * Precedence (mirrors resolveEffortInternal): - * 1. effortCfg.agent_overrides[agentName] - * 2. effortCfg.routing_tier_defaults[agentTier] (if effortCfg present) - * — OR manifest tier defaults when effortCfg is null - * 3. effortCfg.default - * 4. 'high' (hardcoded fallback) - * - * @param {object|null} effortCfg Result of readGsdEffectiveEffortConfig(). - * @param {string} agentName e.g. 'gsd-planner' - * @returns {string} Universal effort string (low/medium/high/xhigh/max/minimal) - */ -function resolveInstallTimeEffort(effortCfg, agentName) { - // Validates each candidate against the canonical EFFORT_SET (sourced once - // from core.cjs) before accepting it, mirroring resolveEffortInternal exactly. - // Invalid values fall through to the next precedence layer; final fallback 'high'. - - // Step 1: agent_overrides - if (effortCfg) { - const ao = effortCfg.agent_overrides; - if (ao && typeof ao === 'object' && !Array.isArray(ao)) { - const v = ao[agentName]; - if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; - } - } - - // Step 2: routing_tier_defaults keyed by the agent's catalog tier - const { AGENT_DEFAULT_TIERS, EFFORT_MANIFEST_TIER_DEFAULTS, EFFORT_MANIFEST_DEFAULT } = _getGsdEffortCatalog(); - const agentTier = AGENT_DEFAULT_TIERS[agentName]; - if (agentTier) { - if (effortCfg && effortCfg.routing_tier_defaults && - typeof effortCfg.routing_tier_defaults === 'object' && - !Array.isArray(effortCfg.routing_tier_defaults)) { - const v = effortCfg.routing_tier_defaults[agentTier]; - if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; - } else if (!effortCfg) { - // No effort config — use manifest tier defaults - const v = EFFORT_MANIFEST_TIER_DEFAULTS[agentTier]; - if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; - } - // effortCfg exists but has no routing_tier_defaults — fall through - } - - // Step 3: effort.default - if (effortCfg) { - const d = effortCfg.default; - if (typeof d === 'string' && GSD_EFFORT_SET.has(d)) return d; - } - - // Step 4: manifest default (sourced from config-defaults.manifest.json effort.default) - // If even the manifest default is invalid, fall back to 'high'. - if (typeof EFFORT_MANIFEST_DEFAULT === 'string' && GSD_EFFORT_SET.has(EFFORT_MANIFEST_DEFAULT)) { - return EFFORT_MANIFEST_DEFAULT; - } - return 'high'; -} - /** * #443 — Inject `effort: ` into YAML frontmatter of a Claude .md agent * file in a newline-agnostic way (LF and CRLF source files are both handled). @@ -1296,29 +1136,6 @@ const READONLY_AGENT_DISALLOWED_TOOLS = { 'gsd-ui-auditor': 'Edit, MultiEdit', }; -/** - * #2517 — Read a single GSD config file (defaults.json or per-project - * config.json) into a plain object, returning null on missing/empty files - * and warning to stderr on JSON parse failures so silent corruption can't - * mask broken configs (review finding #5). - */ -function _readGsdConfigFile(absPath, label) { - if (!fs.existsSync(absPath)) return null; - let raw; - try { - raw = fs.readFileSync(absPath, 'utf-8'); - } catch (err) { - process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${err.message}\n`); - return null; - } - try { - return JSON.parse(raw); - } catch (err) { - process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${err.message}\n`); - return null; - } -} - /** * #2517 — Build a runtime-aware tier resolver for the install path. * diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index d3d251353..4628c7b77 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -356,6 +356,7 @@ "host-integration.cjs", "init-command-router.cjs", "init.cjs", + "install-effort-resolver.cjs", "install-engine.cjs", "install-profiles.cjs", "installer-migration-authoring.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 5dfbe8688..be47f0f48 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -451,6 +451,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `host-integration.cjs` | Host-Integration Interface (ADR-1239 Phase A) — negotiated capability contract over the six host-integration points; `negotiateHostCapabilities` fail-closes on undeclared/unknown/`undocumented` values, typed degradation ladder, host-capability profiles; the 8 `runtime.hostIntegration` axes are validated in `capability-validator.cjs` and sourced per-CLI in `docs/reference/host-integration-capability-matrix.md` | | `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` | | `init.cjs` | Compound context loading for each workflow type | +| `install-effort-resolver.cjs` | Install-time effort resolution — `readGsdEffectiveEffortConfig` (merges `~/.gsd/defaults.json` + project `.planning/config.json`) + `resolveInstallTimeEffort`, extracted from `bin/install.js` (#2071) so `gsd-tools effort sync` can require it from the shipped runtime instead of the never-copied package-root installer; install.js imports them back (single source) | | `install-engine.cjs` | Runtime-artifact install engine — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` + their helpers, extracted from `bin/install.js` (ADR-1239 Phase B, #1679); install.js imports them back and injects `getCommitAttribution` | | `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs | | `installer-migration-authoring.cjs` | Installer migration authoring guardrails for record metadata, explicit scopes, ownership evidence, and runtime contract citations | diff --git a/eslint.config.mjs b/eslint.config.mjs index 148104840..a71f80e39 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -62,6 +62,7 @@ export default tseslint.config( 'gsd-core/bin/lib/host-integration.cjs', 'gsd-core/bin/lib/handshake-serialized.cjs', 'gsd-core/bin/lib/host-integration-sdk.cjs', + 'gsd-core/bin/lib/install-effort-resolver.cjs', 'gsd-core/bin/lib/install-engine.cjs', 'gsd-core/bin/lib/capability-loader.cjs', 'gsd-core/bin/lib/capability-source.cjs', diff --git a/src/commands.cts b/src/commands.cts index ed54ae44e..426923c0d 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -564,9 +564,11 @@ function cmdEffortSync(cwd: string, raw: boolean, opts?: { dryRun?: boolean; con // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method const { getGlobalConfigDir } = require('./runtime-homes.cjs') as { getGlobalConfigDir(runtime: string, explicitDir?: string | null): string }; // Use install-time resolvers: they merge ~/.gsd/defaults.json with project config, - // matching the exact logic used when agents were originally installed. + // matching the exact logic used when agents were originally installed. #2071: these + // live in the shipped sibling install-effort-resolver.cjs (extracted from the + // package-root bin/install.js, which the installer never copies into a runtime home). // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method - const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('../../../bin/install.js') as { + const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('./install-effort-resolver.cjs') as { readGsdEffectiveEffortConfig(cwd: string): Record; resolveInstallTimeEffort(cfg: Record, agentName: string): string; }; diff --git a/src/install-effort-resolver.cts b/src/install-effort-resolver.cts new file mode 100644 index 000000000..3b9690a8f --- /dev/null +++ b/src/install-effort-resolver.cts @@ -0,0 +1,242 @@ +/** + * install-effort-resolver — install-time effort resolution (#443), extracted from + * the package-root `bin/install.js` (#2071). + * + * `gsd-tools effort sync` (src/commands.cts `cmdEffortSync`) must mirror what the + * installer writes — home `~/.gsd/defaults.json` merged with the project's + * `.planning/config.json` — which the runtime resolver (`resolveEffortInternal` + * via `loadConfig`) does NOT do. It previously reached those two functions via + * `require('../../../bin/install.js')`, but the installer never copies the + * package-root `bin/install.js` into a runtime home, so `effort sync` crashed with + * MODULE_NOT_FOUND in every installed runtime (#2071). Moving the logic here — a + * `src/*.cts` module compiled into the shipped `gsd-core/bin/lib/` tree — lets both + * the installer AND `effort sync` require it from a location that is always present, + * keeping a single source of truth (no duplication / drift). + * + * Pure with respect to config: `readGsdEffectiveEffortConfig` performs the config + * reads; `resolveInstallTimeEffort` is pure given a pre-merged effort object. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module +import modelResolver = require('./model-resolver.cjs'); +const { EFFORT_SET: GSD_EFFORT_SET } = modelResolver as { EFFORT_SET: Set }; + +interface EffortConfig { + agent_overrides?: Record; + routing_tier_defaults?: Record; + default?: unknown; + [k: string]: unknown; +} + +/** + * #2517 — Read a single GSD config file (defaults.json or per-project + * config.json) into a plain object, returning null on missing/empty files + * and warning to stderr on JSON parse failures so silent corruption can't + * mask broken configs (review finding #5). + */ +function _readGsdConfigFile(absPath: string, label: string): Record | null { + if (!fs.existsSync(absPath)) return null; + let raw: string; + try { + raw = fs.readFileSync(absPath, 'utf-8'); + } catch (err) { + process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${(err as Error).message}\n`); + return null; + } + try { + return JSON.parse(raw) as Record; + } catch (err) { + process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${(err as Error).message}\n`); + return null; + } +} + +interface EffortCatalog { + AGENT_DEFAULT_TIERS: Record; + renderEffortForRuntime: (runtime: string, effort: string) => { value: string }; + EFFORT_MANIFEST_TIER_DEFAULTS: Record; + EFFORT_MANIFEST_DEFAULT: string; +} + +// #443 — model-catalog and config-defaults.manifest.json exports needed only +// by effort-resolution code paths (resolveInstallTimeEffort / +// generateCodexAgentToml / Claude .md effort injection). Loaded lazily the +// first time they are needed so that requiring this module in test contexts that +// never trigger an install does NOT produce module-load-time side effects (the +// manifest read + hard throw) that could alter subprocess exit codes or stderr. +let _gsdEffortCatalogCache: EffortCatalog | null = null; +function _getGsdEffortCatalog(): EffortCatalog { + if (_gsdEffortCatalogCache) return _gsdEffortCatalogCache; + + // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-catalog.cjs is an export= CommonJS module + const { AGENT_DEFAULT_TIERS, renderEffortForRuntime } = require('./model-catalog.cjs') as { + AGENT_DEFAULT_TIERS: Record; + renderEffortForRuntime: (runtime: string, effort: string) => { value: string }; + }; + + // This module lives in gsd-core/bin/lib/, so the shared manifest is one level + // up in gsd-core/bin/shared/ (bin/lib → bin → shared). (In bin/install.js this + // path was `.., gsd-core, bin, shared` relative to the package-root bin/.) + const manifestPath = path.join(__dirname, '..', 'shared', 'config-defaults.manifest.json'); + let manifestData: { effort?: { routing_tier_defaults?: Record; default?: string } }; + try { + manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { + effort?: { routing_tier_defaults?: Record; default?: string }; + }; + } catch (_err) { + // Fail loudly — a missing manifest is a broken install, not a soft degradation. + throw new Error( + `gsd install: cannot load config-defaults.manifest.json at ${manifestPath}: ${(_err as Error).message}`, + ); + } + + const tierDefaults = + (manifestData.effort && + manifestData.effort.routing_tier_defaults && + typeof manifestData.effort.routing_tier_defaults === 'object' && + !Array.isArray(manifestData.effort.routing_tier_defaults)) + ? manifestData.effort.routing_tier_defaults + : { light: 'low', standard: 'high', heavy: 'xhigh' }; // guard: unreachable if manifest is valid + + const effortDefault = + (manifestData.effort && typeof manifestData.effort.default === 'string') + ? manifestData.effort.default + : 'high'; // guard: unreachable if manifest is valid + + _gsdEffortCatalogCache = { + AGENT_DEFAULT_TIERS, + renderEffortForRuntime, + EFFORT_MANIFEST_TIER_DEFAULTS: tierDefaults, + EFFORT_MANIFEST_DEFAULT: effortDefault, + }; + return _gsdEffortCatalogCache; +} + +/** + * #443 — Read the merged `effort` config block for install-time effort resolution. + * + * Probes the same config sources as readGsdRuntimeProfileResolver (per-project + * `.planning/config.json` wins over `~/.gsd/defaults.json`) but extracts the + * `effort` object instead of the model-profile fields. + * + * Returns the merged `effort` object or null when neither source defines one. + * The caller can pass this to resolveInstallTimeEffort() which is pure and + * requires no filesystem access beyond what this helper already performs. + * + * @param targetDir Runtime install root (walks up to find .planning/). + */ +function readGsdEffectiveEffortConfig(targetDir: string | null = null): EffortConfig | null { + const homeDefaults = _readGsdConfigFile( + path.join(os.homedir(), '.gsd', 'defaults.json'), + '~/.gsd/defaults.json', + ); + + let projectConfig: Record | null = null; + if (targetDir) { + let probeDir = path.resolve(targetDir); + for (let depth = 0; depth < 8; depth += 1) { + const candidate = path.join(probeDir, '.planning', 'config.json'); + if (fs.existsSync(candidate)) { + projectConfig = _readGsdConfigFile(candidate, '.planning/config.json'); + break; + } + const parent = path.dirname(probeDir); + if (parent === probeDir) break; + probeDir = parent; + } + } + + const homeEffort = (homeDefaults && homeDefaults.effort && typeof homeDefaults.effort === 'object' && !Array.isArray(homeDefaults.effort)) + ? (homeDefaults.effort as EffortConfig) + : null; + const projectEffort = (projectConfig && projectConfig.effort && typeof projectConfig.effort === 'object' && !Array.isArray(projectConfig.effort)) + ? (projectConfig.effort as EffortConfig) + : null; + + if (!homeEffort && !projectEffort) return null; + + // Per-project wins on conflict within each sub-field. Merge field-by-field so + // a project config that only sets agent_overrides still inherits global + // routing_tier_defaults and default. + return { + ...(homeEffort || {}), + ...(projectEffort || {}), + // Deep-merge agent_overrides (project wins per-key) + agent_overrides: { + ...((homeEffort && homeEffort.agent_overrides) || {}), + ...((projectEffort && projectEffort.agent_overrides) || {}), + }, + }; +} + +/** + * #443 — Resolve install-time effort for a given agent, using the same + * precedence chain as resolveEffortInternal() in core.cjs, but operating + * on a pre-loaded effortCfg object (no loadConfig side-effects at install). + * + * Precedence (mirrors resolveEffortInternal): + * 1. effortCfg.agent_overrides[agentName] + * 2. effortCfg.routing_tier_defaults[agentTier] (if effortCfg present) + * — OR manifest tier defaults when effortCfg is null + * 3. effortCfg.default + * 4. 'high' (hardcoded fallback) + * + * @param effortCfg Result of readGsdEffectiveEffortConfig(). + * @param agentName e.g. 'gsd-planner' + * @returns Universal effort string (low/medium/high/xhigh/max/minimal) + */ +function resolveInstallTimeEffort(effortCfg: EffortConfig | null, agentName: string): string { + // Validates each candidate against the canonical EFFORT_SET (sourced once + // from model-resolver.cjs) before accepting it, mirroring resolveEffortInternal + // exactly. Invalid values fall through to the next precedence layer; final + // fallback 'high'. + + // Step 1: agent_overrides + if (effortCfg) { + const ao = effortCfg.agent_overrides; + if (ao && typeof ao === 'object' && !Array.isArray(ao)) { + const v = ao[agentName]; + if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; + } + } + + // Step 2: routing_tier_defaults keyed by the agent's catalog tier + const { AGENT_DEFAULT_TIERS, EFFORT_MANIFEST_TIER_DEFAULTS, EFFORT_MANIFEST_DEFAULT } = _getGsdEffortCatalog(); + const agentTier = AGENT_DEFAULT_TIERS[agentName]; + if (agentTier) { + if (effortCfg && effortCfg.routing_tier_defaults && + typeof effortCfg.routing_tier_defaults === 'object' && + !Array.isArray(effortCfg.routing_tier_defaults)) { + const v = effortCfg.routing_tier_defaults[agentTier]; + if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; + } else if (!effortCfg) { + // No effort config — use manifest tier defaults + const v = EFFORT_MANIFEST_TIER_DEFAULTS[agentTier]; + if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v; + } + // effortCfg exists but has no routing_tier_defaults — fall through + } + + // Step 3: effort.default + if (effortCfg) { + const d = effortCfg.default; + if (typeof d === 'string' && GSD_EFFORT_SET.has(d)) return d; + } + + // Step 4: manifest default (sourced from config-defaults.manifest.json effort.default) + // If even the manifest default is invalid, fall back to 'high'. + if (typeof EFFORT_MANIFEST_DEFAULT === 'string' && GSD_EFFORT_SET.has(EFFORT_MANIFEST_DEFAULT)) { + return EFFORT_MANIFEST_DEFAULT; + } + return 'high'; +} + +export = { + readGsdEffectiveEffortConfig, + resolveInstallTimeEffort, + _getGsdEffortCatalog, + _readGsdConfigFile, +}; diff --git a/tests/effort-sync-installed-runtime.test.cjs b/tests/effort-sync-installed-runtime.test.cjs new file mode 100644 index 000000000..34c0ecbd8 --- /dev/null +++ b/tests/effort-sync-installed-runtime.test.cjs @@ -0,0 +1,72 @@ +'use strict'; + +/** + * #2071 — `gsd-tools effort sync` crashed in an INSTALLED runtime because + * commands.cjs did `require('../../../bin/install.js')`, but the installer only + * copies the `gsd-core/` subtree into a runtime home — the package-root + * `bin/install.js` is never present there, so the require threw MODULE_NOT_FOUND. + * + * This does a real minimal install into a temp home (the same helper the + * golden-parity suite uses) and runs the exact repro from the issue against the + * installed shim: `node /gsd-core/bin/gsd-tools.cjs effort sync`. Pre-fix + * this throws `Cannot find module '../../../bin/install.js'`; post-fix the + * install-time resolvers live in the shipped sibling + * `gsd-core/bin/lib/install-effort-resolver.cjs` and the require resolves. + * + * `--config-dir ` keeps it hermetic (targets the temp install, never the + * developer's real ~/.claude); effort sync defaults to dry-run so nothing is written. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { runMinimalInstall } = require('./helpers/install-shared.cjs'); +const { cleanup } = require('./helpers.cjs'); + +describe('#2071: effort sync runs in an installed runtime (no package-root bin/install.js)', () => { + test('effort sync does not crash reaching for the un-shipped bin/install.js', () => { + if (process.platform === 'win32') return; // install layout is POSIX-path-shaped + + const { configDir, root } = runMinimalInstall({ runtime: 'claude', scope: 'global' }); + try { + // Installed layout invariant: the package-root installer is never copied in. + assert.ok(!fs.existsSync(path.join(root, 'bin', 'install.js')), 'installed home must not contain bin/install.js'); + assert.ok(!fs.existsSync(path.join(configDir, 'bin', 'install.js')), 'no bin/install.js beside gsd-core'); + + // A project effort config gives the sync something to resolve. + fs.mkdirSync(path.join(root, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(root, '.planning', 'config.json'), + JSON.stringify({ effort: { default: 'high' } }), + ); + + const gsdTools = path.join(configDir, 'gsd-core', 'bin', 'gsd-tools.cjs'); + let combined = ''; + try { + combined = execFileSync( + process.execPath, + [gsdTools, 'effort', 'sync', '--config-dir', configDir], + { cwd: root, encoding: 'utf-8', env: { ...process.env, HOME: root } }, + ); + } catch (e) { + combined = `${e.stdout || ''}${e.stderr || ''}${e.message || ''}`; + } + + assert.doesNotMatch( + combined, + /Cannot find module[^\n]*install\.js|'\.\.\/\.\.\/\.\.\/bin\/install\.js'/, + `effort sync must not reach for the un-shipped bin/install.js:\n${combined}`, + ); + assert.doesNotMatch( + combined, + /MODULE_NOT_FOUND/, + `effort sync must not crash on module resolution in an installed runtime:\n${combined}`, + ); + } finally { + cleanup(root); + } + }); +});