feat(gsd-uok): enforce plan-v2 compile gates and graph metadata
This commit is contained in:
parent
558ac1067b
commit
5a6a13eb39
4 changed files with 286 additions and 6 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
167
src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts
Normal file
167
src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue