From 5a6a13eb399f7957a502ad1bd56d5da457b3f094 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:41:43 -0500 Subject: [PATCH] feat(gsd-uok): enforce plan-v2 compile gates and graph metadata --- src/resources/extensions/gsd/auto/phases.ts | 14 +- src/resources/extensions/gsd/guided-flow.ts | 32 ++++ .../gsd/tests/uok-plan-v2-wiring.test.ts | 167 ++++++++++++++++++ src/resources/extensions/gsd/uok/plan-v2.ts | 79 ++++++++- 4 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 7f3067778..c4ad7f4d9 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -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 = 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"; diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 8892564a6..dd2fe18ac 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -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. diff --git a/src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts b/src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts new file mode 100644 index 000000000..34b2ba213 --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts @@ -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(); + +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; + 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); +}); diff --git a/src/resources/extensions/gsd/uok/plan-v2.ts b/src/resources/extensions/gsd/uok/plan-v2.ts index ef8198a15..6e58b14ed 100644 --- a/src/resources/extensions/gsd/uok/plan-v2.ts +++ b/src/resources/extensions/gsd/uok/plan-v2.ts @@ -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 = 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 {