feat: add parallel quality gate evaluation with evaluating-gates phase

Introduce infrastructure to spawn parallel sub-agents for independent
quality gate questions (Q3: Threat Surface, Q4: Requirement Impact)
during slice planning, reducing wall-clock time per milestone.

- quality_gates DB table (schema v12) with CRUD functions
- evaluating-gates phase in state machine between planning and executing
- gate-evaluate dispatch rule (opt-in via gate_evaluation preference)
- gsd_save_gate_result tool for sub-agents to persist findings
- Gate seeding inside plan-slice transaction (atomic with plan + tasks)
- Markdown renderer injects gate findings into plan.md and task-plan.md
- Recovery, rogue detection, dashboard, and scope-badge wired for new phase
- 15 new tests (9 storage + 6 dispatch/state)

  plan-slice tool
    └─ transaction: upsertSlicePlanning + insertTask(s) + insertGateRow(s)
    └─ renderPlanFromDb

  deriveState() → phase: "evaluating-gates"  (pending slice gates)

  auto-dispatch: "evaluating-gates → gate-evaluate"
    ├─ if !prefs.gate_evaluation.enabled → markAllGatesOmitted → skip
    └─ dispatch gate-evaluate unit
         └─ parent agent spawns sub-agents in parallel:
              ├─ Q3 agent → gsd_save_gate_result(verdict, findings)
              └─ Q4 agent → gsd_save_gate_result(verdict, findings)

  deriveState() → phase: "executing"  (no pending slice gates)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ethan Hurst 2026-03-26 14:17:18 +10:00
parent 10e18e6a4b
commit f70a912d59
23 changed files with 932 additions and 17 deletions

View file

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

View file

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

View file

@ -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 }) => {

View file

@ -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<string, { question: string; guidance: string }> = {
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<string> {
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,

View file

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

View file

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

View file

@ -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<string, unknown> | null {
).get({ ":path": path });
return row ?? null;
}
// ─── Quality Gates ───────────────────────────────────────────────────────
function rowToGate(row: Record<string, unknown>): 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<string, unknown> = { ":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<string, unknown> = { ":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;
}

View file

@ -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<string, string> = { 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",

View file

@ -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<string>([
"context_selection",
"widget_mode",
"reactive_execution",
"gate_evaluation",
"github",
"service_tier",
"forensics_dedup",
@ -96,7 +98,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
/** 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. */

View file

@ -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<string, unknown>;
const validGe: Record<string, unknown> = {};
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)) {

View file

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

View file

@ -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<GSDState> {
}
}
// ── 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;

View file

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

View file

@ -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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ──────────────────────────────────────────

View file

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

View file

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

View file

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