diff --git a/src/resources/extensions/gsd/auto-artifact-paths.ts b/src/resources/extensions/gsd/auto-artifact-paths.ts index 41b72fe6e..766db05cf 100644 --- a/src/resources/extensions/gsd/auto-artifact-paths.ts +++ b/src/resources/extensions/gsd/auto-artifact-paths.ts @@ -80,6 +80,9 @@ export function resolveExpectedArtifactPath( } case "rewrite-docs": return null; + case "gate-evaluate": + // Gate evaluate writes to DB quality_gates table — verified via state derivation + return null; case "reactive-execute": // Reactive execute produces multiple task summaries — verified separately return null; diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index e926f8253..e35f2baf1 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -151,6 +151,8 @@ export function describeNextUnit(state: GSDState): { label: string; description: return { label: `Replan ${sid}: ${sTitle}`, description: "Blocker found — replan the slice." }; case "completing-milestone": return { label: "Complete milestone", description: "Write milestone summary." }; + case "evaluating-gates": + return { label: `Evaluate gates for ${sid}: ${sTitle}`, description: "Parallel quality gate assessment before execution." }; default: return { label: "Continue", description: "Execute the next step." }; } diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index db88b5e7f..12e9e3eb9 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -13,7 +13,7 @@ import type { GSDState } from "./types.js"; import type { GSDPreferences } from "./preferences.js"; import type { UatType } from "./files.js"; import { loadFile, extractUatType, loadActiveOverrides } from "./files.js"; -import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js"; +import { isDbAvailable, getMilestoneSlices, getPendingGates, markAllGatesOmitted } from "./gsd-db.js"; import { resolveMilestoneFile, @@ -43,6 +43,7 @@ import { buildReassessRoadmapPrompt, buildRewriteDocsPrompt, buildReactiveExecutePrompt, + buildGateEvaluatePrompt, checkNeedsReassessment, checkNeedsRunUat, } from "./auto-prompts.js"; @@ -342,6 +343,38 @@ export const DISPATCH_RULES: DispatchRule[] = [ }; }, }, + { + name: "evaluating-gates → gate-evaluate", + match: async ({ state, mid, midTitle, basePath, prefs }) => { + if (state.phase !== "evaluating-gates") return null; + if (!state.activeSlice) return missingSliceStop(mid, state.phase); + const sid = state.activeSlice.id; + const sTitle = state.activeSlice.title; + + // Gate evaluation is opt-in via preferences + const gateConfig = prefs?.gate_evaluation; + if (!gateConfig?.enabled) { + markAllGatesOmitted(mid, sid); + return { action: "skip" }; + } + + const pending = getPendingGates(mid, sid, "slice"); + if (pending.length === 0) return { action: "skip" }; + + return { + action: "dispatch", + unitType: "gate-evaluate", + unitId: `${mid}/${sid}/gates+${pending.map(g => g.gate_id).join(",")}`, + prompt: await buildGateEvaluatePrompt( + mid, + midTitle, + sid, + sTitle, + basePath, + ), + }; + }, + }, { name: "replanning-slice → replan-slice", match: async ({ state, mid, midTitle, basePath }) => { diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 876e68cb8..16b8053e4 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -23,6 +23,7 @@ import { getLoadedSkills, type Skill } from "@gsd/pi-coding-agent"; import { join, basename } from "node:path"; import { existsSync } from "node:fs"; import { computeBudgets, resolveExecutorContextWindow, truncateAtSectionBoundary } from "./context-budget.js"; +import { getPendingGates } from "./gsd-db.js"; import { formatDecisionsCompact, formatRequirementsCompact } from "./structured-data-formatter.js"; // ─── Preamble Cap ───────────────────────────────────────────────────────────── @@ -1661,6 +1662,96 @@ export async function buildReactiveExecutePrompt( }); } +// ─── Gate Evaluation ────────────────────────────────────────────────────── + +const GATE_QUESTIONS: Record = { + Q3: { + question: "How can this be exploited?", + guidance: [ + "Identify abuse scenarios: parameter tampering, replay attacks, privilege escalation.", + "Map data exposure risks: PII, tokens, secrets accessible through this slice.", + "Define input trust boundaries: untrusted user input reaching DB, API, or filesystem.", + "If none apply, return verdict 'omitted' with rationale explaining why.", + ].join("\n"), + }, + Q4: { + question: "What existing promises does this break?", + guidance: [ + "List which existing requirements (R001, R003, etc.) are touched by this slice.", + "Identify what must be re-tested after shipping.", + "Flag decisions that should be revisited given the new scope.", + "If no existing requirements are affected, return verdict 'omitted'.", + ].join("\n"), + }, +}; + +export async function buildGateEvaluatePrompt( + mid: string, midTitle: string, sid: string, sTitle: string, + base: string, +): Promise { + const pending = getPendingGates(mid, sid, "slice"); + + // Load the slice plan for context + const planFile = resolveSliceFile(base, mid, sid, "PLAN"); + const planContent = planFile ? (await loadFile(planFile)) ?? "(plan file empty)" : "(plan file not found)"; + + // Build per-gate subagent prompts + const subagentSections: string[] = []; + const gateListLines: string[] = []; + + for (const gate of pending) { + const meta = GATE_QUESTIONS[gate.gate_id]; + if (!meta) continue; + + gateListLines.push(`- **${gate.gate_id}**: ${meta.question}`); + + const subPrompt = [ + `You are evaluating quality gate **${gate.gate_id}** for slice ${sid} (${sTitle}).`, + "", + `## Question: ${meta.question}`, + "", + meta.guidance, + "", + "## Slice Plan", + "", + planContent, + "", + "## Instructions", + "", + "Analyze the slice plan above and answer the gate question.", + `Call the \`gsd_save_gate_result\` tool with:`, + `- \`milestoneId\`: "${mid}"`, + `- \`sliceId\`: "${sid}"`, + `- \`gateId\`: "${gate.gate_id}"`, + "- `verdict`: \"pass\" (no concerns), \"flag\" (concerns found), or \"omitted\" (not applicable)", + "- `rationale`: one-sentence justification", + "- `findings`: detailed markdown findings (or empty if omitted)", + ].join("\n"); + + subagentSections.push([ + `### ${gate.gate_id}: ${meta.question}`, + "", + "Use this as the prompt for a `subagent` call:", + "", + "```", + subPrompt, + "```", + ].join("\n")); + } + + return loadPrompt("gate-evaluate", { + workingDirectory: base, + milestoneId: mid, + milestoneTitle: midTitle, + sliceId: sid, + sliceTitle: sTitle, + slicePlanContent: planContent, + gateCount: String(pending.length), + gateList: gateListLines.join("\n"), + subagentPrompts: subagentSections.join("\n\n---\n\n"), + }); +} + export async function buildRewriteDocsPrompt( mid: string, midTitle: string, activeSlice: { id: string; title: string } | null, diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index 7492d64be..2495ae3c4 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -230,6 +230,35 @@ export function verifyExpectedArtifact( return true; } + // Gate-evaluate: verify that each dispatched gate has been resolved in the DB. + // The unitId encodes the batch: "{mid}/{sid}/gates+Q3,Q4" + if (unitType === "gate-evaluate") { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + const batchPart = parts[2]; // "gates+Q3,Q4" + if (!mid || !sid || !batchPart) return false; + + const plusIdx = batchPart.indexOf("+"); + if (plusIdx === -1) return true; // no specific gates encoded — pass + + const gateIds = batchPart.slice(plusIdx + 1).split(",").filter(Boolean); + if (gateIds.length === 0) return true; + + try { + const { getPendingGates: getPending } = require("./gsd-db.js"); + const pending = getPending(mid, sid, "slice"); + const pendingIds = new Set(pending.map((g: any) => g.gate_id)); + // All dispatched gates must no longer be pending + for (const gid of gateIds) { + if (pendingIds.has(gid)) return false; + } + } catch { + // DB unavailable — treat as verified to avoid blocking + } + return true; + } + const absPath = resolveExpectedArtifactPath(unitType, unitId, base); // For unit types with no verifiable artifact (null path), the parent directory // is missing on disk — treat as stale completion state so the key gets evicted (#313). diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index c2f5de270..be4aa072e 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -1112,4 +1112,97 @@ export function registerDbTools(pi: ExtensionAPI): void { pi.registerTool(reassessRoadmapTool); registerAlias(pi, reassessRoadmapTool, "gsd_roadmap_reassess", "gsd_reassess_roadmap"); + + // ─── gsd_save_gate_result ────────────────────────────────────────────── + + const saveGateResultExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text" as const, text: "Error: GSD database is not available." }], + details: { operation: "save_gate_result", error: "db_unavailable" } as any, + }; + } + const validGates = ["Q3", "Q4", "Q5", "Q6", "Q7", "Q8"]; + if (!validGates.includes(params.gateId)) { + return { + content: [{ type: "text" as const, text: `Error: Invalid gateId "${params.gateId}". Must be one of: ${validGates.join(", ")}` }], + details: { operation: "save_gate_result", error: "invalid_gate_id" } as any, + }; + } + const validVerdicts = ["pass", "flag", "omitted"]; + if (!validVerdicts.includes(params.verdict)) { + return { + content: [{ type: "text" as const, text: `Error: Invalid verdict "${params.verdict}". Must be one of: ${validVerdicts.join(", ")}` }], + details: { operation: "save_gate_result", error: "invalid_verdict" } as any, + }; + } + try { + const { saveGateResult } = await import("../gsd-db.js"); + const { invalidateStateCache } = await import("../state.js"); + saveGateResult({ + milestoneId: params.milestoneId, + sliceId: params.sliceId, + gateId: params.gateId, + taskId: params.taskId ?? "", + verdict: params.verdict, + rationale: params.rationale, + findings: params.findings ?? "", + }); + invalidateStateCache(); + return { + content: [{ type: "text" as const, text: `Gate ${params.gateId} result saved: verdict=${params.verdict}` }], + details: { operation: "save_gate_result", gateId: params.gateId, verdict: params.verdict } as any, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logError("tool", `gsd_save_gate_result failed: ${msg}`, { tool: "gsd_save_gate_result", error: String(err) }); + return { + content: [{ type: "text" as const, text: `Error saving gate result: ${msg}` }], + details: { operation: "save_gate_result", error: msg } as any, + }; + } + }; + + const saveGateResultTool = { + name: "gsd_save_gate_result", + label: "Save Gate Result", + description: + "Save the result of a quality gate evaluation (Q3-Q8) to the GSD database. " + + "Called by gate evaluation sub-agents after analyzing a specific quality question.", + promptSnippet: "Save quality gate evaluation result (verdict, rationale, findings)", + promptGuidelines: [ + "Use gsd_save_gate_result after evaluating a quality gate question.", + "gateId must be one of: Q3, Q4, Q5, Q6, Q7, Q8.", + "verdict must be: pass (no concerns), flag (concerns found), or omitted (not applicable).", + "rationale should be a one-sentence justification for the verdict.", + "findings should contain detailed markdown analysis (or empty string if omitted).", + ], + parameters: Type.Object({ + milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), + sliceId: Type.String({ description: "Slice ID (e.g. S01)" }), + gateId: Type.String({ description: "Gate ID: Q3, Q4, Q5, Q6, Q7, or Q8" }), + taskId: Type.Optional(Type.String({ description: "Task ID for task-scoped gates (Q5/Q6/Q7)" })), + verdict: Type.String({ description: "pass, flag, or omitted" }), + rationale: Type.String({ description: "One-sentence justification" }), + findings: Type.Optional(Type.String({ description: "Detailed markdown findings" })), + }), + execute: saveGateResultExecute, + renderCall(args: any, theme: any) { + let text = theme.fg("toolTitle", theme.bold("save_gate_result ")); + text += theme.fg("accent", args.gateId ?? ""); + text += theme.fg("dim", ` → ${args.verdict ?? ""}`); + return new Text(text, 0, 0); + }, + renderResult(result: any, _options: any, theme: any) { + const d = result.details; + if (result.isError || d?.error) { + return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0); + } + const color = d?.verdict === "flag" ? "warning" : "success"; + return new Text(theme.fg(color, `${d?.gateId}: ${d?.verdict}`), 0, 0); + }, + }; + + pi.registerTool(saveGateResultTool); } diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index 2c777e0f0..20a9c11a8 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -8,7 +8,7 @@ import { createRequire } from "node:module"; import { existsSync, copyFileSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; -import type { Decision, Requirement } from "./types.js"; +import type { Decision, Requirement, GateRow, GateId, GateScope, GateStatus, GateVerdict } from "./types.js"; import { GSDError, GSD_STALE_STATE } from "./errors.js"; const _require = createRequire(import.meta.url); @@ -149,7 +149,7 @@ function openRawDb(path: string): unknown { return new Database(path); } -const SCHEMA_VERSION = 11; +const SCHEMA_VERSION = 12; function initSchema(db: DbAdapter, fileBacked: boolean): void { if (fileBacked) db.exec("PRAGMA journal_mode=WAL"); @@ -355,6 +355,23 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { ) `); + db.exec(` + CREATE TABLE IF NOT EXISTS quality_gates ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + gate_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'slice', + task_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + verdict TEXT NOT NULL DEFAULT '', + rationale TEXT NOT NULL DEFAULT '', + findings TEXT NOT NULL DEFAULT '', + evaluated_at TEXT DEFAULT NULL, + PRIMARY KEY (milestone_id, slice_id, gate_id, task_id), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) + ) + `); + db.exec("CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)"); db.exec("CREATE INDEX IF NOT EXISTS idx_replan_history_milestone ON replan_history(milestone_id, created_at)"); @@ -637,6 +654,29 @@ function migrateSchema(db: DbAdapter): void { }); } + if (currentVersion < 12) { + db.exec(` + CREATE TABLE IF NOT EXISTS quality_gates ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + gate_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'slice', + task_id TEXT DEFAULT NULL, + status TEXT NOT NULL DEFAULT 'pending', + verdict TEXT NOT NULL DEFAULT '', + rationale TEXT NOT NULL DEFAULT '', + findings TEXT NOT NULL DEFAULT '', + evaluated_at TEXT DEFAULT NULL, + PRIMARY KEY (milestone_id, slice_id, gate_id, COALESCE(task_id, '')), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) + ) + `); + db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({ + ":version": 12, + ":applied_at": new Date().toISOString(), + }); + } + db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -1722,3 +1762,111 @@ export function getAssessment(path: string): Record | null { ).get({ ":path": path }); return row ?? null; } + +// ─── Quality Gates ─────────────────────────────────────────────────────── + +function rowToGate(row: Record): GateRow { + return { + milestone_id: row["milestone_id"] as string, + slice_id: row["slice_id"] as string, + gate_id: row["gate_id"] as GateId, + scope: row["scope"] as GateScope, + task_id: (row["task_id"] as string) ?? "", + status: row["status"] as GateStatus, + verdict: (row["verdict"] as GateVerdict) || "", + rationale: (row["rationale"] as string) || "", + findings: (row["findings"] as string) || "", + evaluated_at: (row["evaluated_at"] as string) ?? null, + }; +} + +export function insertGateRow(g: { + milestoneId: string; + sliceId: string; + gateId: GateId; + scope: GateScope; + taskId?: string | null; + status?: GateStatus; +}): void { + if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); + currentDb.prepare( + `INSERT OR IGNORE INTO quality_gates (milestone_id, slice_id, gate_id, scope, task_id, status) + VALUES (:mid, :sid, :gid, :scope, :tid, :status)`, + ).run({ + ":mid": g.milestoneId, + ":sid": g.sliceId, + ":gid": g.gateId, + ":scope": g.scope, + ":tid": g.taskId ?? "", + ":status": g.status ?? "pending", + }); +} + +export function saveGateResult(g: { + milestoneId: string; + sliceId: string; + gateId: string; + taskId?: string | null; + verdict: GateVerdict; + rationale: string; + findings: string; +}): void { + if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); + currentDb.prepare( + `UPDATE quality_gates + SET status = 'complete', verdict = :verdict, rationale = :rationale, + findings = :findings, evaluated_at = :evaluated_at + WHERE milestone_id = :mid AND slice_id = :sid AND gate_id = :gid + AND task_id = :tid`, + ).run({ + ":mid": g.milestoneId, + ":sid": g.sliceId, + ":gid": g.gateId, + ":tid": g.taskId ?? "", + ":verdict": g.verdict, + ":rationale": g.rationale, + ":findings": g.findings, + ":evaluated_at": new Date().toISOString(), + }); +} + +export function getPendingGates(milestoneId: string, sliceId: string, scope?: GateScope): GateRow[] { + if (!currentDb) return []; + const sql = scope + ? `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid AND scope = :scope AND status = 'pending'` + : `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid AND status = 'pending'`; + const params: Record = { ":mid": milestoneId, ":sid": sliceId }; + if (scope) params[":scope"] = scope; + return currentDb.prepare(sql).all(params).map(rowToGate); +} + +export function getGateResults(milestoneId: string, sliceId: string, scope?: GateScope): GateRow[] { + if (!currentDb) return []; + const sql = scope + ? `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid AND scope = :scope` + : `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid`; + const params: Record = { ":mid": milestoneId, ":sid": sliceId }; + if (scope) params[":scope"] = scope; + return currentDb.prepare(sql).all(params).map(rowToGate); +} + +export function markAllGatesOmitted(milestoneId: string, sliceId: string): void { + if (!currentDb) return; + currentDb.prepare( + `UPDATE quality_gates SET status = 'omitted', verdict = 'omitted', evaluated_at = :now + WHERE milestone_id = :mid AND slice_id = :sid AND status = 'pending'`, + ).run({ + ":mid": milestoneId, + ":sid": sliceId, + ":now": new Date().toISOString(), + }); +} + +export function getPendingSliceGateCount(milestoneId: string, sliceId: string): number { + if (!currentDb) return 0; + const row = currentDb.prepare( + `SELECT COUNT(*) as cnt FROM quality_gates + WHERE milestone_id = :mid AND slice_id = :sid AND scope = 'slice' AND status = 'pending'`, + ).get({ ":mid": milestoneId, ":sid": sliceId }); + return row ? (row["cnt"] as number) : 0; +} diff --git a/src/resources/extensions/gsd/markdown-renderer.ts b/src/resources/extensions/gsd/markdown-renderer.ts index 0afc7d140..669eb1e0e 100644 --- a/src/resources/extensions/gsd/markdown-renderer.ts +++ b/src/resources/extensions/gsd/markdown-renderer.ts @@ -20,8 +20,10 @@ import { getSlice, getArtifact, insertArtifact, + getGateResults, } from "./gsd-db.js"; import type { MilestoneRow, SliceRow, TaskRow, ArtifactRow } from "./gsd-db.js"; +import type { GateRow } from "./types.js"; import { resolveMilestoneFile, resolveSliceFile, @@ -188,7 +190,7 @@ function renderRoadmapMarkdown(milestone: MilestoneRow, slices: SliceRow[]): str return `${lines.join("\n").trimEnd()}\n`; } -function renderTaskPlanMarkdown(task: TaskRow): string { +function renderTaskPlanMarkdown(task: TaskRow, taskGates: GateRow[] = []): string { const estimatedSteps = Math.max(1, task.description.trim().split(/\n+/).filter(Boolean).length || 1); const estimatedFiles = task.files.length > 0 ? task.files.length @@ -251,10 +253,22 @@ function renderTaskPlanMarkdown(task: TaskRow): string { lines.push(""); } + // ── Quality Gate Sections (Q5/Q6/Q7) ────────────────────────────────── + const gateLabels: Record = { Q5: "Failure Modes", Q6: "Load Profile", Q7: "Negative Tests" }; + for (const [gid, label] of Object.entries(gateLabels)) { + const gate = taskGates.find(g => g.gate_id === gid && g.status === "complete"); + if (gate && gate.verdict !== "omitted") { + lines.push(`## ${label}`); + lines.push(""); + lines.push(gate.findings.trim() || `- **Verdict:** ${gate.verdict}\n- **Rationale:** ${gate.rationale}`); + lines.push(""); + } + } + return `${lines.join("\n").trimEnd()}\n`; } -function renderSlicePlanMarkdown(slice: SliceRow, tasks: TaskRow[]): string { +function renderSlicePlanMarkdown(slice: SliceRow, tasks: TaskRow[], gates: GateRow[] = []): string { const lines: string[] = []; lines.push(`# ${slice.id}: ${slice.title || slice.id}`); @@ -274,6 +288,23 @@ function renderSlicePlanMarkdown(slice: SliceRow, tasks: TaskRow[]): string { } lines.push(""); + // ── Quality Gate Sections (Q3/Q4) ──────────────────────────────────── + const q3 = gates.find(g => g.gate_id === "Q3" && g.status === "complete"); + if (q3 && q3.verdict !== "omitted") { + lines.push("## Threat Surface"); + lines.push(""); + lines.push(q3.findings.trim() || `- **Verdict:** ${q3.verdict}\n- **Rationale:** ${q3.rationale}`); + lines.push(""); + } + + const q4 = gates.find(g => g.gate_id === "Q4" && g.status === "complete"); + if (q4 && q4.verdict !== "omitted") { + lines.push("## Requirement Impact"); + lines.push(""); + lines.push(q4.findings.trim() || `- **Verdict:** ${q4.verdict}\n- **Rationale:** ${q4.rationale}`); + lines.push(""); + } + if (slice.proof_level.trim()) { lines.push("## Proof Level"); lines.push(""); @@ -354,7 +385,8 @@ export async function renderPlanFromDb( const absPath = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN") ?? join(slicePath, `${sliceId}-PLAN.md`); const artifactPath = toArtifactPath(absPath, basePath); - const content = renderSlicePlanMarkdown(slice, tasks); + const sliceGates = getGateResults(milestoneId, sliceId, "slice"); + const content = renderSlicePlanMarkdown(slice, tasks, sliceGates); await writeAndStore(absPath, artifactPath, content, { artifact_type: "PLAN", @@ -387,7 +419,8 @@ export async function renderTaskPlanFromDb( mkdirSync(tasksDir, { recursive: true }); const absPath = join(tasksDir, buildTaskFileName(taskId, "PLAN")); const artifactPath = toArtifactPath(absPath, basePath); - const content = task.full_plan_md.trim() ? task.full_plan_md : renderTaskPlanMarkdown(task); + const taskGates = getGateResults(milestoneId, sliceId, "task").filter(g => g.task_id === taskId); + const content = task.full_plan_md.trim() ? task.full_plan_md : renderTaskPlanMarkdown(task, taskGates); await writeAndStore(absPath, artifactPath, content, { artifact_type: "PLAN", diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 9b0083866..bfad606e4 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -18,6 +18,7 @@ import type { ParallelConfig, ContextSelectionMode, ReactiveExecutionConfig, + GateEvaluationConfig, } from "./types.js"; import type { DynamicRoutingConfig } from "./model-router.js"; import type { GitHubSyncConfig } from "../github-sync/types.js"; @@ -87,6 +88,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "context_selection", "widget_mode", "reactive_execution", + "gate_evaluation", "github", "service_tier", "forensics_dedup", @@ -96,7 +98,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ /** Canonical list of all dispatch unit types. */ export const KNOWN_UNIT_TYPES = [ "research-milestone", "plan-milestone", "research-slice", "plan-slice", - "execute-task", "reactive-execute", "complete-slice", "replan-slice", "reassess-roadmap", + "execute-task", "reactive-execute", "gate-evaluate", "complete-slice", "replan-slice", "reassess-roadmap", "run-uat", "complete-milestone", ] as const; export type UnitType = (typeof KNOWN_UNIT_TYPES)[number]; @@ -221,6 +223,8 @@ export interface GSDPreferences { widget_mode?: "full" | "small" | "min" | "off"; /** Reactive (graph-derived parallel) task execution within slices. Disabled by default. */ reactive_execution?: ReactiveExecutionConfig; + /** Parallel quality gate evaluation during slice planning. Disabled by default. */ + gate_evaluation?: GateEvaluationConfig; /** GitHub sync configuration. Opt-in: syncs GSD events to GitHub Issues, Milestones, and PRs. */ github?: GitHubSyncConfig; /** OpenAI service tier preference. "priority" = 2x cost, faster. "flex" = 0.5x cost, slower. Only affects gpt-5.4 models. */ diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index bc9fc17d8..733035e84 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -538,6 +538,43 @@ export function validatePreferences(preferences: GSDPreferences): { } } + // ─── Gate Evaluation ───────────────────────────────────────────────────── + if (preferences.gate_evaluation !== undefined) { + if (typeof preferences.gate_evaluation === "object" && preferences.gate_evaluation !== null) { + const ge = preferences.gate_evaluation as unknown as Record; + const validGe: Record = {}; + + if (ge.enabled !== undefined) { + if (typeof ge.enabled === "boolean") validGe.enabled = ge.enabled; + else errors.push("gate_evaluation.enabled must be a boolean"); + } + if (ge.slice_gates !== undefined) { + if (Array.isArray(ge.slice_gates) && ge.slice_gates.every((g: unknown) => typeof g === "string")) { + validGe.slice_gates = ge.slice_gates; + } else { + errors.push("gate_evaluation.slice_gates must be an array of strings"); + } + } + if (ge.task_gates !== undefined) { + if (typeof ge.task_gates === "boolean") validGe.task_gates = ge.task_gates; + else errors.push("gate_evaluation.task_gates must be a boolean"); + } + + const knownGeKeys = new Set(["enabled", "slice_gates", "task_gates"]); + for (const key of Object.keys(ge)) { + if (!knownGeKeys.has(key)) { + warnings.push(`unknown gate_evaluation key "${key}" — ignored`); + } + } + + if (Object.keys(validGe).length > 0) { + validated.gate_evaluation = validGe as unknown as import("./types.js").GateEvaluationConfig; + } + } else { + errors.push("gate_evaluation must be an object"); + } + } + // ─── Verification Preferences ─────────────────────────────────────────── if (preferences.verification_commands !== undefined) { if (Array.isArray(preferences.verification_commands)) { diff --git a/src/resources/extensions/gsd/prompts/gate-evaluate.md b/src/resources/extensions/gsd/prompts/gate-evaluate.md new file mode 100644 index 000000000..3ee974097 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/gate-evaluate.md @@ -0,0 +1,32 @@ +# Quality Gate Evaluation — Parallel Dispatch + +**Working directory:** `{{workingDirectory}}` +**Milestone:** {{milestoneId}} — {{milestoneTitle}} +**Slice:** {{sliceId}} — {{sliceTitle}} + +## Mission + +You are evaluating **quality gates in parallel** for this slice. Each gate is an independent question that must be answered before task execution begins. Use the `subagent` tool to dispatch all gate evaluations simultaneously. + +## Slice Plan Context + +{{slicePlanContent}} + +## Gates to Evaluate + +{{gateCount}} gates require evaluation: + +{{gateList}} + +## Execution Protocol + +1. **Dispatch all gates** using `subagent` in parallel mode. Each subagent prompt is provided below. +2. **Wait for all subagents** to complete. +3. **Verify each gate wrote its result** by checking that `gsd_save_gate_result` was called for each gate ID. +4. **Report the batch outcome** — which gates passed, which flagged concerns, and which were omitted as not applicable. + +Gate agents may return `verdict: "omitted"` if the gate question is not applicable to this slice (e.g., no auth surface for Q3, no existing requirements touched for Q4). This is expected for simple slices. + +## Subagent Prompts + +{{subagentPrompts}} diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 7550626c9..2f801d538 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -50,6 +50,7 @@ import { getSlice, insertMilestone, updateTaskStatus, + getPendingSliceGateCount, type MilestoneRow, type SliceRow, type TaskRow, @@ -711,6 +712,22 @@ export async function deriveStateFromDb(basePath: string): Promise { } } + // ── Quality gate evaluation check ────────────────────────────────── + // If slice-scoped gates (Q3/Q4) are still pending, pause before execution + // so the gate-evaluate dispatch rule can run parallel sub-agents. + // Slices with zero gate rows (pre-feature or simple) skip straight through. + const pendingGateCount = getPendingSliceGateCount(activeMilestone.id, activeSlice.id); + if (pendingGateCount > 0) { + return { + activeMilestone, activeSlice, activeTask: null, + phase: 'evaluating-gates', + recentDecisions: [], blockers: [], + nextAction: `Evaluate ${pendingGateCount} quality gate(s) for ${activeSlice.id} before execution.`, + registry, requirements, + progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress }, + }; + } + // ── Blocker detection: check completed tasks for blocker_discovered ── const completedTasks = tasks.filter(t => isStatusDone(t.status)); let blockerTaskId: string | null = null; diff --git a/src/resources/extensions/gsd/tests/complete-slice.test.ts b/src/resources/extensions/gsd/tests/complete-slice.test.ts index 44f78b4c3..1c60324f2 100644 --- a/src/resources/extensions/gsd/tests/complete-slice.test.ts +++ b/src/resources/extensions/gsd/tests/complete-slice.test.ts @@ -125,9 +125,9 @@ console.log('\n=== complete-slice: schema v6 migration ==='); const adapter = _getAdapter()!; - // Verify schema version is current (v10 after M001 planning migrations) + // Verify schema version is current (v12 after quality gates table) const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assertEq(versionRow?.['v'], 11, 'schema version should be 11'); + assertEq(versionRow?.['v'], 12, 'schema version should be 12'); // Verify slices table has full_summary_md and full_uat_md columns const cols = adapter.prepare("PRAGMA table_info(slices)").all(); diff --git a/src/resources/extensions/gsd/tests/complete-task.test.ts b/src/resources/extensions/gsd/tests/complete-task.test.ts index de46a64d9..53e2cce19 100644 --- a/src/resources/extensions/gsd/tests/complete-task.test.ts +++ b/src/resources/extensions/gsd/tests/complete-task.test.ts @@ -109,9 +109,9 @@ console.log('\n=== complete-task: schema v5 migration ==='); const adapter = _getAdapter()!; - // Verify schema version is current (v11 after state machine migration) + // Verify schema version is current (v12 after quality gates table) const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assertEq(versionRow?.['v'], 11, 'schema version should be 11'); + assertEq(versionRow?.['v'], 12, 'schema version should be 12'); // Verify all 4 new tables exist const tables = adapter.prepare( diff --git a/src/resources/extensions/gsd/tests/gate-dispatch.test.ts b/src/resources/extensions/gsd/tests/gate-dispatch.test.ts new file mode 100644 index 000000000..3b18a2fbf --- /dev/null +++ b/src/resources/extensions/gsd/tests/gate-dispatch.test.ts @@ -0,0 +1,189 @@ +// Quality gate dispatch + state derivation tests +// Verifies the evaluating-gates phase and dispatch rule behavior. + +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + openDatabase, + closeDatabase, + insertMilestone, + insertSlice, + insertTask, + upsertSlicePlanning, + upsertTaskPlanning, + insertGateRow, + saveGateResult, + markAllGatesOmitted, + getPendingSliceGateCount, +} from "../gsd-db.ts"; +import { deriveState, invalidateStateCache } from "../state.ts"; +import { renderPlanFromDb } from "../markdown-renderer.ts"; +import { invalidateAllCaches } from "../cache.ts"; + +function setupTestProject(): { tmpDir: string; dbPath: string } { + const tmpDir = mkdtempSync(join(tmpdir(), "gate-dispatch-")); + const dbPath = join(tmpDir, ".gsd", "gsd.db"); + mkdirSync(join(tmpDir, ".gsd"), { recursive: true }); + openDatabase(dbPath); + + // Create milestone + insertMilestone({ + id: "M001", + title: "Test Milestone", + status: "active", + }); + + // Create slice + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Test Slice", + status: "pending", + risk: "medium", + depends: [], + }); + + // Write roadmap file (required for deriveState) + const milestoneDir = join(tmpDir, ".gsd", "milestones", "M001"); + mkdirSync(milestoneDir, { recursive: true }); + writeFileSync( + join(milestoneDir, "M001-ROADMAP.md"), + [ + "# M001: Test Milestone", + "", + "## Vision", + "Test milestone vision.", + "", + "## Success Criteria", + "- Test criteria", + "", + "## Delivery Sequence", + "- [ ] **S01: Test Slice** `risk:medium`", + " After this: test demo", + "", + ].join("\n"), + ); + + return { tmpDir, dbPath }; +} + +function planSlice(tmpDir: string) { + upsertSlicePlanning("M001", "S01", { + goal: "Test goal", + successCriteria: "Test criteria", + proofLevel: "contract", + integrationClosure: "", + observabilityImpact: "Run tests", + }); + insertTask({ + id: "T01", + sliceId: "S01", + milestoneId: "M001", + title: "Test Task", + status: "pending", + }); + upsertTaskPlanning("M001", "S01", "T01", { + title: "Test Task", + description: "Implement test", + estimate: "1h", + files: ["src/test.ts"], + verify: "npm test", + inputs: [], + expectedOutput: ["src/test.ts"], + observabilityImpact: "", + fullPlanMd: "", + }); +} + +describe("evaluating-gates phase", () => { + let tmpDir: string; + + beforeEach(() => { + const setup = setupTestProject(); + tmpDir = setup.tmpDir; + }); + + afterEach(() => { + invalidateAllCaches(); + invalidateStateCache(); + closeDatabase(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("state returns evaluating-gates when slice gates are pending", async () => { + planSlice(tmpDir); + await renderPlanFromDb(tmpDir, "M001", "S01"); + + // Seed gates as pending + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" }); + + invalidateStateCache(); + const state = await deriveState(tmpDir); + assert.equal(state.phase, "evaluating-gates"); + assert.ok(state.nextAction.includes("quality gate")); + }); + + test("state returns executing when all gates are resolved", async () => { + planSlice(tmpDir); + await renderPlanFromDb(tmpDir, "M001", "S01"); + + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" }); + + saveGateResult({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", verdict: "pass", rationale: "OK", findings: "" }); + saveGateResult({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", verdict: "omitted", rationale: "N/A", findings: "" }); + + invalidateStateCache(); + const state = await deriveState(tmpDir); + assert.equal(state.phase, "executing"); + }); + + test("state returns executing when no gates exist (backward compat)", async () => { + planSlice(tmpDir); + await renderPlanFromDb(tmpDir, "M001", "S01"); + + // No gates seeded at all + invalidateStateCache(); + const state = await deriveState(tmpDir); + assert.equal(state.phase, "executing"); + }); + + test("markAllGatesOmitted clears evaluating-gates phase", async () => { + planSlice(tmpDir); + await renderPlanFromDb(tmpDir, "M001", "S01"); + + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" }); + + invalidateStateCache(); + assert.equal((await deriveState(tmpDir)).phase, "evaluating-gates"); + + markAllGatesOmitted("M001", "S01"); + invalidateStateCache(); + assert.equal((await deriveState(tmpDir)).phase, "executing"); + }); + + test("task-scoped gates do not block evaluating-gates phase", async () => { + planSlice(tmpDir); + await renderPlanFromDb(tmpDir, "M001", "S01"); + + // Only task-scoped gates — no slice-scoped gates + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" }); + + invalidateStateCache(); + const state = await deriveState(tmpDir); + // Should be executing, not evaluating-gates, because Q5 is task-scoped + assert.equal(state.phase, "executing"); + }); + + test("getPendingSliceGateCount ignores task-scoped gates", () => { + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" }); + assert.equal(getPendingSliceGateCount("M001", "S01"), 1); + }); +}); diff --git a/src/resources/extensions/gsd/tests/gate-storage.test.ts b/src/resources/extensions/gsd/tests/gate-storage.test.ts new file mode 100644 index 000000000..6b903ed7d --- /dev/null +++ b/src/resources/extensions/gsd/tests/gate-storage.test.ts @@ -0,0 +1,156 @@ +// Quality gate DB storage tests +// Verifies CRUD operations on the quality_gates table. + +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + openDatabase, + closeDatabase, + insertGateRow, + saveGateResult, + getPendingGates, + getGateResults, + markAllGatesOmitted, + getPendingSliceGateCount, + insertMilestone, + insertSlice, +} from "../gsd-db.ts"; + +describe("quality_gates CRUD", () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "gate-test-")); + dbPath = join(tmpDir, "gsd.db"); + openDatabase(dbPath); + // Seed parent rows + insertMilestone({ + id: "M001", + title: "Test Milestone", + status: "active", + }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Test Slice", + status: "pending", + risk: "medium", + depends: [], + }); + }); + + afterEach(() => { + closeDatabase(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("insertGateRow creates a pending gate", () => { + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + const pending = getPendingGates("M001", "S01"); + assert.equal(pending.length, 1); + assert.equal(pending[0].gate_id, "Q3"); + assert.equal(pending[0].status, "pending"); + assert.equal(pending[0].scope, "slice"); + }); + + test("insertGateRow with INSERT OR IGNORE is idempotent", () => { + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + const all = getGateResults("M001", "S01"); + assert.equal(all.length, 1); + }); + + test("saveGateResult updates status and verdict", () => { + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + saveGateResult({ + milestoneId: "M001", + sliceId: "S01", + gateId: "Q3", + verdict: "pass", + rationale: "No auth surface", + findings: "This slice has no user-facing endpoints.", + }); + const results = getGateResults("M001", "S01"); + assert.equal(results.length, 1); + assert.equal(results[0].status, "complete"); + assert.equal(results[0].verdict, "pass"); + assert.equal(results[0].rationale, "No auth surface"); + assert.ok(results[0].evaluated_at); + }); + + test("getPendingGates filters by scope", () => { + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" }); + + const sliceGates = getPendingGates("M001", "S01", "slice"); + assert.equal(sliceGates.length, 1); + assert.equal(sliceGates[0].gate_id, "Q3"); + + const taskGates = getPendingGates("M001", "S01", "task"); + assert.equal(taskGates.length, 1); + assert.equal(taskGates[0].gate_id, "Q5"); + }); + + test("markAllGatesOmitted marks all pending gates as omitted", () => { + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" }); + + markAllGatesOmitted("M001", "S01"); + + const pending = getPendingGates("M001", "S01"); + assert.equal(pending.length, 0); + + const all = getGateResults("M001", "S01"); + assert.equal(all.length, 3); + for (const g of all) { + assert.equal(g.status, "omitted"); + assert.equal(g.verdict, "omitted"); + } + }); + + test("getPendingSliceGateCount returns correct count", () => { + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" }); + + assert.equal(getPendingSliceGateCount("M001", "S01"), 2); + + saveGateResult({ + milestoneId: "M001", sliceId: "S01", gateId: "Q3", + verdict: "pass", rationale: "OK", findings: "", + }); + assert.equal(getPendingSliceGateCount("M001", "S01"), 1); + }); + + test("task-scoped gates with different task_id are distinct", () => { + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" }); + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T02" }); + + const all = getGateResults("M001", "S01", "task"); + assert.equal(all.length, 2); + }); + + test("getGateResults returns empty for nonexistent slice", () => { + const results = getGateResults("M001", "S99"); + assert.equal(results.length, 0); + }); + + test("saveGateResult with flag verdict preserves findings", () => { + insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" }); + saveGateResult({ + milestoneId: "M001", sliceId: "S01", gateId: "Q4", + verdict: "flag", rationale: "Breaks R003", + findings: "## R003 Impact\n\n- Login flow must be re-tested\n- Session token format changed", + }); + const results = getGateResults("M001", "S01", "slice"); + const q4 = results.find(g => g.gate_id === "Q4")!; + assert.equal(q4.verdict, "flag"); + assert.ok(q4.findings.includes("R003 Impact")); + }); +}); diff --git a/src/resources/extensions/gsd/tests/gsd-db.test.ts b/src/resources/extensions/gsd/tests/gsd-db.test.ts index 82eb53c73..324c7be3b 100644 --- a/src/resources/extensions/gsd/tests/gsd-db.test.ts +++ b/src/resources/extensions/gsd/tests/gsd-db.test.ts @@ -64,7 +64,7 @@ describe('gsd-db', () => { // Check schema_version table const adapter = _getAdapter()!; const version = adapter.prepare('SELECT MAX(version) as version FROM schema_version').get(); - assert.deepStrictEqual(version?.['version'], 11, 'schema version should be 11'); + assert.deepStrictEqual(version?.['version'], 12, 'schema version should be 12'); // Check tables exist by querying them const dRows = adapter.prepare('SELECT count(*) as cnt FROM decisions').get(); diff --git a/src/resources/extensions/gsd/tests/md-importer.test.ts b/src/resources/extensions/gsd/tests/md-importer.test.ts index 23eda19e6..4e4c96396 100644 --- a/src/resources/extensions/gsd/tests/md-importer.test.ts +++ b/src/resources/extensions/gsd/tests/md-importer.test.ts @@ -363,7 +363,7 @@ test('md-importer: schema v1→v2 migration', () => { openDatabase(':memory:'); const adapter = _getAdapter(); const version = adapter?.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assert.deepStrictEqual(version?.v, 11, 'new DB should be at schema version 11'); + assert.deepStrictEqual(version?.v, 12, 'new DB should be at schema version 12'); // Artifacts table should exist const tableCheck = adapter?.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='artifacts'").get(); diff --git a/src/resources/extensions/gsd/tests/memory-store.test.ts b/src/resources/extensions/gsd/tests/memory-store.test.ts index 8194b1d1c..b2a900f18 100644 --- a/src/resources/extensions/gsd/tests/memory-store.test.ts +++ b/src/resources/extensions/gsd/tests/memory-store.test.ts @@ -323,9 +323,9 @@ test('memory-store: schema includes memories table', () => { const viewCount = adapter.prepare('SELECT count(*) as cnt FROM active_memories').get(); assert.deepStrictEqual(viewCount?.['cnt'], 0, 'active_memories view should exist'); - // Verify schema version is 11 (after state machine migration) + // Verify schema version is 12 (after quality gates table) const version = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assert.deepStrictEqual(version?.['v'], 11, 'schema version should be 11'); + assert.deepStrictEqual(version?.['v'], 12, 'schema version should be 12'); closeDatabase(); }); diff --git a/src/resources/extensions/gsd/tests/tool-naming.test.ts b/src/resources/extensions/gsd/tests/tool-naming.test.ts index 96609f507..772a4eed6 100644 --- a/src/resources/extensions/gsd/tests/tool-naming.test.ts +++ b/src/resources/extensions/gsd/tests/tool-naming.test.ts @@ -44,7 +44,7 @@ console.log('\n── Tool naming: registration count ──'); const pi = makeMockPi(); registerDbTools(pi); -assert.deepStrictEqual(pi.tools.length, 26, 'Should register exactly 26 tools (13 canonical + 13 aliases)'); +assert.deepStrictEqual(pi.tools.length, 27, 'Should register exactly 27 tools (13 canonical + 13 aliases + 1 gate tool)'); // ─── Both names exist for each pair ────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/tools/plan-slice.ts b/src/resources/extensions/gsd/tools/plan-slice.ts index 3f2951a22..8229aee52 100644 --- a/src/resources/extensions/gsd/tools/plan-slice.ts +++ b/src/resources/extensions/gsd/tools/plan-slice.ts @@ -6,8 +6,10 @@ import { insertTask, upsertSlicePlanning, upsertTaskPlanning, + insertGateRow, _getAdapter, } from "../gsd-db.js"; +import type { GateId } from "../types.js"; import { invalidateStateCache } from "../state.js"; import { renderPlanFromDb } from "../markdown-renderer.js"; import { renderAllProjections } from "../workflow-projections.js"; @@ -190,6 +192,20 @@ export async function handlePlanSlice( fullPlanMd: task.fullPlanMd, }); } + + // Seed quality gate rows inside the transaction — all-or-nothing with + // the plan data so a crash can't leave orphaned gates without tasks. + const sliceGates: GateId[] = ["Q3", "Q4"]; + for (const gid of sliceGates) { + insertGateRow({ milestoneId: params.milestoneId, sliceId: params.sliceId, gateId: gid, scope: "slice" }); + } + const taskGates: GateId[] = ["Q5", "Q6", "Q7"]; + for (const task of params.tasks) { + for (const gid of taskGates) { + insertGateRow({ milestoneId: params.milestoneId, sliceId: params.sliceId, gateId: gid, scope: "task", taskId: task.taskId }); + } + } + insertGateRow({ milestoneId: params.milestoneId, sliceId: params.sliceId, gateId: "Q8", scope: "slice" }); }); } catch (err) { return { error: `db write failed: ${(err as Error).message}` }; diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index 66c9c23f5..e56093f6a 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -11,6 +11,7 @@ export type Phase = | "discussing" | "researching" | "planning" + | "evaluating-gates" | "executing" | "verifying" | "summarizing" @@ -557,3 +558,32 @@ export interface CompleteSliceParams { /** Optional caller-provided reason this action was triggered */ triggerReason?: string; } + +// ─── Quality Gates ─────────────────────────────────────────────────────── + +export type GateId = "Q3" | "Q4" | "Q5" | "Q6" | "Q7" | "Q8"; +export type GateScope = "slice" | "task"; +export type GateStatus = "pending" | "complete" | "omitted"; +export type GateVerdict = "pass" | "flag" | "omitted" | ""; + +export interface GateRow { + milestone_id: string; + slice_id: string; + gate_id: GateId; + scope: GateScope; + task_id: string; + status: GateStatus; + verdict: GateVerdict; + rationale: string; + findings: string; + evaluated_at: string | null; +} + +/** Configuration for parallel quality gate evaluation during slice planning. */ +export interface GateEvaluationConfig { + enabled: boolean; + /** Which slice-scoped gates to evaluate in parallel. Default: ['Q3', 'Q4']. */ + slice_gates?: string[]; + /** Whether to evaluate task-level gates (Q5/Q6/Q7) via reactive-execute. Default: true when enabled. */ + task_gates?: boolean; +} diff --git a/web/components/gsd/scope-badge.tsx b/web/components/gsd/scope-badge.tsx index 0c7d6d80a..127c5329c 100644 --- a/web/components/gsd/scope-badge.tsx +++ b/web/components/gsd/scope-badge.tsx @@ -29,6 +29,8 @@ function phasePresentation(phase: string): { label: string; tone: PhaseTone } { return { label: "Replanning", tone: "info" } case "completing-milestone": return { label: "Completing", tone: "info" } + case "evaluating-gates": + return { label: "Evaluating Gates", tone: "info" } default: return { label: phase, tone: "muted" } }