feat(gsd-uok): enforce plan-v2 compile gates and graph metadata

This commit is contained in:
Jeremy McSpadden 2026-04-14 20:41:43 -05:00
parent 558ac1067b
commit 5a6a13eb39
4 changed files with 286 additions and 6 deletions

View file

@ -12,6 +12,7 @@ import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from
import type { AutoSession, SidecarItem } from "./session.js";
import type { LoopDeps } from "./loop-deps.js";
import type { PostUnitContext, PreVerificationOpts } from "../auto-post-unit.js";
import type { Phase } from "../types.js";
import {
MAX_RECOVERY_CHARS,
BUDGET_THRESHOLDS,
@ -80,6 +81,17 @@ export function _resolveDispatchGuardBasePath(
return s.originalBasePath || s.basePath;
}
const PLAN_V2_GATE_PHASES: ReadonlySet<Phase> = new Set([
"executing",
"summarizing",
"validating-milestone",
"completing-milestone",
]);
function shouldRunPlanV2Gate(phase: Phase): boolean {
return PLAN_V2_GATE_PHASES.has(phase);
}
/**
* Generate and write an HTML milestone report snapshot.
* Extracted from the milestone-transition block in autoLoop.
@ -325,7 +337,7 @@ export async function runPreDispatch(
// Derive state
let state = await deps.deriveState(s.basePath);
if (prefs?.uok?.plan_v2?.enabled) {
if (prefs?.uok?.plan_v2?.enabled && shouldRunPlanV2Gate(state.phase)) {
const compiled = ensurePlanV2Graph(s.basePath, state);
if (!compiled.ok) {
const reason = compiled.reason ?? "Plan v2 compilation failed";

View file

@ -7,6 +7,7 @@
*/
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import type { GSDState } from "./types.js";
import { showNextAction } from "../shared/tui.js";
import { loadFile, saveFile } from "./files.js";
import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js";
@ -36,6 +37,8 @@ import { nativeIsRepo, nativeInit } from "./native-git-bridge.js";
import { isInheritedRepo } from "./repo-identity.js";
import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
import { resolveUokFlags } from "./uok/flags.js";
import { ensurePlanV2Graph } from "./uok/plan-v2.js";
import { detectProjectState } from "./detection.js";
import { showProjectInit, offerMigration } from "./init-wizard.js";
import { validateDirectory } from "./validate-directory.js";
@ -83,6 +86,33 @@ function nextMilestoneIdReserved(existingIds: string[], uniqueEnabled: boolean):
return id;
}
function needsPlanV2Gate(state: GSDState): boolean {
return state.phase === "executing"
|| state.phase === "summarizing"
|| state.phase === "validating-milestone"
|| state.phase === "completing-milestone";
}
function runPlanV2Gate(
ctx: ExtensionContext,
basePath: string,
state: GSDState,
): boolean {
const prefs = loadEffectiveGSDPreferences()?.preferences;
const uokFlags = resolveUokFlags(prefs);
if (!uokFlags.planV2 || !needsPlanV2Gate(state)) return true;
const compiled = ensurePlanV2Graph(basePath, state);
if (!compiled.ok) {
const reason = compiled.reason ?? "plan-v2 compilation failed";
ctx.ui.notify(
`Plan gate failed-closed: ${reason}. Complete plan/discuss artifacts before execution.`,
"error",
);
return false;
}
return true;
}
// ─── Commit Instruction Helpers ──────────────────────────────────────────────
/** Build commit instruction for planning prompts. .gsd/ is managed externally and always gitignored. */
@ -1320,6 +1350,8 @@ export async function showSmartEntry(
logWarning("guided", `STATE.md rebuild failed: ${(err as Error).message}`);
}
if (!runPlanV2Gate(ctx, basePath, state)) return;
if (!state.activeMilestone?.id) {
// Guard: if a discuss session is already in flight, don't re-inject the prompt.
// Both /gsd and /gsd auto reach this branch when no milestone exists yet.

View file

@ -0,0 +1,167 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import {
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
openDatabase,
} from "../gsd-db.ts";
import type { GSDState, Phase } from "../types.ts";
import { ensurePlanV2Graph } from "../uok/plan-v2.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const gsdDir = join(__dirname, "..");
const MILESTONE_ID = "M001";
const SLICE_ID = "S01";
const TASK_ID = "T01";
const tempDirs = new Set<string>();
function createBasePath(): string {
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-planv2-"));
mkdirSync(join(basePath, ".gsd", "milestones", MILESTONE_ID), { recursive: true });
tempDirs.add(basePath);
return basePath;
}
function writeMilestoneFile(basePath: string, suffix: string, content: string): void {
const milestoneDir = join(basePath, ".gsd", "milestones", MILESTONE_ID);
mkdirSync(milestoneDir, { recursive: true });
writeFileSync(join(milestoneDir, `${MILESTONE_ID}-${suffix}.md`), `${content}\n`, "utf-8");
}
function writeSliceFile(basePath: string, suffix: string, content: string): void {
const sliceDir = join(basePath, ".gsd", "milestones", MILESTONE_ID, "slices", SLICE_ID);
mkdirSync(sliceDir, { recursive: true });
writeFileSync(join(sliceDir, `${SLICE_ID}-${suffix}.md`), `${content}\n`, "utf-8");
}
function seedGraphRows(): void {
insertMilestone({ id: MILESTONE_ID, title: "Milestone", status: "active" });
insertSlice({
id: SLICE_ID,
milestoneId: MILESTONE_ID,
title: "Slice",
status: "in_progress",
sequence: 1,
});
insertTask({
id: TASK_ID,
milestoneId: MILESTONE_ID,
sliceId: SLICE_ID,
title: "Task",
status: "pending",
keyFiles: ["src/task.ts"],
sequence: 1,
});
}
function buildState(phase: Phase): GSDState {
return {
phase,
activeMilestone: { id: MILESTONE_ID, title: "Milestone" },
activeSlice: null,
activeTask: null,
recentDecisions: [],
blockers: [],
nextAction: "dispatch",
registry: [],
};
}
test.beforeEach(() => {
closeDatabase();
const opened = openDatabase(":memory:");
assert.equal(opened, true);
});
test.afterEach(() => {
closeDatabase();
for (const path of tempDirs) {
rmSync(path, { recursive: true, force: true });
}
tempDirs.clear();
});
test("guided flow enforces plan-v2 gate before execution-oriented dispatch", () => {
const source = readFileSync(join(gsdDir, "guided-flow.ts"), "utf-8");
assert.ok(
source.includes("needsPlanV2Gate") &&
source.includes("ensurePlanV2Graph") &&
source.includes("Plan gate failed-closed"),
"guided flow should fail-closed when plan-v2 graph compilation fails",
);
});
test("plan-v2 gate fails closed for execution phase when finalized context is missing", () => {
const basePath = createBasePath();
seedGraphRows();
writeMilestoneFile(basePath, "CONTEXT-DRAFT", "Draft context only.");
const compiled = ensurePlanV2Graph(basePath, buildState("executing"));
assert.equal(compiled.ok, false);
assert.match(compiled.reason ?? "", /CONTEXT\.md/i);
});
test("plan-v2 compiler writes pipeline metadata for clarify/research/draft stages", () => {
const basePath = createBasePath();
seedGraphRows();
writeMilestoneFile(basePath, "CONTEXT", "Finalized context.");
writeMilestoneFile(basePath, "CONTEXT-DRAFT", "Draft context retained.");
writeMilestoneFile(basePath, "RESEARCH", "Milestone research synthesis.");
writeSliceFile(basePath, "RESEARCH", "Slice research detail.");
const compiled = ensurePlanV2Graph(basePath, buildState("executing"));
assert.equal(compiled.ok, true);
assert.equal(compiled.clarifyRoundLimit, 3);
assert.equal(compiled.researchSynthesized, true);
assert.equal(compiled.draftContextIncluded, true);
assert.equal(compiled.finalizedContextIncluded, true);
const graphPath = compiled.graphPath ?? "";
const graphRaw = readFileSync(graphPath, "utf-8");
const graph = JSON.parse(graphRaw) as {
pipeline?: Record<string, unknown>;
nodes?: unknown[];
};
assert.equal(graph.pipeline?.["clarifyRoundLimit"], 3);
assert.equal(graph.pipeline?.["researchSynthesized"], true);
assert.equal(graph.pipeline?.["draftContextIncluded"], true);
assert.equal(graph.pipeline?.["finalizedContextIncluded"], true);
assert.equal(Array.isArray(graph.nodes), true);
});
test("plan-v2 graph may compile during planning even without finalized context", () => {
const basePath = createBasePath();
seedGraphRows();
writeMilestoneFile(basePath, "CONTEXT-DRAFT", "Planning draft context.");
const compiled = ensurePlanV2Graph(basePath, buildState("planning"));
assert.equal(compiled.ok, true);
});
test("plan-v2 ensure rejects empty executable graph", () => {
const basePath = createBasePath();
writeMilestoneFile(basePath, "CONTEXT", "Finalized context.");
insertMilestone({ id: MILESTONE_ID, title: "Milestone", status: "active" });
insertSlice({
id: SLICE_ID,
milestoneId: MILESTONE_ID,
title: "Slice",
status: "pending",
sequence: 1,
});
const compiled = ensurePlanV2Graph(basePath, buildState("executing"));
assert.equal(compiled.ok, false);
assert.match(compiled.reason ?? "", /compiled graph is empty/i);
});

View file

@ -1,22 +1,57 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { GSDState } from "../types.js";
import { gsdRoot } from "../paths.js";
import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "../gsd-db.js";
import type { GSDState, Phase } from "../types.js";
import { gsdRoot, resolveMilestoneFile, resolveSliceFile } from "../paths.js";
import { isDbAvailable, getMilestoneSlices, getSliceTasks, type SliceRow } from "../gsd-db.js";
import type { UokGraphNode } from "./contracts.js";
const PLAN_V2_CLARIFY_ROUND_LIMIT = 3;
const EXECUTION_ENTRY_PHASES: ReadonlySet<Phase> = new Set([
"executing",
"summarizing",
"validating-milestone",
"completing-milestone",
]);
export interface PlanV2CompileResult {
ok: boolean;
reason?: string;
graphPath?: string;
nodeCount?: number;
clarifyRoundLimit?: number;
researchSynthesized?: boolean;
draftContextIncluded?: boolean;
finalizedContextIncluded?: boolean;
}
function graphOutputPath(basePath: string): string {
return join(gsdRoot(basePath), "runtime", "uok-plan-v2-graph.json");
}
function hasFileContent(path: string | null): boolean {
if (!path || !existsSync(path)) return false;
try {
return readFileSync(path, "utf-8").trim().length > 0;
} catch {
return false;
}
}
function countSliceResearchArtifacts(basePath: string, milestoneId: string, slices: SliceRow[]): number {
let count = 0;
for (const slice of slices) {
if (hasFileContent(resolveSliceFile(basePath, milestoneId, slice.id, "RESEARCH"))) {
count += 1;
}
}
return count;
}
function isExecutionEntryPhase(phase: Phase): boolean {
return EXECUTION_ENTRY_PHASES.has(phase);
}
export function compileUnitGraphFromState(basePath: string, state: GSDState): PlanV2CompileResult {
const mid = state.activeMilestone?.id;
if (!mid) return { ok: false, reason: "no active milestone" };
@ -24,6 +59,25 @@ export function compileUnitGraphFromState(basePath: string, state: GSDState): Pl
const slices = getMilestoneSlices(mid).sort((a, b) => Number(a.sequence ?? 0) - Number(b.sequence ?? 0));
const nodes: UokGraphNode[] = [];
const clarifyRoundLimit = PLAN_V2_CLARIFY_ROUND_LIMIT;
const draftContextIncluded = hasFileContent(resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"));
const finalizedContextIncluded = hasFileContent(resolveMilestoneFile(basePath, mid, "CONTEXT"));
const researchSynthesized = hasFileContent(resolveMilestoneFile(basePath, mid, "RESEARCH"))
|| countSliceResearchArtifacts(basePath, mid, slices) > 0;
if (isExecutionEntryPhase(state.phase) && !finalizedContextIncluded) {
const reason = draftContextIncluded
? "milestone context draft exists but finalized CONTEXT.md is missing"
: "missing milestone CONTEXT.md";
return {
ok: false,
reason,
clarifyRoundLimit,
researchSynthesized,
draftContextIncluded,
finalizedContextIncluded,
};
}
for (const slice of slices) {
const sid = slice.id;
@ -67,6 +121,13 @@ export function compileUnitGraphFromState(basePath: string, state: GSDState): Pl
const output = {
compiledAt: new Date().toISOString(),
milestoneId: mid,
pipeline: {
clarifyRoundLimit,
researchSynthesized,
draftContextIncluded,
finalizedContextIncluded,
sourcePhase: state.phase,
},
nodes,
};
@ -74,7 +135,15 @@ export function compileUnitGraphFromState(basePath: string, state: GSDState): Pl
mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true });
writeFileSync(outPath, JSON.stringify(output, null, 2) + "\n", "utf-8");
return { ok: true, graphPath: outPath, nodeCount: nodes.length };
return {
ok: true,
graphPath: outPath,
nodeCount: nodes.length,
clarifyRoundLimit,
researchSynthesized: output.pipeline.researchSynthesized,
draftContextIncluded: output.pipeline.draftContextIncluded,
finalizedContextIncluded: output.pipeline.finalizedContextIncluded,
};
}
export function ensurePlanV2Graph(basePath: string, state: GSDState): PlanV2CompileResult {