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:
parent
10e18e6a4b
commit
f70a912d59
23 changed files with 932 additions and 17 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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." };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
32
src/resources/extensions/gsd/prompts/gate-evaluate.md
Normal file
32
src/resources/extensions/gsd/prompts/gate-evaluate.md
Normal 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}}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
189
src/resources/extensions/gsd/tests/gate-dispatch.test.ts
Normal file
189
src/resources/extensions/gsd/tests/gate-dispatch.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
156
src/resources/extensions/gsd/tests/gate-storage.test.ts
Normal file
156
src/resources/extensions/gsd/tests/gate-storage.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue