From bb1b9dce07c82df79fbdcb6e7283b75cee5d89e2 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 18:45:05 -0500 Subject: [PATCH 01/11] Integrate UOK model policy gates and kernel loop adapter --- .../extensions/gsd/auto-model-selection.ts | 53 ++- .../extensions/gsd/auto-verification.ts | 34 ++ src/resources/extensions/gsd/auto.ts | 17 +- .../extensions/gsd/auto/loop-deps.ts | 4 + src/resources/extensions/gsd/auto/loop.ts | 152 ++++++++- src/resources/extensions/gsd/auto/phases.ts | 10 + .../extensions/gsd/commands-prefs-wizard.ts | 2 +- .../gsd/docs/preferences-reference.md | 13 +- src/resources/extensions/gsd/gsd-db.ts | 320 +++++++++++++++++- src/resources/extensions/gsd/init-wizard.ts | 5 +- .../extensions/gsd/preferences-models.ts | 23 +- .../extensions/gsd/preferences-types.ts | 29 ++ .../extensions/gsd/preferences-validation.ts | 107 +++++- src/resources/extensions/gsd/preferences.ts | 25 ++ .../extensions/gsd/templates/PREFERENCES.md | 16 + .../extensions/gsd/tests/auto-loop.test.ts | 10 +- .../gsd/tests/complete-slice.test.ts | 4 +- .../gsd/tests/complete-task.test.ts | 4 +- .../extensions/gsd/tests/gsd-db.test.ts | 2 +- .../extensions/gsd/tests/md-importer.test.ts | 3 +- .../extensions/gsd/tests/memory-store.test.ts | 5 +- .../gsd/tests/token-profile.test.ts | 13 +- .../gsd/tests/uok-contracts.test.ts | 85 +++++ .../gsd/tests/uok-gate-runner.test.ts | 70 ++++ .../gsd/tests/uok-preferences.test.ts | 40 +++ src/resources/extensions/gsd/types.ts | 2 +- src/resources/extensions/gsd/uok/audit.ts | 51 +++ src/resources/extensions/gsd/uok/contracts.ts | 135 ++++++++ .../extensions/gsd/uok/execution-graph.ts | 121 +++++++ src/resources/extensions/gsd/uok/flags.ts | 34 ++ .../extensions/gsd/uok/gate-runner.ts | 146 ++++++++ src/resources/extensions/gsd/uok/gitops.ts | 75 ++++ src/resources/extensions/gsd/uok/kernel.ts | 98 ++++++ .../extensions/gsd/uok/loop-adapter.ts | 162 +++++++++ .../extensions/gsd/uok/model-policy.ts | 112 ++++++ src/resources/extensions/gsd/uok/plan-v2.ts | 87 +++++ 36 files changed, 2026 insertions(+), 43 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/uok-contracts.test.ts create mode 100644 src/resources/extensions/gsd/tests/uok-gate-runner.test.ts create mode 100644 src/resources/extensions/gsd/tests/uok-preferences.test.ts create mode 100644 src/resources/extensions/gsd/uok/audit.ts create mode 100644 src/resources/extensions/gsd/uok/contracts.ts create mode 100644 src/resources/extensions/gsd/uok/execution-graph.ts create mode 100644 src/resources/extensions/gsd/uok/flags.ts create mode 100644 src/resources/extensions/gsd/uok/gate-runner.ts create mode 100644 src/resources/extensions/gsd/uok/gitops.ts create mode 100644 src/resources/extensions/gsd/uok/kernel.ts create mode 100644 src/resources/extensions/gsd/uok/loop-adapter.ts create mode 100644 src/resources/extensions/gsd/uok/model-policy.ts create mode 100644 src/resources/extensions/gsd/uok/plan-v2.ts diff --git a/src/resources/extensions/gsd/auto-model-selection.ts b/src/resources/extensions/gsd/auto-model-selection.ts index c3311085b..75f2b434f 100644 --- a/src/resources/extensions/gsd/auto-model-selection.ts +++ b/src/resources/extensions/gsd/auto-model-selection.ts @@ -16,6 +16,8 @@ import { getLedger, getProjectTotals } from "./metrics.js"; import { unitPhaseLabel } from "./auto-dashboard.js"; import { getSessionModelOverride } from "./session-model-override.js"; import { logWarning } from "./workflow-logger.js"; +import { resolveUokFlags } from "./uok/flags.js"; +import { applyModelPolicyFilter } from "./uok/model-policy.js"; export interface ModelSelectionResult { /** Routing metadata for metrics recording */ @@ -75,6 +77,7 @@ export async function selectAndApplyModel( /** Explicit /gsd model pin captured at bootstrap for long-running auto loops. */ sessionModelOverride?: { provider: string; id: string } | null, ): Promise { + const uokFlags = resolveUokFlags(prefs); const effectiveSessionModelOverride = sessionModelOverride === undefined ? getSessionModelOverride(ctx.sessionManager.getSessionId()) : (sessionModelOverride ?? undefined); @@ -97,6 +100,9 @@ export async function selectAndApplyModel( if (modelConfig) { const availableModels = ctx.modelRegistry.getAvailable(); + const modelPolicyTraceId = `model:${ctx.sessionManager.getSessionId()}:${Date.now()}`; + const modelPolicyTurnId = `${unitType}:${unitId}`; + let policyAllowedModelKeys: Set | null = null; // ─── Dynamic Model Routing ───────────────────────────────────────── // Dynamic routing (complexity-based downgrading) only applies in auto-mode. @@ -106,8 +112,35 @@ export async function selectAndApplyModel( if (!isAutoMode) { routingConfig.enabled = false; } + // burn-max defaults to quality-first dispatch (no downgrade routing). + if (prefs?.token_profile === "burn-max") { + routingConfig.enabled = false; + } let effectiveModelConfig = modelConfig; let routingTierLabel = ""; + let routingEligibleModels = availableModels; + + if (uokFlags.modelPolicy) { + const policy = applyModelPolicyFilter( + availableModels, + { + basePath, + traceId: modelPolicyTraceId, + turnId: modelPolicyTurnId, + unitType, + currentProvider: ctx.model?.provider, + allowCrossProvider: routingConfig.cross_provider !== false, + requiredTools: pi.getActiveTools(), + }, + ); + routingEligibleModels = policy.eligible; + policyAllowedModelKeys = new Set( + policy.eligible.map((m) => `${m.provider.toLowerCase()}/${m.id.toLowerCase()}`), + ); + if (routingEligibleModels.length === 0) { + throw new Error(`Model policy denied all candidate models for ${unitType}/${unitId}`); + } + } // Disable routing for flat-rate providers like GitHub Copilot (#3453). // All models cost the same per request, so downgrading to a cheaper @@ -115,7 +148,7 @@ export async function selectAndApplyModel( // Fail-closed: if primary model can't be resolved, fall back to // provider-level signals rather than allowing unwanted downgrades. if (routingConfig.enabled) { - const primaryModel = resolveModelId(modelConfig.primary, availableModels, ctx.model?.provider); + const primaryModel = resolveModelId(modelConfig.primary, routingEligibleModels, ctx.model?.provider); if (primaryModel) { const primaryFlatRateCtx = buildFlatRateContext(primaryModel.provider, ctx, prefs); if (isFlatRateProvider(primaryModel.provider, primaryFlatRateCtx)) { @@ -150,7 +183,7 @@ export async function selectAndApplyModel( if (shouldClassify) { let classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct); - const availableModelIds = availableModels.map(m => m.id); + const availableModelIds = routingEligibleModels.map(m => m.id); // Escalate tier on retry when escalate_on_failure is enabled (default: true) if ( @@ -257,6 +290,7 @@ export async function selectAndApplyModel( } const modelsToTry = [effectiveModelConfig.primary, ...effectiveModelConfig.fallbacks]; + let attemptedPolicyEligible = false; for (const modelId of modelsToTry) { const model = resolveModelId(modelId, availableModels, ctx.model?.provider); @@ -266,6 +300,17 @@ export async function selectAndApplyModel( continue; } + if (policyAllowedModelKeys) { + const key = `${model.provider.toLowerCase()}/${model.id.toLowerCase()}`; + if (!policyAllowedModelKeys.has(key)) { + if (verbose) { + ctx.ui.notify(`Model policy denied ${model.provider}/${model.id}; trying fallback.`, "warning"); + } + continue; + } + attemptedPolicyEligible = true; + } + // Warn if the ID is ambiguous across providers if (!modelId.includes("/")) { const providers = availableModels.filter(m => m.id === modelId).map(m => m.provider); @@ -331,6 +376,10 @@ export async function selectAndApplyModel( } } } + + if (uokFlags.modelPolicy && policyAllowedModelKeys && !attemptedPolicyEligible) { + throw new Error(`Model policy denied dispatch for ${unitType}/${unitId} before prompt send`); + } } else if (autoModeStartModel) { // No model preference for this unit type — re-apply the model captured // at auto-mode start to prevent bleed from shared global settings.json (#650). diff --git a/src/resources/extensions/gsd/auto-verification.ts b/src/resources/extensions/gsd/auto-verification.ts index 943edf4c8..3de3ac918 100644 --- a/src/resources/extensions/gsd/auto-verification.ts +++ b/src/resources/extensions/gsd/auto-verification.ts @@ -33,6 +33,8 @@ import { runPostExecutionChecks, type PostExecutionResult } from "./post-executi import type { AutoSession } from "./auto/session.js"; import type { VerificationResult as VerificationGateResult } from "./types.js"; import { join } from "node:path"; +import { resolveUokFlags } from "./uok/flags.js"; +import { UokGateRunner } from "./uok/gate-runner.js"; export interface VerificationContext { s: AutoSession; @@ -158,6 +160,7 @@ export async function runPostUnitVerification( try { const effectivePrefs = loadEffectiveGSDPreferences(); const prefs = effectivePrefs?.preferences; + const uokFlags = resolveUokFlags(prefs); // Read task plan verify field const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id); @@ -196,6 +199,37 @@ export async function runPostUnitVerification( } } + if (uokFlags.gates) { + const gateRunner = new UokGateRunner(); + gateRunner.register({ + id: "verification-gate", + type: "verification", + execute: async () => ({ + outcome: result.passed ? "pass" : "fail", + failureClass: result.runtimeErrors?.some((e) => e.blocking) + ? "execution" + : "verification", + rationale: result.passed + ? "verification checks passed" + : "verification checks failed", + findings: result.passed + ? "" + : formatFailureContext(result), + }), + }); + + await gateRunner.run("verification-gate", { + basePath: s.basePath, + traceId: `verification:${s.currentUnit.id}`, + turnId: s.currentUnit.id, + milestoneId: mid ?? undefined, + sliceId: sid ?? undefined, + taskId: tid ?? undefined, + unitType: s.currentUnit.type, + unitId: s.currentUnit.id, + }); + } + // Auto-fix retry preferences const autoFixEnabled = prefs?.verification_auto_fix !== false; const maxRetries = diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index f41abec54..6cc197b1c 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -202,6 +202,7 @@ import { import { bootstrapAutoSession, openProjectDbIfPresent, type BootstrapDeps } from "./auto-start.js"; import { initHealthWidget } from "./health-widget.js"; import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight, type LoopDeps, type ErrorContext } from "./auto-loop.js"; +import { runAutoLoopWithUok } from "./uok/kernel.js"; // Slice-level parallelism (#2340) import { getEligibleSlices } from "./slice-parallel-eligibility.js"; import { startSliceParallel } from "./slice-parallel-orchestrator.js"; @@ -1513,7 +1514,13 @@ export async function startAuto( logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress"); captureProjectRootEnv(s.originalBasePath || s.basePath); - await autoLoop(ctx, pi, s, buildLoopDeps()); + await runAutoLoopWithUok({ + ctx, + pi, + s, + deps: buildLoopDeps(), + runLegacyLoop: autoLoop, + }); cleanupAfterLoopExit(ctx); return; } @@ -1548,7 +1555,13 @@ export async function startAuto( logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, requestedStepMode ? "Step-mode started." : "Auto-mode started.", "progress"); // Dispatch the first unit - await autoLoop(ctx, pi, s, buildLoopDeps()); + await runAutoLoopWithUok({ + ctx, + pi, + s, + deps: buildLoopDeps(), + runLegacyLoop: autoLoop, + }); cleanupAfterLoopExit(ctx); } diff --git a/src/resources/extensions/gsd/auto/loop-deps.ts b/src/resources/extensions/gsd/auto/loop-deps.ts index 55286eba6..a92612098 100644 --- a/src/resources/extensions/gsd/auto/loop-deps.ts +++ b/src/resources/extensions/gsd/auto/loop-deps.ts @@ -21,6 +21,7 @@ import type { WorktreeResolver } from "../worktree-resolver.js"; import type { CmuxLogLevel } from "../../cmux/index.js"; import type { JournalEntry } from "../journal.js"; import type { MergeReconcileResult } from "../auto-recovery.js"; +import type { UokTurnObserver } from "../uok/contracts.js"; /** * Dependencies injected by the caller (auto.ts startAuto) so autoLoop @@ -274,4 +275,7 @@ export interface LoopDeps { // Journal emitJournalEvent: (entry: JournalEntry) => void; + + // UOK (optional, flag-gated) + uokObserver?: UokTurnObserver; } diff --git a/src/resources/extensions/gsd/auto/loop.ts b/src/resources/extensions/gsd/auto/loop.ts index eff106f33..a5620bbb6 100644 --- a/src/resources/extensions/gsd/auto/loop.ts +++ b/src/resources/extensions/gsd/auto/loop.ts @@ -128,6 +128,39 @@ export async function autoLoop( const flowId = randomUUID(); let seqCounter = 0; const nextSeq = () => ++seqCounter; + const turnId = randomUUID(); + const turnStartedAt = new Date().toISOString(); + let observedUnitType: string | undefined; + let observedUnitId: string | undefined; + let turnFinished = false; + const finishTurn = ( + status: "completed" | "failed" | "paused" | "stopped" | "skipped" | "retry", + failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" = "none", + error?: string, + ): void => { + if (turnFinished) return; + turnFinished = true; + deps.uokObserver?.onTurnResult({ + traceId: flowId, + turnId, + iteration, + unitType: observedUnitType, + unitId: observedUnitId, + status, + failureClass, + phaseResults: [], + error, + startedAt: turnStartedAt, + finishedAt: new Date().toISOString(), + }); + }; + deps.uokObserver?.onTurnStart({ + traceId: flowId, + turnId, + iteration, + basePath: s.basePath, + startedAt: turnStartedAt, + }); if (iteration > MAX_LOOP_ITERATIONS) { debugLog("autoLoop", { @@ -140,6 +173,7 @@ export async function autoLoop( pi, `Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`, ); + finishTurn("stopped", "manual-attention", "max-iterations"); break; } @@ -157,12 +191,14 @@ export async function autoLoop( `Stopping gracefully to prevent OOM kill after ${iteration} iterations. ` + `Resume with /gsd auto to continue from where you left off.`, ); + finishTurn("stopped", "timeout", "memory-pressure"); break; } } if (!s.cmdCtx) { debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" }); + finishTurn("stopped", "manual-attention", "missing-command-context"); break; } @@ -256,27 +292,53 @@ export async function autoLoop( isRetry: false, previousTier: undefined, }; + observedUnitType = iterData.unitType; + observedUnitId = iterData.unitId; // ── Progress widget (mirrors dev path in runDispatch) ── deps.updateProgressWidget(ctx, iterData.unitType, iterData.unitId, iterData.state); // ── Guards (shared with dev path) ── const guardsResult = await runGuards(ic, s.currentMilestoneId ?? "workflow"); - if (guardsResult.action === "break") break; + deps.uokObserver?.onPhaseResult("guard", guardsResult.action, { + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + if (guardsResult.action === "break") { + finishTurn("stopped", "manual-attention", "guard-break"); + break; + } // ── Unit execution (shared with dev path) ── const unitPhaseResult = await runUnitPhase(ic, iterData, loopState); - if (unitPhaseResult.action === "break") break; + deps.uokObserver?.onPhaseResult("unit", unitPhaseResult.action, { + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + if (unitPhaseResult.action === "break") { + finishTurn("stopped", "execution", "unit-break"); + break; + } // ── Verify first, then reconcile (only mark complete on pass) ── debugLog("autoLoop", { phase: "custom-engine-verify", iteration, unitId: iterData.unitId }); const verifyResult = await policy.verify(iterData.unitType, iterData.unitId, { basePath: s.basePath }); if (verifyResult === "pause") { await deps.pauseAuto(ctx, pi); + deps.uokObserver?.onPhaseResult("custom-engine", "pause", { + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + finishTurn("paused", "manual-attention", "custom-engine-verify-pause"); break; } if (verifyResult === "retry") { debugLog("autoLoop", { phase: "custom-engine-verify-retry", iteration, unitId: iterData.unitId }); + deps.uokObserver?.onPhaseResult("custom-engine", "retry", { + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + finishTurn("retry"); continue; } @@ -299,36 +361,77 @@ export async function autoLoop( if (reconcileResult.outcome === "milestone-complete") { await deps.stopAuto(ctx, pi, "Workflow complete"); + deps.uokObserver?.onPhaseResult("custom-engine", "milestone-complete", { + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + finishTurn("completed"); break; } if (reconcileResult.outcome === "pause") { await deps.pauseAuto(ctx, pi); + deps.uokObserver?.onPhaseResult("custom-engine", "pause", { + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + finishTurn("paused", "manual-attention"); break; } if (reconcileResult.outcome === "stop") { await deps.stopAuto(ctx, pi, reconcileResult.reason ?? "Engine stopped"); + deps.uokObserver?.onPhaseResult("custom-engine", "stop", { + unitType: iterData.unitType, + unitId: iterData.unitId, + reason: reconcileResult.reason, + }); + finishTurn("stopped", "manual-attention", reconcileResult.reason); break; } + deps.uokObserver?.onPhaseResult("custom-engine", "continue", { + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + finishTurn("completed"); continue; } if (!sidecarItem) { // ── Phase 1: Pre-dispatch ───────────────────────────────────────── const preDispatchResult = await runPreDispatch(ic, loopState); - if (preDispatchResult.action === "break") break; - if (preDispatchResult.action === "continue") continue; + deps.uokObserver?.onPhaseResult("pre-dispatch", preDispatchResult.action); + if (preDispatchResult.action === "break") { + finishTurn("stopped", "manual-attention", "pre-dispatch-break"); + break; + } + if (preDispatchResult.action === "continue") { + finishTurn("skipped"); + continue; + } const preData = preDispatchResult.data; // ── Phase 2: Guards ─────────────────────────────────────────────── const guardsResult = await runGuards(ic, preData.mid); - if (guardsResult.action === "break") break; + deps.uokObserver?.onPhaseResult("guard", guardsResult.action); + if (guardsResult.action === "break") { + finishTurn("stopped", "manual-attention", "guard-break"); + break; + } // ── Phase 3: Dispatch ───────────────────────────────────────────── const dispatchResult = await runDispatch(ic, preData, loopState); - if (dispatchResult.action === "break") break; - if (dispatchResult.action === "continue") continue; + deps.uokObserver?.onPhaseResult("dispatch", dispatchResult.action); + if (dispatchResult.action === "break") { + finishTurn("stopped", "manual-attention", "dispatch-break"); + break; + } + if (dispatchResult.action === "continue") { + finishTurn("skipped"); + continue; + } iterData = dispatchResult.data; + observedUnitType = iterData.unitType; + observedUnitId = iterData.unitId; } else { // ── Sidecar path: use values from the sidecar item directly ── const sidecarState = await deps.deriveState(s.basePath); @@ -343,22 +446,47 @@ export async function autoLoop( midTitle: sidecarState.activeMilestone?.title, isRetry: false, previousTier: undefined, }; + observedUnitType = iterData.unitType; + observedUnitId = iterData.unitId; + deps.uokObserver?.onPhaseResult("dispatch", "sidecar", { + unitType: iterData.unitType, + unitId: iterData.unitId, + sidecarKind: sidecarItem.kind, + }); } const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem); - if (unitPhaseResult.action === "break") break; + deps.uokObserver?.onPhaseResult("unit", unitPhaseResult.action, { + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + if (unitPhaseResult.action === "break") { + finishTurn("stopped", "execution", "unit-break"); + break; + } // ── Phase 5: Finalize ─────────────────────────────────────────────── const finalizeResult = await runFinalize(ic, iterData, loopState, sidecarItem); - if (finalizeResult.action === "break") break; - if (finalizeResult.action === "continue") continue; + deps.uokObserver?.onPhaseResult("finalize", finalizeResult.action, { + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + if (finalizeResult.action === "break") { + finishTurn("stopped", "closeout", "finalize-break"); + break; + } + if (finalizeResult.action === "continue") { + finishTurn("retry"); + continue; + } consecutiveErrors = 0; // Iteration completed successfully consecutiveCooldowns = 0; recentErrorMessages.length = 0; deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } }); debugLog("autoLoop", { phase: "iteration-complete", iteration }); + finishTurn("completed"); } catch (loopErr) { // ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ── const msg = loopErr instanceof Error ? loopErr.message : String(loopErr); @@ -388,6 +516,7 @@ export async function autoLoop( pi, `Infrastructure error (${infraCode}): not recoverable by retry`, ); + finishTurn("failed", "execution", msg); break; } @@ -429,6 +558,7 @@ export async function autoLoop( "warning", ); await new Promise(resolve => setTimeout(resolve, waitMs)); + finishTurn("retry", "timeout", msg); continue; // Retry iteration without incrementing consecutiveErrors } @@ -455,6 +585,7 @@ export async function autoLoop( pi, `${consecutiveErrors} consecutive iteration failures`, ); + finishTurn("failed", "execution", msg); break; } else if (consecutiveErrors === 2) { // 2nd consecutive: try invalidating caches + re-deriving state @@ -467,6 +598,7 @@ export async function autoLoop( // 1st error: log and retry — transient failures happen ctx.ui.notify(`Iteration error: ${msg}. Retrying.`, "warning"); } + finishTurn("retry", "execution", msg); } } diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index c051e4ffd..f6aeefa98 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -47,6 +47,7 @@ import { withTimeout, FINALIZE_PRE_TIMEOUT_MS, FINALIZE_POST_TIMEOUT_MS } from " import { getEligibleSlices } from "../slice-parallel-eligibility.js"; import { startSliceParallel } from "../slice-parallel-orchestrator.js"; import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js"; +import { ensurePlanV2Graph } from "../uok/plan-v2.js"; import { resetEvidence } from "../safety/evidence-collector.js"; import { createCheckpoint, cleanupCheckpoint, rollbackToCheckpoint } from "../safety/git-checkpoint.js"; import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js"; @@ -252,6 +253,15 @@ export async function runPreDispatch( // Derive state let state = await deps.deriveState(s.basePath); + if (prefs?.uok?.plan_v2?.enabled) { + const compiled = ensurePlanV2Graph(s.basePath, state); + if (!compiled.ok) { + const reason = compiled.reason ?? "Plan v2 compilation failed"; + ctx.ui.notify(`Plan gate failed-closed: ${reason}`, "error"); + await deps.pauseAuto(ctx, pi); + return { action: "break", reason: "plan-v2-gate-failed" }; + } + } deps.syncCmuxSidebar(prefs, state); let mid = state.activeMilestone?.id; let midTitle = state.activeMilestone?.title; diff --git a/src/resources/extensions/gsd/commands-prefs-wizard.ts b/src/resources/extensions/gsd/commands-prefs-wizard.ts index f94a78010..6e2eaf60c 100644 --- a/src/resources/extensions/gsd/commands-prefs-wizard.ts +++ b/src/resources/extensions/gsd/commands-prefs-wizard.ts @@ -822,7 +822,7 @@ export function serializePreferencesToFrontmatter(prefs: Record "budget_ceiling", "budget_enforcement", "context_pause_threshold", "notifications", "cmux", "remote_questions", "git", "post_unit_hooks", "pre_dispatch_hooks", - "dynamic_routing", "token_profile", "phases", "parallel", + "dynamic_routing", "uok", "token_profile", "phases", "parallel", "auto_visualize", "auto_report", "verification_commands", "verification_auto_fix", "verification_max_retries", "search_provider", "context_selection", diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index c2e0dfdfc..956819e4c 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -153,7 +153,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `context_pause_threshold`: number (0-100) — context window usage percentage at which auto-mode should pause to suggest checkpointing. Set to `0` to disable. Default: `0` (disabled). -- `token_profile`: `"budget"`, `"balanced"`, or `"quality"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) skips research/reassessment to reduce token burn; `quality` prefers higher-quality models. See token-optimization docs. +- `token_profile`: `"budget"`, `"balanced"`, `"quality"`, or `"burn-max"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) skips research/reassessment to reduce token burn; `quality` prefers higher-quality models; `burn-max` keeps full-context defaults, disables downgrade routing, and keeps phase skips off. - `phases`: fine-grained control over which phases run. Usually set by `token_profile`, but can be overridden. Keys: - `skip_research`: boolean — skip milestone-level research. Default: `false`. @@ -191,6 +191,17 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `hooks`: boolean — enable routing hooks. Default: `true`. - `capability_routing`: boolean — enable capability-profile scoring for model selection within a tier. Requires `enabled: true`. Default: `false`. +- `uok`: Unified Orchestration Kernel controls (all flags default to `false` during migration). Keys: + - `enabled`: boolean — enable kernel wrappers and contract observers. + - `gates.enabled`: boolean — route checks through the unified gate runner and persist `gate_runs`. + - `model_policy.enabled`: boolean — enforce policy filtering before model capability scoring. + - `execution_graph.enabled`: boolean — enable DAG scheduler facade/adapters for execution. + - `gitops.enabled`: boolean — persist turn-level git transaction records. + - `gitops.turn_action`: `"commit"` | `"snapshot"` | `"status-only"` — turn transaction mode. + - `gitops.turn_push`: boolean — whether turn transactions should include push intent metadata. + - `audit_unified.enabled`: boolean — dual-write unified audit envelope events. + - `plan_v2.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow. + - `context_management`: configures context hygiene for auto-mode sessions. Keys: - `observation_masking`: boolean — mask old tool results to reduce context bloat. Default: `true`. - `observation_mask_turns`: number — keep this many recent turns verbatim (1-50). Default: `8`. diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index 274c4b73b..5975c79b6 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -180,7 +180,7 @@ function openRawDb(path: string): unknown { return new Database(path); } -const SCHEMA_VERSION = 14; +const SCHEMA_VERSION = 15; function indexExists(db: DbAdapter, name: string): boolean { return !!db.prepare( @@ -443,6 +443,70 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { ) `); + db.exec(` + CREATE TABLE IF NOT EXISTS gate_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + gate_id TEXT NOT NULL, + gate_type TEXT NOT NULL DEFAULT '', + unit_type TEXT DEFAULT NULL, + unit_id TEXT DEFAULT NULL, + milestone_id TEXT DEFAULT NULL, + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + outcome TEXT NOT NULL DEFAULT 'pass', + failure_class TEXT NOT NULL DEFAULT 'none', + rationale TEXT NOT NULL DEFAULT '', + findings TEXT NOT NULL DEFAULT '', + attempt INTEGER NOT NULL DEFAULT 1, + max_attempts INTEGER NOT NULL DEFAULT 1, + retryable INTEGER NOT NULL DEFAULT 0, + evaluated_at TEXT NOT NULL DEFAULT '' + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS turn_git_transactions ( + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + unit_type TEXT DEFAULT NULL, + unit_id TEXT DEFAULT NULL, + stage TEXT NOT NULL DEFAULT 'turn-start', + action TEXT NOT NULL DEFAULT 'status-only', + push INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'ok', + error TEXT DEFAULT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (trace_id, turn_id, stage) + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS audit_events ( + event_id TEXT PRIMARY KEY, + trace_id TEXT NOT NULL, + turn_id TEXT DEFAULT NULL, + caused_by TEXT DEFAULT NULL, + category TEXT NOT NULL, + type TEXT NOT NULL, + ts TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}' + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS audit_turn_index ( + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + first_ts TEXT NOT NULL, + last_ts TEXT NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (trace_id, turn_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)"); @@ -456,6 +520,11 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { // v14 index — slice dependency lookups db.exec("CREATE INDEX IF NOT EXISTS idx_slice_deps_target ON slice_dependencies(milestone_id, depends_on_slice_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_gate_runs_turn ON gate_runs(trace_id, turn_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_gate_runs_lookup ON gate_runs(milestone_id, slice_id, task_id, gate_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_turn_git_tx_turn ON turn_git_transactions(trace_id, turn_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_audit_events_trace ON audit_events(trace_id, ts)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_audit_events_turn ON audit_events(trace_id, turn_id, ts)"); db.exec(`CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`); db.exec(`CREATE VIEW IF NOT EXISTS active_requirements AS SELECT * FROM requirements WHERE superseded_by IS NULL`); @@ -810,6 +879,78 @@ function migrateSchema(db: DbAdapter): void { }); } + if (currentVersion < 15) { + db.exec(` + CREATE TABLE IF NOT EXISTS gate_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + gate_id TEXT NOT NULL, + gate_type TEXT NOT NULL DEFAULT '', + unit_type TEXT DEFAULT NULL, + unit_id TEXT DEFAULT NULL, + milestone_id TEXT DEFAULT NULL, + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + outcome TEXT NOT NULL DEFAULT 'pass', + failure_class TEXT NOT NULL DEFAULT 'none', + rationale TEXT NOT NULL DEFAULT '', + findings TEXT NOT NULL DEFAULT '', + attempt INTEGER NOT NULL DEFAULT 1, + max_attempts INTEGER NOT NULL DEFAULT 1, + retryable INTEGER NOT NULL DEFAULT 0, + evaluated_at TEXT NOT NULL DEFAULT '' + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS turn_git_transactions ( + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + unit_type TEXT DEFAULT NULL, + unit_id TEXT DEFAULT NULL, + stage TEXT NOT NULL DEFAULT 'turn-start', + action TEXT NOT NULL DEFAULT 'status-only', + push INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'ok', + error TEXT DEFAULT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (trace_id, turn_id, stage) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS audit_events ( + event_id TEXT PRIMARY KEY, + trace_id TEXT NOT NULL, + turn_id TEXT DEFAULT NULL, + caused_by TEXT DEFAULT NULL, + category TEXT NOT NULL, + type TEXT NOT NULL, + ts TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}' + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS audit_turn_index ( + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + first_ts TEXT NOT NULL, + last_ts TEXT NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (trace_id, turn_id) + ) + `); + db.exec("CREATE INDEX IF NOT EXISTS idx_gate_runs_turn ON gate_runs(trace_id, turn_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_gate_runs_lookup ON gate_runs(milestone_id, slice_id, task_id, gate_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_turn_git_tx_turn ON turn_git_transactions(trace_id, turn_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_audit_events_trace ON audit_events(trace_id, ts)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_audit_events_turn ON audit_events(trace_id, turn_id, ts)"); + db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({ + ":version": 15, + ":applied_at": new Date().toISOString(), + }); + } + db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); @@ -2287,6 +2428,9 @@ export function deleteMilestone(milestoneId: string): void { currentDb!.prepare( `DELETE FROM quality_gates WHERE milestone_id = :mid`, ).run({ ":mid": milestoneId }); + currentDb!.prepare( + `DELETE FROM gate_runs WHERE milestone_id = :mid`, + ).run({ ":mid": milestoneId }); currentDb!.prepare( `DELETE FROM tasks WHERE milestone_id = :mid`, ).run({ ":mid": milestoneId }); @@ -2420,6 +2564,30 @@ export function saveGateResult(g: { ":findings": g.findings, ":evaluated_at": new Date().toISOString(), }); + + const outcome = + g.verdict === "pass" + ? "pass" + : g.verdict === "omitted" + ? "manual-attention" + : "fail"; + insertGateRun({ + traceId: `quality-gate:${g.milestoneId}:${g.sliceId}`, + turnId: `gate:${g.gateId}:${g.taskId ?? "slice"}`, + gateId: g.gateId, + gateType: "quality-gate", + milestoneId: g.milestoneId, + sliceId: g.sliceId, + taskId: g.taskId ?? undefined, + outcome, + failureClass: outcome === "fail" ? "verification" : outcome === "manual-attention" ? "manual-attention" : "none", + rationale: g.rationale, + findings: g.findings, + attempt: 1, + maxAttempts: 1, + retryable: false, + evaluatedAt: new Date().toISOString(), + }); } export function getPendingGates(milestoneId: string, sliceId: string, scope?: GateScope): GateRow[] { @@ -2513,6 +2681,156 @@ export function getPendingGateCountForTurn( return getPendingGatesForTurn(milestoneId, sliceId, turn).length; } +export function insertGateRun(entry: { + traceId: string; + turnId: string; + gateId: string; + gateType: string; + unitType?: string; + unitId?: string; + milestoneId?: string; + sliceId?: string; + taskId?: string; + outcome: "pass" | "fail" | "retry" | "manual-attention"; + failureClass: "none" | "policy" | "input" | "execution" | "artifact" | "verification" | "closeout" | "git" | "timeout" | "manual-attention" | "unknown"; + rationale?: string; + findings?: string; + attempt: number; + maxAttempts: number; + retryable: boolean; + evaluatedAt: string; +}): void { + if (!currentDb) return; + currentDb.prepare( + `INSERT INTO gate_runs ( + trace_id, turn_id, gate_id, gate_type, unit_type, unit_id, milestone_id, slice_id, task_id, + outcome, failure_class, rationale, findings, attempt, max_attempts, retryable, evaluated_at + ) VALUES ( + :trace_id, :turn_id, :gate_id, :gate_type, :unit_type, :unit_id, :milestone_id, :slice_id, :task_id, + :outcome, :failure_class, :rationale, :findings, :attempt, :max_attempts, :retryable, :evaluated_at + )`, + ).run({ + ":trace_id": entry.traceId, + ":turn_id": entry.turnId, + ":gate_id": entry.gateId, + ":gate_type": entry.gateType, + ":unit_type": entry.unitType ?? null, + ":unit_id": entry.unitId ?? null, + ":milestone_id": entry.milestoneId ?? null, + ":slice_id": entry.sliceId ?? null, + ":task_id": entry.taskId ?? null, + ":outcome": entry.outcome, + ":failure_class": entry.failureClass, + ":rationale": entry.rationale ?? "", + ":findings": entry.findings ?? "", + ":attempt": entry.attempt, + ":max_attempts": entry.maxAttempts, + ":retryable": entry.retryable ? 1 : 0, + ":evaluated_at": entry.evaluatedAt, + }); +} + +export function upsertTurnGitTransaction(entry: { + traceId: string; + turnId: string; + unitType?: string; + unitId?: string; + stage: string; + action: "commit" | "snapshot" | "status-only"; + push: boolean; + status: "ok" | "failed"; + error?: string; + metadata?: Record; + updatedAt: string; +}): void { + if (!currentDb) return; + currentDb.prepare( + `INSERT OR REPLACE INTO turn_git_transactions ( + trace_id, turn_id, unit_type, unit_id, stage, action, push, status, error, metadata_json, updated_at + ) VALUES ( + :trace_id, :turn_id, :unit_type, :unit_id, :stage, :action, :push, :status, :error, :metadata_json, :updated_at + )`, + ).run({ + ":trace_id": entry.traceId, + ":turn_id": entry.turnId, + ":unit_type": entry.unitType ?? null, + ":unit_id": entry.unitId ?? null, + ":stage": entry.stage, + ":action": entry.action, + ":push": entry.push ? 1 : 0, + ":status": entry.status, + ":error": entry.error ?? null, + ":metadata_json": JSON.stringify(entry.metadata ?? {}), + ":updated_at": entry.updatedAt, + }); +} + +export function insertAuditEvent(entry: { + eventId: string; + traceId: string; + turnId?: string; + causedBy?: string; + category: string; + type: string; + ts: string; + payload: Record; +}): void { + if (!currentDb) return; + transaction(() => { + currentDb!.prepare( + `INSERT OR IGNORE INTO audit_events ( + event_id, trace_id, turn_id, caused_by, category, type, ts, payload_json + ) VALUES ( + :event_id, :trace_id, :turn_id, :caused_by, :category, :type, :ts, :payload_json + )`, + ).run({ + ":event_id": entry.eventId, + ":trace_id": entry.traceId, + ":turn_id": entry.turnId ?? null, + ":caused_by": entry.causedBy ?? null, + ":category": entry.category, + ":type": entry.type, + ":ts": entry.ts, + ":payload_json": JSON.stringify(entry.payload ?? {}), + }); + + if (entry.turnId) { + const row = currentDb!.prepare( + `SELECT event_count, first_ts, last_ts + FROM audit_turn_index + WHERE trace_id = :trace_id AND turn_id = :turn_id`, + ).get({ + ":trace_id": entry.traceId, + ":turn_id": entry.turnId, + }); + if (row) { + currentDb!.prepare( + `UPDATE audit_turn_index + SET first_ts = CASE WHEN :ts < first_ts THEN :ts ELSE first_ts END, + last_ts = CASE WHEN :ts > last_ts THEN :ts ELSE last_ts END, + event_count = event_count + 1 + WHERE trace_id = :trace_id AND turn_id = :turn_id`, + ).run({ + ":trace_id": entry.traceId, + ":turn_id": entry.turnId, + ":ts": entry.ts, + }); + } else { + currentDb!.prepare( + `INSERT INTO audit_turn_index (trace_id, turn_id, first_ts, last_ts, event_count) + VALUES (:trace_id, :turn_id, :first_ts, :last_ts, :event_count)`, + ).run({ + ":trace_id": entry.traceId, + ":turn_id": entry.turnId, + ":first_ts": entry.ts, + ":last_ts": entry.ts, + ":event_count": 1, + }); + } + } + }); +} + // ─── Single-writer bypass wrappers ─────────────────────────────────────── // These wrappers exist so modules outside this file never need to call // `_getAdapter()` for writes. Each one is a byte-equivalent replacement for diff --git a/src/resources/extensions/gsd/init-wizard.ts b/src/resources/extensions/gsd/init-wizard.ts index b7251471e..341997309 100644 --- a/src/resources/extensions/gsd/init-wizard.ts +++ b/src/resources/extensions/gsd/init-wizard.ts @@ -33,7 +33,7 @@ interface ProjectPreferences { mainBranch: string; verificationCommands: string[]; customInstructions: string[]; - tokenProfile: "budget" | "balanced" | "quality"; + tokenProfile: "budget" | "balanced" | "quality" | "burn-max"; skipResearch: boolean; autoPush: boolean; } @@ -413,10 +413,11 @@ async function customizeAdvancedPrefs( { id: "balanced", label: "Balanced", description: "Good trade-off (default)", recommended: true }, { id: "budget", label: "Budget", description: "Minimize token usage" }, { id: "quality", label: "Quality", description: "Maximize thoroughness" }, + { id: "burn-max", label: "Burn Max", description: "Maximum depth, no phase skips" }, ], }); if (profileChoice !== "not_yet") { - prefs.tokenProfile = profileChoice as "budget" | "balanced" | "quality"; + prefs.tokenProfile = profileChoice as "budget" | "balanced" | "quality" | "burn-max"; } // Skip research diff --git a/src/resources/extensions/gsd/preferences-models.ts b/src/resources/extensions/gsd/preferences-models.ts index 0d6e7555b..bce92a82f 100644 --- a/src/resources/extensions/gsd/preferences-models.ts +++ b/src/resources/extensions/gsd/preferences-models.ts @@ -355,7 +355,7 @@ export function resolveAutoSupervisorConfig(): AutoSupervisorConfig { // ─── Token Profile Resolution ───────────────────────────────────────────── -const VALID_TOKEN_PROFILES = new Set(["budget", "balanced", "quality"]); +const VALID_TOKEN_PROFILES = new Set(["budget", "balanced", "quality", "burn-max"]); /** * Resolve profile defaults for a given token profile tier. @@ -400,6 +400,22 @@ export function resolveProfileDefaults(profile: TokenProfile): Partial minimal, balanced -> standard, quality -> full. + * budget -> minimal, balanced -> standard, quality/burn-max -> full. */ export function resolveInlineLevel(): InlineLevel { const profile = resolveEffectiveProfile(); @@ -424,12 +440,13 @@ export function resolveInlineLevel(): InlineLevel { case "budget": return "minimal"; case "balanced": return "standard"; case "quality": return "full"; + case "burn-max": return "full"; } } /** * Resolve the context selection mode from the active token profile. - * budget -> "smart", balanced/quality -> "full". + * budget -> "smart", balanced/quality/burn-max -> "full". * Explicit preference always wins. */ export function resolveContextSelection(): import("./types.js").ContextSelectionMode { diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 329faebd7..3809f3d20 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -83,6 +83,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "post_unit_hooks", "pre_dispatch_hooks", "dynamic_routing", + "uok", "token_profile", "phases", "auto_visualize", @@ -208,6 +209,32 @@ export interface CmuxPreferences { browser?: boolean; } +export type UokTurnActionMode = "commit" | "snapshot" | "status-only"; + +export interface UokPreferences { + enabled?: boolean; + gates?: { + enabled?: boolean; + }; + model_policy?: { + enabled?: boolean; + }; + execution_graph?: { + enabled?: boolean; + }; + gitops?: { + enabled?: boolean; + turn_action?: UokTurnActionMode; + turn_push?: boolean; + }; + audit_unified?: { + enabled?: boolean; + }; + plan_v2?: { + enabled?: boolean; + }; +} + /** * Opt-in experimental features. All features in this block are disabled by * default and must be explicitly enabled. They may change or be removed without @@ -256,6 +283,8 @@ export interface GSDPreferences { post_unit_hooks?: PostUnitHookConfig[]; pre_dispatch_hooks?: PreDispatchHookConfig[]; dynamic_routing?: DynamicRoutingConfig; + /** Unified Orchestration Kernel controls (all flags default off). */ + uok?: UokPreferences; /** Per-model capability overrides. Deep-merged with built-in profiles for capability-aware routing (ADR-004). */ modelOverrides?: Record }>; context_management?: ContextManagementConfig; diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index c703abd1c..198bc97af 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -22,7 +22,12 @@ import { type GSDSkillRule, } from "./preferences-types.js"; -const VALID_TOKEN_PROFILES = new Set(["budget", "balanced", "quality"]); +const VALID_TOKEN_PROFILES = new Set(["budget", "balanced", "quality", "burn-max"]); +const VALID_UOK_TURN_ACTIONS = new Set<"commit" | "snapshot" | "status-only">([ + "commit", + "snapshot", + "status-only", +]); export function validatePreferences(preferences: GSDPreferences): { preferences: GSDPreferences; @@ -161,12 +166,110 @@ export function validatePreferences(preferences: GSDPreferences): { } } + // ─── UOK Flags ────────────────────────────────────────────────────── + if (preferences.uok !== undefined) { + if (typeof preferences.uok === "object" && preferences.uok !== null) { + const raw = preferences.uok as Record; + const valid: NonNullable = {}; + + if (raw.enabled !== undefined) { + if (typeof raw.enabled === "boolean") valid.enabled = raw.enabled; + else errors.push("uok.enabled must be a boolean"); + } + + const parseEnabledBlock = ( + key: "gates" | "model_policy" | "execution_graph" | "audit_unified" | "plan_v2", + ): void => { + const value = raw[key]; + if (value === undefined) return; + if (typeof value !== "object" || value === null) { + errors.push(`uok.${key} must be an object`); + return; + } + const block = value as Record; + const parsed: { enabled?: boolean } = {}; + if (block.enabled !== undefined) { + if (typeof block.enabled === "boolean") parsed.enabled = block.enabled; + else errors.push(`uok.${key}.enabled must be a boolean`); + } + const unknown = Object.keys(block).filter((k) => k !== "enabled"); + for (const unk of unknown) { + warnings.push(`unknown uok.${key} key "${unk}" — ignored`); + } + if (Object.keys(parsed).length > 0) { + valid[key] = parsed; + } + }; + + parseEnabledBlock("gates"); + parseEnabledBlock("model_policy"); + parseEnabledBlock("execution_graph"); + parseEnabledBlock("audit_unified"); + parseEnabledBlock("plan_v2"); + + if (raw.gitops !== undefined) { + if (typeof raw.gitops !== "object" || raw.gitops === null) { + errors.push("uok.gitops must be an object"); + } else { + const gitops = raw.gitops as Record; + const parsed: NonNullable["gitops"]> = {}; + if (gitops.enabled !== undefined) { + if (typeof gitops.enabled === "boolean") parsed.enabled = gitops.enabled; + else errors.push("uok.gitops.enabled must be a boolean"); + } + if (gitops.turn_action !== undefined) { + if ( + typeof gitops.turn_action === "string" && + VALID_UOK_TURN_ACTIONS.has(gitops.turn_action as "commit" | "snapshot" | "status-only") + ) { + parsed.turn_action = gitops.turn_action as "commit" | "snapshot" | "status-only"; + } else { + errors.push("uok.gitops.turn_action must be one of: commit, snapshot, status-only"); + } + } + if (gitops.turn_push !== undefined) { + if (typeof gitops.turn_push === "boolean") parsed.turn_push = gitops.turn_push; + else errors.push("uok.gitops.turn_push must be a boolean"); + } + const unknown = Object.keys(gitops).filter((k) => !["enabled", "turn_action", "turn_push"].includes(k)); + for (const unk of unknown) { + warnings.push(`unknown uok.gitops key "${unk}" — ignored`); + } + if (Object.keys(parsed).length > 0) { + valid.gitops = parsed; + } + } + } + + const knownUokKeys = new Set([ + "enabled", + "gates", + "model_policy", + "execution_graph", + "gitops", + "audit_unified", + "plan_v2", + ]); + for (const key of Object.keys(raw)) { + if (!knownUokKeys.has(key)) { + warnings.push(`unknown uok key "${key}" — ignored`); + } + } + + if (Object.keys(valid).length > 0) { + validated.uok = valid; + } + } else { + errors.push("uok must be an object"); + } + } + // ─── Token Profile ───────────────────────────────────────────────── if (preferences.token_profile !== undefined) { if (typeof preferences.token_profile === "string" && VALID_TOKEN_PROFILES.has(preferences.token_profile as TokenProfile)) { validated.token_profile = preferences.token_profile as TokenProfile; } else { - errors.push(`token_profile must be one of: budget, balanced, quality`); + errors.push(`token_profile must be one of: budget, balanced, quality, burn-max`); } } diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 7a7ac6751..845676390 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -50,6 +50,8 @@ export type { AutoSupervisorConfig, RemoteQuestionsConfig, CmuxPreferences, + UokTurnActionMode, + UokPreferences, CodebaseMapPreferences, GSDPreferences, LoadedGSDPreferences, @@ -378,6 +380,29 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr dynamic_routing: (base.dynamic_routing || override.dynamic_routing) ? { ...(base.dynamic_routing ?? {}), ...(override.dynamic_routing ?? {}) } as DynamicRoutingConfig : undefined, + uok: (base.uok || override.uok) + ? { + enabled: override.uok?.enabled ?? base.uok?.enabled, + gates: (base.uok?.gates || override.uok?.gates) + ? { ...(base.uok?.gates ?? {}), ...(override.uok?.gates ?? {}) } + : undefined, + model_policy: (base.uok?.model_policy || override.uok?.model_policy) + ? { ...(base.uok?.model_policy ?? {}), ...(override.uok?.model_policy ?? {}) } + : undefined, + execution_graph: (base.uok?.execution_graph || override.uok?.execution_graph) + ? { ...(base.uok?.execution_graph ?? {}), ...(override.uok?.execution_graph ?? {}) } + : undefined, + gitops: (base.uok?.gitops || override.uok?.gitops) + ? { ...(base.uok?.gitops ?? {}), ...(override.uok?.gitops ?? {}) } + : undefined, + audit_unified: (base.uok?.audit_unified || override.uok?.audit_unified) + ? { ...(base.uok?.audit_unified ?? {}), ...(override.uok?.audit_unified ?? {}) } + : undefined, + plan_v2: (base.uok?.plan_v2 || override.uok?.plan_v2) + ? { ...(base.uok?.plan_v2 ?? {}), ...(override.uok?.plan_v2 ?? {}) } + : undefined, + } + : undefined, token_profile: override.token_profile ?? base.token_profile, phases: (base.phases || override.phases) ? { ...(base.phases ?? {}), ...(override.phases ?? {}) } diff --git a/src/resources/extensions/gsd/templates/PREFERENCES.md b/src/resources/extensions/gsd/templates/PREFERENCES.md index 878e2ccdf..5cbdf757f 100644 --- a/src/resources/extensions/gsd/templates/PREFERENCES.md +++ b/src/resources/extensions/gsd/templates/PREFERENCES.md @@ -39,6 +39,22 @@ dynamic_routing: budget_pressure: cross_provider: hooks: +uok: + enabled: false + gates: + enabled: false + model_policy: + enabled: false + execution_graph: + enabled: false + gitops: + enabled: false + turn_action: status-only + turn_push: false + audit_unified: + enabled: false + plan_v2: + enabled: false auto_visualize: auto_report: parallel: diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts index 2d506c815..dfd50eb7c 100644 --- a/src/resources/extensions/gsd/tests/auto-loop.test.ts +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -1267,7 +1267,7 @@ test("auto-loop.ts barrel re-exports autoLoop, runUnit, and resolveAgentEnd", () ); }); -test("auto.ts startAuto calls autoLoop (not dispatchNextUnit as first dispatch)", () => { +test("auto.ts startAuto dispatches through the UOK kernel wrapper (legacy loop adapter)", () => { const src = readFileSync( resolve(import.meta.dirname, "..", "auto.ts"), "utf-8", @@ -1279,8 +1279,12 @@ test("auto.ts startAuto calls autoLoop (not dispatchNextUnit as first dispatch)" const fnBlock = fnEnd > -1 ? src.slice(fnIdx, fnEnd) : src.slice(fnIdx, fnIdx + 5000); assert.ok( - fnBlock.includes("autoLoop("), - "startAuto must call autoLoop() instead of dispatchNextUnit()", + fnBlock.includes("runAutoLoopWithUok("), + "startAuto must dispatch through runAutoLoopWithUok()", + ); + assert.ok( + fnBlock.includes("runLegacyLoop: autoLoop"), + "startAuto must preserve the legacy autoLoop adapter in kernel dispatch", ); }); diff --git a/src/resources/extensions/gsd/tests/complete-slice.test.ts b/src/resources/extensions/gsd/tests/complete-slice.test.ts index ed5073ff8..4b9765db3 100644 --- a/src/resources/extensions/gsd/tests/complete-slice.test.ts +++ b/src/resources/extensions/gsd/tests/complete-slice.test.ts @@ -125,9 +125,9 @@ console.log('\n=== complete-slice: schema v6 migration ==='); const adapter = _getAdapter()!; - // Verify schema version is current (v14 after indexes + slice_dependencies) + // Verify schema version is current (v15 with UOK projection tables) const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assertEq(versionRow?.['v'], 14, 'schema version should be 14'); + assertEq(versionRow?.['v'], 15, 'schema version should be 15'); // Verify slices table has full_summary_md and full_uat_md columns const cols = adapter.prepare("PRAGMA table_info(slices)").all(); diff --git a/src/resources/extensions/gsd/tests/complete-task.test.ts b/src/resources/extensions/gsd/tests/complete-task.test.ts index c65f1ff05..c589bb7a9 100644 --- a/src/resources/extensions/gsd/tests/complete-task.test.ts +++ b/src/resources/extensions/gsd/tests/complete-task.test.ts @@ -109,9 +109,9 @@ console.log('\n=== complete-task: schema v5 migration ==='); const adapter = _getAdapter()!; - // Verify schema version is current (v14 after indexes + slice_dependencies) + // Verify schema version is current (v15 with UOK projection tables) const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assertEq(versionRow?.['v'], 14, 'schema version should be 14'); + assertEq(versionRow?.['v'], 15, 'schema version should be 15'); // Verify all 4 new tables exist const tables = adapter.prepare( diff --git a/src/resources/extensions/gsd/tests/gsd-db.test.ts b/src/resources/extensions/gsd/tests/gsd-db.test.ts index 4685b6dcc..f1542f9d5 100644 --- a/src/resources/extensions/gsd/tests/gsd-db.test.ts +++ b/src/resources/extensions/gsd/tests/gsd-db.test.ts @@ -80,7 +80,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'], 14, 'schema version should be 14'); + assert.deepStrictEqual(version?.['version'], 15, 'schema version should be 15'); // Check tables exist by querying them const dRows = adapter.prepare('SELECT count(*) as cnt FROM decisions').get(); diff --git a/src/resources/extensions/gsd/tests/md-importer.test.ts b/src/resources/extensions/gsd/tests/md-importer.test.ts index e1b622615..00d209b02 100644 --- a/src/resources/extensions/gsd/tests/md-importer.test.ts +++ b/src/resources/extensions/gsd/tests/md-importer.test.ts @@ -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, 14, 'new DB should be at schema version 14'); + assert.deepStrictEqual(version?.v, 15, 'new DB should be at schema version 15'); // Artifacts table should exist const tableCheck = adapter?.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='artifacts'").get(); @@ -413,4 +413,3 @@ test('md-importer: round-trip fidelity', () => { }); // ═══════════════════════════════════════════════════════════════════════════ - diff --git a/src/resources/extensions/gsd/tests/memory-store.test.ts b/src/resources/extensions/gsd/tests/memory-store.test.ts index 2b17dd955..0bb1b0d89 100644 --- a/src/resources/extensions/gsd/tests/memory-store.test.ts +++ b/src/resources/extensions/gsd/tests/memory-store.test.ts @@ -323,10 +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 14 (after indexes + slice_dependencies) + // Verify schema version is 15 (UOK gate/git/audit projection tables included) const version = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get(); - assert.deepStrictEqual(version?.['v'], 14, 'schema version should be 14'); + assert.deepStrictEqual(version?.['v'], 15, 'schema version should be 15'); closeDatabase(); }); - diff --git a/src/resources/extensions/gsd/tests/token-profile.test.ts b/src/resources/extensions/gsd/tests/token-profile.test.ts index 90ccb1907..fee6bb386 100644 --- a/src/resources/extensions/gsd/tests/token-profile.test.ts +++ b/src/resources/extensions/gsd/tests/token-profile.test.ts @@ -34,11 +34,12 @@ const typesSrc = readFileSync(join(__dirname, "..", "types.ts"), "utf-8"); // Type Definitions // ═══════════════════════════════════════════════════════════════════════════ -test("types: TokenProfile type exported with budget/balanced/quality", () => { +test("types: TokenProfile type exported with budget/balanced/quality/burn-max", () => { assert.ok(typesSrc.includes("export type TokenProfile"), "TokenProfile should be exported"); assert.match(typesSrc, /["']budget["']/, "should include budget"); assert.match(typesSrc, /["']balanced["']/, "should include balanced"); assert.match(typesSrc, /["']quality["']/, "should include quality"); + assert.match(typesSrc, /["']burn-max["']/, "should include burn-max"); }); test("types: InlineLevel type exported with full/standard/minimal", () => { @@ -91,7 +92,7 @@ test("preferences: KNOWN_PREFERENCE_KEYS includes token_profile and phases", () // Profile Resolution // ═══════════════════════════════════════════════════════════════════════════ -test("profile: resolveProfileDefaults exists and handles all 3 tiers", () => { +test("profile: resolveProfileDefaults exists and handles all 4 tiers", () => { assert.ok( preferencesSrc.includes("export function resolveProfileDefaults"), "resolveProfileDefaults should be exported", @@ -99,8 +100,9 @@ test("profile: resolveProfileDefaults exists and handles all 3 tiers", () => { assert.ok( preferencesSrc.includes('case "budget"') && preferencesSrc.includes('case "balanced"') && - preferencesSrc.includes('case "quality"'), - "resolveProfileDefaults should handle all 3 tiers", + preferencesSrc.includes('case "quality"') && + preferencesSrc.includes('case "burn-max"'), + "resolveProfileDefaults should handle all 4 tiers", ); }); @@ -158,6 +160,7 @@ test("profile: resolveInlineLevel maps profile to inline level", () => { assert.ok(preferencesSrc.includes('case "budget": return "minimal"'), "budget → minimal"); assert.ok(preferencesSrc.includes('case "balanced": return "standard"'), "balanced → standard"); assert.ok(preferencesSrc.includes('case "quality": return "full"'), "quality → full"); + assert.ok(preferencesSrc.includes('case "burn-max": return "full"'), "burn-max → full"); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -167,7 +170,7 @@ test("profile: resolveInlineLevel maps profile to inline level", () => { test("validate: validatePreferences handles token_profile", () => { assert.ok( preferencesSrc.includes("preferences.token_profile") && - preferencesSrc.includes("budget, balanced, quality"), + preferencesSrc.includes("budget, balanced, quality, burn-max"), "validatePreferences should validate token_profile enum values", ); }); diff --git a/src/resources/extensions/gsd/tests/uok-contracts.test.ts b/src/resources/extensions/gsd/tests/uok-contracts.test.ts new file mode 100644 index 000000000..14ac3ff03 --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-contracts.test.ts @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import type { + AuditEventEnvelope, + GateResult, + TurnContract, + TurnResult, + UokNodeKind, +} from "../uok/contracts.ts"; +import { buildAuditEnvelope } from "../uok/audit.ts"; + +test("uok contracts serialize/deserialize turn envelopes", () => { + const contract: TurnContract = { + traceId: "trace-1", + turnId: "turn-1", + iteration: 1, + basePath: "/tmp/project", + unitType: "execute-task", + unitId: "M001.S01.T01", + startedAt: new Date().toISOString(), + }; + + const gate: GateResult = { + gateId: "Q3", + gateType: "policy", + outcome: "pass", + failureClass: "none", + attempt: 1, + maxAttempts: 1, + retryable: false, + evaluatedAt: new Date().toISOString(), + }; + + const result: TurnResult = { + traceId: contract.traceId, + turnId: contract.turnId, + iteration: contract.iteration, + unitType: contract.unitType, + unitId: contract.unitId, + status: "completed", + failureClass: "none", + phaseResults: [ + { phase: "dispatch", action: "next", ts: new Date().toISOString() }, + { phase: "unit", action: "continue", ts: new Date().toISOString() }, + { phase: "finalize", action: "next", ts: new Date().toISOString() }, + ], + gateResults: [gate], + startedAt: contract.startedAt, + finishedAt: new Date().toISOString(), + }; + + const roundTrip = JSON.parse(JSON.stringify(result)) as TurnResult; + assert.equal(roundTrip.turnId, "turn-1"); + assert.equal(roundTrip.gateResults?.[0]?.gateId, "Q3"); + assert.equal(roundTrip.phaseResults.length, 3); +}); + +test("uok contracts include required DAG node kinds", () => { + const required: UokNodeKind[] = [ + "unit", + "hook", + "subagent", + "team-worker", + "verification", + "reprocess", + ]; + assert.deepEqual(required.length, 6); +}); + +test("uok audit envelope includes trace/turn/causality fields", () => { + const event: AuditEventEnvelope = buildAuditEnvelope({ + traceId: "trace-xyz", + turnId: "turn-xyz", + causedBy: "turn-start", + category: "orchestration", + type: "turn-result", + payload: { status: "completed" }, + }); + + assert.equal(event.traceId, "trace-xyz"); + assert.equal(event.turnId, "turn-xyz"); + assert.equal(event.causedBy, "turn-start"); + assert.equal(event.payload.status, "completed"); +}); diff --git a/src/resources/extensions/gsd/tests/uok-gate-runner.test.ts b/src/resources/extensions/gsd/tests/uok-gate-runner.test.ts new file mode 100644 index 000000000..d8dedd9c0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-gate-runner.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { closeDatabase, openDatabase, _getAdapter } from "../gsd-db.ts"; +import { UokGateRunner } from "../uok/gate-runner.ts"; + +test.beforeEach(() => { + closeDatabase(); + const ok = openDatabase(":memory:"); + assert.equal(ok, true); +}); + +test.afterEach(() => { + closeDatabase(); +}); + +test("uok gate runner retries timeout failures using deterministic matrix", async () => { + const runner = new UokGateRunner(); + + let calls = 0; + runner.register({ + id: "timeout-gate", + type: "verification", + execute: async (_ctx, attempt) => { + calls += 1; + if (attempt < 2) { + return { + outcome: "fail", + failureClass: "timeout", + rationale: "first attempt timed out", + }; + } + return { + outcome: "pass", + failureClass: "none", + rationale: "second attempt passed", + }; + }, + }); + + const result = await runner.run("timeout-gate", { + basePath: process.cwd(), + traceId: "trace-a", + turnId: "turn-a", + milestoneId: "M001", + sliceId: "S01", + taskId: "T01", + }); + + assert.equal(result.outcome, "pass"); + assert.equal(calls, 2); + + const adapter = _getAdapter(); + const rows = adapter?.prepare("SELECT gate_id, outcome, attempt FROM gate_runs ORDER BY id").all() ?? []; + assert.equal(rows.length, 2); + assert.equal(rows[0]?.["outcome"], "retry"); + assert.equal(rows[1]?.["outcome"], "pass"); +}); + +test("uok gate runner returns manual-attention for unknown gate id", async () => { + const runner = new UokGateRunner(); + const result = await runner.run("missing-gate", { + basePath: process.cwd(), + traceId: "trace-b", + turnId: "turn-b", + }); + + assert.equal(result.outcome, "manual-attention"); + assert.equal(result.failureClass, "unknown"); +}); diff --git a/src/resources/extensions/gsd/tests/uok-preferences.test.ts b/src/resources/extensions/gsd/tests/uok-preferences.test.ts new file mode 100644 index 000000000..b13deeaec --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-preferences.test.ts @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { validatePreferences } from "../preferences-validation.ts"; + +test("uok preferences validate nested flags and turn_action", () => { + const input = { + uok: { + enabled: true, + gates: { enabled: true }, + model_policy: { enabled: true }, + execution_graph: { enabled: false }, + gitops: { + enabled: true, + turn_action: "status-only", + turn_push: false, + }, + audit_unified: { enabled: true }, + plan_v2: { enabled: true }, + }, + }; + + const result = validatePreferences(input as never); + assert.equal(result.errors.length, 0); + assert.equal(result.preferences.uok?.enabled, true); + assert.equal(result.preferences.uok?.gitops?.turn_action, "status-only"); + assert.equal(result.preferences.uok?.plan_v2?.enabled, true); +}); + +test("uok preferences reject invalid turn_action", () => { + const result = validatePreferences({ + uok: { + gitops: { + turn_action: "push-everything", + }, + }, + } as never); + + assert.ok(result.errors.some((e) => e.includes("uok.gitops.turn_action"))); +}); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index 292aa462a..de109463a 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -306,7 +306,7 @@ export interface HookDispatchResult { export type BudgetEnforcementMode = "warn" | "pause" | "halt"; -export type TokenProfile = "budget" | "balanced" | "quality"; +export type TokenProfile = "budget" | "balanced" | "quality" | "burn-max"; export type InlineLevel = "full" | "standard" | "minimal"; diff --git a/src/resources/extensions/gsd/uok/audit.ts b/src/resources/extensions/gsd/uok/audit.ts new file mode 100644 index 000000000..2c65061db --- /dev/null +++ b/src/resources/extensions/gsd/uok/audit.ts @@ -0,0 +1,51 @@ +import { appendFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +import { gsdRoot } from "../paths.js"; +import { isDbAvailable, insertAuditEvent } from "../gsd-db.js"; +import type { AuditEventEnvelope } from "./contracts.js"; + +function auditLogPath(basePath: string): string { + return join(gsdRoot(basePath), "audit", "events.jsonl"); +} + +function ensureAuditDir(basePath: string): void { + mkdirSync(join(gsdRoot(basePath), "audit"), { recursive: true }); +} + +export function buildAuditEnvelope(args: { + traceId: string; + turnId?: string; + causedBy?: string; + category: AuditEventEnvelope["category"]; + type: string; + payload?: Record; +}): AuditEventEnvelope { + return { + eventId: randomUUID(), + traceId: args.traceId, + turnId: args.turnId, + causedBy: args.causedBy, + category: args.category, + type: args.type, + ts: new Date().toISOString(), + payload: args.payload ?? {}, + }; +} + +export function emitUokAuditEvent(basePath: string, event: AuditEventEnvelope): void { + try { + ensureAuditDir(basePath); + appendFileSync(auditLogPath(basePath), `${JSON.stringify(event)}\n`, "utf-8"); + } catch { + // Best-effort: audit writes must never break orchestration. + } + + if (!isDbAvailable()) return; + try { + insertAuditEvent(event); + } catch { + // Projection failures are non-fatal while legacy readers are still active. + } +} diff --git a/src/resources/extensions/gsd/uok/contracts.ts b/src/resources/extensions/gsd/uok/contracts.ts new file mode 100644 index 000000000..c997f31d6 --- /dev/null +++ b/src/resources/extensions/gsd/uok/contracts.ts @@ -0,0 +1,135 @@ +export type FailureClass = + | "none" + | "policy" + | "input" + | "execution" + | "artifact" + | "verification" + | "closeout" + | "git" + | "timeout" + | "manual-attention" + | "unknown"; + +export type GateOutcome = "pass" | "fail" | "retry" | "manual-attention"; + +export interface GateResult { + gateId: string; + gateType: string; + outcome: GateOutcome; + failureClass: FailureClass; + rationale?: string; + findings?: string; + attempt: number; + maxAttempts: number; + retryable: boolean; + evaluatedAt: string; +} + +export type TurnPhase = + | "pre-dispatch" + | "dispatch" + | "unit" + | "finalize" + | "guard" + | "custom-engine"; + +export type TurnStatus = + | "completed" + | "failed" + | "paused" + | "stopped" + | "skipped" + | "retry"; + +export interface TurnContract { + traceId: string; + turnId: string; + iteration: number; + basePath: string; + unitType?: string; + unitId?: string; + sidecarKind?: string; + startedAt: string; + metadata?: Record; +} + +export interface TurnCloseoutRecord { + traceId: string; + turnId: string; + unitType?: string; + unitId?: string; + status: TurnStatus; + failureClass: FailureClass; + gitAction: "commit" | "snapshot" | "status-only"; + gitPushed: boolean; + activityFile?: string; + finishedAt: string; +} + +export interface TurnResult { + traceId: string; + turnId: string; + iteration: number; + unitType?: string; + unitId?: string; + status: TurnStatus; + failureClass: FailureClass; + phaseResults: Array<{ + phase: TurnPhase; + action: string; + ts: string; + data?: Record; + }>; + gateResults?: GateResult[]; + closeout?: TurnCloseoutRecord; + error?: string; + startedAt: string; + finishedAt: string; +} + +export interface AuditEventEnvelope { + eventId: string; + traceId: string; + turnId?: string; + causedBy?: string; + category: + | "orchestration" + | "gate" + | "model-policy" + | "gitops" + | "verification" + | "metrics" + | "plan" + | "execution"; + type: string; + ts: string; + payload: Record; +} + +export type UokNodeKind = + | "unit" + | "hook" + | "subagent" + | "team-worker" + | "verification" + | "reprocess"; + +export interface UokGraphNode { + id: string; + kind: UokNodeKind; + dependsOn: string[]; + writes?: string[]; + reads?: string[]; + metadata?: Record; +} + +export interface UokTurnObserver { + onTurnStart(contract: TurnContract): void; + onPhaseResult( + phase: TurnPhase, + action: string, + data?: Record, + ): void; + onTurnResult(result: TurnResult): void; +} diff --git a/src/resources/extensions/gsd/uok/execution-graph.ts b/src/resources/extensions/gsd/uok/execution-graph.ts new file mode 100644 index 000000000..243ac4093 --- /dev/null +++ b/src/resources/extensions/gsd/uok/execution-graph.ts @@ -0,0 +1,121 @@ +import type { UokGraphNode } from "./contracts.js"; + +export interface ExecutionGraphRunOptions { + parallel?: boolean; + maxWorkers?: number; +} + +export interface ExecutionGraphResult { + order: string[]; + conflicts: Array<{ nodeA: string; nodeB: string; file: string }>; +} + +export type ExecutionNodeHandler = (node: UokGraphNode) => Promise; + +export class ExecutionGraphScheduler { + private readonly handlers = new Map(); + + registerHandler(kind: UokGraphNode["kind"], handler: ExecutionNodeHandler): void { + this.handlers.set(kind, handler); + } + + async run(nodes: UokGraphNode[], options?: ExecutionGraphRunOptions): Promise { + const sorted = topologicalSort(nodes); + const conflicts = detectFileConflicts(nodes); + + // Default deterministic serial execution remains the reference path. + if (!options?.parallel) { + for (const node of sorted) { + const handler = this.handlers.get(node.kind); + if (handler) await handler(node); + } + return { order: sorted.map((n) => n.id), conflicts }; + } + + // Parallel mode only for nodes whose dependencies are already satisfied. + const maxWorkers = Math.max(1, Math.min(8, options.maxWorkers ?? 2)); + const remaining = new Map(nodes.map((n) => [n.id, n])); + const done = new Set(); + const order: string[] = []; + + while (remaining.size > 0) { + const ready = Array.from(remaining.values()).filter((node) => + node.dependsOn.every((dep) => done.has(dep)), + ); + if (ready.length === 0) { + throw new Error("Execution graph deadlock detected: no ready nodes and graph not complete"); + } + + const batch = ready.slice(0, maxWorkers); + await Promise.all( + batch.map(async (node) => { + const handler = this.handlers.get(node.kind); + if (handler) await handler(node); + done.add(node.id); + order.push(node.id); + remaining.delete(node.id); + }), + ); + } + + return { order, conflicts }; + } +} + +function topologicalSort(nodes: UokGraphNode[]): UokGraphNode[] { + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + const inDegree = new Map(nodes.map((n) => [n.id, 0])); + + for (const node of nodes) { + for (const dep of node.dependsOn) { + if (nodeMap.has(dep)) { + inDegree.set(node.id, (inDegree.get(node.id) ?? 0) + 1); + } + } + } + + const queue = nodes + .filter((n) => (inDegree.get(n.id) ?? 0) === 0) + .sort((a, b) => a.id.localeCompare(b.id)); + const ordered: UokGraphNode[] = []; + + while (queue.length > 0) { + const current = queue.shift()!; + ordered.push(current); + + for (const next of nodes) { + if (!next.dependsOn.includes(current.id)) continue; + const deg = (inDegree.get(next.id) ?? 0) - 1; + inDegree.set(next.id, deg); + if (deg === 0) { + queue.push(next); + queue.sort((a, b) => a.id.localeCompare(b.id)); + } + } + } + + if (ordered.length !== nodes.length) { + throw new Error("Execution graph has cyclic dependencies"); + } + + return ordered; +} + +function detectFileConflicts(nodes: UokGraphNode[]): Array<{ nodeA: string; nodeB: string; file: string }> { + const conflicts: Array<{ nodeA: string; nodeB: string; file: string }> = []; + for (let i = 0; i < nodes.length; i++) { + const a = nodes[i]; + const writesA = new Set(a.writes ?? []); + if (writesA.size === 0) continue; + + for (let j = i + 1; j < nodes.length; j++) { + const b = nodes[j]; + for (const file of b.writes ?? []) { + if (writesA.has(file)) { + conflicts.push({ nodeA: a.id, nodeB: b.id, file }); + } + } + } + } + return conflicts; +} diff --git a/src/resources/extensions/gsd/uok/flags.ts b/src/resources/extensions/gsd/uok/flags.ts new file mode 100644 index 000000000..24e1cd0c9 --- /dev/null +++ b/src/resources/extensions/gsd/uok/flags.ts @@ -0,0 +1,34 @@ +import type { GSDPreferences } from "../preferences.js"; +import { loadEffectiveGSDPreferences } from "../preferences.js"; + +export interface UokFlags { + enabled: boolean; + gates: boolean; + modelPolicy: boolean; + executionGraph: boolean; + gitops: boolean; + gitopsTurnAction: "commit" | "snapshot" | "status-only"; + gitopsTurnPush: boolean; + auditUnified: boolean; + planV2: boolean; +} + +export function resolveUokFlags(prefs: GSDPreferences | undefined): UokFlags { + const uok = prefs?.uok; + return { + enabled: uok?.enabled === true, + gates: uok?.gates?.enabled === true, + modelPolicy: uok?.model_policy?.enabled === true, + executionGraph: uok?.execution_graph?.enabled === true, + gitops: uok?.gitops?.enabled === true, + gitopsTurnAction: uok?.gitops?.turn_action ?? "status-only", + gitopsTurnPush: uok?.gitops?.turn_push === true, + auditUnified: uok?.audit_unified?.enabled === true, + planV2: uok?.plan_v2?.enabled === true, + }; +} + +export function loadUokFlags(): UokFlags { + const prefs = loadEffectiveGSDPreferences()?.preferences; + return resolveUokFlags(prefs); +} diff --git a/src/resources/extensions/gsd/uok/gate-runner.ts b/src/resources/extensions/gsd/uok/gate-runner.ts new file mode 100644 index 000000000..5e12407bb --- /dev/null +++ b/src/resources/extensions/gsd/uok/gate-runner.ts @@ -0,0 +1,146 @@ +import type { FailureClass, GateResult } from "./contracts.js"; +import { insertGateRun } from "../gsd-db.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; + +export interface GateRunnerContext { + basePath: string; + traceId: string; + turnId: string; + milestoneId?: string; + sliceId?: string; + taskId?: string; + unitType?: string; + unitId?: string; +} + +export interface GateExecutionInput { + id: string; + type: string; + execute: (ctx: GateRunnerContext, attempt: number) => Promise<{ + outcome: "pass" | "fail" | "retry" | "manual-attention"; + rationale?: string; + findings?: string; + failureClass?: FailureClass; + }>; +} + +const RETRY_MATRIX: Record = { + none: 0, + policy: 0, + input: 0, + execution: 1, + artifact: 1, + verification: 1, + closeout: 1, + git: 1, + timeout: 2, + "manual-attention": 0, + unknown: 0, +}; + +export class UokGateRunner { + private readonly registry = new Map(); + + register(gate: GateExecutionInput): void { + this.registry.set(gate.id, gate); + } + + list(): GateExecutionInput[] { + return Array.from(this.registry.values()); + } + + async run(id: string, ctx: GateRunnerContext): Promise { + const gate = this.registry.get(id); + if (!gate) { + return { + gateId: id, + gateType: "unknown", + outcome: "manual-attention", + failureClass: "unknown", + rationale: `Gate ${id} not registered`, + attempt: 1, + maxAttempts: 1, + retryable: false, + evaluatedAt: new Date().toISOString(), + }; + } + + let attempt = 0; + let final: GateResult | null = null; + const maxAttemptsByFailureClass = RETRY_MATRIX; + + while (attempt < 3) { + attempt += 1; + const now = new Date().toISOString(); + const result = await gate.execute(ctx, attempt); + const failureClass = result.failureClass ?? (result.outcome === "pass" ? "none" : "unknown"); + const retryBudget = maxAttemptsByFailureClass[failureClass] ?? 0; + const retryable = result.outcome !== "pass" && attempt <= retryBudget; + + final = { + gateId: gate.id, + gateType: gate.type, + outcome: retryable ? "retry" : result.outcome, + failureClass, + rationale: result.rationale, + findings: result.findings, + attempt, + maxAttempts: Math.max(1, retryBudget), + retryable, + evaluatedAt: now, + }; + + insertGateRun({ + traceId: ctx.traceId, + turnId: ctx.turnId, + gateId: final.gateId, + gateType: final.gateType, + unitType: ctx.unitType, + unitId: ctx.unitId, + milestoneId: ctx.milestoneId, + sliceId: ctx.sliceId, + taskId: ctx.taskId, + outcome: final.outcome, + failureClass: final.failureClass, + rationale: final.rationale, + findings: final.findings, + attempt: final.attempt, + maxAttempts: final.maxAttempts, + retryable: final.retryable, + evaluatedAt: final.evaluatedAt, + }); + + emitUokAuditEvent( + ctx.basePath, + buildAuditEnvelope({ + traceId: ctx.traceId, + turnId: ctx.turnId, + category: "gate", + type: "gate-run", + payload: { + gateId: final.gateId, + gateType: final.gateType, + outcome: final.outcome, + failureClass: final.failureClass, + attempt: final.attempt, + maxAttempts: final.maxAttempts, + retryable: final.retryable, + }, + }), + ); + + if (!retryable) break; + } + + return final ?? { + gateId: gate.id, + gateType: gate.type, + outcome: "manual-attention", + failureClass: "unknown", + attempt: 1, + maxAttempts: 1, + retryable: false, + evaluatedAt: new Date().toISOString(), + }; + } +} diff --git a/src/resources/extensions/gsd/uok/gitops.ts b/src/resources/extensions/gsd/uok/gitops.ts new file mode 100644 index 000000000..81caa7943 --- /dev/null +++ b/src/resources/extensions/gsd/uok/gitops.ts @@ -0,0 +1,75 @@ +import { isDbAvailable, upsertTurnGitTransaction } from "../gsd-db.js"; +import type { TurnCloseoutRecord } from "./contracts.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; + +export type TurnGitStage = "turn-start" | "stage" | "checkpoint" | "publish" | "record"; + +interface GitTxArgs { + basePath: string; + traceId: string; + turnId: string; + unitType?: string; + unitId?: string; + stage: TurnGitStage; + action: "commit" | "snapshot" | "status-only"; + push: boolean; + status: "ok" | "failed"; + error?: string; + metadata?: Record; +} + +export function writeTurnGitTransaction(args: GitTxArgs): void { + if (!isDbAvailable()) return; + upsertTurnGitTransaction({ + traceId: args.traceId, + turnId: args.turnId, + unitType: args.unitType, + unitId: args.unitId, + stage: args.stage, + action: args.action, + push: args.push, + status: args.status, + error: args.error, + metadata: args.metadata, + updatedAt: new Date().toISOString(), + }); + + emitUokAuditEvent( + args.basePath, + buildAuditEnvelope({ + traceId: args.traceId, + turnId: args.turnId, + category: "gitops", + type: `turn-git-${args.stage}`, + payload: { + unitType: args.unitType, + unitId: args.unitId, + action: args.action, + push: args.push, + status: args.status, + error: args.error, + ...(args.metadata ?? {}), + }, + }), + ); +} + +export function writeTurnCloseoutGitRecord(basePath: string, record: TurnCloseoutRecord): void { + writeTurnGitTransaction({ + basePath, + traceId: record.traceId, + turnId: record.turnId, + unitType: record.unitType, + unitId: record.unitId, + stage: "record", + action: record.gitAction, + push: record.gitPushed, + status: record.failureClass === "git" ? "failed" : "ok", + error: record.failureClass === "git" ? "git closeout failure" : undefined, + metadata: { + turnStatus: record.status, + finishedAt: record.finishedAt, + activityFile: record.activityFile, + }, + }); +} diff --git a/src/resources/extensions/gsd/uok/kernel.ts b/src/resources/extensions/gsd/uok/kernel.ts new file mode 100644 index 000000000..6c5d60f13 --- /dev/null +++ b/src/resources/extensions/gsd/uok/kernel.ts @@ -0,0 +1,98 @@ +import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; +import { appendFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import type { AutoSession } from "../auto/session.js"; +import type { LoopDeps } from "../auto/loop-deps.js"; +import { gsdRoot } from "../paths.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; +import { resolveUokFlags } from "./flags.js"; +import { createTurnObserver } from "./loop-adapter.js"; + +interface RunAutoLoopWithUokArgs { + ctx: ExtensionContext; + pi: ExtensionAPI; + s: AutoSession; + deps: LoopDeps; + runLegacyLoop: ( + ctx: ExtensionContext, + pi: ExtensionAPI, + s: AutoSession, + deps: LoopDeps, + ) => Promise; +} + +function parityLogPath(basePath: string): string { + return join(gsdRoot(basePath), "runtime", "uok-parity.jsonl"); +} + +function writeParityEvent(basePath: string, event: Record): void { + try { + mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true }); + appendFileSync(parityLogPath(basePath), `${JSON.stringify(event)}\n`, "utf-8"); + } catch { + // parity telemetry must never block orchestration + } +} + +export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise { + const { ctx, pi, s, deps, runLegacyLoop } = args; + const prefs = deps.loadEffectiveGSDPreferences()?.preferences; + const flags = resolveUokFlags(prefs); + + writeParityEvent(s.basePath, { + ts: new Date().toISOString(), + path: flags.enabled ? "uok-wrapper" : "legacy-wrapper", + flags, + phase: "enter", + }); + + if (flags.auditUnified) { + emitUokAuditEvent( + s.basePath, + buildAuditEnvelope({ + traceId: `session:${String(s.autoStartTime || Date.now())}`, + category: "orchestration", + type: "uok-kernel-enter", + payload: { + flags, + sessionId: ctx.sessionManager?.getSessionId?.(), + }, + }), + ); + } + + const decoratedDeps: LoopDeps = flags.enabled + ? { + ...deps, + uokObserver: createTurnObserver({ + basePath: s.basePath, + gitAction: flags.gitopsTurnAction, + gitPush: flags.gitopsTurnPush, + enableAudit: flags.auditUnified, + enableGitops: flags.gitops, + }), + } + : deps; + + try { + await runLegacyLoop(ctx, pi, s, decoratedDeps); + writeParityEvent(s.basePath, { + ts: new Date().toISOString(), + path: flags.enabled ? "uok-wrapper" : "legacy-wrapper", + flags, + phase: "exit", + status: "ok", + }); + } catch (err) { + writeParityEvent(s.basePath, { + ts: new Date().toISOString(), + path: flags.enabled ? "uok-wrapper" : "legacy-wrapper", + flags, + phase: "exit", + status: "error", + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} diff --git a/src/resources/extensions/gsd/uok/loop-adapter.ts b/src/resources/extensions/gsd/uok/loop-adapter.ts new file mode 100644 index 000000000..e23cb6e34 --- /dev/null +++ b/src/resources/extensions/gsd/uok/loop-adapter.ts @@ -0,0 +1,162 @@ +import type { + TurnCloseoutRecord, + TurnContract, + TurnResult, + UokTurnObserver, +} from "./contracts.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; +import { writeTurnCloseoutGitRecord, writeTurnGitTransaction } from "./gitops.js"; + +export interface CreateTurnObserverOptions { + basePath: string; + gitAction: "commit" | "snapshot" | "status-only"; + gitPush: boolean; + enableAudit: boolean; + enableGitops: boolean; +} + +export function createTurnObserver(options: CreateTurnObserverOptions): UokTurnObserver { + let current: TurnContract | null = null; + const phaseResults: TurnResult["phaseResults"] = []; + + return { + onTurnStart(contract): void { + current = contract; + phaseResults.length = 0; + + if (options.enableGitops) { + writeTurnGitTransaction({ + basePath: options.basePath, + traceId: contract.traceId, + turnId: contract.turnId, + unitType: contract.unitType, + unitId: contract.unitId, + stage: "turn-start", + action: options.gitAction, + push: options.gitPush, + status: "ok", + metadata: { + iteration: contract.iteration, + sidecarKind: contract.sidecarKind, + }, + }); + } + + if (options.enableAudit) { + emitUokAuditEvent( + options.basePath, + buildAuditEnvelope({ + traceId: contract.traceId, + turnId: contract.turnId, + category: "orchestration", + type: "turn-start", + payload: { + iteration: contract.iteration, + unitType: contract.unitType, + unitId: contract.unitId, + sidecarKind: contract.sidecarKind, + }, + }), + ); + } + }, + + onPhaseResult(phase, action, data): void { + phaseResults.push({ + phase, + action, + ts: new Date().toISOString(), + data, + }); + + if (!current || !options.enableGitops) return; + if (phase === "dispatch") { + writeTurnGitTransaction({ + basePath: options.basePath, + traceId: current.traceId, + turnId: current.turnId, + unitType: data?.unitType as string | undefined, + unitId: data?.unitId as string | undefined, + stage: "stage", + action: options.gitAction, + push: options.gitPush, + status: "ok", + metadata: { action }, + }); + } + if (phase === "unit") { + writeTurnGitTransaction({ + basePath: options.basePath, + traceId: current.traceId, + turnId: current.turnId, + unitType: data?.unitType as string | undefined, + unitId: data?.unitId as string | undefined, + stage: "checkpoint", + action: options.gitAction, + push: options.gitPush, + status: "ok", + metadata: { action }, + }); + } + if (phase === "finalize") { + writeTurnGitTransaction({ + basePath: options.basePath, + traceId: current.traceId, + turnId: current.turnId, + unitType: data?.unitType as string | undefined, + unitId: data?.unitId as string | undefined, + stage: "publish", + action: options.gitAction, + push: options.gitPush, + status: "ok", + metadata: { action }, + }); + } + }, + + onTurnResult(result): void { + const merged: TurnResult = { + ...result, + phaseResults: result.phaseResults.length > 0 ? result.phaseResults : [...phaseResults], + }; + + if (options.enableAudit) { + emitUokAuditEvent( + options.basePath, + buildAuditEnvelope({ + traceId: merged.traceId, + turnId: merged.turnId, + category: "orchestration", + type: "turn-result", + payload: { + unitType: merged.unitType, + unitId: merged.unitId, + status: merged.status, + failureClass: merged.failureClass, + error: merged.error, + phaseCount: merged.phaseResults.length, + }, + }), + ); + } + + if (options.enableGitops) { + const closeout: TurnCloseoutRecord = merged.closeout ?? { + traceId: merged.traceId, + turnId: merged.turnId, + unitType: merged.unitType, + unitId: merged.unitId, + status: merged.status, + failureClass: merged.failureClass, + gitAction: options.gitAction, + gitPushed: options.gitPush, + finishedAt: merged.finishedAt, + }; + writeTurnCloseoutGitRecord(options.basePath, closeout); + } + + current = null; + phaseResults.length = 0; + }, + }; +} diff --git a/src/resources/extensions/gsd/uok/model-policy.ts b/src/resources/extensions/gsd/uok/model-policy.ts new file mode 100644 index 000000000..876fba374 --- /dev/null +++ b/src/resources/extensions/gsd/uok/model-policy.ts @@ -0,0 +1,112 @@ +import type { TaskMetadata } from "../model-router.js"; +import { computeTaskRequirements, filterToolsForProvider } from "../model-router.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; + +export interface ModelCandidate { + id: string; + provider: string; + api: string; +} + +export interface ModelPolicyDecision { + modelId: string; + provider: string; + allowed: boolean; + reason: string; +} + +export interface ModelPolicyOptions { + basePath: string; + traceId: string; + turnId?: string; + unitType?: string; + taskMetadata?: TaskMetadata; + currentProvider?: string; + allowCrossProvider?: boolean; + requiredTools?: string[]; + deniedProviders?: string[]; + allowedApis?: string[]; +} + +export function buildRequirementVector(unitType?: string, taskMetadata?: TaskMetadata): Partial> { + if (!unitType) return {}; + return computeTaskRequirements(unitType, taskMetadata) as unknown as Partial>; +} + +export function applyModelPolicyFilter( + candidates: ModelCandidate[], + options: ModelPolicyOptions, +): { + eligible: ModelCandidate[]; + decisions: ModelPolicyDecision[]; + requirements: Partial>; +} { + const requiredTools = options.requiredTools ?? []; + const deniedProviders = new Set((options.deniedProviders ?? []).map((p) => p.toLowerCase())); + const allowedApis = options.allowedApis ? new Set(options.allowedApis) : null; + const requirements = buildRequirementVector(options.unitType, options.taskMetadata); + const decisions: ModelPolicyDecision[] = []; + const eligible: ModelCandidate[] = []; + + for (const model of candidates) { + let allowed = true; + let reason = "allowed"; + + if (options.allowCrossProvider === false && options.currentProvider && model.provider !== options.currentProvider) { + allowed = false; + reason = `cross-provider routing disabled (${model.provider} != ${options.currentProvider})`; + } + + if (allowed && deniedProviders.has(model.provider.toLowerCase())) { + allowed = false; + reason = `provider denied by policy: ${model.provider}`; + } + + if (allowed && allowedApis && !allowedApis.has(model.api)) { + allowed = false; + reason = `transport/api denied by policy: ${model.api}`; + } + + if (allowed && requiredTools.length > 0) { + const compatibility = filterToolsForProvider(requiredTools, model.api); + if (compatibility.filtered.length > 0) { + allowed = false; + reason = `tool policy denied (${compatibility.filtered.join(", ")}) for ${model.api}`; + } + } + + const decision: ModelPolicyDecision = { + modelId: model.id, + provider: model.provider, + allowed, + reason, + }; + decisions.push(decision); + + emitUokAuditEvent( + options.basePath, + buildAuditEnvelope({ + traceId: options.traceId, + turnId: options.turnId, + category: "model-policy", + type: allowed ? "model-policy-allow" : "model-policy-deny", + payload: { + modelId: model.id, + provider: model.provider, + api: model.api, + reason, + unitType: options.unitType, + requirements, + }, + }), + ); + + if (allowed) eligible.push(model); + } + + return { + eligible, + decisions, + requirements, + }; +} diff --git a/src/resources/extensions/gsd/uok/plan-v2.ts b/src/resources/extensions/gsd/uok/plan-v2.ts new file mode 100644 index 000000000..ef8198a15 --- /dev/null +++ b/src/resources/extensions/gsd/uok/plan-v2.ts @@ -0,0 +1,87 @@ +import { mkdirSync, 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 { UokGraphNode } from "./contracts.js"; + +export interface PlanV2CompileResult { + ok: boolean; + reason?: string; + graphPath?: string; + nodeCount?: number; +} + +function graphOutputPath(basePath: string): string { + return join(gsdRoot(basePath), "runtime", "uok-plan-v2-graph.json"); +} + +export function compileUnitGraphFromState(basePath: string, state: GSDState): PlanV2CompileResult { + const mid = state.activeMilestone?.id; + if (!mid) return { ok: false, reason: "no active milestone" }; + if (!isDbAvailable()) return { ok: false, reason: "database not available" }; + + const slices = getMilestoneSlices(mid).sort((a, b) => Number(a.sequence ?? 0) - Number(b.sequence ?? 0)); + const nodes: UokGraphNode[] = []; + + for (const slice of slices) { + const sid = slice.id; + const tasks = getSliceTasks(mid, sid) + .sort((a, b) => Number(a.sequence ?? 0) - Number(b.sequence ?? 0)); + + let previousTaskNodeId: string | null = null; + for (const task of tasks) { + const nodeId = `execute-task:${mid}:${sid}:${task.id}`; + const dependsOn = previousTaskNodeId ? [previousTaskNodeId] : []; + nodes.push({ + id: nodeId, + kind: "unit", + dependsOn, + writes: task.key_files, + metadata: { + unitType: "execute-task", + unitId: `${mid}.${sid}.${task.id}`, + title: task.title, + status: task.status, + }, + }); + previousTaskNodeId = nodeId; + } + + if (previousTaskNodeId) { + nodes.push({ + id: `complete-slice:${mid}:${sid}`, + kind: "verification", + dependsOn: [previousTaskNodeId], + metadata: { + unitType: "complete-slice", + unitId: `${mid}.${sid}`, + title: slice.title, + status: slice.status, + }, + }); + } + } + + const output = { + compiledAt: new Date().toISOString(), + milestoneId: mid, + nodes, + }; + + const outPath = graphOutputPath(basePath); + 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 }; +} + +export function ensurePlanV2Graph(basePath: string, state: GSDState): PlanV2CompileResult { + const compiled = compileUnitGraphFromState(basePath, state); + if (!compiled.ok) return compiled; + if ((compiled.nodeCount ?? 0) <= 0) { + return { ok: false, reason: "compiled graph is empty" }; + } + return compiled; +} From 76a85300aecba51abaddf60ac8956fd25364d55a Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 19:54:04 -0500 Subject: [PATCH 02/11] fix(gsd): align ADR-009 integration with type-safe builds Add ADR-009 docs and resolve compile/runtime typing regressions in UOK and extension modules. Refs #4214 --- docs/dev/ADR-009-IMPLEMENTATION-PLAN.md | 497 ++++++++++++++++++ .../ADR-009-orchestration-kernel-refactor.md | 401 ++++++++++++++ .../src/types/ambient-modules.d.ts | 69 +++ packages/pi-coding-agent/tsconfig.json | 4 +- src/resources/extensions/gsd/auto/loop.ts | 2 +- src/resources/extensions/gsd/gsd-db.ts | 4 +- src/resources/extensions/gsd/session-lock.ts | 16 +- .../extensions/gsd/unit-ownership.ts | 4 +- .../extensions/gsd/uok/model-policy.ts | 10 +- src/resources/extensions/ttsr/ttsr-manager.ts | 15 +- 10 files changed, 1003 insertions(+), 19 deletions(-) create mode 100644 docs/dev/ADR-009-IMPLEMENTATION-PLAN.md create mode 100644 docs/dev/ADR-009-orchestration-kernel-refactor.md create mode 100644 packages/pi-coding-agent/src/types/ambient-modules.d.ts diff --git a/docs/dev/ADR-009-IMPLEMENTATION-PLAN.md b/docs/dev/ADR-009-IMPLEMENTATION-PLAN.md new file mode 100644 index 000000000..beec2891d --- /dev/null +++ b/docs/dev/ADR-009-IMPLEMENTATION-PLAN.md @@ -0,0 +1,497 @@ +# ADR-009 Implementation Plan + +**Related ADR:** [ADR-009-orchestration-kernel-refactor.md](/Users/jeremymcspadden/Github/gsd-2/docs/dev/ADR-009-orchestration-kernel-refactor.md) +**Status:** Draft +**Date:** 2026-04-14 +**Target Window:** 8-10 waves (incremental, no big-bang rewrite) + +## Objective + +Implement ADR-009 by migrating GSD orchestration internals to a Unified Orchestration Kernel (UOK) with six control planes: + +1. Plan +2. Execution +3. Model +4. Gate +5. GitOps +6. Audit + +without breaking existing CLI/web/MCP workflows. + +The first production-safe outcome is: + +- existing auto-mode behavior remains stable +- new kernel contracts exist behind feature flags +- every turn is traceable with deterministic gate outcomes + +## Non-Goals + +- Rewriting user-facing command surfaces +- Replacing all legacy modules in a single PR +- Introducing new provider auth flows that bypass existing compliance boundaries +- Forcing `burn-max` behavior as default + +## Constraints + +- Maintain current runtime compatibility and defaults +- Preserve existing state-on-disk and DB-backed transition model +- Keep provider-agnostic behavior while enforcing provider-specific policy constraints +- All migration steps must be reversible behind flags +- High-risk changes require parity tests against existing behavior + +## Program Structure + +Implementation is organized into parallel workstreams and executed in waves. + +### Workstream A: Kernel Contracts and Orchestrator Spine + +Goal: define typed contracts and a new orchestration spine without changing behavior. + +Primary targets: + +- `src/resources/extensions/gsd/auto.ts` +- `src/resources/extensions/gsd/auto/loop.ts` +- `src/resources/extensions/gsd/auto/types.ts` +- `src/resources/extensions/gsd/auto/session.ts` + +Deliverables: + +- `TurnContract` and `TurnResult` types +- `GateResult` envelope +- kernel entrypoint that wraps current dispatch loop via adapter + +### Workstream B: Gate Plane + +Goal: normalize all checks into a unified gate runner. + +Primary targets: + +- `src/resources/extensions/gsd/verification-gate.ts` +- `src/resources/extensions/gsd/auto-verification.ts` +- `src/resources/extensions/gsd/pre-execution-checks.ts` +- `src/resources/extensions/gsd/post-execution-checks.ts` +- `src/resources/extensions/gsd/milestone-validation-gates.ts` + +Deliverables: + +- unified gate registry and execution API +- deterministic failure classes and retry policies +- explicit terminal status persistence + +### Workstream C: Model Plane + Policy Engine + +Goal: enable any-model-any-phase through requirement-based selection plus policy filtering. + +Primary targets: + +- `src/resources/extensions/gsd/model-router.ts` +- `src/resources/extensions/gsd/auto-model-selection.ts` +- `src/resources/extensions/gsd/preferences-models.ts` +- `src/resources/extensions/gsd/model-cost-table.ts` +- `src/resources/extensions/gsd/custom-execution-policy.ts` + +Deliverables: + +- requirement vector builder for units +- policy filter before capability scoring +- new `burn-max` profile +- policy decision audit events + +### Workstream D: Execution Graph (Agents/Subagents/Parallel/Teams) + +Goal: move to one DAG scheduler contract. + +Primary targets: + +- `src/resources/extensions/gsd/reactive-graph.ts` +- `src/resources/extensions/gsd/slice-parallel-orchestrator.ts` +- `src/resources/extensions/gsd/parallel-orchestrator.ts` +- `src/resources/extensions/gsd/graph.ts` +- `src/resources/extensions/gsd/unit-runtime.ts` + +Deliverables: + +- typed node kinds (`unit`, `hook`, `subagent`, `team-worker`, `verification`, `reprocess`) +- shared dependency/conflict resolver +- scheduler adapter for current parallel and reactive paths + +### Workstream E: GitOps Transaction Layer + +Goal: guarantee git action and metadata record per turn. + +Primary targets: + +- `src/resources/extensions/gsd/git-service.ts` +- `src/resources/extensions/gsd/auto-post-unit.ts` +- `src/resources/extensions/gsd/auto-unit-closeout.ts` +- `src/resources/extensions/gsd/auto-worktree.ts` + +Deliverables: + +- `turn-start -> stage -> checkpoint -> publish -> record` transaction API +- configurable turn action mode (`commit|snapshot|status-only`) +- closeout gate integration for git failures + +### Workstream F: Unified Audit Plane + +Goal: unify journal/activity/metrics into a causal event model. + +Primary targets: + +- `src/resources/extensions/gsd/journal.ts` +- `src/resources/extensions/gsd/activity-log.ts` +- `src/resources/extensions/gsd/metrics.ts` +- `src/resources/extensions/gsd/workflow-logger.ts` +- `src/resources/extensions/gsd/gsd-db.ts` + +Deliverables: + +- common `AuditEventEnvelope` +- trace/turn IDs on all events +- append-only JSONL raw log + DB projection index + +### Workstream G: Plan Plane v2 + +Goal: formal multi-round clarify/research/draft/compile flow. + +Primary targets: + +- `src/resources/extensions/gsd/guided-flow.ts` +- `src/resources/extensions/gsd/preparation.ts` +- `src/resources/extensions/gsd/auto/phases.ts` +- `src/resources/extensions/gsd/auto-prompts.ts` +- prompt templates under `src/resources/extensions/gsd/prompts/` + +Deliverables: + +- bounded multi-round question loop +- plan compile step producing executable unit graph +- plan gate fail-closed behavior + +## Wave Plan (Execution Order) + +## Wave 0: Baseline and Flag Scaffolding + +Purpose: establish safe rollout controls and baseline telemetry. + +Tasks: + +- Add feature flags: + - `uok.enabled` + - `uok.gates.enabled` + - `uok.model_policy.enabled` + - `uok.execution_graph.enabled` + - `uok.gitops.enabled` + - `uok.audit_unified.enabled` + - `uok.plan_v2.enabled` +- Add no-op kernel wrapper around current auto loop +- Add baseline metrics for parity comparison + +Exit criteria: + +- zero behavior change with all flags off +- parity telemetry collected for existing loop + +Verification: + +- `npm run typecheck:extensions` +- `npm run test:unit` + +## Wave 1: Contract Extraction + +Purpose: create stable internal API boundaries. + +Tasks: + +- Introduce: + - `TurnContract` + - `UnitExecutionContext` + - `GateResult` + - `FailureClass` + - `TurnCloseoutRecord` +- Adapter layer from legacy auto loop into contracts +- Add contract fixtures and serialization tests + +Exit criteria: + +- current auto dispatch runs through adapter path without behavior change +- all turn outcomes represented in structured result type + +Verification: + +- targeted tests in `src/resources/extensions/gsd/tests/*auto*` +- `npm run test:unit` + +## Wave 2: Gate Plane Unification + +Purpose: centralize pre/in/post checks and retries. + +Tasks: + +- Build `gate-runner` and gate registry +- Port existing checks into registered gates: + - policy/input/execution/artifact/verification/closeout +- Implement deterministic retry matrix by failure class + +Exit criteria: + +- every unit passes through gate runner +- explicit gate result persisted for pass/fail/retry/manual-attention + +Verification: + +- extend `verification-gate.test.ts` +- extend `validation-gate-patterns.test.ts` +- add integration tests for retry escalation + +## Wave 3: Model Plane + Policy Filter + +Purpose: enable requirement-based selection constrained by policy. + +Tasks: + +- Add requirement extraction from unit metadata +- Insert policy filter before model scoring +- Add `burn-max` token profile wiring +- Emit model policy allow/deny events + +Exit criteria: + +- units can select any eligible model across phases +- policy-denied routes fail before dispatch +- fallback chains remain deterministic + +Verification: + +- extend `model-cost-table.test.ts` +- extend model routing tests (`interactive-routing-bypass`, `tool-compatibility`, related router suites) +- add policy denial regression tests + +## Wave 4: Execution Graph Scheduler + +Purpose: unify hooks/subagents/parallel/team work under one scheduler contract. + +Tasks: + +- Introduce graph scheduler facade +- Map reactive execution nodes to shared node model +- Map slice/milestone parallel orchestrators onto scheduler +- Add file IO conflict lock integration + +Exit criteria: + +- same task set can execute in deterministic single-worker or parallel graph mode +- no deadlock under known reactive/parallel fixtures + +Verification: + +- `slice-parallel-orchestrator.test.ts` +- `slice-parallel-conflict.test.ts` +- `sidecar-queue.test.ts` +- integration: `src/resources/extensions/gsd/tests/integration/*.test.ts` + +## Wave 5: GitOps Transactions Per Turn + +Purpose: enforce turn-level git actions and closeout discipline. + +Tasks: + +- Implement turn transaction API +- Wire turn transactions into auto closeout path +- Add configurable `turn_action` and `turn_push` semantics +- Persist git transaction metadata into audit stream + +Exit criteria: + +- each turn has a git transaction record +- blocked git states surface as closeout gate failures + +Verification: + +- `git-service` integration tests +- worktree-related integration suites +- closeout and merge regression suites + +## Wave 6: Unified Audit Plane + +Purpose: converge logging/metrics/journal into one causal model. + +Tasks: + +- Define `AuditEventEnvelope` schema +- Add `traceId`, `turnId`, `causedBy` to event emitters +- Write projection pipeline into DB index tables +- Maintain append-only raw JSONL logs + +Exit criteria: + +- action-level traceability across model/tool/git/gate/test events +- legacy readers remain functional through compatibility projection + +Verification: + +- `workflow-logger*.test.ts` +- `workflow-events.test.ts` +- `journal` and `metrics` regression tests + +## Wave 7: Plan Plane v2 + +Purpose: deliver full multi-round planning and compile-to-unit graph. + +Tasks: + +- Implement bounded clarify rounds +- Add explicit research synthesis stage +- Add plan compile stage with dependency graph output +- Add plan gate with fail-closed checks + +Exit criteria: + +- full roadmap and unit graph produced before execution begins (when enabled) +- invalid plans cannot proceed to execution + +Verification: + +- prompt and plan parsing tests +- planning tool tests (`plan-milestone`, `plan-slice`, `plan-task`) +- discuss/guided flow regression tests + +## Wave 8: Legacy Branch Retirement + Default Flip + +Purpose: reduce maintenance burden and enable UOK as default. + +Tasks: + +- remove superseded code paths in `auto.ts`, `auto-phases`, and legacy closeout paths +- keep legacy fallback behind emergency flag for one release window +- update docs and preferences reference + +Exit criteria: + +- UOK default in stable channel +- no critical parity regressions in one full release cycle + +Verification: + +- full `npm test` +- smoke + integration suites +- targeted manual UAT for CLI/web/headless + +## Testing and Validation Matrix + +### 1. Unit + +- contract serialization +- gate runner behavior by failure class +- model policy filter decisions +- git transaction state machine +- event envelope schema validation + +### 2. Integration + +- auto dispatch across plan/execute/complete/reassess/uat +- worktree/branch/none isolation behaviors +- parallel and reactive execution parity +- policy-denied dispatch fast-fail + +### 3. End-to-End + +- greenfield milestone from discuss -> plan -> execute -> complete -> merge +- failure reprocessing (test failure, tool failure, model failure) +- full audit trace reconstruction by `traceId` +- provider compliance scenarios (allowed vs denied paths) + +### 4. Parity Harness + +- replay selected historical workflows against legacy and UOK paths +- compare: + - state transitions + - produced artifacts + - gate decisions + - commit outcomes + +## Rollout Strategy + +### Stages + +1. Internal dogfood with flags on +2. Beta cohort opt-in via project preference +3. General availability with flags default-on +4. Legacy fallback removed after stability window + +### Safety Controls + +- runtime kill-switch for each plane +- release-note explicit migration warnings +- auto-rollback trigger on critical regressions (gates, git integrity, state corruption) + +## Data and Schema Changes + +Expected schema additions: + +- audit projection tables in `gsd.db` +- gate result persistence tables +- turn transaction metadata + +Rules: + +- additive migrations only until Wave 8 +- keep backwards-compatible readers during migration window + +## Dependencies + +1. Stable contract definitions before gate/model/scheduler rewires +2. Gate plane before gitops hard enforcement +3. Model policy engine before enabling any-model-any-phase by default +4. Audit envelope before legacy logger removal +5. Plan v2 before enforcing front-loaded planning defaults + +## Risk Register + +### Risk 1: Hidden Coupling in Auto Loop + +Impact: migration bugs due to implicit side effects. +Mitigation: adapter-first extraction and parity harness before path switch. + +### Risk 2: Parallel Deadlocks + +Impact: blocked runs or inconsistent state. +Mitigation: graph-level deadlock checks, IO lock tests, staged rollout behind flags. + +### Risk 3: Git Noise / Team Workflow Friction + +Impact: commit churn and review overhead. +Mitigation: milestone squash defaults and configurable turn transaction modes. + +### Risk 4: Policy Drift Across Providers + +Impact: compliance regressions. +Mitigation: provider policy registry tests and release checklist gates. + +### Risk 5: Telemetry Volume Growth + +Impact: storage/perf pressure in long-running projects. +Mitigation: append-only raw + indexed projection + retention policies. + +## Definition of Done (ADR-009) + +ADR-009 is complete when all are true: + +1. UOK path is default and stable. +2. All units execute through unified gate runner. +3. Model selection supports any eligible model in any phase with policy enforcement. +4. Hooks/agents/subagents/parallel/team execution runs through one scheduler contract. +5. Turn-level git transaction record exists for every executed turn. +6. Unified audit events provide causal traceability across orchestration, model, tool, git, and test actions. +7. Plan v2 can produce a complete unit graph with fail-closed plan gate. +8. `burn-max` profile is available and policy-safe. +9. Legacy orchestration branches are retired or behind emergency-only fallback. +10. CLI/web/headless behavior remains user-compatible. + +## Recommended Immediate Next Tasks (Week 1) + +1. Add Wave 0 feature flags and default-off wiring. +2. Introduce contract types and adapter shell (Wave 1 scaffolding). +3. Add parity telemetry capture for legacy loop baseline. +4. Land initial tests for contract serialization and turn result envelopes. + diff --git a/docs/dev/ADR-009-orchestration-kernel-refactor.md b/docs/dev/ADR-009-orchestration-kernel-refactor.md new file mode 100644 index 000000000..8612762c5 --- /dev/null +++ b/docs/dev/ADR-009-orchestration-kernel-refactor.md @@ -0,0 +1,401 @@ +# ADR-009: Unified Orchestration Kernel Refactor + +**Status:** Proposed +**Date:** 2026-04-14 +**Deciders:** Jeremy McSpadden, GSD Core Team +**Related:** ADR-001 (worktree architecture), ADR-003 (pipeline simplification), ADR-004 (capability-aware routing), ADR-005 (multi-provider strategy), ADR-008 (tools over MCP) + +## Context + +GSD already ships many advanced features: + +- dynamic model routing and multi-provider support +- hooks (`pre_dispatch_hooks`, `post_unit_hooks`) +- subagents and parallel execution +- worktree/branch isolation and automated git flows +- per-unit metrics and cost ledgers +- activity logs and structured journal events +- verification retries and failure recovery + +The current limitation is not missing capability. The limitation is **distribution of control logic across large, mixed-concern modules**, especially in auto-mode and related orchestration files. This raises change risk, creates duplicated policy paths, and slows the introduction of stronger guarantees. + +The target requirements for the next architecture are: + +1. User can use any available model during any phase. +2. First-class hooks, agents, sub-agents, team execution, and parallel workflows. +3. Git actions on every turn with deterministic, auditable behavior. +4. Logging of every action with causal traceability. +5. Long upfront planning via multi-round questioning and research. +6. Plan slicing and controlled dispatch through strict gate validation. +7. Deterministic failure reprocessing loops. +8. Automatic testing during build and gate transitions. +9. Explicit token usage controls including a high-burn mode. +10. Enforced compliance with provider/model terms of service. + +## Decision + +Refactor GSD into a **Unified Orchestration Kernel (UOK)** with explicit control planes, typed contracts, and an incremental strangler migration. This is a staged architectural replacement of orchestration internals, not a rewrite of user-facing CLI/web/MCP surfaces. + +### Core Architectural Model + +The orchestrator is split into six control planes: + +1. **Plan Plane** +2. **Execution Plane** +3. **Model Plane** +4. **Gate Plane** +5. **GitOps Plane** +6. **Audit Plane** + +Each dispatched unit (turn) executes through a single deterministic pipeline: + +```text +Discover/Clarify/Research -> Plan Compile -> Model Select -> Execute -> Validate -> Git Transaction -> Persist Audit -> Next Unit +``` + +## Detailed Design + +### 1) Plan Plane: Multi-Round Front-Loaded Planning + +Add a formal planning lifecycle: + +1. `discover`: codebase and state scan +2. `clarify`: multi-round user questions (bounded rounds, explicit stop condition) +3. `research`: internal and external synthesis +4. `draft-plan`: produce full roadmap and milestones +5. `compile`: slice into executable units with IO boundaries +6. `plan-gate`: reject/repair invalid plans before execution starts + +Required outputs: + +- `ROADMAP.md` (complete) +- per-milestone slice graph +- per-task executable unit specs +- requirement trace matrix (requirement -> unit(s) -> verification) +- plan risk register + +Plan gate fails closed if: + +- missing acceptance criteria +- missing verification strategy +- cyclic task dependencies +- unowned artifacts +- missing rollback/recovery semantics for risky units + +### 2) Execution Plane: Agents, Sub-Agents, Teams, Parallel + +Unify all execution into a typed DAG scheduler. + +Node kinds: + +- `unit` (single execution task) +- `hook` +- `subagent` +- `team-worker` +- `verification` +- `reprocess` + +Edges express: + +- hard dependencies +- resource conflicts (file-level IO locks) +- ordering constraints (gate-before-merge, test-before-closeout) + +Execution modes: + +- single-worker deterministic mode +- multi-worker parallel mode +- team mode (shared repo, unique milestone IDs, gated merge) + +This removes ad-hoc parallel behavior and makes sub-agent and team paths first-class scheduler decisions. + +### 3) Model Plane: Any Model in Any Phase + +Replace rigid phase->model assumptions with **requirement-based eligibility**. + +Selection pipeline: + +1. gather phase/unit requirements (capabilities, context size, latency profile) +2. gather eligible models from configured providers +3. apply hard policy filters (provider auth, TOS, tool compatibility, org rules) +4. apply soft scoring (capability vectors, budget profile, historical outcomes) +5. choose primary + fallback chain + +Rules: + +- Any model can run any phase if it passes policy and capability constraints. +- User pins remain hard ceilings only when configured explicitly. +- Unknown models are allowed with conservative default capability scores. + +Add model intent profiles: + +- `economy` (lowest cost) +- `balanced` +- `quality` +- `burn-max` (highest compute/token burn within policy and budget limits) + +### 4) Gate Plane: Controlled Dispatch and Reprocessing + +All units pass explicit gates: + +1. `policy-gate` (provider/tool/TOS/security checks) +2. `input-gate` (unit contract completeness, artifact readiness) +3. `execution-gate` (runtime guardrails, timeout strategy, tool allowlist) +4. `artifact-gate` (expected outputs and format validation) +5. `verification-gate` (lint/test/typecheck/security checks) +6. `closeout-gate` (state transition safety + git transaction outcome) + +Gate outcomes: + +- `pass` +- `retryable-fail` +- `hard-fail` +- `manual-attention` + +Failure reprocessing matrix (deterministic): + +- code failure -> targeted fix prompt + bounded retry +- test failure -> impacted test fix loop +- tool failure -> alternate tool/provider fallback +- model failure -> fallback model chain +- policy failure -> immediate hard stop and explicit reason + +Retry policy: + +- bounded attempts per gate +- escalating strategy per attempt +- terminal state persisted with full evidence + +### 5) GitOps Plane: Git Action Every Turn + +Every dispatched unit is wrapped in a git transaction: + +1. `turn-start`: capture branch/worktree status and dirty-state snapshot +2. `turn-exec`: run unit +3. `turn-stage`: stage relevant changes +4. `turn-checkpoint`: commit checkpoint or structured no-op record +5. `turn-publish`: optional push per policy +6. `turn-record`: write commit metadata into audit ledger + +Defaults: + +- checkpoint commit each turn in milestone branch/worktree +- squash on milestone merge to keep main history clean + +Configurable strictness: + +- `git.turn_action: commit|snapshot|status-only` +- `git.turn_push: never|milestone|always` + +If a repo state blocks commit (e.g., conflicts), turn fails at closeout gate with explicit diagnostics. + +### 6) Audit Plane: Log Every Action + +Promote current activity/journal into a single causal event model. + +Event classes: + +- orchestrator (`dispatch`, `gate-result`, `state-transition`) +- model (`selection`, `fallback`, `provider-switch`) +- tool (`call`, `result`, `error`) +- git (`status`, `stage`, `commit`, `merge`, `push`) +- test (`command`, `result`, `retry`) +- policy (`allow`, `deny`, `warning`) +- cost (`tokens`, `cost`, `cache-hit`, `budget-pressure`) + +Every event includes: + +- `eventId` +- `traceId` (session) +- `turnId` (unit) +- `causedBy` reference +- timestamp +- durable payload + +Storage: + +- append-only JSONL + indexed SQLite projection for queryability +- no destructive rewrites of source audit logs + +## Compliance and TOS Enforcement + +Introduce a provider policy engine as a hard dependency of the policy gate. + +Provider policy definition includes: + +- allowed auth modes +- prohibited token exchange paths +- tool/protocol constraints +- subscription vs API usage boundaries +- model-specific restrictions + +Enforcement rules: + +- deny disallowed auth/routing before dispatch +- deny model selection if provider constraints are not met +- emit policy evidence events on every allow/deny decision + +This formalizes current compliance work (notably Anthropic/Claude Code boundaries) into a reusable engine rather than scattered checks. + +## Automatic Testing Strategy + +Testing becomes mandatory at three levels: + +1. **Per-turn**: impacted tests + lint/typecheck subset +2. **Per-slice closeout**: full slice verification profile +3. **Per-milestone closeout**: full suite (or policy-defined release profile) + +Verification commands become declarative policies by unit type, not ad-hoc shell lists only. + +## Token Strategy and Burn-Max Mode + +Existing token optimization modes remain, plus explicit high-burn profile. + +`burn-max` behavior: + +- maximize context inclusion +- prefer high-capability models +- enable deeper critique/review passes +- increase planning/research depth + +Hard limits still apply: + +- budget ceiling and enforcement rules +- provider rate limits +- TOS/policy constraints + +The system must never bypass provider restrictions to increase usage. + +## Migration Plan (Strangler Refactor) + +No big-bang rewrite. Migrate in waves with compatibility adapters. + +### Wave 0: Contracts and Telemetry Baseline + +- define turn contract and gate result schemas +- add trace IDs/turn IDs to current paths +- keep behavior unchanged + +### Wave 1: Gate Plane Extraction + +- extract gate runner from auto loop +- route existing checks through unified gate API + +### Wave 2: Model Plane Unification + +- requirement-based model selection +- policy filter insertion before scoring +- preserve existing model config semantics + +### Wave 3: Scheduler and Execution Graph + +- introduce DAG scheduler +- map existing subagent/parallel features to graph nodes +- enable graph mode behind flag + +### Wave 4: GitOps Transaction Layer + +- enforce turn-level git actions +- add deterministic checkpoint behavior + +### Wave 5: Audit Plane Consolidation + +- unify journal/activity/metrics events under common envelope +- add query projection + +### Wave 6: Plan Plane v2 + +- multi-round clarify/research planner +- compiled unit graph + plan gate + +### Wave 7: Legacy Path Retirement + +- remove obsolete branches in `auto.ts` and related modules +- keep CLI/API compatibility + +## Module Extraction Targets + +Primary decomposition targets: + +- `auto.ts` -> orchestrator kernel + adapters +- `auto-prompts.ts` -> plan compiler + prompt renderers +- `state.ts` -> state query service + immutable state views +- `gsd-db.ts` -> data access layer + event projection store +- `auto-post-unit.ts` / `auto-verification.ts` -> closeout gate services + +## Acceptance Criteria + +The refactor is accepted when all conditions are true: + +1. Any configured model can be selected in any phase when policy permits. +2. Hooks, agents, sub-agents, teams, and parallel all execute under one scheduler contract. +3. Every turn produces at least one git action record and auditable turn closeout. +4. Every dispatch and action is traceable by `traceId` and `turnId`. +5. Multi-round planning produces a full executable unit graph before execution. +6. Gate outcomes are explicit, deterministic, and persisted. +7. Failure reprocessing uses typed failure classes, not generic retries. +8. Automatic tests run per policy on every turn/slice/milestone gate. +9. Token usage is tracked at turn granularity with burn-max profile support. +10. Policy engine blocks TOS-violating routes and records evidence. + +## Consequences + +### Positive + +- Stronger reliability through fail-closed gates +- Faster feature delivery by isolating orchestration concerns +- Clear compliance and audit posture +- Better debuggability from causal event logs +- Controlled support for aggressive high-burn workflows + +### Negative + +- Significant migration effort across core modules +- More configuration surface area +- Temporary complexity during dual-path migration + +### Neutral + +- Existing user commands and workflows remain stable during migration +- Existing preferences remain supported with compatibility adapters + +## Alternatives Considered + +### A) Full rewrite in a new codebase + +Rejected. Too risky for a live project with broad surface area and active releases. + +### B) Continue incremental patching without architecture split + +Rejected. Slows delivery and increases regression risk as orchestration complexity grows. + +### C) Keep existing optimization-first token model only + +Rejected. Does not satisfy explicit requirement for intentional high-burn workflows. + +## Risks and Mitigations + +1. **Migration regressions** + - Mitigation: golden-path replay tests and shadow mode comparisons per wave. +2. **Audit log volume growth** + - Mitigation: append-only raw logs plus indexed projections and retention policies. +3. **Git noise from per-turn commits** + - Mitigation: milestone squash merge defaults and configurable checkpoint modes. +4. **Provider policy drift** + - Mitigation: versioned provider policy registry with test fixtures per provider. + +## Open Questions + +1. Should `turn_action: commit` be mandatory default for all modes or only auto-mode? +2. Should `burn-max` be opt-in global, project-scoped, or both? +3. Should policy violations always halt or allow configurable warn-only mode for local development? + +## Implementation Note + +This ADR intentionally aligns with current architecture principles: + +- extension-first where practical +- strong test contracts +- pragmatic incremental rollout +- provider-agnostic execution with explicit policy constraints + diff --git a/packages/pi-coding-agent/src/types/ambient-modules.d.ts b/packages/pi-coding-agent/src/types/ambient-modules.d.ts new file mode 100644 index 000000000..c054a087b --- /dev/null +++ b/packages/pi-coding-agent/src/types/ambient-modules.d.ts @@ -0,0 +1,69 @@ +declare module "proper-lockfile" { + export interface RetryOptions { + retries?: number; + factor?: number; + minTimeout?: number; + maxTimeout?: number; + randomize?: boolean; + } + + export interface LockOptions { + realpath?: boolean; + retries?: number | RetryOptions; + stale?: number; + onCompromised?: (err: Error) => void; + } + + export type ReleaseSync = () => void; + export type ReleaseAsync = () => Promise; + + export interface ProperLockfileApi { + lockSync(path: string, options?: LockOptions): ReleaseSync; + lock(path: string, options?: LockOptions): Promise; + } + + const lockfile: ProperLockfileApi; + export default lockfile; +} + +declare module "sql.js" { + export interface Statement { + bind(values: (string | number | null | Uint8Array)[]): void; + step(): boolean; + getAsObject(): Record; + free(): void; + } + + export interface Database { + run(sql: string, params?: unknown[]): void; + prepare(sql: string): Statement; + export(): Uint8Array; + close(): void; + } + + export interface SqlJsStatic { + Database: new (data?: Uint8Array | ArrayBuffer | Buffer) => Database; + } + + export interface SqlJsConfig { + locateFile?: (file: string) => string; + } + + export default function initSqlJs(config?: SqlJsConfig): Promise; +} + +declare module "hosted-git-info" { + export interface HostedGitInfo { + domain?: string; + user?: string; + project?: string; + committish?: string; + } + + export interface HostedGitInfoApi { + fromUrl(url: string): HostedGitInfo | undefined; + } + + const hostedGitInfo: HostedGitInfoApi; + export default hostedGitInfo; +} diff --git a/packages/pi-coding-agent/tsconfig.json b/packages/pi-coding-agent/tsconfig.json index 818f84dbe..20173b8a8 100644 --- a/packages/pi-coding-agent/tsconfig.json +++ b/packages/pi-coding-agent/tsconfig.json @@ -23,6 +23,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/src/resources/extensions/gsd/auto/loop.ts b/src/resources/extensions/gsd/auto/loop.ts index a5620bbb6..be8128bb1 100644 --- a/src/resources/extensions/gsd/auto/loop.ts +++ b/src/resources/extensions/gsd/auto/loop.ts @@ -135,7 +135,7 @@ export async function autoLoop( let turnFinished = false; const finishTurn = ( status: "completed" | "failed" | "paused" | "stopped" | "skipped" | "retry", - failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" = "none", + failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" | "closeout" = "none", error?: string, ): void => { if (turnFinished) return; diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index 5975c79b6..2a20cea38 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -54,8 +54,8 @@ let loadAttempted = false; function suppressSqliteWarning(): void { const origEmit = process.emit; - // @ts-expect-error overriding process.emit for warning filter - process.emit = function (event: string, ...args: unknown[]): boolean { + // Override via loose cast: Node's overloaded emit signature is not directly assignable. + (process as any).emit = function (event: string, ...args: unknown[]): boolean { if ( event === "warning" && args[0] && diff --git a/src/resources/extensions/gsd/session-lock.ts b/src/resources/extensions/gsd/session-lock.ts index e3bbe7c49..3fe5b73d1 100644 --- a/src/resources/extensions/gsd/session-lock.ts +++ b/src/resources/extensions/gsd/session-lock.ts @@ -52,6 +52,18 @@ export interface SessionLockStatus { recovered?: boolean; } +interface ProperLockfileApi { + lockSync( + path: string, + options?: { + realpath?: boolean; + stale?: number; + update?: number; + onCompromised?: () => void; + }, + ): () => void; +} + // ─── Module State ─────────────────────────────────────────────────────────── /** Release function from proper-lockfile — calling it releases the OS lock. */ @@ -277,9 +289,9 @@ export function acquireSessionLock(basePath: string): SessionLockResult { unitStartedAt: new Date().toISOString(), }; - let lockfile: typeof import("proper-lockfile"); + let lockfile: ProperLockfileApi; try { - lockfile = _require("proper-lockfile") as typeof import("proper-lockfile"); + lockfile = _require("proper-lockfile") as ProperLockfileApi; } catch { // proper-lockfile not available — fall back to PID-based check return acquireFallbackLock(basePath, lp, lockData); diff --git a/src/resources/extensions/gsd/unit-ownership.ts b/src/resources/extensions/gsd/unit-ownership.ts index acae94999..eef05e534 100644 --- a/src/resources/extensions/gsd/unit-ownership.ts +++ b/src/resources/extensions/gsd/unit-ownership.ts @@ -46,8 +46,8 @@ let loadAttempted = false; function suppressSqliteWarning(): void { const origEmit = process.emit; - // @ts-expect-error overriding process.emit for warning filter - process.emit = function (event: string, ...args: unknown[]): boolean { + // Override via loose cast: Node's overloaded emit signature is not directly assignable. + (process as any).emit = function (event: string, ...args: unknown[]): boolean { if ( event === "warning" && args[0] && diff --git a/src/resources/extensions/gsd/uok/model-policy.ts b/src/resources/extensions/gsd/uok/model-policy.ts index 876fba374..c8fbec3f3 100644 --- a/src/resources/extensions/gsd/uok/model-policy.ts +++ b/src/resources/extensions/gsd/uok/model-policy.ts @@ -1,4 +1,4 @@ -import type { TaskMetadata } from "../model-router.js"; +import type { TaskMetadata } from "../complexity-classifier.js"; import { computeTaskRequirements, filterToolsForProvider } from "../model-router.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; @@ -33,11 +33,11 @@ export function buildRequirementVector(unitType?: string, taskMetadata?: TaskMet return computeTaskRequirements(unitType, taskMetadata) as unknown as Partial>; } -export function applyModelPolicyFilter( - candidates: ModelCandidate[], +export function applyModelPolicyFilter( + candidates: T[], options: ModelPolicyOptions, ): { - eligible: ModelCandidate[]; + eligible: T[]; decisions: ModelPolicyDecision[]; requirements: Partial>; } { @@ -46,7 +46,7 @@ export function applyModelPolicyFilter( const allowedApis = options.allowedApis ? new Set(options.allowedApis) : null; const requirements = buildRequirementVector(options.unitType, options.taskMetadata); const decisions: ModelPolicyDecision[] = []; - const eligible: ModelCandidate[] = []; + const eligible: T[] = []; for (const model of candidates) { let allowed = true; diff --git a/src/resources/extensions/ttsr/ttsr-manager.ts b/src/resources/extensions/ttsr/ttsr-manager.ts index ec4f7cff6..0e59d124e 100644 --- a/src/resources/extensions/ttsr/ttsr-manager.ts +++ b/src/resources/extensions/ttsr/ttsr-manager.ts @@ -9,9 +9,14 @@ * available, testing all patterns in a single DFA pass. Falls back to * per-rule JS RegExp iteration when the native module is not loaded. */ -import picomatch from "picomatch"; +import { createRequire } from "node:module"; import { debugTime, debugCount, debugPeak } from "../gsd/debug-logger.js"; +const _require = createRequire(import.meta.url); +type PicomatchMatcher = (input: string) => boolean; +type PicomatchFn = (pattern: string) => PicomatchMatcher; +const picomatch = _require("picomatch") as PicomatchFn; + // ── Native TTSR engine (optional) ───────────────────────────────────── let nativeTtsr: { ttsrCompileRules: (rules: { name: string; conditions: string[] }[]) => number; @@ -65,7 +70,7 @@ export interface TtsrSettings { interface ToolScope { toolName?: string; - pathMatcher?: picomatch.Matcher; + pathMatcher?: PicomatchMatcher; pathPattern?: string; } @@ -80,7 +85,7 @@ interface TtsrEntry { rule: Rule; conditions: RegExp[]; scope: TtsrScope; - globalPathMatchers?: picomatch.Matcher[]; + globalPathMatchers?: PicomatchMatcher[]; } /** Tracks when a rule was last injected (for repeat gating). */ @@ -147,7 +152,7 @@ export class TtsrManager { return compiled; } - #compileGlobalPathMatchers(globs: Rule["globs"]): picomatch.Matcher[] | undefined { + #compileGlobalPathMatchers(globs: Rule["globs"]): PicomatchMatcher[] | undefined { if (!globs || globs.length === 0) return undefined; const matchers = globs .map((g) => g.trim()) @@ -239,7 +244,7 @@ export class TtsrManager { return pathValue.replaceAll("\\", "/"); } - #matchesGlob(matcher: picomatch.Matcher, filePaths: string[] | undefined): boolean { + #matchesGlob(matcher: PicomatchMatcher, filePaths: string[] | undefined): boolean { if (!filePaths || filePaths.length === 0) return false; for (const filePath of filePaths) { const normalized = this.#normalizePath(filePath); From 00521b14186d8050f272c86a1047e0c840c41164 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:41:03 -0500 Subject: [PATCH 03/11] feat(gsd-uok): unify gate plane across pre/post validation checks --- .../extensions/gsd/auto-post-unit.ts | 60 +++++++++++- .../extensions/gsd/auto-verification.ts | 97 ++++++++++++++++++- src/resources/extensions/gsd/auto/phases.ts | 89 +++++++++++++++++ .../gsd/tests/post-exec-retry-bypass.test.ts | 80 ++++++++++++++- .../tests/pre-execution-pause-wiring.test.ts | 41 +++++++- .../validate-milestone-write-order.test.ts | 39 ++++++++ .../gsd/tools/validate-milestone.ts | 51 +++++++++- 7 files changed, 449 insertions(+), 8 deletions(-) diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 0d796629a..a2b714c86 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -66,6 +66,8 @@ import { getSliceTasks } from "./gsd-db.js"; import { runPreExecutionChecks, type PreExecutionResult } from "./pre-execution-checks.js"; import { writePreExecutionEvidence } from "./verification-evidence.js"; import { ensureCodebaseMapFresh } from "./codebase-generator.js"; +import { resolveUokFlags } from "./uok/flags.js"; +import { UokGateRunner } from "./uok/gate-runner.js"; /** Maximum verification retry attempts before escalating to blocker placeholder (#2653). */ const MAX_VERIFICATION_RETRIES = 3; @@ -871,9 +873,10 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" ) { let preExecPauseNeeded = false; await runSafely("postUnitPostVerification", "pre-execution-checks", async () => { + const prefs = loadEffectiveGSDPreferences()?.preferences; + const uokFlags = resolveUokFlags(prefs); try { // Check preferences — respect enhanced_verification and enhanced_verification_pre - const prefs = loadEffectiveGSDPreferences()?.preferences; const enhancedEnabled = prefs?.enhanced_verification !== false; // default true const preEnabled = prefs?.enhanced_verification_pre !== false; // default true @@ -908,6 +911,8 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" return; } + const strictMode = prefs?.enhanced_verification_strict === true; + // Run pre-execution checks const result: PreExecutionResult = await runPreExecutionChecks(tasks, s.basePath); @@ -931,6 +936,36 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" writePreExecutionEvidence(result, slicePath, mid, sid); } + if (uokFlags.gates) { + const failedChecks = result.checks + .filter((check) => !check.passed) + .map((check) => `[${check.category}] ${check.target}: ${check.message}`); + const warnEscalated = result.status === "warn" && strictMode; + const blockingFailure = result.status === "fail" || warnEscalated; + const gateRunner = new UokGateRunner(); + gateRunner.register({ + id: "pre-execution-checks", + type: "input", + execute: async () => ({ + outcome: blockingFailure ? "fail" : "pass", + failureClass: result.status === "fail" ? "input" : warnEscalated ? "policy" : "none", + rationale: blockingFailure + ? `pre-execution checks ${result.status}${warnEscalated ? " (strict)" : ""}` + : "pre-execution checks passed", + findings: failedChecks.join("\n"), + }), + }); + await gateRunner.run("pre-execution-checks", { + basePath: s.basePath, + traceId: `pre-execution:${s.currentUnit.id}`, + turnId: s.currentUnit.id, + milestoneId: mid, + sliceId: sid, + unitType: s.currentUnit.type, + unitId: s.currentUnit.id, + }); + } + // Notify UI if (result.status === "fail") { const blockingCount = result.checks.filter(c => !c.passed && c.blocking).length; @@ -969,6 +1004,29 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" `Pre-execution checks error: ${errorMessage} — pausing for human review`, "error", ); + if (uokFlags.gates && s.currentUnit) { + const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id); + const gateRunner = new UokGateRunner(); + gateRunner.register({ + id: "pre-execution-checks", + type: "input", + execute: async () => ({ + outcome: "manual-attention", + failureClass: "manual-attention", + rationale: "pre-execution checks threw before completion", + findings: errorMessage, + }), + }); + await gateRunner.run("pre-execution-checks", { + basePath: s.basePath, + traceId: `pre-execution:${s.currentUnit.id}`, + turnId: s.currentUnit.id, + milestoneId: mid ?? undefined, + sliceId: sid ?? undefined, + unitType: s.currentUnit.type, + unitId: s.currentUnit.id, + }); + } preExecPauseNeeded = true; } }); diff --git a/src/resources/extensions/gsd/auto-verification.ts b/src/resources/extensions/gsd/auto-verification.ts index 3de3ac918..d97483110 100644 --- a/src/resources/extensions/gsd/auto-verification.ts +++ b/src/resources/extensions/gsd/auto-verification.ts @@ -69,6 +69,37 @@ async function runValidateMilestonePostCheck( pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise, ): Promise { const { s, ctx, pi } = vctx; + const prefs = loadEffectiveGSDPreferences()?.preferences; + const uokFlags = resolveUokFlags(prefs); + const persistMilestoneValidationGate = async ( + outcome: "pass" | "fail" | "retry" | "manual-attention", + failureClass: "none" | "verification" | "manual-attention", + rationale: string, + findings = "", + milestoneId?: string, + ): Promise => { + if (!uokFlags.gates || !s.currentUnit) return; + const gateRunner = new UokGateRunner(); + gateRunner.register({ + id: "milestone-validation-post-check", + type: "verification", + execute: async () => ({ + outcome, + failureClass, + rationale, + findings, + }), + }); + await gateRunner.run("milestone-validation-post-check", { + basePath: s.basePath, + traceId: `validation-post-check:${s.currentUnit.id}`, + turnId: s.currentUnit.id, + milestoneId, + unitType: s.currentUnit.type, + unitId: s.currentUnit.id, + }); + }; + if (!s.currentUnit) return "continue"; const { milestone: mid } = parseUnitId(s.currentUnit.id); @@ -81,14 +112,32 @@ async function runValidateMilestonePostCheck( if (!validationContent) return "continue"; const verdict = extractVerdict(validationContent); - if (verdict !== "needs-remediation") return "continue"; + if (verdict !== "needs-remediation") { + await persistMilestoneValidationGate( + "pass", + "none", + `milestone validation verdict is ${verdict}; no remediation loop risk`, + "", + mid, + ); + return "continue"; + } const incompleteSliceCount = await countIncompleteSlices(s.basePath, mid); // If any non-closed slices exist, the agent successfully queued remediation // work — proceed normally. The state machine will execute those slices and // re-validate per the #3596/#3670 fix. - if (incompleteSliceCount > 0) return "continue"; + if (incompleteSliceCount > 0) { + await persistMilestoneValidationGate( + "pass", + "none", + `remediation slices present (${incompleteSliceCount}); validation can continue`, + "", + mid, + ); + return "continue"; + } ctx.ui.notify( `Milestone ${mid} validation returned verdict=needs-remediation but no remediation slices were added. Pausing for human review.`, @@ -98,6 +147,13 @@ async function runValidateMilestonePostCheck( `validate-milestone: pausing — verdict=needs-remediation with no incomplete slices for ${mid}. ` + `The agent must call gsd_reassess_roadmap to add remediation slices before re-validation.\n`, ); + await persistMilestoneValidationGate( + "manual-attention", + "manual-attention", + "needs-remediation verdict without queued remediation slices", + `No incomplete slices found for ${mid} while verdict=needs-remediation`, + mid, + ); await pauseAuto(ctx, pi); return "pause"; } @@ -372,6 +428,43 @@ export async function runPostUnitVerification( ); } + if (uokFlags.gates) { + const strictMode = prefs?.enhanced_verification_strict === true; + const warnEscalated = postExecResult.status === "warn" && strictMode; + const blockingFailure = postExecResult.status === "fail" || warnEscalated; + const findings = postExecResult.checks + .filter((check) => !check.passed) + .map((check) => `[${check.category}] ${check.target}: ${check.message}`) + .join("\n"); + const gateRunner = new UokGateRunner(); + gateRunner.register({ + id: "post-execution-checks", + type: "artifact", + execute: async () => ({ + outcome: blockingFailure ? "fail" : "pass", + failureClass: postExecResult.status === "fail" + ? "artifact" + : warnEscalated + ? "policy" + : "none", + rationale: blockingFailure + ? `post-execution checks ${postExecResult.status}${warnEscalated ? " (strict)" : ""}` + : "post-execution checks passed", + findings, + }), + }); + await gateRunner.run("post-execution-checks", { + basePath: s.basePath, + traceId: `verification:${s.currentUnit.id}`, + turnId: s.currentUnit.id, + milestoneId: mid, + sliceId: sid, + taskId: tid, + unitType: s.currentUnit.type, + unitId: s.currentUnit.id, + }); + } + // Check for blocking failures if (postExecResult.status === "fail") { postExecBlockingFailure = true; diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index f6aeefa98..97c485c44 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -48,6 +48,8 @@ import { getEligibleSlices } from "../slice-parallel-eligibility.js"; import { startSliceParallel } from "../slice-parallel-orchestrator.js"; import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js"; import { ensurePlanV2Graph } from "../uok/plan-v2.js"; +import { resolveUokFlags } from "../uok/flags.js"; +import { UokGateRunner } from "../uok/gate-runner.js"; import { resetEvidence } from "../safety/evidence-collector.js"; import { createCheckpoint, cleanupCheckpoint, rollbackToCheckpoint } from "../safety/git-checkpoint.js"; import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js"; @@ -203,14 +205,60 @@ export async function runPreDispatch( loopState: LoopState, ): Promise> { const { ctx, pi, s, deps, prefs } = ic; + const uokFlags = resolveUokFlags(prefs); + const runPreDispatchGate = async (input: { + gateId: string; + gateType: string; + outcome: "pass" | "fail" | "retry" | "manual-attention"; + failureClass: "none" | "policy" | "input" | "execution" | "artifact" | "verification" | "closeout" | "git" | "timeout" | "manual-attention" | "unknown"; + rationale: string; + findings?: string; + milestoneId?: string; + }): Promise => { + if (!uokFlags.gates) return; + const gateRunner = new UokGateRunner(); + gateRunner.register({ + id: input.gateId, + type: input.gateType, + execute: async () => ({ + outcome: input.outcome, + failureClass: input.failureClass, + rationale: input.rationale, + findings: input.findings ?? "", + }), + }); + await gateRunner.run(input.gateId, { + basePath: s.basePath, + traceId: `pre-dispatch:${ic.flowId}`, + turnId: `iter-${ic.iteration}`, + milestoneId: input.milestoneId ?? s.currentMilestoneId ?? undefined, + unitType: "pre-dispatch", + unitId: `iter-${ic.iteration}`, + }); + }; // Resource version guard const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart); if (staleMsg) { + await runPreDispatchGate({ + gateId: "resource-version-guard", + gateType: "policy", + outcome: "fail", + failureClass: "policy", + rationale: "resource version guard blocked dispatch", + findings: staleMsg, + }); await deps.stopAuto(ctx, pi, staleMsg); debugLog("autoLoop", { phase: "exit", reason: "resources-stale" }); return { action: "break", reason: "resources-stale" }; } + await runPreDispatchGate({ + gateId: "resource-version-guard", + gateType: "policy", + outcome: "pass", + failureClass: "none", + rationale: "resource version guard passed", + }); deps.invalidateAllCaches(); s.lastPromptCharCount = undefined; @@ -226,6 +274,14 @@ export async function runPreDispatch( ); } if (!healthGate.proceed) { + await runPreDispatchGate({ + gateId: "pre-dispatch-health-gate", + gateType: "execution", + outcome: "manual-attention", + failureClass: "manual-attention", + rationale: "pre-dispatch health gate blocked dispatch", + findings: healthGate.reason, + }); ctx.ui.notify( healthGate.reason || "Pre-dispatch health check failed — run /gsd doctor for details.", "error", @@ -234,7 +290,23 @@ export async function runPreDispatch( debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" }); return { action: "break", reason: "health-gate-failed" }; } + await runPreDispatchGate({ + gateId: "pre-dispatch-health-gate", + gateType: "execution", + outcome: "pass", + failureClass: "none", + rationale: "pre-dispatch health gate passed", + findings: healthGate.fixesApplied.length > 0 ? healthGate.fixesApplied.join(", ") : "", + }); } catch (e) { + await runPreDispatchGate({ + gateId: "pre-dispatch-health-gate", + gateType: "execution", + outcome: "manual-attention", + failureClass: "manual-attention", + rationale: "pre-dispatch health gate threw unexpectedly", + findings: String(e), + }); logWarning("engine", "Pre-dispatch health gate threw unexpectedly", { error: String(e) }); } @@ -257,10 +329,27 @@ export async function runPreDispatch( const compiled = ensurePlanV2Graph(s.basePath, state); if (!compiled.ok) { const reason = compiled.reason ?? "Plan v2 compilation failed"; + await runPreDispatchGate({ + gateId: "plan-v2-gate", + gateType: "policy", + outcome: "manual-attention", + failureClass: "manual-attention", + rationale: "plan v2 compile gate failed", + findings: reason, + milestoneId: state.activeMilestone?.id ?? undefined, + }); ctx.ui.notify(`Plan gate failed-closed: ${reason}`, "error"); await deps.pauseAuto(ctx, pi); return { action: "break", reason: "plan-v2-gate-failed" }; } + await runPreDispatchGate({ + gateId: "plan-v2-gate", + gateType: "policy", + outcome: "pass", + failureClass: "none", + rationale: "plan v2 compile gate passed", + milestoneId: state.activeMilestone?.id ?? undefined, + }); } deps.syncCmuxSidebar(prefs, state); let mid = state.activeMilestone?.id; diff --git a/src/resources/extensions/gsd/tests/post-exec-retry-bypass.test.ts b/src/resources/extensions/gsd/tests/post-exec-retry-bypass.test.ts index 60de86f21..51476f56e 100644 --- a/src/resources/extensions/gsd/tests/post-exec-retry-bypass.test.ts +++ b/src/resources/extensions/gsd/tests/post-exec-retry-bypass.test.ts @@ -14,7 +14,7 @@ import { join } from "node:path"; import { runPostUnitVerification, type VerificationContext } from "../auto-verification.ts"; import { AutoSession } from "../auto/session.ts"; -import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask } from "../gsd-db.ts"; +import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, _getAdapter } from "../gsd-db.ts"; import { invalidateAllCaches } from "../cache.ts"; import { _clearGsdRootCache } from "../paths.ts"; @@ -140,6 +140,43 @@ function createBasicTask(): void { }); } +function createPostExecFailureTask(): void { + insertMilestone({ id: "M001" }); + insertSlice({ + id: "S01", + milestoneId: "M001", + title: "Test Slice", + risk: "low", + }); + + const srcDir = join(tempDir, "src"); + mkdirSync(srcDir, { recursive: true }); + writeFileSync( + join(srcDir, "broken.ts"), + "import { missing } from './does-not-exist.js';\nexport const ok = 1;\n", + "utf-8", + ); + + insertTask({ + id: "T01", + sliceId: "S01", + milestoneId: "M001", + title: "Task with broken import", + status: "pending", + keyFiles: ["src/broken.ts"], + planning: { + description: "Task that introduces an unresolved import in key files", + estimate: "1h", + files: ["src/broken.ts"], + verify: "echo pass", + inputs: [], + expectedOutput: [], + observabilityImpact: "", + }, + sequence: 0, + }); +} + // ─── Tests ─────────────────────────────────────────────────────────────────── describe("Post-execution blocking failure retry bypass", () => { @@ -249,6 +286,47 @@ describe("Post-execution blocking failure retry bypass", () => { // This test mainly confirms the wiring is correct assert.equal(result, "continue"); }); + + test("uok gate runner persists post-execution gate failures when enabled", async () => { + createPostExecFailureTask(); + writePreferences({ + enhanced_verification: true, + enhanced_verification_post: true, + verification_auto_fix: true, + verification_max_retries: 2, + uok: { + enabled: true, + gates: { enabled: true }, + }, + }); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const pauseAutoMock = mock.fn(async () => {}); + const s = makeMockSession(tempDir, { type: "execute-task", id: "M001/S01/T01" }); + const vctx: VerificationContext = { s, ctx, pi }; + + const result = await runPostUnitVerification(vctx, pauseAutoMock); + + assert.equal(result, "pause"); + assert.equal(pauseAutoMock.mock.callCount(), 1); + + const adapter = _getAdapter(); + const row = adapter + ?.prepare( + `SELECT gate_id, outcome, failure_class + FROM gate_runs + WHERE gate_id = 'post-execution-checks' + ORDER BY id DESC + LIMIT 1`, + ) + .get() as { gate_id: string; outcome: string; failure_class: string } | undefined; + + assert.ok(row, "post-execution gate run should be persisted when uok.gates is enabled"); + assert.equal(row?.gate_id, "post-execution-checks"); + assert.equal(row?.outcome, "fail"); + assert.equal(row?.failure_class, "artifact"); + }); }); describe("Post-execution retry behavior", () => { diff --git a/src/resources/extensions/gsd/tests/pre-execution-pause-wiring.test.ts b/src/resources/extensions/gsd/tests/pre-execution-pause-wiring.test.ts index 7a540d86b..eddc6cc49 100644 --- a/src/resources/extensions/gsd/tests/pre-execution-pause-wiring.test.ts +++ b/src/resources/extensions/gsd/tests/pre-execution-pause-wiring.test.ts @@ -17,7 +17,7 @@ import { join } from "node:path"; import { postUnitPostVerification, type PostUnitContext } from "../auto-post-unit.ts"; import { AutoSession } from "../auto/session.ts"; -import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask } from "../gsd-db.ts"; +import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, _getAdapter } from "../gsd-db.ts"; import { invalidateAllCaches } from "../cache.ts"; import { _clearGsdRootCache } from "../paths.ts"; @@ -454,4 +454,43 @@ describe("Pre-execution checks → pauseAuto wiring", () => { "postUnitPostVerification should return 'continue' when pre-execution checks are disabled" ); }); + + test("uok gate runner persists pre-execution gate outcomes when enabled", async () => { + writePreferences({ + enhanced_verification: true, + enhanced_verification_pre: true, + enhanced_verification_strict: true, + uok: { + enabled: true, + gates: { enabled: true }, + }, + }); + + createFailingTasks(); + + const ctx = makeMockCtx(); + const pi = makeMockPi(); + const pauseAutoMock = mock.fn(async () => {}); + const s = makeMockSession(tempDir, { type: "plan-slice", id: "M001/S01" }); + const pctx = makePostUnitContext(s, ctx, pi, pauseAutoMock); + + const result = await postUnitPostVerification(pctx); + assert.equal(result, "stopped"); + + const adapter = _getAdapter(); + const row = adapter + ?.prepare( + `SELECT gate_id, outcome, failure_class + FROM gate_runs + WHERE gate_id = 'pre-execution-checks' + ORDER BY id DESC + LIMIT 1`, + ) + .get() as { gate_id: string; outcome: string; failure_class: string } | undefined; + + assert.ok(row, "pre-execution gate run should be persisted when uok.gates is enabled"); + assert.equal(row?.gate_id, "pre-execution-checks"); + assert.equal(row?.outcome, "fail"); + assert.equal(row?.failure_class, "input"); + }); }); diff --git a/src/resources/extensions/gsd/tests/validate-milestone-write-order.test.ts b/src/resources/extensions/gsd/tests/validate-milestone-write-order.test.ts index 1f07791e0..e0bd70ebd 100644 --- a/src/resources/extensions/gsd/tests/validate-milestone-write-order.test.ts +++ b/src/resources/extensions/gsd/tests/validate-milestone-write-order.test.ts @@ -112,4 +112,43 @@ describe("handleValidateMilestone write ordering (#2725)", () => { ).get(); assert.equal(row, undefined, "assessment row should be deleted after disk-write rollback"); }); + + it("persists milestone validation gate_runs rows when UOK gates are enabled", async () => { + base = makeTmpBase(); + const dbPath = join(base, ".gsd", "gsd.db"); + openDatabase(dbPath); + insertMilestone({ id: "M001" }); + insertSlice({ id: "S01", milestoneId: "M001" }); + + const result = await handleValidateMilestone(VALID_PARAMS, base, { + uokGatesEnabled: true, + traceId: "trace-val-1", + turnId: "turn-val-1", + }); + assert.ok(!("error" in result), `unexpected error: ${"error" in result ? result.error : ""}`); + + const adapter = _getAdapter()!; + const row = adapter.prepare( + `SELECT gate_id, outcome, failure_class, trace_id, turn_id + FROM gate_runs + WHERE gate_id = 'milestone-validation-gates' + ORDER BY id DESC + LIMIT 1`, + ).get() as + | { + gate_id: string; + outcome: string; + failure_class: string; + trace_id: string; + turn_id: string; + } + | undefined; + + assert.ok(row, "milestone validation gate row should be persisted"); + assert.equal(row?.gate_id, "milestone-validation-gates"); + assert.equal(row?.outcome, "pass"); + assert.equal(row?.failure_class, "none"); + assert.equal(row?.trace_id, "trace-val-1"); + assert.equal(row?.turn_id, "turn-val-1"); + }); }); diff --git a/src/resources/extensions/gsd/tools/validate-milestone.ts b/src/resources/extensions/gsd/tools/validate-milestone.ts index b5e62acb9..fcccb87e8 100644 --- a/src/resources/extensions/gsd/tools/validate-milestone.ts +++ b/src/resources/extensions/gsd/tools/validate-milestone.ts @@ -23,6 +23,9 @@ import { invalidateStateCache } from "../state.js"; import { VALIDATION_VERDICTS, isValidMilestoneVerdict } from "../verdict-parser.js"; import { insertMilestoneValidationGates } from "../milestone-validation-gates.js"; import { logWarning } from "../workflow-logger.js"; +import { UokGateRunner } from "../uok/gate-runner.js"; +import { loadEffectiveGSDPreferences } from "../preferences.js"; +import { resolveUokFlags } from "../uok/flags.js"; export interface ValidateMilestoneParams { milestoneId: string; @@ -43,6 +46,12 @@ export interface ValidateMilestoneResult { validationPath: string; } +export interface ValidateMilestoneOptions { + uokGatesEnabled?: boolean; + traceId?: string; + turnId?: string; +} + function renderValidationMarkdown(params: ValidateMilestoneParams): string { let md = `--- verdict: ${params.verdict} @@ -81,6 +90,7 @@ ${params.verdictRationale} export async function handleValidateMilestone( params: ValidateMilestoneParams, basePath: string, + opts?: ValidateMilestoneOptions, ): Promise { if (!params.milestoneId || typeof params.milestoneId !== "string" || params.milestoneId.trim() === "") { return { error: "milestoneId is required and must be a non-empty string" }; @@ -108,6 +118,8 @@ export async function handleValidateMilestone( // rendering can regenerate. The inverse (file exists, no DB row) is // harder to detect and recover from (#2725). const validatedAt = new Date().toISOString(); + const slices = getMilestoneSlices(params.milestoneId); + const gateSliceId = slices.length > 0 ? slices[0].id : "_milestone"; transaction(() => { insertAssessment({ @@ -123,11 +135,9 @@ export async function handleValidateMilestone( // #2945 Bug 4: persist quality_gates records alongside the assessment. // Previously only the assessment was written, leaving M002+ milestones // with zero quality_gate records despite passing validation. - const slices = getMilestoneSlices(params.milestoneId); - const sliceId = slices.length > 0 ? slices[0].id : "_milestone"; insertMilestoneValidationGates( params.milestoneId, - sliceId, + gateSliceId, params.verdict, validatedAt, ); @@ -147,6 +157,41 @@ export async function handleValidateMilestone( clearPathCache(); clearParseCache(); + const prefs = loadEffectiveGSDPreferences()?.preferences; + const gatesEnabled = opts?.uokGatesEnabled ?? resolveUokFlags(prefs).gates; + if (gatesEnabled) { + try { + const gateRunner = new UokGateRunner(); + const nonPassVerdict = params.verdict !== "pass"; + gateRunner.register({ + id: "milestone-validation-gates", + type: "verification", + execute: async () => ({ + outcome: nonPassVerdict ? "manual-attention" : "pass", + failureClass: nonPassVerdict ? "manual-attention" : "none", + rationale: `milestone validation verdict: ${params.verdict}`, + findings: nonPassVerdict + ? [params.verdictRationale, params.remediationPlan ?? ""].filter(Boolean).join("\n") + : "", + }), + }); + await gateRunner.run("milestone-validation-gates", { + basePath, + traceId: opts?.traceId ?? `validate-milestone:${params.milestoneId}`, + turnId: opts?.turnId ?? `${params.milestoneId}:validate`, + milestoneId: params.milestoneId, + sliceId: gateSliceId, + unitType: "validate-milestone", + unitId: params.milestoneId, + }); + } catch (err) { + logWarning( + "tool", + `validate_milestone — failed to persist UOK gate result: ${(err as Error).message}`, + ); + } + } + return { milestoneId: params.milestoneId, verdict: params.verdict, From 414c2ee58c24ee7b3ce1a5f7ea11e4a13fb80db1 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:41:08 -0500 Subject: [PATCH 04/11] feat(gsd-uok): enforce model policy filtering before routing --- .../extensions/gsd/auto-model-selection.ts | 18 +++- .../gsd/tests/auto-model-selection.test.ts | 20 +++++ .../gsd/tests/uok-model-policy.test.ts | 89 +++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/uok-model-policy.test.ts diff --git a/src/resources/extensions/gsd/auto-model-selection.ts b/src/resources/extensions/gsd/auto-model-selection.ts index 75f2b434f..57a2b8904 100644 --- a/src/resources/extensions/gsd/auto-model-selection.ts +++ b/src/resources/extensions/gsd/auto-model-selection.ts @@ -10,7 +10,7 @@ import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; import type { GSDPreferences } from "./preferences.js"; import { resolveModelWithFallbacksForUnit, resolveDynamicRoutingConfig } from "./preferences.js"; import type { ComplexityTier } from "./complexity-classifier.js"; -import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js"; +import { classifyUnitComplexity, extractTaskMetadata, tierLabel } from "./complexity-classifier.js"; import { resolveModelForComplexity, escalateTier, getEligibleModels, loadCapabilityOverrides, adjustToolSet, filterToolsForProvider } from "./model-router.js"; import { getLedger, getProjectTotals } from "./metrics.js"; import { unitPhaseLabel } from "./auto-dashboard.js"; @@ -120,6 +120,10 @@ export async function selectAndApplyModel( let routingTierLabel = ""; let routingEligibleModels = availableModels; + const taskMetadataForPolicy = unitType === "execute-task" + ? extractTaskMetadata(unitId, basePath) + : undefined; + if (uokFlags.modelPolicy) { const policy = applyModelPolicyFilter( availableModels, @@ -128,6 +132,7 @@ export async function selectAndApplyModel( traceId: modelPolicyTraceId, turnId: modelPolicyTurnId, unitType, + taskMetadata: taskMetadataForPolicy, currentProvider: ctx.model?.provider, allowCrossProvider: routingConfig.cross_provider !== false, requiredTools: pi.getActiveTools(), @@ -182,7 +187,13 @@ export async function selectAndApplyModel( const shouldClassify = !isHook || routingConfig.hooks !== false; if (shouldClassify) { - let classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct); + let classification = classifyUnitComplexity( + unitType, + unitId, + basePath, + budgetPct, + taskMetadataForPolicy, + ); const availableModelIds = routingEligibleModels.map(m => m.id); // Escalate tier on retry when escalate_on_failure is enabled (default: true) @@ -293,7 +304,8 @@ export async function selectAndApplyModel( let attemptedPolicyEligible = false; for (const modelId of modelsToTry) { - const model = resolveModelId(modelId, availableModels, ctx.model?.provider); + const resolutionPool = uokFlags.modelPolicy ? routingEligibleModels : availableModels; + const model = resolveModelId(modelId, resolutionPool, ctx.model?.provider); if (!model) { if (verbose) ctx.ui.notify(`Model ${modelId} not found, trying fallback.`, "info"); diff --git a/src/resources/extensions/gsd/tests/auto-model-selection.test.ts b/src/resources/extensions/gsd/tests/auto-model-selection.test.ts index 1551888d4..7bb1cb7ba 100644 --- a/src/resources/extensions/gsd/tests/auto-model-selection.test.ts +++ b/src/resources/extensions/gsd/tests/auto-model-selection.test.ts @@ -227,6 +227,26 @@ test("model change notify in selectAndApplyModel is gated behind verbose flag", ); }); +test("model policy resolves candidates from the policy-eligible pool", () => { + const src = readFileSync(join(__dirname, "..", "auto-model-selection.ts"), "utf-8"); + assert.ok( + src.includes("const resolutionPool = uokFlags.modelPolicy ? routingEligibleModels : availableModels"), + "selectAndApplyModel should resolve model IDs against policy-eligible models when model policy is enabled", + ); +}); + +test("model policy receives task metadata for requirement-vector decisions", () => { + const src = readFileSync(join(__dirname, "..", "auto-model-selection.ts"), "utf-8"); + assert.ok( + src.includes("taskMetadata: taskMetadataForPolicy"), + "applyModelPolicyFilter should receive task metadata so requirement vectors are unit-aware", + ); + assert.ok( + src.includes("extractTaskMetadata(unitId, basePath)"), + "execute-task dispatch should derive metadata before policy filtering", + ); +}); + test("resolveModelId: anthropic wins over claude-code when session provider is not claude-code", () => { const availableModels = [ { id: "claude-sonnet-4-6", provider: "claude-code" }, diff --git a/src/resources/extensions/gsd/tests/uok-model-policy.test.ts b/src/resources/extensions/gsd/tests/uok-model-policy.test.ts new file mode 100644 index 000000000..dd2b2b93a --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-model-policy.test.ts @@ -0,0 +1,89 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + applyModelPolicyFilter, + buildRequirementVector, +} from "../uok/model-policy.ts"; +import { + registerToolCompatibility, + resetToolCompatibilityRegistry, +} from "@gsd/pi-coding-agent"; + +test.afterEach(() => { + resetToolCompatibilityRegistry(); +}); + +test("uok model policy builds requirement vectors from unit metadata", () => { + const requirements = buildRequirementVector("execute-task", { + tags: ["docs"], + fileCount: 8, + estimatedLines: 600, + }); + + assert.equal(requirements.instruction, 0.9); + assert.equal(requirements.coding, 0.3); + assert.equal(requirements.speed, 0.7); +}); + +test("uok model policy enforces provider/api/tool constraints and emits decision audit events", () => { + const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-model-policy-")); + try { + mkdirSync(join(basePath, ".gsd"), { recursive: true }); + registerToolCompatibility("screenshot", { producesImages: true }); + + const result = applyModelPolicyFilter( + [ + { id: "openai-image", provider: "openai", api: "openai-responses" }, + { id: "anthropic-ok", provider: "anthropic", api: "anthropic-messages" }, + { id: "gemini-api-deny", provider: "google", api: "google-generative-ai" }, + { id: "blocked-provider", provider: "blocked", api: "anthropic-messages" }, + ], + { + basePath, + traceId: "trace-model-policy-1", + turnId: "turn-model-policy-1", + unitType: "execute-task", + taskMetadata: { tags: ["docs"] }, + allowCrossProvider: true, + requiredTools: ["screenshot"], + allowedApis: ["anthropic-messages", "openai-responses"], + deniedProviders: ["blocked"], + }, + ); + + assert.deepEqual( + result.eligible.map((m) => m.id), + ["anthropic-ok"], + "only the policy-compliant anthropic model should remain eligible", + ); + assert.equal(result.decisions.length, 4); + assert.equal(result.decisions[0]?.allowed, false); + assert.match(result.decisions[0]?.reason ?? "", /tool policy denied/); + assert.equal(result.decisions[1]?.allowed, true); + assert.equal(result.decisions[2]?.allowed, false); + assert.match(result.decisions[2]?.reason ?? "", /transport\/api denied by policy/); + assert.equal(result.decisions[3]?.allowed, false); + assert.match(result.decisions[3]?.reason ?? "", /provider denied by policy/); + + const auditLogPath = join(basePath, ".gsd", "audit", "events.jsonl"); + const auditLines = readFileSync(auditLogPath, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { type: string; payload?: { reason?: string } }); + const decisionTypes = auditLines.map((event) => event.type); + + assert.equal(auditLines.length, 4); + assert.ok(decisionTypes.includes("model-policy-allow")); + assert.ok(decisionTypes.includes("model-policy-deny")); + assert.ok( + auditLines.some((event) => (event.payload?.reason ?? "").includes("tool policy denied")), + "audit stream should include explicit deny reasons", + ); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +}); From a2cc151bc9b60cb9fc80d12071d64cae231e0615 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:41:15 -0500 Subject: [PATCH 05/11] feat(gsd-uok): unify reactive and parallel scheduling via execution graph --- src/resources/extensions/gsd/auto-dispatch.ts | 22 +++- src/resources/extensions/gsd/auto/loop.ts | 10 ++ src/resources/extensions/gsd/auto/phases.ts | 5 +- .../extensions/gsd/parallel-orchestrator.ts | 41 +++++- .../gsd/slice-parallel-orchestrator.ts | 21 ++- .../gsd/tests/uok-execution-graph.test.ts | 69 ++++++++++ .../extensions/gsd/uok/execution-graph.ts | 120 ++++++++++++++++++ 7 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/uok-execution-graph.test.ts diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 16e437dc2..4ce22e36e 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -53,6 +53,8 @@ import { checkNeedsRunUat, } from "./auto-prompts.js"; import { resolveModelWithFallbacksForUnit } from "./preferences-models.js"; +import { resolveUokFlags } from "./uok/flags.js"; +import { selectReactiveDispatchBatch } from "./uok/execution-graph.js"; // ─── Types ──────────────────────────────────────────────────────────────── @@ -584,12 +586,20 @@ export const DISPATCH_RULES: DispatchRule[] = [ // Only activate reactive dispatch when >1 task is ready if (readyIds.length <= 1) return null; - const selected = chooseNonConflictingSubset( - readyIds, - graph, - maxParallel, - new Set(), - ); + const uokFlags = resolveUokFlags(prefs); + const selected = uokFlags.executionGraph + ? selectReactiveDispatchBatch({ + graph, + readyIds, + maxParallel, + inFlightOutputs: new Set(), + }).selected + : chooseNonConflictingSubset( + readyIds, + graph, + maxParallel, + new Set(), + ); if (selected.length <= 1) return null; // Log graph metrics for observability diff --git a/src/resources/extensions/gsd/auto/loop.ts b/src/resources/extensions/gsd/auto/loop.ts index be8128bb1..b2c273254 100644 --- a/src/resources/extensions/gsd/auto/loop.ts +++ b/src/resources/extensions/gsd/auto/loop.ts @@ -31,6 +31,8 @@ import { isInfrastructureError, isTransientCooldownError, getCooldownRetryAfterM import { resolveEngine } from "../engine-resolver.js"; import { logWarning } from "../workflow-logger.js"; import { gsdRoot } from "../paths.js"; +import { resolveUokFlags } from "../uok/flags.js"; +import { scheduleSidecarQueue } from "../uok/execution-graph.js"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; @@ -205,10 +207,18 @@ export async function autoLoop( try { // ── Blanket try/catch: one bad iteration must not kill the session const prefs = deps.loadEffectiveGSDPreferences()?.preferences; + const uokFlags = resolveUokFlags(prefs); // ── Check sidecar queue before deriveState ── let sidecarItem: SidecarItem | undefined; if (s.sidecarQueue.length > 0) { + if (uokFlags.executionGraph && s.sidecarQueue.length > 1) { + try { + s.sidecarQueue = await scheduleSidecarQueue(s.sidecarQueue); + } catch (err) { + logWarning("dispatch", `sidecar queue scheduling failed: ${err instanceof Error ? err.message : String(err)}`); + } + } sidecarItem = s.sidecarQueue.shift()!; debugLog("autoLoop", { phase: "sidecar-dequeue", diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 97c485c44..647cafa90 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -396,7 +396,10 @@ export async function runPreDispatch( s.basePath, mid, eligible, - { maxWorkers: prefs.slice_parallel.max_workers ?? 2 }, + { + maxWorkers: prefs.slice_parallel.max_workers ?? 2, + useExecutionGraph: uokFlags.executionGraph, + }, ); if (result.started.length > 0) { ctx.ui.notify( diff --git a/src/resources/extensions/gsd/parallel-orchestrator.ts b/src/resources/extensions/gsd/parallel-orchestrator.ts index 689de6ce2..f6e68a020 100644 --- a/src/resources/extensions/gsd/parallel-orchestrator.ts +++ b/src/resources/extensions/gsd/parallel-orchestrator.ts @@ -42,6 +42,8 @@ import { } from "./parallel-eligibility.js"; import { getErrorMessage } from "./error-utils.js"; import { logWarning } from "./workflow-logger.js"; +import { resolveUokFlags } from "./uok/flags.js"; +import { selectConflictFreeBatch } from "./uok/execution-graph.js"; // ─── Types ───────────────────────────────────────────────────────────────── @@ -69,6 +71,10 @@ export interface OrchestratorState { let state: OrchestratorState | null = null; +function overlapKey(a: string, b: string): string { + return a < b ? `${a}::${b}` : `${b}::${a}`; +} + // ─── Persistence ────────────────────────────────────────────────────────── const ORCHESTRATOR_STATE_FILE = "orchestrator.json"; @@ -365,6 +371,7 @@ export async function startParallel( } const config = resolveParallelConfig(prefs); + const uokFlags = resolveUokFlags(prefs); // Release any leftover state from a previous session before reassigning if (state) { @@ -418,8 +425,40 @@ export async function startParallel( const started: string[] = []; const errors: Array<{ mid: string; error: string }> = []; + let filteredMilestoneIds = milestoneIds; + if (uokFlags.executionGraph && milestoneIds.length > 1) { + try { + const requestedIds = new Set(milestoneIds); + const candidates = await analyzeParallelEligibility(basePath); + const overlapPairs = new Set(); + for (const overlap of candidates.fileOverlaps) { + if (!requestedIds.has(overlap.mid1) || !requestedIds.has(overlap.mid2)) continue; + overlapPairs.add(overlapKey(overlap.mid1, overlap.mid2)); + } + filteredMilestoneIds = selectConflictFreeBatch({ + orderedIds: milestoneIds, + maxParallel: milestoneIds.length, + hasConflict: (candidate, existing) => + overlapPairs.has(overlapKey(candidate, existing)), + }); + if (filteredMilestoneIds.length < milestoneIds.length) { + const skipped = milestoneIds.filter((mid) => !filteredMilestoneIds.includes(mid)); + logWarning( + "parallel", + `uok execution graph filtered ${skipped.length} conflicting milestone(s): ${skipped.join(", ")}`, + ); + } + } catch (e) { + logWarning( + "parallel", + `uok execution graph overlap analysis failed; using legacy milestone selection: ${(e as Error).message}`, + ); + filteredMilestoneIds = milestoneIds; + } + } + // Cap to max_workers - const toStart = milestoneIds.slice(0, config.max_workers); + const toStart = filteredMilestoneIds.slice(0, config.max_workers); for (const mid of toStart) { // Check budget ceiling before each spawn diff --git a/src/resources/extensions/gsd/slice-parallel-orchestrator.ts b/src/resources/extensions/gsd/slice-parallel-orchestrator.ts index 346237651..974107ac9 100644 --- a/src/resources/extensions/gsd/slice-parallel-orchestrator.ts +++ b/src/resources/extensions/gsd/slice-parallel-orchestrator.ts @@ -32,6 +32,7 @@ import { } from "./session-status-io.js"; import { hasFileConflict } from "./slice-parallel-conflict.js"; import { getErrorMessage } from "./error-utils.js"; +import { selectConflictFreeBatch } from "./uok/execution-graph.js"; // ─── Types ───────────────────────────────────────────────────────────────── @@ -61,6 +62,7 @@ export interface SliceOrchestratorState { export interface StartSliceParallelOpts { maxWorkers?: number; budgetCeiling?: number; + useExecutionGraph?: boolean; } // ─── Module State ────────────────────────────────────────────────────────── @@ -118,7 +120,12 @@ export async function startSliceParallel( const errors: Array<{ sid: string; error: string }> = []; // Filter out conflicting slices (conservative: check all pairs) - const safeSlices = filterConflictingSlices(basePath, milestoneId, eligibleSlices); + const safeSlices = filterConflictingSlices( + basePath, + milestoneId, + eligibleSlices, + opts.useExecutionGraph === true, + ); // Limit to maxWorkers const toSpawn = safeSlices.slice(0, maxWorkers); @@ -245,7 +252,19 @@ function filterConflictingSlices( basePath: string, milestoneId: string, slices: Array<{ id: string }>, + useExecutionGraph: boolean, ): Array<{ id: string }> { + if (useExecutionGraph) { + const selectedIds = selectConflictFreeBatch({ + orderedIds: slices.map((slice) => slice.id), + maxParallel: slices.length, + hasConflict: (candidate, existing) => + hasFileConflict(basePath, milestoneId, candidate, existing), + }); + const selected = new Set(selectedIds); + return slices.filter((slice) => selected.has(slice.id)); + } + const safe: Array<{ id: string }> = []; for (const candidate of slices) { diff --git a/src/resources/extensions/gsd/tests/uok-execution-graph.test.ts b/src/resources/extensions/gsd/tests/uok-execution-graph.test.ts new file mode 100644 index 000000000..448a7249a --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-execution-graph.test.ts @@ -0,0 +1,69 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { SidecarItem } from "../auto/session.ts"; +import { + selectConflictFreeBatch, + selectReactiveDispatchBatch, + buildSidecarQueueNodes, + scheduleSidecarQueue, +} from "../uok/execution-graph.ts"; + +test("uok execution graph selects deterministic conflict-free IDs", () => { + const selected = selectConflictFreeBatch({ + orderedIds: ["S01", "S02", "S03", "S04"], + maxParallel: 4, + hasConflict: (candidate, existing) => + (candidate === "S02" && existing === "S01") || + (candidate === "S01" && existing === "S02"), + }); + + assert.deepEqual(selected, ["S01", "S03", "S04"]); +}); + +test("uok execution graph reactive batch honors file conflicts and in-flight writes", () => { + const result = selectReactiveDispatchBatch({ + graph: [ + { id: "T01", dependsOn: [], outputFiles: ["src/a.ts"] }, + { id: "T02", dependsOn: [], outputFiles: ["src/a.ts"] }, + { id: "T03", dependsOn: [], outputFiles: ["src/b.ts"] }, + { id: "T04", dependsOn: ["T03"], outputFiles: ["src/c.ts"] }, + ], + readyIds: ["T01", "T02", "T03", "T04"], + maxParallel: 3, + inFlightOutputs: new Set(["src/c.ts"]), + }); + + assert.deepEqual(result.selected, ["T01", "T03"]); + assert.ok( + result.conflicts.some((c) => c.nodeA === "T01" && c.nodeB === "T02" && c.file === "src/a.ts"), + "conflict list should include overlapping outputs", + ); +}); + +test("uok execution graph sidecar nodes map queue kinds to supported DAG kinds", () => { + const queue: SidecarItem[] = [ + { kind: "hook", unitType: "execute-task", unitId: "M001/S01/T01", prompt: "hook" }, + { kind: "triage", unitType: "triage", unitId: "M001/S01", prompt: "triage" }, + { kind: "quick-task", unitType: "quick-task", unitId: "M001/S01/Q01", prompt: "quick" }, + ]; + + const nodes = buildSidecarQueueNodes(queue); + assert.equal(nodes[0]?.kind, "hook"); + assert.equal(nodes[1]?.kind, "verification"); + assert.equal(nodes[2]?.kind, "team-worker"); + assert.equal(nodes[1]?.dependsOn.length, 1); +}); + +test("uok execution graph sidecar scheduler preserves deterministic queue order", async () => { + const queue: SidecarItem[] = [ + { kind: "quick-task", unitType: "quick-task", unitId: "M001/S01/Q01", prompt: "q1" }, + { kind: "hook", unitType: "hook", unitId: "M001/S01/H01", prompt: "h1" }, + { kind: "triage", unitType: "triage", unitId: "M001/S01/TR1", prompt: "t1" }, + ]; + + const scheduled = await scheduleSidecarQueue(queue); + assert.deepEqual( + scheduled.map((item) => item.unitId), + queue.map((item) => item.unitId), + ); +}); diff --git a/src/resources/extensions/gsd/uok/execution-graph.ts b/src/resources/extensions/gsd/uok/execution-graph.ts index 243ac4093..445b1f145 100644 --- a/src/resources/extensions/gsd/uok/execution-graph.ts +++ b/src/resources/extensions/gsd/uok/execution-graph.ts @@ -1,4 +1,6 @@ import type { UokGraphNode } from "./contracts.js"; +import type { DerivedTaskNode } from "../types.js"; +import type { SidecarItem } from "../auto/session.js"; export interface ExecutionGraphRunOptions { parallel?: boolean; @@ -12,6 +14,123 @@ export interface ExecutionGraphResult { export type ExecutionNodeHandler = (node: UokGraphNode) => Promise; +export interface ConflictFreeBatchInput { + orderedIds: string[]; + maxParallel: number; + hasConflict: (leftId: string, rightId: string) => boolean; +} + +export interface ReactiveDispatchSelectionInput { + graph: Array>; + readyIds: string[]; + maxParallel: number; + inFlightOutputs?: Set; +} + +export interface ReactiveDispatchSelectionResult { + selected: string[]; + conflicts: Array<{ nodeA: string; nodeB: string; file: string }>; +} + +export function selectConflictFreeBatch({ + orderedIds, + maxParallel, + hasConflict, +}: ConflictFreeBatchInput): string[] { + if (maxParallel <= 0 || orderedIds.length === 0) return []; + const selected: string[] = []; + for (const candidate of orderedIds) { + if (selected.length >= maxParallel) break; + const conflictsExisting = selected.some((existing) => hasConflict(candidate, existing)); + if (conflictsExisting) continue; + selected.push(candidate); + } + return selected; +} + +function buildReactiveNodes( + graph: Array>, +): UokGraphNode[] { + return graph.map((node) => ({ + id: node.id, + kind: "unit", + dependsOn: [...node.dependsOn], + writes: [...node.outputFiles], + })); +} + +export function selectReactiveDispatchBatch( + input: ReactiveDispatchSelectionInput, +): ReactiveDispatchSelectionResult { + const nodeMap = new Map(buildReactiveNodes(input.graph).map((n) => [n.id, n])); + const readyNodes = input.readyIds + .map((id) => nodeMap.get(id)) + .filter((node): node is UokGraphNode => !!node); + const conflicts = detectFileConflicts(readyNodes); + if (readyNodes.length === 0 || input.maxParallel <= 0) { + return { selected: [], conflicts }; + } + + const claimed = new Set(input.inFlightOutputs ?? []); + const selected: string[] = []; + const selectedSet = new Set(); + const readySet = new Set(input.readyIds); + + for (const id of input.readyIds) { + if (selected.length >= input.maxParallel) break; + const node = nodeMap.get(id); + if (!node) continue; + + const hasUnmetReadyDependency = node.dependsOn.some( + (dep) => readySet.has(dep) && !selectedSet.has(dep), + ); + if (hasUnmetReadyDependency) continue; + + const writes = node.writes ?? []; + const conflictsWithClaimed = writes.some((file) => claimed.has(file)); + if (conflictsWithClaimed) continue; + + selected.push(node.id); + selectedSet.add(node.id); + for (const file of writes) claimed.add(file); + } + + return { selected, conflicts }; +} + +function sidecarToNodeKind(kind: SidecarItem["kind"]): UokGraphNode["kind"] { + if (kind === "hook") return "hook"; + if (kind === "triage") return "verification"; + return "team-worker"; +} + +export function buildSidecarQueueNodes(queue: SidecarItem[]): UokGraphNode[] { + return queue.map((item, index) => ({ + id: `sidecar-${String(index).padStart(4, "0")}:${item.kind}:${item.unitType}:${item.unitId}`, + kind: sidecarToNodeKind(item.kind), + dependsOn: index > 0 ? [`sidecar-${String(index - 1).padStart(4, "0")}:${queue[index - 1].kind}:${queue[index - 1].unitType}:${queue[index - 1].unitId}`] : [], + metadata: { index }, + })); +} + +export async function scheduleSidecarQueue(queue: SidecarItem[]): Promise { + if (queue.length <= 1) return [...queue]; + const nodes = buildSidecarQueueNodes(queue); + const scheduler = new ExecutionGraphScheduler(); + const orderedIndexes: number[] = []; + const seenKinds = new Set(nodes.map((n) => n.kind)); + + for (const kind of seenKinds) { + scheduler.registerHandler(kind, async (node) => { + const idx = Number(node.metadata?.index); + if (Number.isInteger(idx) && idx >= 0) orderedIndexes.push(idx); + }); + } + + await scheduler.run(nodes, { parallel: false }); + return orderedIndexes.map((idx) => queue[idx]).filter((item): item is SidecarItem => !!item); +} + export class ExecutionGraphScheduler { private readonly handlers = new Map(); @@ -42,6 +161,7 @@ export class ExecutionGraphScheduler { const ready = Array.from(remaining.values()).filter((node) => node.dependsOn.every((dep) => done.has(dep)), ); + ready.sort((a, b) => a.id.localeCompare(b.id)); if (ready.length === 0) { throw new Error("Execution graph deadlock detected: no ready nodes and graph not complete"); } From d6c93ef07f51b5e27a234434cbe5d96bab956098 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:41:26 -0500 Subject: [PATCH 06/11] feat(gsd-uok): add turn-level git transaction modes and closeout gates --- .../extensions/gsd/auto-post-unit.ts | 172 +++++++++++++++++- .../extensions/gsd/auto-unit-closeout.ts | 26 ++- src/resources/extensions/gsd/auto.ts | 19 ++ src/resources/extensions/gsd/auto/loop.ts | 11 +- src/resources/extensions/gsd/auto/phases.ts | 10 +- src/resources/extensions/gsd/auto/session.ts | 10 + src/resources/extensions/gsd/git-service.ts | 68 +++++++ .../gsd/tests/uok-gitops-turn-action.test.ts | 85 +++++++++ .../gsd/tests/uok-gitops-wiring.test.ts | 35 ++++ 9 files changed, 422 insertions(+), 14 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts create mode 100644 src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index a2b714c86..cddb84120 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -29,9 +29,10 @@ import { rebuildState } from "./doctor.js"; import { parseUnitId } from "./unit-id.js"; import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js"; import { - autoCommitCurrentBranch, + runTurnGitAction, type TaskCommitContext, -} from "./worktree.js"; + type TurnGitActionMode, +} from "./git-service.js"; import { verifyExpectedArtifact, resolveExpectedArtifactPath, @@ -68,6 +69,7 @@ import { writePreExecutionEvidence } from "./verification-evidence.js"; import { ensureCodebaseMapFresh } from "./codebase-generator.js"; import { resolveUokFlags } from "./uok/flags.js"; import { UokGateRunner } from "./uok/gate-runner.js"; +import { writeTurnGitTransaction } from "./uok/gitops.js"; /** Maximum verification retry attempts before escalating to blocker placeholder (#2653). */ const MAX_VERIFICATION_RETRIES = 3; @@ -359,10 +361,161 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV await new Promise(r => setTimeout(r, 100)); } - // Auto-commit + const prefs = loadEffectiveGSDPreferences()?.preferences; + const uokFlags = resolveUokFlags(prefs); + + // Turn-level git action (commit | snapshot | status-only) if (s.currentUnit) { const unit = s.currentUnit; - await autoCommitUnit(s.basePath, unit.type, unit.id, ctx); + const turnAction: TurnGitActionMode = uokFlags.gitops ? uokFlags.gitopsTurnAction : "commit"; + const traceId = s.currentTraceId ?? `turn:${unit.startedAt}`; + const turnId = s.currentTurnId ?? `${unit.type}/${unit.id}/${unit.startedAt}`; + s.lastGitActionFailure = null; + s.lastGitActionStatus = null; + try { + let taskContext: TaskCommitContext | undefined; + + if (turnAction === "commit" && s.currentUnit.type === "execute-task") { + const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id); + if (mid && sid && tid) { + const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY"); + if (summaryPath) { + try { + const summaryContent = await loadFile(summaryPath); + if (summaryContent) { + const summary = parseSummary(summaryContent); + // Look up GitHub issue number for commit linking + let ghIssueNumber: number | undefined; + try { + const { getTaskIssueNumberForCommit } = await import("../github-sync/sync.js"); + ghIssueNumber = getTaskIssueNumberForCommit(s.basePath, mid, sid, tid) ?? undefined; + } catch (err) { + // GitHub sync not available — skip + logWarning("engine", `GitHub issue lookup failed: ${err instanceof Error ? err.message : String(err)}`); + } + + taskContext = { + taskId: `${sid}/${tid}`, + taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid, + oneLiner: summary.oneLiner || undefined, + keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined, + issueNumber: ghIssueNumber, + }; + } + } catch (e) { + debugLog("postUnit", { phase: "task-summary-parse", error: String(e) }); + } + } + } + } + + // Invalidate the nativeHasChanges cache before auto-commit (#1853). + // The cache has a 10-second TTL and is keyed by basePath. A stale + // `false` result causes autoCommit to skip staging entirely, leaving + // code files only in the working tree where they are destroyed by + // `git worktree remove --force` during teardown. + _resetHasChangesCache(); + + const skipLifecycleCommit = + turnAction === "commit" && LIFECYCLE_ONLY_UNITS.has(s.currentUnit.type); + + if (skipLifecycleCommit) { + debugLog("postUnit", { + phase: "git-action-skipped", + reason: "lifecycle-only-unit", + unitType: s.currentUnit.type, + unitId: s.currentUnit.id, + }); + } else { + const gitResult = runTurnGitAction({ + basePath: s.basePath, + action: turnAction, + unitType: s.currentUnit.type, + unitId: s.currentUnit.id, + taskContext, + }); + + if (uokFlags.gitops) { + writeTurnGitTransaction({ + basePath: s.basePath, + traceId, + turnId, + unitType: unit.type, + unitId: unit.id, + stage: "publish", + action: turnAction, + push: uokFlags.gitopsTurnPush, + status: gitResult.status, + error: gitResult.error, + metadata: { + dirty: gitResult.dirty, + commitMessage: gitResult.commitMessage, + snapshotLabel: gitResult.snapshotLabel, + }, + }); + } + + if (gitResult.status === "failed") { + s.lastGitActionFailure = gitResult.error ?? `git ${turnAction} failed`; + s.lastGitActionStatus = "failed"; + if (uokFlags.gitops && uokFlags.gates) { + const parsed = parseUnitId(unit.id); + const gateRunner = new UokGateRunner(); + gateRunner.register({ + id: "closeout-git-action", + type: "closeout", + execute: async () => ({ + outcome: "fail", + failureClass: "git", + rationale: `turn git action "${turnAction}" failed`, + findings: gitResult.error ?? "unknown git failure", + }), + }); + await gateRunner.run("closeout-git-action", { + basePath: s.basePath, + traceId, + turnId, + milestoneId: parsed.milestone ?? undefined, + sliceId: parsed.slice ?? undefined, + taskId: parsed.task ?? undefined, + unitType: unit.type, + unitId: unit.id, + }); + } + + const failureMsg = `Git ${turnAction} failed: ${(gitResult.error ?? "unknown error").split("\n")[0]}`; + if (uokFlags.gitops) { + ctx.ui.notify(failureMsg, "error"); + await pauseAuto(ctx, pi); + return "dispatched"; + } + ctx.ui.notify(failureMsg, "warning"); + debugLog("postUnit", { + phase: "git-action-failed-nonblocking", + action: turnAction, + error: gitResult.error ?? "unknown error", + }); + } + + s.lastGitActionStatus = "ok"; + + if (turnAction === "commit" && gitResult.commitMessage) { + ctx.ui.notify(`Committed: ${gitResult.commitMessage.split("\n")[0]}`, "info"); + } else if (turnAction === "snapshot" && gitResult.snapshotLabel) { + ctx.ui.notify(`Snapshot recorded: ${gitResult.snapshotLabel}`, "info"); + } + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + s.lastGitActionFailure = message; + s.lastGitActionStatus = "failed"; + debugLog("postUnit", { phase: "git-action", error: message, action: turnAction }); + ctx.ui.notify(`Git ${turnAction} failed: ${message.split("\n")[0]}`, uokFlags.gitops ? "error" : "warning"); + if (uokFlags.gitops) { + await pauseAuto(ctx, pi); + return "dispatched"; + } + } // GitHub sync (non-blocking, opt-in) await runSafely("postUnit", "github-sync", async () => { @@ -871,6 +1024,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" s.currentUnit && s.currentUnit.type === "plan-slice" ) { + const currentUnit = s.currentUnit; let preExecPauseNeeded = false; await runSafely("postUnitPostVerification", "pre-execution-checks", async () => { const prefs = loadEffectiveGSDPreferences()?.preferences; @@ -890,7 +1044,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" } // Parse the unit ID to get milestone/slice IDs - const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit!.id); + const { milestone: mid, slice: sid } = parseUnitId(currentUnit.id); if (!mid || !sid) { debugLog("postUnitPostVerification", { phase: "pre-execution-checks", @@ -957,12 +1111,12 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" }); await gateRunner.run("pre-execution-checks", { basePath: s.basePath, - traceId: `pre-execution:${s.currentUnit.id}`, - turnId: s.currentUnit.id, + traceId: `pre-execution:${currentUnit.id}`, + turnId: currentUnit.id, milestoneId: mid, sliceId: sid, - unitType: s.currentUnit.type, - unitId: s.currentUnit.id, + unitType: currentUnit.type, + unitId: currentUnit.id, }); } diff --git a/src/resources/extensions/gsd/auto-unit-closeout.ts b/src/resources/extensions/gsd/auto-unit-closeout.ts index ccd274176..45d8dce78 100644 --- a/src/resources/extensions/gsd/auto-unit-closeout.ts +++ b/src/resources/extensions/gsd/auto-unit-closeout.ts @@ -8,6 +8,7 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { snapshotUnitMetrics } from "./metrics.js"; import { saveActivityLog } from "./activity-log.js"; import { logWarning } from "./workflow-logger.js"; +import { writeTurnGitTransaction } from "./uok/gitops.js"; export interface CloseoutOptions { promptCharCount?: number; @@ -15,6 +16,12 @@ export interface CloseoutOptions { tier?: string; modelDowngraded?: boolean; continueHereFired?: boolean; + traceId?: string; + turnId?: string; + gitAction?: "commit" | "snapshot" | "status-only"; + gitPush?: boolean; + gitStatus?: "ok" | "failed"; + gitError?: string; } /** @@ -47,6 +54,23 @@ export async function closeoutUnit( } } + if (opts?.traceId && opts.turnId && opts.gitAction && opts.gitStatus) { + writeTurnGitTransaction({ + basePath, + traceId: opts.traceId, + turnId: opts.turnId, + unitType, + unitId, + stage: "record", + action: opts.gitAction, + push: opts.gitPush === true, + status: opts.gitStatus, + error: opts.gitError, + metadata: { + activityFile, + }, + }); + } + return activityFile ?? undefined; } - diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 6cc197b1c..e39653e15 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -203,6 +203,7 @@ import { bootstrapAutoSession, openProjectDbIfPresent, type BootstrapDeps } from import { initHealthWidget } from "./health-widget.js"; import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight, type LoopDeps, type ErrorContext } from "./auto-loop.js"; import { runAutoLoopWithUok } from "./uok/kernel.js"; +import { resolveUokFlags } from "./uok/flags.js"; // Slice-level parallelism (#2340) import { getEligibleSlices } from "./slice-parallel-eligibility.js"; import { startSliceParallel } from "./slice-parallel-orchestrator.js"; @@ -606,11 +607,29 @@ function buildSnapshotOpts( continueHereFired?: boolean; promptCharCount?: number; baselineCharCount?: number; + traceId?: string; + turnId?: string; + gitAction?: "commit" | "snapshot" | "status-only"; + gitPush?: boolean; + gitStatus?: "ok" | "failed"; + gitError?: string; } & Record { + const prefs = loadEffectiveGSDPreferences()?.preferences; + const uokFlags = resolveUokFlags(prefs); return { ...(s.autoStartTime > 0 ? { autoSessionKey: String(s.autoStartTime) } : {}), promptCharCount: s.lastPromptCharCount, baselineCharCount: s.lastBaselineCharCount, + traceId: s.currentTraceId ?? undefined, + turnId: s.currentTurnId ?? undefined, + ...(uokFlags.gitops + ? { + gitAction: uokFlags.gitopsTurnAction, + gitPush: uokFlags.gitopsTurnPush, + gitStatus: s.lastGitActionStatus ?? undefined, + gitError: s.lastGitActionFailure ?? undefined, + } + : {}), ...(s.currentUnitRouting ?? {}), }; } diff --git a/src/resources/extensions/gsd/auto/loop.ts b/src/resources/extensions/gsd/auto/loop.ts index b2c273254..a35f8d672 100644 --- a/src/resources/extensions/gsd/auto/loop.ts +++ b/src/resources/extensions/gsd/auto/loop.ts @@ -131,13 +131,15 @@ export async function autoLoop( let seqCounter = 0; const nextSeq = () => ++seqCounter; const turnId = randomUUID(); + s.currentTraceId = flowId; + s.currentTurnId = turnId; const turnStartedAt = new Date().toISOString(); let observedUnitType: string | undefined; let observedUnitId: string | undefined; let turnFinished = false; const finishTurn = ( status: "completed" | "failed" | "paused" | "stopped" | "skipped" | "retry", - failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" | "closeout" = "none", + failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" | "closeout" | "git" = "none", error?: string, ): void => { if (turnFinished) return; @@ -155,6 +157,8 @@ export async function autoLoop( startedAt: turnStartedAt, finishedAt: new Date().toISOString(), }); + s.currentTraceId = null; + s.currentTurnId = null; }; deps.uokObserver?.onTurnStart({ traceId: flowId, @@ -483,7 +487,10 @@ export async function autoLoop( unitId: iterData.unitId, }); if (finalizeResult.action === "break") { - finishTurn("stopped", "closeout", "finalize-break"); + const finalizeFailureClass = finalizeResult.reason === "git-closeout-failure" + ? "git" + : "closeout"; + finishTurn("stopped", finalizeFailureClass, "finalize-break"); break; } if (finalizeResult.action === "continue") { diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 647cafa90..7f3067778 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -1219,6 +1219,8 @@ export async function runUnitPhase( // unit in the same Node process (see workflow-logger.ts module header). _resetLogs(); s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; + s.lastGitActionFailure = null; + s.lastGitActionStatus = null; setCurrentPhase(unitType); s.lastToolInvocationError = null; // #2883: clear stale error from previous unit const unitStartSeq = ic.nextSeq(); @@ -1727,11 +1729,15 @@ export async function runFinalize( const preResult = preResultGuard.value; if (preResult === "dispatched") { + const dispatchedReason = s.lastGitActionFailure + ? "git-closeout-failure" + : "pre-verification-dispatched"; debugLog("autoLoop", { phase: "exit", - reason: "pre-verification-dispatched", + reason: dispatchedReason, + gitError: s.lastGitActionFailure ?? undefined, }); - return { action: "break", reason: "pre-verification-dispatched" }; + return { action: "break", reason: dispatchedReason }; } if (preResult === "retry") { if (sidecarItem) { diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts index 426713411..fd2d6a9d1 100644 --- a/src/resources/extensions/gsd/auto/session.ts +++ b/src/resources/extensions/gsd/auto/session.ts @@ -106,6 +106,8 @@ export class AutoSession { // ── Current unit ───────────────────────────────────────────────────────── currentUnit: CurrentUnit | null = null; + currentTraceId: string | null = null; + currentTurnId: string | null = null; currentUnitRouting: UnitRouting | null = null; currentMilestoneId: string | null = null; @@ -137,6 +139,10 @@ export class AutoSession { /** Set when a GSD tool execution ends with isError due to malformed/truncated * JSON arguments. Checked by postUnitPreVerification to break retry loops. */ lastToolInvocationError: string | null = null; + /** Set when turn-level git action fails during closeout. */ + lastGitActionFailure: string | null = null; + /** Last turn-level git action status captured during finalize. */ + lastGitActionStatus: "ok" | "failed" | null = null; // ── Isolation degradation ──────────────────────────────────────────── /** Set to true when worktree creation fails; prevents merge of nonexistent branch. */ @@ -219,6 +225,8 @@ export class AutoSession { // Unit this.currentUnit = null; + this.currentTraceId = null; + this.currentTurnId = null; this.currentUnitRouting = null; this.currentMilestoneId = null; @@ -250,6 +258,8 @@ export class AutoSession { this.rewriteAttemptCount = 0; this.consecutiveCompleteBootstraps = 0; this.lastToolInvocationError = null; + this.lastGitActionFailure = null; + this.lastGitActionStatus = null; this.isolationDegraded = false; this.milestoneMergedInPhases = false; this.checkpointSha = null; diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index c937da714..bc1698505 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -34,6 +34,7 @@ import { nativeAddPaths, nativeResetSoft, nativeCommitSubject, + _resetHasChangesCache, } from "./native-git-bridge.js"; import { GSDError, GSD_MERGE_CONFLICT, GSD_GIT_ERROR } from "./errors.js"; import { getErrorMessage } from "./error-utils.js"; @@ -93,6 +94,17 @@ export interface CommitOptions { allowEmpty?: boolean; } +export type TurnGitActionMode = "commit" | "snapshot" | "status-only"; + +export interface TurnGitActionResult { + action: TurnGitActionMode; + status: "ok" | "failed"; + commitMessage?: string; + snapshotLabel?: string; + dirty?: boolean; + error?: string; +} + // ─── Meaningful Commit Message Generation ─────────────────────────────────── /** Context for generating a meaningful commit message from task execution results. */ @@ -822,6 +834,62 @@ export function createGitService(basePath: string): GitServiceImpl { return new GitServiceImpl(basePath, gitPrefs); } +function buildTurnSnapshotLabel(unitType: string, unitId: string): string { + const raw = `${unitType}/${unitId}`.trim(); + if (!raw) return "turn"; + return raw + .replace(/[^a-zA-Z0-9._/-]/g, "-") + .replace(/\/{2,}/g, "/") + .replace(/-{2,}/g, "-") + .replace(/^[-/]+|[-/]+$/g, "") || "turn"; +} + +export function runTurnGitAction(args: { + basePath: string; + action: TurnGitActionMode; + unitType: string; + unitId: string; + taskContext?: TaskCommitContext; +}): TurnGitActionResult { + try { + // Force fresh working-tree status per turn; nativeHasChanges caches briefly. + _resetHasChangesCache(); + if (args.action === "status-only") { + return { + action: args.action, + status: "ok", + dirty: nativeHasChanges(args.basePath), + }; + } + + const git = createGitService(args.basePath); + if (args.action === "snapshot") { + const label = buildTurnSnapshotLabel(args.unitType, args.unitId); + git.createSnapshot(label); + return { + action: args.action, + status: "ok", + snapshotLabel: label, + dirty: nativeHasChanges(args.basePath), + }; + } + + const commitMessage = git.autoCommit(args.unitType, args.unitId, [], args.taskContext) ?? undefined; + return { + action: args.action, + status: "ok", + commitMessage, + dirty: nativeHasChanges(args.basePath), + }; + } catch (err) { + return { + action: args.action, + status: "failed", + error: getErrorMessage(err), + }; + } +} + // ─── Commit Type Inference ───────────────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts b/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts new file mode 100644 index 000000000..0e2a6fc83 --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; +import { runTurnGitAction } from "../git-service.ts"; + +function run(cmd: string, cwd: string): string { + return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf-8" }).trim(); +} + +function makeRepo(): string { + const repo = mkdtempSync(join(tmpdir(), "gsd-uok-gitops-")); + run("git init", repo); + run('git config user.email "test@example.com"', repo); + run('git config user.name "Test User"', repo); + writeFileSync(join(repo, "README.md"), "# Test\n", "utf-8"); + run("git add README.md", repo); + run('git commit -m "chore: init"', repo); + return repo; +} + +test("uok gitops turn action status-only reports working tree dirtiness", () => { + const repo = makeRepo(); + try { + const clean = runTurnGitAction({ + basePath: repo, + action: "status-only", + unitType: "execute-task", + unitId: "M001/S01/T01", + }); + assert.equal(clean.status, "ok"); + assert.equal(clean.dirty, false); + + writeFileSync(join(repo, "README.md"), "# Dirty\n", "utf-8"); + const dirty = runTurnGitAction({ + basePath: repo, + action: "status-only", + unitType: "execute-task", + unitId: "M001/S01/T01", + }); + assert.equal(dirty.status, "ok"); + assert.equal(dirty.dirty, true); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("uok gitops turn action snapshot writes snapshot refs", () => { + const repo = makeRepo(); + try { + const result = runTurnGitAction({ + basePath: repo, + action: "snapshot", + unitType: "execute-task", + unitId: "M001/S01/T01", + }); + assert.equal(result.status, "ok"); + assert.ok(result.snapshotLabel?.includes("execute-task/M001/S01/T01")); + const refs = run("git for-each-ref refs/gsd/snapshots/ --format='%(refname)'", repo); + assert.ok(refs.includes("refs/gsd/snapshots/execute-task/M001/S01/T01/")); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("uok gitops turn action commit creates commit with unit trailer", () => { + const repo = makeRepo(); + try { + writeFileSync(join(repo, "feature.ts"), "export const x = 1;\n", "utf-8"); + const result = runTurnGitAction({ + basePath: repo, + action: "commit", + unitType: "execute-task", + unitId: "M001/S01/T02", + }); + assert.equal(result.status, "ok"); + assert.ok(result.commitMessage?.includes("chore: auto-commit after execute-task")); + const body = run("git log -1 --pretty=%B", repo); + assert.ok(body.includes("GSD-Unit: M001/S01/T02")); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts b/src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts new file mode 100644 index 000000000..80c9dafd2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const gsdDir = join(__dirname, ".."); + +test("post-unit pre-verification selects turn git action from UOK gitops flags", () => { + const source = readFileSync(join(gsdDir, "auto-post-unit.ts"), "utf-8"); + assert.ok( + source.includes("const turnAction: TurnGitActionMode = uokFlags.gitops ? uokFlags.gitopsTurnAction : \"commit\""), + "postUnitPreVerification should derive turn action from uok.gitops.turn_action when enabled", + ); +}); + +test("post-unit pre-verification routes git failures through closeout gate", () => { + const source = readFileSync(join(gsdDir, "auto-post-unit.ts"), "utf-8"); + assert.ok( + source.includes('id: "closeout-git-action"') && + source.includes('type: "closeout"') && + source.includes('failureClass: "git"'), + "git failures should be persisted via a closeout gate with failureClass=git", + ); +}); + +test("auto snapshot opts carry trace/turn IDs for turn closeout records", () => { + const source = readFileSync(join(gsdDir, "auto.ts"), "utf-8"); + assert.ok( + source.includes("traceId: s.currentTraceId ?? undefined") && + source.includes("turnId: s.currentTurnId ?? undefined"), + "buildSnapshotOpts should pass trace/turn IDs into closeout options", + ); +}); From 558ac1067bc01a08f28464f0d501cedf7c0c9295 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:41:38 -0500 Subject: [PATCH 07/11] feat(gsd-uok): unify audit envelopes across logger metrics and activity --- src/resources/extensions/gsd/activity-log.ts | 21 ++++ src/resources/extensions/gsd/journal.ts | 30 ++++++ src/resources/extensions/gsd/metrics.ts | 26 +++++ .../gsd/tests/uok-audit-unified.test.ts | 101 ++++++++++++++++++ .../extensions/gsd/uok/audit-toggle.ts | 9 ++ src/resources/extensions/gsd/uok/kernel.ts | 2 + .../extensions/gsd/workflow-logger.ts | 24 +++++ 7 files changed, 213 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/uok-audit-unified.test.ts create mode 100644 src/resources/extensions/gsd/uok/audit-toggle.ts diff --git a/src/resources/extensions/gsd/activity-log.ts b/src/resources/extensions/gsd/activity-log.ts index 82896ea5b..5e93c0240 100644 --- a/src/resources/extensions/gsd/activity-log.ts +++ b/src/resources/extensions/gsd/activity-log.ts @@ -16,6 +16,8 @@ import { GSDError, GSD_IO_ERROR } from "./errors.js"; const SEQ_PREFIX_RE = /^(\d+)-/; import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { gsdRoot } from "./paths.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; +import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; interface ActivityLogState { nextSeq: number; @@ -132,6 +134,25 @@ export function saveActivityLog( } state.nextSeq += 1; state.lastSnapshotKeyByUnit.set(unitKey, key); + + if (isUnifiedAuditEnabled()) { + emitUokAuditEvent( + basePath, + buildAuditEnvelope({ + traceId: `activity:${unitType}:${unitId}`, + turnId: unitId, + category: "execution", + type: "activity-log-saved", + payload: { + unitType, + unitId, + filePath, + entryCount: entries.length, + }, + }), + ); + } + return filePath; } catch (e) { // Don't let logging failures break auto-mode diff --git a/src/resources/extensions/gsd/journal.ts b/src/resources/extensions/gsd/journal.ts index 5b7003781..3d635b5ce 100644 --- a/src/resources/extensions/gsd/journal.ts +++ b/src/resources/extensions/gsd/journal.ts @@ -15,6 +15,8 @@ import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { gsdRoot } from "./paths.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; +import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -90,6 +92,34 @@ export function emitJournalEvent(basePath: string, entry: JournalEntry): void { } catch { // Silent failure — journal must never break auto-mode } + + if (!isUnifiedAuditEnabled()) return; + try { + const causedBy = entry.causedBy + ? `${entry.causedBy.flowId}:${entry.causedBy.seq}` + : undefined; + const turnId = + typeof entry.data?.turnId === "string" + ? entry.data.turnId + : undefined; + emitUokAuditEvent( + basePath, + buildAuditEnvelope({ + traceId: entry.flowId, + turnId, + causedBy, + category: "orchestration", + type: `journal-${entry.eventType}`, + payload: { + seq: entry.seq, + rule: entry.rule, + data: entry.data ?? {}, + }, + }), + ); + } catch { + // Best-effort: audit projection must never block journal writes. + } } // ─── Query ──────────────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index 85f3484bb..0bccfe52d 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -19,6 +19,8 @@ import { gsdRoot } from "./paths.js"; import { getAndClearSkills } from "./skill-telemetry.js"; import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; import { parseUnitId } from "./unit-id.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; +import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; // Re-export from shared — import directly from format-utils to avoid pulling // in the full barrel (mod.js → ui.js → @gsd/pi-tui) which breaks when loaded @@ -143,6 +145,9 @@ export function snapshotUnitMetrics( promptCharCount?: number; baselineCharCount?: number; autoSessionKey?: string; + traceId?: string; + turnId?: string; + causedBy?: string; }, ): UnitMetrics | null { if (!ledger) return null; @@ -235,6 +240,27 @@ export function snapshotUnitMetrics( } saveLedger(basePath, ledger); + if (isUnifiedAuditEnabled()) { + emitUokAuditEvent( + basePath, + buildAuditEnvelope({ + traceId: opts?.traceId ?? `metrics:${unitType}:${unitId}`, + turnId: opts?.turnId, + causedBy: opts?.causedBy, + category: "metrics", + type: "unit-metrics-snapshot", + payload: { + unitType, + unitId, + model, + tokens: unit.tokens, + cost: unit.cost, + toolCalls: unit.toolCalls, + }, + }), + ); + } + return unit; } diff --git a/src/resources/extensions/gsd/tests/uok-audit-unified.test.ts b/src/resources/extensions/gsd/tests/uok-audit-unified.test.ts new file mode 100644 index 000000000..884b88115 --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-audit-unified.test.ts @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { emitJournalEvent } from "../journal.ts"; +import { saveActivityLog } from "../activity-log.ts"; +import { initMetrics, resetMetrics, snapshotUnitMetrics } from "../metrics.ts"; +import { setLogBasePath, logWarning } from "../workflow-logger.ts"; +import { setUnifiedAuditEnabled } from "../uok/audit-toggle.ts"; + +function readAuditEvents(basePath: string): Array> { + const file = join(basePath, ".gsd", "audit", "events.jsonl"); + if (!existsSync(file)) return []; + const raw = readFileSync(file, "utf-8"); + return raw + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); +} + +function makeMockContext(entries: unknown[]): any { + return { + sessionManager: { + getEntries: () => entries, + }, + }; +} + +test("unified audit plane bridges journal/activity/metrics/workflow logger into audit envelope log", () => { + const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-")); + setUnifiedAuditEnabled(true); + try { + emitJournalEvent(basePath, { + ts: new Date().toISOString(), + flowId: "trace-123", + seq: 1, + eventType: "iteration-start", + data: { turnId: "turn-123", unitId: "M001/S01/T01" }, + }); + + const activityCtx = makeMockContext([ + { type: "message", message: { role: "assistant", content: [{ type: "text", text: "hello" }] } }, + ]); + const activityPath = saveActivityLog(activityCtx, basePath, "execute-task", "M001/S01/T01"); + assert.ok(activityPath); + + initMetrics(basePath); + const metricsCtx = makeMockContext([ + { + type: "message", + message: { + role: "assistant", + usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15, cost: 0.01 }, + content: [], + }, + }, + ]); + const unit = snapshotUnitMetrics( + metricsCtx, + "execute-task", + "M001/S01/T01", + Date.now() - 1000, + "openai/gpt-5.4", + { traceId: "trace-123", turnId: "turn-123" }, + ); + assert.ok(unit); + resetMetrics(); + + setLogBasePath(basePath); + logWarning("engine", "audit bridge check", { id: "turn-123" }); + + const events = readAuditEvents(basePath); + const types = new Set(events.map((event) => String(event.type ?? ""))); + assert.ok(types.has("journal-iteration-start")); + assert.ok(types.has("activity-log-saved")); + assert.ok(types.has("unit-metrics-snapshot")); + assert.ok(types.has("workflow-log-warn")); + } finally { + setUnifiedAuditEnabled(false); + resetMetrics(); + rmSync(basePath, { recursive: true, force: true }); + } +}); + +test("unified audit bridge is disabled when toggle is off", () => { + const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-off-")); + setUnifiedAuditEnabled(false); + try { + emitJournalEvent(basePath, { + ts: new Date().toISOString(), + flowId: "trace-off", + seq: 1, + eventType: "iteration-start", + }); + const events = readAuditEvents(basePath); + assert.equal(events.length, 0); + } finally { + rmSync(basePath, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/uok/audit-toggle.ts b/src/resources/extensions/gsd/uok/audit-toggle.ts new file mode 100644 index 000000000..688c5c53e --- /dev/null +++ b/src/resources/extensions/gsd/uok/audit-toggle.ts @@ -0,0 +1,9 @@ +const AUDIT_ENV_KEY = "GSD_UOK_AUDIT_UNIFIED"; + +export function setUnifiedAuditEnabled(enabled: boolean): void { + process.env[AUDIT_ENV_KEY] = enabled ? "1" : "0"; +} + +export function isUnifiedAuditEnabled(): boolean { + return process.env[AUDIT_ENV_KEY] === "1"; +} diff --git a/src/resources/extensions/gsd/uok/kernel.ts b/src/resources/extensions/gsd/uok/kernel.ts index 6c5d60f13..656c6db92 100644 --- a/src/resources/extensions/gsd/uok/kernel.ts +++ b/src/resources/extensions/gsd/uok/kernel.ts @@ -6,6 +6,7 @@ import type { AutoSession } from "../auto/session.js"; import type { LoopDeps } from "../auto/loop-deps.js"; import { gsdRoot } from "../paths.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; +import { setUnifiedAuditEnabled } from "./audit-toggle.js"; import { resolveUokFlags } from "./flags.js"; import { createTurnObserver } from "./loop-adapter.js"; @@ -39,6 +40,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise< const { ctx, pi, s, deps, runLegacyLoop } = args; const prefs = deps.loadEffectiveGSDPreferences()?.preferences; const flags = resolveUokFlags(prefs); + setUnifiedAuditEnabled(flags.auditUnified); writeParityEvent(s.basePath, { ts: new Date().toISOString(), diff --git a/src/resources/extensions/gsd/workflow-logger.ts b/src/resources/extensions/gsd/workflow-logger.ts index cdff396a3..996bed98b 100644 --- a/src/resources/extensions/gsd/workflow-logger.ts +++ b/src/resources/extensions/gsd/workflow-logger.ts @@ -20,6 +20,8 @@ import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { appendNotification } from "./notification-store.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; +import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; // ─── Types ────────────────────────────────────────────────────────────── @@ -275,6 +277,28 @@ function _push( _buffer.shift(); } + if (_auditBasePath && isUnifiedAuditEnabled()) { + try { + emitUokAuditEvent( + _auditBasePath, + buildAuditEnvelope({ + traceId: `workflow-log:${component}`, + turnId: context?.id, + causedBy: context?.fn ?? context?.tool, + category: "orchestration", + type: severity === "error" ? "workflow-log-error" : "workflow-log-warn", + payload: { + component, + message, + context: context ?? {}, + }, + }), + ); + } catch { + // Best-effort: unified audit projection must never block workflow logger. + } + } + // Persist errors to .gsd/audit-log.jsonl so they survive context resets. // Only error-severity entries are persisted — warnings are ephemeral (stderr + buffer) // to avoid log amplification from expected-control-flow catch paths. From 5a6a13eb399f7957a502ad1bd56d5da457b3f094 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:41:43 -0500 Subject: [PATCH 08/11] 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 { From f9f712098ddfe99e7972bb8d9a4d30143a46fed9 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:41:57 -0500 Subject: [PATCH 09/11] feat(gsd-uok): flip default to UOK with emergency legacy fallback --- .../gsd/docs/preferences-reference.md | 6 ++- .../extensions/gsd/preferences-types.ts | 3 ++ .../extensions/gsd/preferences-validation.ts | 4 +- src/resources/extensions/gsd/preferences.ts | 3 ++ .../extensions/gsd/templates/PREFERENCES.md | 4 +- .../extensions/gsd/tests/uok-flags.test.ts | 39 +++++++++++++++++++ .../gsd/tests/uok-preferences.test.ts | 2 + src/resources/extensions/gsd/uok/flags.ts | 13 ++++++- src/resources/extensions/gsd/uok/kernel.ts | 11 ++++-- 9 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/uok-flags.test.ts diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index 956819e4c..23830100f 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -191,8 +191,10 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `hooks`: boolean — enable routing hooks. Default: `true`. - `capability_routing`: boolean — enable capability-profile scoring for model selection within a tier. Requires `enabled: true`. Default: `false`. -- `uok`: Unified Orchestration Kernel controls (all flags default to `false` during migration). Keys: - - `enabled`: boolean — enable kernel wrappers and contract observers. +- `uok`: Unified Orchestration Kernel controls. Keys: + - `enabled`: boolean — enable kernel wrappers and contract observers. Default: `true`. + - `legacy_fallback.enabled`: boolean — emergency release fallback that forces legacy orchestration behavior even when `uok.enabled` is `true`. Default: `false`. + - Runtime override: set `GSD_UOK_FORCE_LEGACY=1` (or `GSD_UOK_LEGACY_FALLBACK=1`) to force legacy behavior for the current process. - `gates.enabled`: boolean — route checks through the unified gate runner and persist `gate_runs`. - `model_policy.enabled`: boolean — enforce policy filtering before model capability scoring. - `execution_graph.enabled`: boolean — enable DAG scheduler facade/adapters for execution. diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 3809f3d20..430c7fe85 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -213,6 +213,9 @@ export type UokTurnActionMode = "commit" | "snapshot" | "status-only"; export interface UokPreferences { enabled?: boolean; + legacy_fallback?: { + enabled?: boolean; + }; gates?: { enabled?: boolean; }; diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index 198bc97af..78faebf96 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -178,7 +178,7 @@ export function validatePreferences(preferences: GSDPreferences): { } const parseEnabledBlock = ( - key: "gates" | "model_policy" | "execution_graph" | "audit_unified" | "plan_v2", + key: "legacy_fallback" | "gates" | "model_policy" | "execution_graph" | "audit_unified" | "plan_v2", ): void => { const value = raw[key]; if (value === undefined) return; @@ -201,6 +201,7 @@ export function validatePreferences(preferences: GSDPreferences): { } }; + parseEnabledBlock("legacy_fallback"); parseEnabledBlock("gates"); parseEnabledBlock("model_policy"); parseEnabledBlock("execution_graph"); @@ -243,6 +244,7 @@ export function validatePreferences(preferences: GSDPreferences): { const knownUokKeys = new Set([ "enabled", + "legacy_fallback", "gates", "model_policy", "execution_graph", diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 845676390..414a5f0c8 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -383,6 +383,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr uok: (base.uok || override.uok) ? { enabled: override.uok?.enabled ?? base.uok?.enabled, + legacy_fallback: (base.uok?.legacy_fallback || override.uok?.legacy_fallback) + ? { ...(base.uok?.legacy_fallback ?? {}), ...(override.uok?.legacy_fallback ?? {}) } + : undefined, gates: (base.uok?.gates || override.uok?.gates) ? { ...(base.uok?.gates ?? {}), ...(override.uok?.gates ?? {}) } : undefined, diff --git a/src/resources/extensions/gsd/templates/PREFERENCES.md b/src/resources/extensions/gsd/templates/PREFERENCES.md index 5cbdf757f..9117bf24a 100644 --- a/src/resources/extensions/gsd/templates/PREFERENCES.md +++ b/src/resources/extensions/gsd/templates/PREFERENCES.md @@ -40,7 +40,9 @@ dynamic_routing: cross_provider: hooks: uok: - enabled: false + enabled: true + legacy_fallback: + enabled: false gates: enabled: false model_policy: diff --git a/src/resources/extensions/gsd/tests/uok-flags.test.ts b/src/resources/extensions/gsd/tests/uok-flags.test.ts new file mode 100644 index 000000000..d8ccb9f23 --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-flags.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveUokFlags } from "../uok/flags.ts"; + +test("uok flags default to enabled when preference is unset", () => { + const flags = resolveUokFlags(undefined); + assert.equal(flags.enabled, true); + assert.equal(flags.legacyFallback, false); +}); + +test("uok legacy fallback preference forces legacy path", () => { + const flags = resolveUokFlags({ + uok: { + enabled: true, + legacy_fallback: { enabled: true }, + }, + }); + assert.equal(flags.enabled, false); + assert.equal(flags.legacyFallback, true); +}); + +test("uok legacy fallback env var forces legacy path", () => { + const previous = process.env.GSD_UOK_FORCE_LEGACY; + process.env.GSD_UOK_FORCE_LEGACY = "1"; + try { + const flags = resolveUokFlags({ + uok: { + enabled: true, + }, + }); + assert.equal(flags.enabled, false); + assert.equal(flags.legacyFallback, true); + } finally { + if (previous === undefined) delete process.env.GSD_UOK_FORCE_LEGACY; + else process.env.GSD_UOK_FORCE_LEGACY = previous; + } +}); + diff --git a/src/resources/extensions/gsd/tests/uok-preferences.test.ts b/src/resources/extensions/gsd/tests/uok-preferences.test.ts index b13deeaec..31b141d46 100644 --- a/src/resources/extensions/gsd/tests/uok-preferences.test.ts +++ b/src/resources/extensions/gsd/tests/uok-preferences.test.ts @@ -7,6 +7,7 @@ test("uok preferences validate nested flags and turn_action", () => { const input = { uok: { enabled: true, + legacy_fallback: { enabled: false }, gates: { enabled: true }, model_policy: { enabled: true }, execution_graph: { enabled: false }, @@ -23,6 +24,7 @@ test("uok preferences validate nested flags and turn_action", () => { const result = validatePreferences(input as never); assert.equal(result.errors.length, 0); assert.equal(result.preferences.uok?.enabled, true); + assert.equal(result.preferences.uok?.legacy_fallback?.enabled, false); assert.equal(result.preferences.uok?.gitops?.turn_action, "status-only"); assert.equal(result.preferences.uok?.plan_v2?.enabled, true); }); diff --git a/src/resources/extensions/gsd/uok/flags.ts b/src/resources/extensions/gsd/uok/flags.ts index 24e1cd0c9..8eacf1dd7 100644 --- a/src/resources/extensions/gsd/uok/flags.ts +++ b/src/resources/extensions/gsd/uok/flags.ts @@ -3,6 +3,7 @@ import { loadEffectiveGSDPreferences } from "../preferences.js"; export interface UokFlags { enabled: boolean; + legacyFallback: boolean; gates: boolean; modelPolicy: boolean; executionGraph: boolean; @@ -13,10 +14,20 @@ export interface UokFlags { planV2: boolean; } +function envForcesLegacyFallback(): boolean { + const raw = process.env.GSD_UOK_FORCE_LEGACY ?? process.env.GSD_UOK_LEGACY_FALLBACK; + if (!raw) return false; + const normalized = raw.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +} + export function resolveUokFlags(prefs: GSDPreferences | undefined): UokFlags { const uok = prefs?.uok; + const legacyFallback = uok?.legacy_fallback?.enabled === true || envForcesLegacyFallback(); + const enabledByPreference = uok?.enabled ?? true; return { - enabled: uok?.enabled === true, + enabled: enabledByPreference && !legacyFallback, + legacyFallback, gates: uok?.gates?.enabled === true, modelPolicy: uok?.model_policy?.enabled === true, executionGraph: uok?.execution_graph?.enabled === true, diff --git a/src/resources/extensions/gsd/uok/kernel.ts b/src/resources/extensions/gsd/uok/kernel.ts index 656c6db92..69138d4bc 100644 --- a/src/resources/extensions/gsd/uok/kernel.ts +++ b/src/resources/extensions/gsd/uok/kernel.ts @@ -36,6 +36,11 @@ function writeParityEvent(basePath: string, event: Record): voi } } +function resolveKernelPathLabel(flags: ReturnType): "uok-wrapper" | "legacy-wrapper" | "legacy-fallback" { + if (flags.legacyFallback) return "legacy-fallback"; + return flags.enabled ? "uok-wrapper" : "legacy-wrapper"; +} + export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise { const { ctx, pi, s, deps, runLegacyLoop } = args; const prefs = deps.loadEffectiveGSDPreferences()?.preferences; @@ -44,7 +49,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise< writeParityEvent(s.basePath, { ts: new Date().toISOString(), - path: flags.enabled ? "uok-wrapper" : "legacy-wrapper", + path: resolveKernelPathLabel(flags), flags, phase: "enter", }); @@ -81,7 +86,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise< await runLegacyLoop(ctx, pi, s, decoratedDeps); writeParityEvent(s.basePath, { ts: new Date().toISOString(), - path: flags.enabled ? "uok-wrapper" : "legacy-wrapper", + path: resolveKernelPathLabel(flags), flags, phase: "exit", status: "ok", @@ -89,7 +94,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise< } catch (err) { writeParityEvent(s.basePath, { ts: new Date().toISOString(), - path: flags.enabled ? "uok-wrapper" : "legacy-wrapper", + path: resolveKernelPathLabel(flags), flags, phase: "exit", status: "error", From 249bf1119690753a76293cfc23f9af6981fe87ec Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:49:17 -0500 Subject: [PATCH 10/11] fix(gsd): restore autoCommit import after rebase conflict --- src/resources/extensions/gsd/auto-post-unit.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index cddb84120..3b59555da 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -113,6 +113,7 @@ import { import { existsSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { _resetHasChangesCache } from "./native-git-bridge.js"; +import { autoCommitCurrentBranch } from "./worktree.js"; // ─── Rogue File Detection ────────────────────────────────────────────────── From f9926996e51bc51cdfe71dcd516a548e87f6b2c0 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 20:58:29 -0500 Subject: [PATCH 11/11] fix(ci): harden graph fallback and update regression guards --- src/resources/extensions/gsd/graph-context.ts | 151 ++++++++++++++++-- .../gsd/tests/auto-project-root-env.test.ts | 10 +- .../gsd/tests/cold-resume-db-reopen.test.ts | 8 +- .../gsd/tests/finalize-timeout-guard.test.ts | 17 +- .../gsd/tests/post-unit-state-rebuild.test.ts | 3 +- .../extensions/gsd/tools/complete-slice.ts | 11 +- .../extensions/gsd/workflow-logger.ts | 3 +- 7 files changed, 175 insertions(+), 28 deletions(-) diff --git a/src/resources/extensions/gsd/graph-context.ts b/src/resources/extensions/gsd/graph-context.ts index 8e563d142..39eb3c4fe 100644 --- a/src/resources/extensions/gsd/graph-context.ts +++ b/src/resources/extensions/gsd/graph-context.ts @@ -7,13 +7,144 @@ */ import { logWarning } from "./workflow-logger.js"; -import type { GraphQueryResult, GraphStatusResult } from "@gsd-build/mcp-server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +interface GraphNode { + id: string; + label: string; + type: string; + confidence: string; + description?: string; +} + +interface GraphEdge { + from: string; + to: string; + type: string; +} + +interface GraphQueryResult { + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +interface GraphStatusResult { + exists: boolean; + stale: boolean; + ageHours?: number; +} + +interface GraphApi { + graphQuery: (projectDir: string, term: string, budget?: number) => Promise; + graphStatus: (projectDir: string) => Promise; +} + +interface GraphFileShape { + nodes: GraphNode[]; + edges: GraphEdge[]; + builtAt?: string; +} + +let cachedGraphApi: GraphApi | null = null; +let resolvedGraphApi = false; export interface GraphSubgraphOptions { /** Budget in tokens passed to graphQuery (1 node ≈ 20 tokens, 1 edge ≈ 10 tokens) */ budget: number; } +function readGraphFile(projectDir: string): GraphFileShape | null { + try { + const graphPath = join(projectDir, ".gsd", "graphs", "graph.json"); + const raw = readFileSync(graphPath, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + const nodes = Array.isArray(parsed.nodes) ? parsed.nodes : []; + const edges = Array.isArray(parsed.edges) ? parsed.edges : []; + return { nodes, edges, builtAt: typeof parsed.builtAt === "string" ? parsed.builtAt : undefined }; + } catch { + return null; + } +} + +async function fallbackGraphQuery(projectDir: string, term: string, budget = 3000): Promise { + const graph = readGraphFile(projectDir); + if (!graph) return { nodes: [], edges: [] }; + + const needle = term.trim().toLowerCase(); + const matches = graph.nodes.filter((node) => { + const hay = [node.id, node.label, node.description].filter(Boolean).join(" ").toLowerCase(); + return hay.includes(needle); + }); + + const maxNodes = Math.max(1, Math.floor(Math.max(1, budget) / 20)); + const selectedIds = new Set(matches.slice(0, maxNodes).map((node) => node.id)); + const nodeById = new Map(graph.nodes.map((node) => [node.id, node] as const)); + + // Pull one-hop neighbors so relation context survives even when the term + // matches only one side of an edge. + for (const edge of graph.edges) { + if (selectedIds.size >= maxNodes) break; + const touchesSelection = selectedIds.has(edge.from) || selectedIds.has(edge.to); + if (!touchesSelection) continue; + if (selectedIds.has(edge.from) && !selectedIds.has(edge.to) && nodeById.has(edge.to)) { + selectedIds.add(edge.to); + } else if (selectedIds.has(edge.to) && !selectedIds.has(edge.from) && nodeById.has(edge.from)) { + selectedIds.add(edge.from); + } + } + + const nodes = graph.nodes.filter((node) => selectedIds.has(node.id)); + + const remainingBudget = Math.max(0, budget - nodes.length * 20); + const maxEdges = Math.floor(remainingBudget / 10); + const edges = graph.edges + .filter((edge) => selectedIds.has(edge.from) && selectedIds.has(edge.to)) + .slice(0, maxEdges); + + return { nodes, edges }; +} + +async function fallbackGraphStatus(projectDir: string): Promise { + const graph = readGraphFile(projectDir); + if (!graph) return { exists: false, stale: false }; + if (!graph.builtAt) return { exists: true, stale: false }; + + const builtAtMs = Date.parse(graph.builtAt); + if (!Number.isFinite(builtAtMs)) return { exists: true, stale: false }; + + const ageHours = (Date.now() - builtAtMs) / (1000 * 60 * 60); + return { exists: true, stale: ageHours > 24, ageHours }; +} + +function isGraphApi(mod: unknown): mod is GraphApi { + if (!mod || typeof mod !== "object") return false; + const candidate = mod as Record; + return typeof candidate.graphQuery === "function" && typeof candidate.graphStatus === "function"; +} + +async function resolveGraphApi(): Promise { + if (resolvedGraphApi && cachedGraphApi) return cachedGraphApi; + + resolvedGraphApi = true; + try { + const imported = await import("@gsd-build/mcp-server"); + if (isGraphApi(imported)) { + cachedGraphApi = imported; + return cachedGraphApi; + } + logWarning("prompt", "@gsd-build/mcp-server graph exports unavailable; using local graph fallback"); + } catch { + // Fall back to local reader implementation. + } + + cachedGraphApi = { + graphQuery: fallbackGraphQuery, + graphStatus: fallbackGraphStatus, + }; + return cachedGraphApi; +} + /** * Query the knowledge graph for nodes related to the given term and format * the result as an inlined context block. @@ -33,18 +164,14 @@ export async function inlineGraphSubgraph( if (!term || !term.trim()) return null; try { - const { graphQuery, graphStatus } = await import("@gsd-build/mcp-server") as { - graphQuery: (projectDir: string, term: string, budget?: number) => Promise; - graphStatus: (projectDir: string) => Promise; - }; - - const result = await graphQuery(projectDir, term, opts.budget); + const graphApi = await resolveGraphApi(); + const result = await graphApi.graphQuery(projectDir, term, opts.budget); if (result.nodes.length === 0) return null; // Check staleness for annotation let staleAnnotation = ""; try { - const status = await graphStatus(projectDir); + const status = await graphApi.graphStatus(projectDir); if (status.exists && status.stale && status.ageHours !== undefined) { const hours = Math.round(status.ageHours); staleAnnotation = `\n> ⚠ Graph last built ${hours}h ago — context may be outdated`; @@ -54,14 +181,14 @@ export async function inlineGraphSubgraph( } // Format nodes as a compact list - const nodeLines = result.nodes.map((n) => { - const desc = n.description ? ` — ${n.description}` : ""; - return `- **${n.label}** (\`${n.type}\`, ${n.confidence})${desc}`; + const nodeLines = result.nodes.map((node) => { + const desc = node.description ? ` — ${node.description}` : ""; + return `- **${node.label}** (\`${node.type}\`, ${node.confidence})${desc}`; }); // Format edges as relations (only if present) const edgeLines = result.edges.length > 0 - ? result.edges.map((e) => `- \`${e.from}\` →[${e.type}]→ \`${e.to}\``) + ? result.edges.map((edge) => `- \`${edge.from}\` →[${edge.type}]→ \`${edge.to}\``) : []; const sections: string[] = [ diff --git a/src/resources/extensions/gsd/tests/auto-project-root-env.test.ts b/src/resources/extensions/gsd/tests/auto-project-root-env.test.ts index 98f6a11e2..703538645 100644 --- a/src/resources/extensions/gsd/tests/auto-project-root-env.test.ts +++ b/src/resources/extensions/gsd/tests/auto-project-root-env.test.ts @@ -13,11 +13,15 @@ test("auto-mode captures GSD_PROJECT_ROOT before entering the dispatch loop", () const resumeCallIdx = source.indexOf("captureProjectRootEnv(s.originalBasePath || s.basePath);"); assert.ok(resumeCallIdx > -1, "auto.ts should capture GSD_PROJECT_ROOT before resume autoLoop"); - const firstAutoLoopIdx = source.indexOf("await autoLoop(ctx, pi, s, buildLoopDeps());"); - assert.ok(firstAutoLoopIdx > -1, "auto.ts should invoke autoLoop()"); + const firstLoopIdxCandidates = [ + source.indexOf("await runAutoLoopWithUok({"), + source.indexOf("await autoLoop(ctx, pi, s, buildLoopDeps());"), + ].filter((idx) => idx > -1); + const firstAutoLoopIdx = firstLoopIdxCandidates.length > 0 ? Math.min(...firstLoopIdxCandidates) : -1; + assert.ok(firstAutoLoopIdx > -1, "auto.ts should invoke the auto dispatch loop"); assert.ok( resumeCallIdx < firstAutoLoopIdx, - "auto.ts must set GSD_PROJECT_ROOT before the first autoLoop() call", + "auto.ts must set GSD_PROJECT_ROOT before the first loop call", ); }); diff --git a/src/resources/extensions/gsd/tests/cold-resume-db-reopen.test.ts b/src/resources/extensions/gsd/tests/cold-resume-db-reopen.test.ts index 7f4806424..3b4b46fff 100644 --- a/src/resources/extensions/gsd/tests/cold-resume-db-reopen.test.ts +++ b/src/resources/extensions/gsd/tests/cold-resume-db-reopen.test.ts @@ -25,8 +25,12 @@ console.log("\n=== resume path refreshes resources and opens DB before rebuildSt const resumeSectionStart = autoSrc.indexOf("if (s.paused) {", autoSrc.indexOf("// If resuming from paused state")); assertTrue(resumeSectionStart > 0, "auto.ts has the paused-session resume block"); -const resumeSectionEnd = autoSrc.indexOf("await autoLoop(", resumeSectionStart); -assertTrue(resumeSectionEnd > resumeSectionStart, "resume block reaches autoLoop"); +const resumeSectionEndCandidates = [ + autoSrc.indexOf("await runAutoLoopWithUok(", resumeSectionStart), + autoSrc.indexOf("await autoLoop(", resumeSectionStart), +].filter((idx) => idx > resumeSectionStart); +const resumeSectionEnd = resumeSectionEndCandidates.length > 0 ? Math.min(...resumeSectionEndCandidates) : -1; +assertTrue(resumeSectionEnd > resumeSectionStart, "resume block reaches the dispatch loop"); const resumeSection = autoSrc.slice(resumeSectionStart, resumeSectionEnd); diff --git a/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts b/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts index d4eae33c1..8cb9ab972 100644 --- a/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts +++ b/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts @@ -26,6 +26,14 @@ import { MAX_FINALIZE_TIMEOUTS } from "../auto/types.ts"; const { assertTrue, assertEq, report } = createTestContext(); +function getRunFinalizeBody(phasesSource: string): string { + const fnIdx = phasesSource.indexOf("export async function runFinalize("); + assertTrue(fnIdx > 0, "runFinalize function should exist in phases.ts"); + + const nextExportIdx = phasesSource.indexOf("\nexport ", fnIdx + 1); + return phasesSource.slice(fnIdx, nextExportIdx > fnIdx ? nextExportIdx : undefined); +} + // ═══ Test: withTimeout resolves when inner promise resolves promptly ══════════ { @@ -145,11 +153,7 @@ const { assertTrue, assertEq, report } = createTestContext(); "utf-8", ); - // Find the runFinalize function body - const fnIdx = phasesSource.indexOf("export async function runFinalize("); - assertTrue(fnIdx > 0, "runFinalize function should exist in phases.ts"); - - const fnBody = phasesSource.slice(fnIdx, fnIdx + 8000); + const fnBody = getRunFinalizeBody(phasesSource); // postUnitPreVerification must be wrapped in withTimeout const preTimeoutIdx = fnBody.indexOf("withTimeout("); @@ -207,8 +211,7 @@ const { assertTrue, assertEq, report } = createTestContext(); "utf-8", ); - const fnIdx = phasesSource.indexOf("export async function runFinalize("); - const fnBody = phasesSource.slice(fnIdx, fnIdx + 8000); + const fnBody = getRunFinalizeBody(phasesSource); // Both timeout handlers should increment consecutiveFinalizeTimeouts const incrementCount = (fnBody.match(/consecutiveFinalizeTimeouts\+\+/g) || []).length; diff --git a/src/resources/extensions/gsd/tests/post-unit-state-rebuild.test.ts b/src/resources/extensions/gsd/tests/post-unit-state-rebuild.test.ts index 43e73388d..e469568a2 100644 --- a/src/resources/extensions/gsd/tests/post-unit-state-rebuild.test.ts +++ b/src/resources/extensions/gsd/tests/post-unit-state-rebuild.test.ts @@ -21,7 +21,8 @@ test("postUnitPreVerification rebuilds STATE.md before worktree sync", () => { const fnStart = source.indexOf("export async function postUnitPreVerification"); assert.ok(fnStart > 0, "postUnitPreVerification should exist"); - const section = source.slice(fnStart, fnStart + 8000); + const fnEnd = source.indexOf("export async function postUnitPostVerification", fnStart); + const section = source.slice(fnStart, fnEnd > fnStart ? fnEnd : undefined); const rebuildIdx = section.indexOf('await runSafely("postUnit", "state-rebuild"'); const syncIdx = section.indexOf('await runSafely("postUnit", "worktree-sync"'); diff --git a/src/resources/extensions/gsd/tools/complete-slice.ts b/src/resources/extensions/gsd/tools/complete-slice.ts index 99ec86996..7300ce91d 100644 --- a/src/resources/extensions/gsd/tools/complete-slice.ts +++ b/src/resources/extensions/gsd/tools/complete-slice.ts @@ -430,11 +430,18 @@ export async function handleCompleteSlice( // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { try { - const graphMod = await import("@gsd-build/mcp-server") as { + const graphMod = await import("@gsd-build/mcp-server") as unknown as Partial<{ buildGraph: (dir: string) => Promise<{ nodes: unknown[]; edges: unknown[]; builtAt: string }>; writeGraph: (gsdRoot: string, graph: unknown) => Promise; resolveGsdRoot: (basePath: string) => string; - }; + }>; + if ( + typeof graphMod.buildGraph !== "function" + || typeof graphMod.writeGraph !== "function" + || typeof graphMod.resolveGsdRoot !== "function" + ) { + throw new Error("graph helpers unavailable from @gsd-build/mcp-server"); + } const g = await graphMod.buildGraph(basePath); await graphMod.writeGraph(graphMod.resolveGsdRoot(basePath), g); } catch (graphErr) { diff --git a/src/resources/extensions/gsd/workflow-logger.ts b/src/resources/extensions/gsd/workflow-logger.ts index 996bed98b..ec9fb55bb 100644 --- a/src/resources/extensions/gsd/workflow-logger.ts +++ b/src/resources/extensions/gsd/workflow-logger.ts @@ -294,8 +294,9 @@ function _push( }, }), ); - } catch { + } catch (auditEmitErr) { // Best-effort: unified audit projection must never block workflow logger. + _writeStderr(`[gsd:workflow-logger] unified-audit emit failed: ${(auditEmitErr as Error).message}\n`); } }