From bb1b9dce07c82df79fbdcb6e7283b75cee5d89e2 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 14 Apr 2026 18:45:05 -0500 Subject: [PATCH] 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; +}