Merge pull request #4215 from gsd-build/fix/adr-009-rfc-and-build-fixes

fix(gsd): align ADR-009 integration with type-safe builds
This commit is contained in:
Jeremy McSpadden 2026-04-14 21:07:13 -05:00 committed by GitHub
commit c63f801412
74 changed files with 5028 additions and 111 deletions

View file

@ -0,0 +1,497 @@
# ADR-009 Implementation Plan
**Related ADR:** [ADR-009-orchestration-kernel-refactor.md](/Users/jeremymcspadden/Github/gsd-2/docs/dev/ADR-009-orchestration-kernel-refactor.md)
**Status:** Draft
**Date:** 2026-04-14
**Target Window:** 8-10 waves (incremental, no big-bang rewrite)
## Objective
Implement ADR-009 by migrating GSD orchestration internals to a Unified Orchestration Kernel (UOK) with six control planes:
1. Plan
2. Execution
3. Model
4. Gate
5. GitOps
6. Audit
without breaking existing CLI/web/MCP workflows.
The first production-safe outcome is:
- existing auto-mode behavior remains stable
- new kernel contracts exist behind feature flags
- every turn is traceable with deterministic gate outcomes
## Non-Goals
- Rewriting user-facing command surfaces
- Replacing all legacy modules in a single PR
- Introducing new provider auth flows that bypass existing compliance boundaries
- Forcing `burn-max` behavior as default
## Constraints
- Maintain current runtime compatibility and defaults
- Preserve existing state-on-disk and DB-backed transition model
- Keep provider-agnostic behavior while enforcing provider-specific policy constraints
- All migration steps must be reversible behind flags
- High-risk changes require parity tests against existing behavior
## Program Structure
Implementation is organized into parallel workstreams and executed in waves.
### Workstream A: Kernel Contracts and Orchestrator Spine
Goal: define typed contracts and a new orchestration spine without changing behavior.
Primary targets:
- `src/resources/extensions/gsd/auto.ts`
- `src/resources/extensions/gsd/auto/loop.ts`
- `src/resources/extensions/gsd/auto/types.ts`
- `src/resources/extensions/gsd/auto/session.ts`
Deliverables:
- `TurnContract` and `TurnResult` types
- `GateResult` envelope
- kernel entrypoint that wraps current dispatch loop via adapter
### Workstream B: Gate Plane
Goal: normalize all checks into a unified gate runner.
Primary targets:
- `src/resources/extensions/gsd/verification-gate.ts`
- `src/resources/extensions/gsd/auto-verification.ts`
- `src/resources/extensions/gsd/pre-execution-checks.ts`
- `src/resources/extensions/gsd/post-execution-checks.ts`
- `src/resources/extensions/gsd/milestone-validation-gates.ts`
Deliverables:
- unified gate registry and execution API
- deterministic failure classes and retry policies
- explicit terminal status persistence
### Workstream C: Model Plane + Policy Engine
Goal: enable any-model-any-phase through requirement-based selection plus policy filtering.
Primary targets:
- `src/resources/extensions/gsd/model-router.ts`
- `src/resources/extensions/gsd/auto-model-selection.ts`
- `src/resources/extensions/gsd/preferences-models.ts`
- `src/resources/extensions/gsd/model-cost-table.ts`
- `src/resources/extensions/gsd/custom-execution-policy.ts`
Deliverables:
- requirement vector builder for units
- policy filter before capability scoring
- new `burn-max` profile
- policy decision audit events
### Workstream D: Execution Graph (Agents/Subagents/Parallel/Teams)
Goal: move to one DAG scheduler contract.
Primary targets:
- `src/resources/extensions/gsd/reactive-graph.ts`
- `src/resources/extensions/gsd/slice-parallel-orchestrator.ts`
- `src/resources/extensions/gsd/parallel-orchestrator.ts`
- `src/resources/extensions/gsd/graph.ts`
- `src/resources/extensions/gsd/unit-runtime.ts`
Deliverables:
- typed node kinds (`unit`, `hook`, `subagent`, `team-worker`, `verification`, `reprocess`)
- shared dependency/conflict resolver
- scheduler adapter for current parallel and reactive paths
### Workstream E: GitOps Transaction Layer
Goal: guarantee git action and metadata record per turn.
Primary targets:
- `src/resources/extensions/gsd/git-service.ts`
- `src/resources/extensions/gsd/auto-post-unit.ts`
- `src/resources/extensions/gsd/auto-unit-closeout.ts`
- `src/resources/extensions/gsd/auto-worktree.ts`
Deliverables:
- `turn-start -> stage -> checkpoint -> publish -> record` transaction API
- configurable turn action mode (`commit|snapshot|status-only`)
- closeout gate integration for git failures
### Workstream F: Unified Audit Plane
Goal: unify journal/activity/metrics into a causal event model.
Primary targets:
- `src/resources/extensions/gsd/journal.ts`
- `src/resources/extensions/gsd/activity-log.ts`
- `src/resources/extensions/gsd/metrics.ts`
- `src/resources/extensions/gsd/workflow-logger.ts`
- `src/resources/extensions/gsd/gsd-db.ts`
Deliverables:
- common `AuditEventEnvelope`
- trace/turn IDs on all events
- append-only JSONL raw log + DB projection index
### Workstream G: Plan Plane v2
Goal: formal multi-round clarify/research/draft/compile flow.
Primary targets:
- `src/resources/extensions/gsd/guided-flow.ts`
- `src/resources/extensions/gsd/preparation.ts`
- `src/resources/extensions/gsd/auto/phases.ts`
- `src/resources/extensions/gsd/auto-prompts.ts`
- prompt templates under `src/resources/extensions/gsd/prompts/`
Deliverables:
- bounded multi-round question loop
- plan compile step producing executable unit graph
- plan gate fail-closed behavior
## Wave Plan (Execution Order)
## Wave 0: Baseline and Flag Scaffolding
Purpose: establish safe rollout controls and baseline telemetry.
Tasks:
- Add feature flags:
- `uok.enabled`
- `uok.gates.enabled`
- `uok.model_policy.enabled`
- `uok.execution_graph.enabled`
- `uok.gitops.enabled`
- `uok.audit_unified.enabled`
- `uok.plan_v2.enabled`
- Add no-op kernel wrapper around current auto loop
- Add baseline metrics for parity comparison
Exit criteria:
- zero behavior change with all flags off
- parity telemetry collected for existing loop
Verification:
- `npm run typecheck:extensions`
- `npm run test:unit`
## Wave 1: Contract Extraction
Purpose: create stable internal API boundaries.
Tasks:
- Introduce:
- `TurnContract`
- `UnitExecutionContext`
- `GateResult`
- `FailureClass`
- `TurnCloseoutRecord`
- Adapter layer from legacy auto loop into contracts
- Add contract fixtures and serialization tests
Exit criteria:
- current auto dispatch runs through adapter path without behavior change
- all turn outcomes represented in structured result type
Verification:
- targeted tests in `src/resources/extensions/gsd/tests/*auto*`
- `npm run test:unit`
## Wave 2: Gate Plane Unification
Purpose: centralize pre/in/post checks and retries.
Tasks:
- Build `gate-runner` and gate registry
- Port existing checks into registered gates:
- policy/input/execution/artifact/verification/closeout
- Implement deterministic retry matrix by failure class
Exit criteria:
- every unit passes through gate runner
- explicit gate result persisted for pass/fail/retry/manual-attention
Verification:
- extend `verification-gate.test.ts`
- extend `validation-gate-patterns.test.ts`
- add integration tests for retry escalation
## Wave 3: Model Plane + Policy Filter
Purpose: enable requirement-based selection constrained by policy.
Tasks:
- Add requirement extraction from unit metadata
- Insert policy filter before model scoring
- Add `burn-max` token profile wiring
- Emit model policy allow/deny events
Exit criteria:
- units can select any eligible model across phases
- policy-denied routes fail before dispatch
- fallback chains remain deterministic
Verification:
- extend `model-cost-table.test.ts`
- extend model routing tests (`interactive-routing-bypass`, `tool-compatibility`, related router suites)
- add policy denial regression tests
## Wave 4: Execution Graph Scheduler
Purpose: unify hooks/subagents/parallel/team work under one scheduler contract.
Tasks:
- Introduce graph scheduler facade
- Map reactive execution nodes to shared node model
- Map slice/milestone parallel orchestrators onto scheduler
- Add file IO conflict lock integration
Exit criteria:
- same task set can execute in deterministic single-worker or parallel graph mode
- no deadlock under known reactive/parallel fixtures
Verification:
- `slice-parallel-orchestrator.test.ts`
- `slice-parallel-conflict.test.ts`
- `sidecar-queue.test.ts`
- integration: `src/resources/extensions/gsd/tests/integration/*.test.ts`
## Wave 5: GitOps Transactions Per Turn
Purpose: enforce turn-level git actions and closeout discipline.
Tasks:
- Implement turn transaction API
- Wire turn transactions into auto closeout path
- Add configurable `turn_action` and `turn_push` semantics
- Persist git transaction metadata into audit stream
Exit criteria:
- each turn has a git transaction record
- blocked git states surface as closeout gate failures
Verification:
- `git-service` integration tests
- worktree-related integration suites
- closeout and merge regression suites
## Wave 6: Unified Audit Plane
Purpose: converge logging/metrics/journal into one causal model.
Tasks:
- Define `AuditEventEnvelope` schema
- Add `traceId`, `turnId`, `causedBy` to event emitters
- Write projection pipeline into DB index tables
- Maintain append-only raw JSONL logs
Exit criteria:
- action-level traceability across model/tool/git/gate/test events
- legacy readers remain functional through compatibility projection
Verification:
- `workflow-logger*.test.ts`
- `workflow-events.test.ts`
- `journal` and `metrics` regression tests
## Wave 7: Plan Plane v2
Purpose: deliver full multi-round planning and compile-to-unit graph.
Tasks:
- Implement bounded clarify rounds
- Add explicit research synthesis stage
- Add plan compile stage with dependency graph output
- Add plan gate with fail-closed checks
Exit criteria:
- full roadmap and unit graph produced before execution begins (when enabled)
- invalid plans cannot proceed to execution
Verification:
- prompt and plan parsing tests
- planning tool tests (`plan-milestone`, `plan-slice`, `plan-task`)
- discuss/guided flow regression tests
## Wave 8: Legacy Branch Retirement + Default Flip
Purpose: reduce maintenance burden and enable UOK as default.
Tasks:
- remove superseded code paths in `auto.ts`, `auto-phases`, and legacy closeout paths
- keep legacy fallback behind emergency flag for one release window
- update docs and preferences reference
Exit criteria:
- UOK default in stable channel
- no critical parity regressions in one full release cycle
Verification:
- full `npm test`
- smoke + integration suites
- targeted manual UAT for CLI/web/headless
## Testing and Validation Matrix
### 1. Unit
- contract serialization
- gate runner behavior by failure class
- model policy filter decisions
- git transaction state machine
- event envelope schema validation
### 2. Integration
- auto dispatch across plan/execute/complete/reassess/uat
- worktree/branch/none isolation behaviors
- parallel and reactive execution parity
- policy-denied dispatch fast-fail
### 3. End-to-End
- greenfield milestone from discuss -> plan -> execute -> complete -> merge
- failure reprocessing (test failure, tool failure, model failure)
- full audit trace reconstruction by `traceId`
- provider compliance scenarios (allowed vs denied paths)
### 4. Parity Harness
- replay selected historical workflows against legacy and UOK paths
- compare:
- state transitions
- produced artifacts
- gate decisions
- commit outcomes
## Rollout Strategy
### Stages
1. Internal dogfood with flags on
2. Beta cohort opt-in via project preference
3. General availability with flags default-on
4. Legacy fallback removed after stability window
### Safety Controls
- runtime kill-switch for each plane
- release-note explicit migration warnings
- auto-rollback trigger on critical regressions (gates, git integrity, state corruption)
## Data and Schema Changes
Expected schema additions:
- audit projection tables in `gsd.db`
- gate result persistence tables
- turn transaction metadata
Rules:
- additive migrations only until Wave 8
- keep backwards-compatible readers during migration window
## Dependencies
1. Stable contract definitions before gate/model/scheduler rewires
2. Gate plane before gitops hard enforcement
3. Model policy engine before enabling any-model-any-phase by default
4. Audit envelope before legacy logger removal
5. Plan v2 before enforcing front-loaded planning defaults
## Risk Register
### Risk 1: Hidden Coupling in Auto Loop
Impact: migration bugs due to implicit side effects.
Mitigation: adapter-first extraction and parity harness before path switch.
### Risk 2: Parallel Deadlocks
Impact: blocked runs or inconsistent state.
Mitigation: graph-level deadlock checks, IO lock tests, staged rollout behind flags.
### Risk 3: Git Noise / Team Workflow Friction
Impact: commit churn and review overhead.
Mitigation: milestone squash defaults and configurable turn transaction modes.
### Risk 4: Policy Drift Across Providers
Impact: compliance regressions.
Mitigation: provider policy registry tests and release checklist gates.
### Risk 5: Telemetry Volume Growth
Impact: storage/perf pressure in long-running projects.
Mitigation: append-only raw + indexed projection + retention policies.
## Definition of Done (ADR-009)
ADR-009 is complete when all are true:
1. UOK path is default and stable.
2. All units execute through unified gate runner.
3. Model selection supports any eligible model in any phase with policy enforcement.
4. Hooks/agents/subagents/parallel/team execution runs through one scheduler contract.
5. Turn-level git transaction record exists for every executed turn.
6. Unified audit events provide causal traceability across orchestration, model, tool, git, and test actions.
7. Plan v2 can produce a complete unit graph with fail-closed plan gate.
8. `burn-max` profile is available and policy-safe.
9. Legacy orchestration branches are retired or behind emergency-only fallback.
10. CLI/web/headless behavior remains user-compatible.
## Recommended Immediate Next Tasks (Week 1)
1. Add Wave 0 feature flags and default-off wiring.
2. Introduce contract types and adapter shell (Wave 1 scaffolding).
3. Add parity telemetry capture for legacy loop baseline.
4. Land initial tests for contract serialization and turn result envelopes.

View file

@ -0,0 +1,401 @@
# ADR-009: Unified Orchestration Kernel Refactor
**Status:** Proposed
**Date:** 2026-04-14
**Deciders:** Jeremy McSpadden, GSD Core Team
**Related:** ADR-001 (worktree architecture), ADR-003 (pipeline simplification), ADR-004 (capability-aware routing), ADR-005 (multi-provider strategy), ADR-008 (tools over MCP)
## Context
GSD already ships many advanced features:
- dynamic model routing and multi-provider support
- hooks (`pre_dispatch_hooks`, `post_unit_hooks`)
- subagents and parallel execution
- worktree/branch isolation and automated git flows
- per-unit metrics and cost ledgers
- activity logs and structured journal events
- verification retries and failure recovery
The current limitation is not missing capability. The limitation is **distribution of control logic across large, mixed-concern modules**, especially in auto-mode and related orchestration files. This raises change risk, creates duplicated policy paths, and slows the introduction of stronger guarantees.
The target requirements for the next architecture are:
1. User can use any available model during any phase.
2. First-class hooks, agents, sub-agents, team execution, and parallel workflows.
3. Git actions on every turn with deterministic, auditable behavior.
4. Logging of every action with causal traceability.
5. Long upfront planning via multi-round questioning and research.
6. Plan slicing and controlled dispatch through strict gate validation.
7. Deterministic failure reprocessing loops.
8. Automatic testing during build and gate transitions.
9. Explicit token usage controls including a high-burn mode.
10. Enforced compliance with provider/model terms of service.
## Decision
Refactor GSD into a **Unified Orchestration Kernel (UOK)** with explicit control planes, typed contracts, and an incremental strangler migration. This is a staged architectural replacement of orchestration internals, not a rewrite of user-facing CLI/web/MCP surfaces.
### Core Architectural Model
The orchestrator is split into six control planes:
1. **Plan Plane**
2. **Execution Plane**
3. **Model Plane**
4. **Gate Plane**
5. **GitOps Plane**
6. **Audit Plane**
Each dispatched unit (turn) executes through a single deterministic pipeline:
```text
Discover/Clarify/Research -> Plan Compile -> Model Select -> Execute -> Validate -> Git Transaction -> Persist Audit -> Next Unit
```
## Detailed Design
### 1) Plan Plane: Multi-Round Front-Loaded Planning
Add a formal planning lifecycle:
1. `discover`: codebase and state scan
2. `clarify`: multi-round user questions (bounded rounds, explicit stop condition)
3. `research`: internal and external synthesis
4. `draft-plan`: produce full roadmap and milestones
5. `compile`: slice into executable units with IO boundaries
6. `plan-gate`: reject/repair invalid plans before execution starts
Required outputs:
- `ROADMAP.md` (complete)
- per-milestone slice graph
- per-task executable unit specs
- requirement trace matrix (requirement -> unit(s) -> verification)
- plan risk register
Plan gate fails closed if:
- missing acceptance criteria
- missing verification strategy
- cyclic task dependencies
- unowned artifacts
- missing rollback/recovery semantics for risky units
### 2) Execution Plane: Agents, Sub-Agents, Teams, Parallel
Unify all execution into a typed DAG scheduler.
Node kinds:
- `unit` (single execution task)
- `hook`
- `subagent`
- `team-worker`
- `verification`
- `reprocess`
Edges express:
- hard dependencies
- resource conflicts (file-level IO locks)
- ordering constraints (gate-before-merge, test-before-closeout)
Execution modes:
- single-worker deterministic mode
- multi-worker parallel mode
- team mode (shared repo, unique milestone IDs, gated merge)
This removes ad-hoc parallel behavior and makes sub-agent and team paths first-class scheduler decisions.
### 3) Model Plane: Any Model in Any Phase
Replace rigid phase->model assumptions with **requirement-based eligibility**.
Selection pipeline:
1. gather phase/unit requirements (capabilities, context size, latency profile)
2. gather eligible models from configured providers
3. apply hard policy filters (provider auth, TOS, tool compatibility, org rules)
4. apply soft scoring (capability vectors, budget profile, historical outcomes)
5. choose primary + fallback chain
Rules:
- Any model can run any phase if it passes policy and capability constraints.
- User pins remain hard ceilings only when configured explicitly.
- Unknown models are allowed with conservative default capability scores.
Add model intent profiles:
- `economy` (lowest cost)
- `balanced`
- `quality`
- `burn-max` (highest compute/token burn within policy and budget limits)
### 4) Gate Plane: Controlled Dispatch and Reprocessing
All units pass explicit gates:
1. `policy-gate` (provider/tool/TOS/security checks)
2. `input-gate` (unit contract completeness, artifact readiness)
3. `execution-gate` (runtime guardrails, timeout strategy, tool allowlist)
4. `artifact-gate` (expected outputs and format validation)
5. `verification-gate` (lint/test/typecheck/security checks)
6. `closeout-gate` (state transition safety + git transaction outcome)
Gate outcomes:
- `pass`
- `retryable-fail`
- `hard-fail`
- `manual-attention`
Failure reprocessing matrix (deterministic):
- code failure -> targeted fix prompt + bounded retry
- test failure -> impacted test fix loop
- tool failure -> alternate tool/provider fallback
- model failure -> fallback model chain
- policy failure -> immediate hard stop and explicit reason
Retry policy:
- bounded attempts per gate
- escalating strategy per attempt
- terminal state persisted with full evidence
### 5) GitOps Plane: Git Action Every Turn
Every dispatched unit is wrapped in a git transaction:
1. `turn-start`: capture branch/worktree status and dirty-state snapshot
2. `turn-exec`: run unit
3. `turn-stage`: stage relevant changes
4. `turn-checkpoint`: commit checkpoint or structured no-op record
5. `turn-publish`: optional push per policy
6. `turn-record`: write commit metadata into audit ledger
Defaults:
- checkpoint commit each turn in milestone branch/worktree
- squash on milestone merge to keep main history clean
Configurable strictness:
- `git.turn_action: commit|snapshot|status-only`
- `git.turn_push: never|milestone|always`
If a repo state blocks commit (e.g., conflicts), turn fails at closeout gate with explicit diagnostics.
### 6) Audit Plane: Log Every Action
Promote current activity/journal into a single causal event model.
Event classes:
- orchestrator (`dispatch`, `gate-result`, `state-transition`)
- model (`selection`, `fallback`, `provider-switch`)
- tool (`call`, `result`, `error`)
- git (`status`, `stage`, `commit`, `merge`, `push`)
- test (`command`, `result`, `retry`)
- policy (`allow`, `deny`, `warning`)
- cost (`tokens`, `cost`, `cache-hit`, `budget-pressure`)
Every event includes:
- `eventId`
- `traceId` (session)
- `turnId` (unit)
- `causedBy` reference
- timestamp
- durable payload
Storage:
- append-only JSONL + indexed SQLite projection for queryability
- no destructive rewrites of source audit logs
## Compliance and TOS Enforcement
Introduce a provider policy engine as a hard dependency of the policy gate.
Provider policy definition includes:
- allowed auth modes
- prohibited token exchange paths
- tool/protocol constraints
- subscription vs API usage boundaries
- model-specific restrictions
Enforcement rules:
- deny disallowed auth/routing before dispatch
- deny model selection if provider constraints are not met
- emit policy evidence events on every allow/deny decision
This formalizes current compliance work (notably Anthropic/Claude Code boundaries) into a reusable engine rather than scattered checks.
## Automatic Testing Strategy
Testing becomes mandatory at three levels:
1. **Per-turn**: impacted tests + lint/typecheck subset
2. **Per-slice closeout**: full slice verification profile
3. **Per-milestone closeout**: full suite (or policy-defined release profile)
Verification commands become declarative policies by unit type, not ad-hoc shell lists only.
## Token Strategy and Burn-Max Mode
Existing token optimization modes remain, plus explicit high-burn profile.
`burn-max` behavior:
- maximize context inclusion
- prefer high-capability models
- enable deeper critique/review passes
- increase planning/research depth
Hard limits still apply:
- budget ceiling and enforcement rules
- provider rate limits
- TOS/policy constraints
The system must never bypass provider restrictions to increase usage.
## Migration Plan (Strangler Refactor)
No big-bang rewrite. Migrate in waves with compatibility adapters.
### Wave 0: Contracts and Telemetry Baseline
- define turn contract and gate result schemas
- add trace IDs/turn IDs to current paths
- keep behavior unchanged
### Wave 1: Gate Plane Extraction
- extract gate runner from auto loop
- route existing checks through unified gate API
### Wave 2: Model Plane Unification
- requirement-based model selection
- policy filter insertion before scoring
- preserve existing model config semantics
### Wave 3: Scheduler and Execution Graph
- introduce DAG scheduler
- map existing subagent/parallel features to graph nodes
- enable graph mode behind flag
### Wave 4: GitOps Transaction Layer
- enforce turn-level git actions
- add deterministic checkpoint behavior
### Wave 5: Audit Plane Consolidation
- unify journal/activity/metrics events under common envelope
- add query projection
### Wave 6: Plan Plane v2
- multi-round clarify/research planner
- compiled unit graph + plan gate
### Wave 7: Legacy Path Retirement
- remove obsolete branches in `auto.ts` and related modules
- keep CLI/API compatibility
## Module Extraction Targets
Primary decomposition targets:
- `auto.ts` -> orchestrator kernel + adapters
- `auto-prompts.ts` -> plan compiler + prompt renderers
- `state.ts` -> state query service + immutable state views
- `gsd-db.ts` -> data access layer + event projection store
- `auto-post-unit.ts` / `auto-verification.ts` -> closeout gate services
## Acceptance Criteria
The refactor is accepted when all conditions are true:
1. Any configured model can be selected in any phase when policy permits.
2. Hooks, agents, sub-agents, teams, and parallel all execute under one scheduler contract.
3. Every turn produces at least one git action record and auditable turn closeout.
4. Every dispatch and action is traceable by `traceId` and `turnId`.
5. Multi-round planning produces a full executable unit graph before execution.
6. Gate outcomes are explicit, deterministic, and persisted.
7. Failure reprocessing uses typed failure classes, not generic retries.
8. Automatic tests run per policy on every turn/slice/milestone gate.
9. Token usage is tracked at turn granularity with burn-max profile support.
10. Policy engine blocks TOS-violating routes and records evidence.
## Consequences
### Positive
- Stronger reliability through fail-closed gates
- Faster feature delivery by isolating orchestration concerns
- Clear compliance and audit posture
- Better debuggability from causal event logs
- Controlled support for aggressive high-burn workflows
### Negative
- Significant migration effort across core modules
- More configuration surface area
- Temporary complexity during dual-path migration
### Neutral
- Existing user commands and workflows remain stable during migration
- Existing preferences remain supported with compatibility adapters
## Alternatives Considered
### A) Full rewrite in a new codebase
Rejected. Too risky for a live project with broad surface area and active releases.
### B) Continue incremental patching without architecture split
Rejected. Slows delivery and increases regression risk as orchestration complexity grows.
### C) Keep existing optimization-first token model only
Rejected. Does not satisfy explicit requirement for intentional high-burn workflows.
## Risks and Mitigations
1. **Migration regressions**
- Mitigation: golden-path replay tests and shadow mode comparisons per wave.
2. **Audit log volume growth**
- Mitigation: append-only raw logs plus indexed projections and retention policies.
3. **Git noise from per-turn commits**
- Mitigation: milestone squash merge defaults and configurable checkpoint modes.
4. **Provider policy drift**
- Mitigation: versioned provider policy registry with test fixtures per provider.
## Open Questions
1. Should `turn_action: commit` be mandatory default for all modes or only auto-mode?
2. Should `burn-max` be opt-in global, project-scoped, or both?
3. Should policy violations always halt or allow configurable warn-only mode for local development?
## Implementation Note
This ADR intentionally aligns with current architecture principles:
- extension-first where practical
- strong test contracts
- pragmatic incremental rollout
- provider-agnostic execution with explicit policy constraints

View file

@ -0,0 +1,69 @@
declare module "proper-lockfile" {
export interface RetryOptions {
retries?: number;
factor?: number;
minTimeout?: number;
maxTimeout?: number;
randomize?: boolean;
}
export interface LockOptions {
realpath?: boolean;
retries?: number | RetryOptions;
stale?: number;
onCompromised?: (err: Error) => void;
}
export type ReleaseSync = () => void;
export type ReleaseAsync = () => Promise<void>;
export interface ProperLockfileApi {
lockSync(path: string, options?: LockOptions): ReleaseSync;
lock(path: string, options?: LockOptions): Promise<ReleaseAsync>;
}
const lockfile: ProperLockfileApi;
export default lockfile;
}
declare module "sql.js" {
export interface Statement {
bind(values: (string | number | null | Uint8Array)[]): void;
step(): boolean;
getAsObject(): Record<string, unknown>;
free(): void;
}
export interface Database {
run(sql: string, params?: unknown[]): void;
prepare(sql: string): Statement;
export(): Uint8Array;
close(): void;
}
export interface SqlJsStatic {
Database: new (data?: Uint8Array | ArrayBuffer | Buffer) => Database;
}
export interface SqlJsConfig {
locateFile?: (file: string) => string;
}
export default function initSqlJs(config?: SqlJsConfig): Promise<SqlJsStatic>;
}
declare module "hosted-git-info" {
export interface HostedGitInfo {
domain?: string;
user?: string;
project?: string;
committish?: string;
}
export interface HostedGitInfoApi {
fromUrl(url: string): HostedGitInfo | undefined;
}
const hostedGitInfo: HostedGitInfoApi;
export default hostedGitInfo;
}

View file

@ -23,6 +23,6 @@
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["node_modules", "dist"]
}

View file

@ -16,6 +16,8 @@ import { GSDError, GSD_IO_ERROR } from "./errors.js";
const SEQ_PREFIX_RE = /^(\d+)-/;
import type { ExtensionContext } from "@gsd/pi-coding-agent";
import { gsdRoot } from "./paths.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js";
interface ActivityLogState {
nextSeq: number;
@ -132,6 +134,25 @@ export function saveActivityLog(
}
state.nextSeq += 1;
state.lastSnapshotKeyByUnit.set(unitKey, key);
if (isUnifiedAuditEnabled()) {
emitUokAuditEvent(
basePath,
buildAuditEnvelope({
traceId: `activity:${unitType}:${unitId}`,
turnId: unitId,
category: "execution",
type: "activity-log-saved",
payload: {
unitType,
unitId,
filePath,
entryCount: entries.length,
},
}),
);
}
return filePath;
} catch (e) {
// Don't let logging failures break auto-mode

View file

@ -53,6 +53,8 @@ import {
checkNeedsRunUat,
} from "./auto-prompts.js";
import { resolveModelWithFallbacksForUnit } from "./preferences-models.js";
import { resolveUokFlags } from "./uok/flags.js";
import { selectReactiveDispatchBatch } from "./uok/execution-graph.js";
// ─── Types ────────────────────────────────────────────────────────────────
@ -584,12 +586,20 @@ export const DISPATCH_RULES: DispatchRule[] = [
// Only activate reactive dispatch when >1 task is ready
if (readyIds.length <= 1) return null;
const selected = chooseNonConflictingSubset(
readyIds,
graph,
maxParallel,
new Set(),
);
const uokFlags = resolveUokFlags(prefs);
const selected = uokFlags.executionGraph
? selectReactiveDispatchBatch({
graph,
readyIds,
maxParallel,
inFlightOutputs: new Set(),
}).selected
: chooseNonConflictingSubset(
readyIds,
graph,
maxParallel,
new Set(),
);
if (selected.length <= 1) return null;
// Log graph metrics for observability

View file

@ -10,12 +10,14 @@ import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
import type { GSDPreferences } from "./preferences.js";
import { resolveModelWithFallbacksForUnit, resolveDynamicRoutingConfig } from "./preferences.js";
import type { ComplexityTier } from "./complexity-classifier.js";
import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js";
import { classifyUnitComplexity, extractTaskMetadata, tierLabel } from "./complexity-classifier.js";
import { resolveModelForComplexity, escalateTier, getEligibleModels, loadCapabilityOverrides, adjustToolSet, filterToolsForProvider } from "./model-router.js";
import { getLedger, getProjectTotals } from "./metrics.js";
import { unitPhaseLabel } from "./auto-dashboard.js";
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<ModelSelectionResult> {
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<string> | null = null;
// ─── Dynamic Model Routing ─────────────────────────────────────────
// Dynamic routing (complexity-based downgrading) only applies in auto-mode.
@ -106,8 +112,40 @@ 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;
const taskMetadataForPolicy = unitType === "execute-task"
? extractTaskMetadata(unitId, basePath)
: undefined;
if (uokFlags.modelPolicy) {
const policy = applyModelPolicyFilter(
availableModels,
{
basePath,
traceId: modelPolicyTraceId,
turnId: modelPolicyTurnId,
unitType,
taskMetadata: taskMetadataForPolicy,
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 +153,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)) {
@ -149,8 +187,14 @@ export async function selectAndApplyModel(
const shouldClassify = !isHook || routingConfig.hooks !== false;
if (shouldClassify) {
let classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
const availableModelIds = availableModels.map(m => m.id);
let classification = classifyUnitComplexity(
unitType,
unitId,
basePath,
budgetPct,
taskMetadataForPolicy,
);
const availableModelIds = routingEligibleModels.map(m => m.id);
// Escalate tier on retry when escalate_on_failure is enabled (default: true)
if (
@ -257,15 +301,28 @@ 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);
const resolutionPool = uokFlags.modelPolicy ? routingEligibleModels : availableModels;
const model = resolveModelId(modelId, resolutionPool, ctx.model?.provider);
if (!model) {
if (verbose) ctx.ui.notify(`Model ${modelId} not found, trying fallback.`, "info");
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 +388,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).

View file

@ -29,9 +29,10 @@ import { rebuildState } from "./doctor.js";
import { parseUnitId } from "./unit-id.js";
import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js";
import {
autoCommitCurrentBranch,
runTurnGitAction,
type TaskCommitContext,
} from "./worktree.js";
type TurnGitActionMode,
} from "./git-service.js";
import {
verifyExpectedArtifact,
resolveExpectedArtifactPath,
@ -66,6 +67,9 @@ import { getSliceTasks } from "./gsd-db.js";
import { runPreExecutionChecks, type PreExecutionResult } from "./pre-execution-checks.js";
import { writePreExecutionEvidence } from "./verification-evidence.js";
import { ensureCodebaseMapFresh } from "./codebase-generator.js";
import { resolveUokFlags } from "./uok/flags.js";
import { UokGateRunner } from "./uok/gate-runner.js";
import { writeTurnGitTransaction } from "./uok/gitops.js";
/** Maximum verification retry attempts before escalating to blocker placeholder (#2653). */
const MAX_VERIFICATION_RETRIES = 3;
@ -109,6 +113,7 @@ import {
import { existsSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { _resetHasChangesCache } from "./native-git-bridge.js";
import { autoCommitCurrentBranch } from "./worktree.js";
// ─── Rogue File Detection ──────────────────────────────────────────────────
@ -357,10 +362,161 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
await new Promise(r => setTimeout(r, 100));
}
// Auto-commit
const prefs = loadEffectiveGSDPreferences()?.preferences;
const uokFlags = resolveUokFlags(prefs);
// Turn-level git action (commit | snapshot | status-only)
if (s.currentUnit) {
const unit = s.currentUnit;
await autoCommitUnit(s.basePath, unit.type, unit.id, ctx);
const turnAction: TurnGitActionMode = uokFlags.gitops ? uokFlags.gitopsTurnAction : "commit";
const traceId = s.currentTraceId ?? `turn:${unit.startedAt}`;
const turnId = s.currentTurnId ?? `${unit.type}/${unit.id}/${unit.startedAt}`;
s.lastGitActionFailure = null;
s.lastGitActionStatus = null;
try {
let taskContext: TaskCommitContext | undefined;
if (turnAction === "commit" && s.currentUnit.type === "execute-task") {
const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
if (mid && sid && tid) {
const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY");
if (summaryPath) {
try {
const summaryContent = await loadFile(summaryPath);
if (summaryContent) {
const summary = parseSummary(summaryContent);
// Look up GitHub issue number for commit linking
let ghIssueNumber: number | undefined;
try {
const { getTaskIssueNumberForCommit } = await import("../github-sync/sync.js");
ghIssueNumber = getTaskIssueNumberForCommit(s.basePath, mid, sid, tid) ?? undefined;
} catch (err) {
// GitHub sync not available — skip
logWarning("engine", `GitHub issue lookup failed: ${err instanceof Error ? err.message : String(err)}`);
}
taskContext = {
taskId: `${sid}/${tid}`,
taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid,
oneLiner: summary.oneLiner || undefined,
keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined,
issueNumber: ghIssueNumber,
};
}
} catch (e) {
debugLog("postUnit", { phase: "task-summary-parse", error: String(e) });
}
}
}
}
// Invalidate the nativeHasChanges cache before auto-commit (#1853).
// The cache has a 10-second TTL and is keyed by basePath. A stale
// `false` result causes autoCommit to skip staging entirely, leaving
// code files only in the working tree where they are destroyed by
// `git worktree remove --force` during teardown.
_resetHasChangesCache();
const skipLifecycleCommit =
turnAction === "commit" && LIFECYCLE_ONLY_UNITS.has(s.currentUnit.type);
if (skipLifecycleCommit) {
debugLog("postUnit", {
phase: "git-action-skipped",
reason: "lifecycle-only-unit",
unitType: s.currentUnit.type,
unitId: s.currentUnit.id,
});
} else {
const gitResult = runTurnGitAction({
basePath: s.basePath,
action: turnAction,
unitType: s.currentUnit.type,
unitId: s.currentUnit.id,
taskContext,
});
if (uokFlags.gitops) {
writeTurnGitTransaction({
basePath: s.basePath,
traceId,
turnId,
unitType: unit.type,
unitId: unit.id,
stage: "publish",
action: turnAction,
push: uokFlags.gitopsTurnPush,
status: gitResult.status,
error: gitResult.error,
metadata: {
dirty: gitResult.dirty,
commitMessage: gitResult.commitMessage,
snapshotLabel: gitResult.snapshotLabel,
},
});
}
if (gitResult.status === "failed") {
s.lastGitActionFailure = gitResult.error ?? `git ${turnAction} failed`;
s.lastGitActionStatus = "failed";
if (uokFlags.gitops && uokFlags.gates) {
const parsed = parseUnitId(unit.id);
const gateRunner = new UokGateRunner();
gateRunner.register({
id: "closeout-git-action",
type: "closeout",
execute: async () => ({
outcome: "fail",
failureClass: "git",
rationale: `turn git action "${turnAction}" failed`,
findings: gitResult.error ?? "unknown git failure",
}),
});
await gateRunner.run("closeout-git-action", {
basePath: s.basePath,
traceId,
turnId,
milestoneId: parsed.milestone ?? undefined,
sliceId: parsed.slice ?? undefined,
taskId: parsed.task ?? undefined,
unitType: unit.type,
unitId: unit.id,
});
}
const failureMsg = `Git ${turnAction} failed: ${(gitResult.error ?? "unknown error").split("\n")[0]}`;
if (uokFlags.gitops) {
ctx.ui.notify(failureMsg, "error");
await pauseAuto(ctx, pi);
return "dispatched";
}
ctx.ui.notify(failureMsg, "warning");
debugLog("postUnit", {
phase: "git-action-failed-nonblocking",
action: turnAction,
error: gitResult.error ?? "unknown error",
});
}
s.lastGitActionStatus = "ok";
if (turnAction === "commit" && gitResult.commitMessage) {
ctx.ui.notify(`Committed: ${gitResult.commitMessage.split("\n")[0]}`, "info");
} else if (turnAction === "snapshot" && gitResult.snapshotLabel) {
ctx.ui.notify(`Snapshot recorded: ${gitResult.snapshotLabel}`, "info");
}
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
s.lastGitActionFailure = message;
s.lastGitActionStatus = "failed";
debugLog("postUnit", { phase: "git-action", error: message, action: turnAction });
ctx.ui.notify(`Git ${turnAction} failed: ${message.split("\n")[0]}`, uokFlags.gitops ? "error" : "warning");
if (uokFlags.gitops) {
await pauseAuto(ctx, pi);
return "dispatched";
}
}
// GitHub sync (non-blocking, opt-in)
await runSafely("postUnit", "github-sync", async () => {
@ -869,11 +1025,13 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
s.currentUnit &&
s.currentUnit.type === "plan-slice"
) {
const currentUnit = s.currentUnit;
let preExecPauseNeeded = false;
await runSafely("postUnitPostVerification", "pre-execution-checks", async () => {
const prefs = loadEffectiveGSDPreferences()?.preferences;
const uokFlags = resolveUokFlags(prefs);
try {
// Check preferences — respect enhanced_verification and enhanced_verification_pre
const prefs = loadEffectiveGSDPreferences()?.preferences;
const enhancedEnabled = prefs?.enhanced_verification !== false; // default true
const preEnabled = prefs?.enhanced_verification_pre !== false; // default true
@ -887,7 +1045,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
}
// Parse the unit ID to get milestone/slice IDs
const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit!.id);
const { milestone: mid, slice: sid } = parseUnitId(currentUnit.id);
if (!mid || !sid) {
debugLog("postUnitPostVerification", {
phase: "pre-execution-checks",
@ -908,6 +1066,8 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
return;
}
const strictMode = prefs?.enhanced_verification_strict === true;
// Run pre-execution checks
const result: PreExecutionResult = await runPreExecutionChecks(tasks, s.basePath);
@ -931,6 +1091,36 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
writePreExecutionEvidence(result, slicePath, mid, sid);
}
if (uokFlags.gates) {
const failedChecks = result.checks
.filter((check) => !check.passed)
.map((check) => `[${check.category}] ${check.target}: ${check.message}`);
const warnEscalated = result.status === "warn" && strictMode;
const blockingFailure = result.status === "fail" || warnEscalated;
const gateRunner = new UokGateRunner();
gateRunner.register({
id: "pre-execution-checks",
type: "input",
execute: async () => ({
outcome: blockingFailure ? "fail" : "pass",
failureClass: result.status === "fail" ? "input" : warnEscalated ? "policy" : "none",
rationale: blockingFailure
? `pre-execution checks ${result.status}${warnEscalated ? " (strict)" : ""}`
: "pre-execution checks passed",
findings: failedChecks.join("\n"),
}),
});
await gateRunner.run("pre-execution-checks", {
basePath: s.basePath,
traceId: `pre-execution:${currentUnit.id}`,
turnId: currentUnit.id,
milestoneId: mid,
sliceId: sid,
unitType: currentUnit.type,
unitId: currentUnit.id,
});
}
// Notify UI
if (result.status === "fail") {
const blockingCount = result.checks.filter(c => !c.passed && c.blocking).length;
@ -969,6 +1159,29 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
`Pre-execution checks error: ${errorMessage} — pausing for human review`,
"error",
);
if (uokFlags.gates && s.currentUnit) {
const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id);
const gateRunner = new UokGateRunner();
gateRunner.register({
id: "pre-execution-checks",
type: "input",
execute: async () => ({
outcome: "manual-attention",
failureClass: "manual-attention",
rationale: "pre-execution checks threw before completion",
findings: errorMessage,
}),
});
await gateRunner.run("pre-execution-checks", {
basePath: s.basePath,
traceId: `pre-execution:${s.currentUnit.id}`,
turnId: s.currentUnit.id,
milestoneId: mid ?? undefined,
sliceId: sid ?? undefined,
unitType: s.currentUnit.type,
unitId: s.currentUnit.id,
});
}
preExecPauseNeeded = true;
}
});

View file

@ -8,6 +8,7 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent";
import { snapshotUnitMetrics } from "./metrics.js";
import { saveActivityLog } from "./activity-log.js";
import { logWarning } from "./workflow-logger.js";
import { writeTurnGitTransaction } from "./uok/gitops.js";
export interface CloseoutOptions {
promptCharCount?: number;
@ -15,6 +16,12 @@ export interface CloseoutOptions {
tier?: string;
modelDowngraded?: boolean;
continueHereFired?: boolean;
traceId?: string;
turnId?: string;
gitAction?: "commit" | "snapshot" | "status-only";
gitPush?: boolean;
gitStatus?: "ok" | "failed";
gitError?: string;
}
/**
@ -47,6 +54,23 @@ export async function closeoutUnit(
}
}
if (opts?.traceId && opts.turnId && opts.gitAction && opts.gitStatus) {
writeTurnGitTransaction({
basePath,
traceId: opts.traceId,
turnId: opts.turnId,
unitType,
unitId,
stage: "record",
action: opts.gitAction,
push: opts.gitPush === true,
status: opts.gitStatus,
error: opts.gitError,
metadata: {
activityFile,
},
});
}
return activityFile ?? undefined;
}

View file

@ -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;
@ -67,6 +69,37 @@ async function runValidateMilestonePostCheck(
pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise<void>,
): Promise<VerificationResult> {
const { s, ctx, pi } = vctx;
const prefs = loadEffectiveGSDPreferences()?.preferences;
const uokFlags = resolveUokFlags(prefs);
const persistMilestoneValidationGate = async (
outcome: "pass" | "fail" | "retry" | "manual-attention",
failureClass: "none" | "verification" | "manual-attention",
rationale: string,
findings = "",
milestoneId?: string,
): Promise<void> => {
if (!uokFlags.gates || !s.currentUnit) return;
const gateRunner = new UokGateRunner();
gateRunner.register({
id: "milestone-validation-post-check",
type: "verification",
execute: async () => ({
outcome,
failureClass,
rationale,
findings,
}),
});
await gateRunner.run("milestone-validation-post-check", {
basePath: s.basePath,
traceId: `validation-post-check:${s.currentUnit.id}`,
turnId: s.currentUnit.id,
milestoneId,
unitType: s.currentUnit.type,
unitId: s.currentUnit.id,
});
};
if (!s.currentUnit) return "continue";
const { milestone: mid } = parseUnitId(s.currentUnit.id);
@ -79,14 +112,32 @@ async function runValidateMilestonePostCheck(
if (!validationContent) return "continue";
const verdict = extractVerdict(validationContent);
if (verdict !== "needs-remediation") return "continue";
if (verdict !== "needs-remediation") {
await persistMilestoneValidationGate(
"pass",
"none",
`milestone validation verdict is ${verdict}; no remediation loop risk`,
"",
mid,
);
return "continue";
}
const incompleteSliceCount = await countIncompleteSlices(s.basePath, mid);
// If any non-closed slices exist, the agent successfully queued remediation
// work — proceed normally. The state machine will execute those slices and
// re-validate per the #3596/#3670 fix.
if (incompleteSliceCount > 0) return "continue";
if (incompleteSliceCount > 0) {
await persistMilestoneValidationGate(
"pass",
"none",
`remediation slices present (${incompleteSliceCount}); validation can continue`,
"",
mid,
);
return "continue";
}
ctx.ui.notify(
`Milestone ${mid} validation returned verdict=needs-remediation but no remediation slices were added. Pausing for human review.`,
@ -96,6 +147,13 @@ async function runValidateMilestonePostCheck(
`validate-milestone: pausing — verdict=needs-remediation with no incomplete slices for ${mid}. ` +
`The agent must call gsd_reassess_roadmap to add remediation slices before re-validation.\n`,
);
await persistMilestoneValidationGate(
"manual-attention",
"manual-attention",
"needs-remediation verdict without queued remediation slices",
`No incomplete slices found for ${mid} while verdict=needs-remediation`,
mid,
);
await pauseAuto(ctx, pi);
return "pause";
}
@ -158,6 +216,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 +255,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 =
@ -338,6 +428,43 @@ export async function runPostUnitVerification(
);
}
if (uokFlags.gates) {
const strictMode = prefs?.enhanced_verification_strict === true;
const warnEscalated = postExecResult.status === "warn" && strictMode;
const blockingFailure = postExecResult.status === "fail" || warnEscalated;
const findings = postExecResult.checks
.filter((check) => !check.passed)
.map((check) => `[${check.category}] ${check.target}: ${check.message}`)
.join("\n");
const gateRunner = new UokGateRunner();
gateRunner.register({
id: "post-execution-checks",
type: "artifact",
execute: async () => ({
outcome: blockingFailure ? "fail" : "pass",
failureClass: postExecResult.status === "fail"
? "artifact"
: warnEscalated
? "policy"
: "none",
rationale: blockingFailure
? `post-execution checks ${postExecResult.status}${warnEscalated ? " (strict)" : ""}`
: "post-execution checks passed",
findings,
}),
});
await gateRunner.run("post-execution-checks", {
basePath: s.basePath,
traceId: `verification:${s.currentUnit.id}`,
turnId: s.currentUnit.id,
milestoneId: mid,
sliceId: sid,
taskId: tid,
unitType: s.currentUnit.type,
unitId: s.currentUnit.id,
});
}
// Check for blocking failures
if (postExecResult.status === "fail") {
postExecBlockingFailure = true;

View file

@ -202,6 +202,8 @@ 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";
import { resolveUokFlags } from "./uok/flags.js";
// Slice-level parallelism (#2340)
import { getEligibleSlices } from "./slice-parallel-eligibility.js";
import { startSliceParallel } from "./slice-parallel-orchestrator.js";
@ -605,11 +607,29 @@ function buildSnapshotOpts(
continueHereFired?: boolean;
promptCharCount?: number;
baselineCharCount?: number;
traceId?: string;
turnId?: string;
gitAction?: "commit" | "snapshot" | "status-only";
gitPush?: boolean;
gitStatus?: "ok" | "failed";
gitError?: string;
} & Record<string, unknown> {
const prefs = loadEffectiveGSDPreferences()?.preferences;
const uokFlags = resolveUokFlags(prefs);
return {
...(s.autoStartTime > 0 ? { autoSessionKey: String(s.autoStartTime) } : {}),
promptCharCount: s.lastPromptCharCount,
baselineCharCount: s.lastBaselineCharCount,
traceId: s.currentTraceId ?? undefined,
turnId: s.currentTurnId ?? undefined,
...(uokFlags.gitops
? {
gitAction: uokFlags.gitopsTurnAction,
gitPush: uokFlags.gitopsTurnPush,
gitStatus: s.lastGitActionStatus ?? undefined,
gitError: s.lastGitActionFailure ?? undefined,
}
: {}),
...(s.currentUnitRouting ?? {}),
};
}
@ -1513,7 +1533,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 +1574,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);
}

View file

@ -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;
}

View file

@ -31,6 +31,8 @@ import { isInfrastructureError, isTransientCooldownError, getCooldownRetryAfterM
import { resolveEngine } from "../engine-resolver.js";
import { logWarning } from "../workflow-logger.js";
import { gsdRoot } from "../paths.js";
import { resolveUokFlags } from "../uok/flags.js";
import { scheduleSidecarQueue } from "../uok/execution-graph.js";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
@ -128,6 +130,43 @@ export async function autoLoop(
const flowId = randomUUID();
let seqCounter = 0;
const nextSeq = () => ++seqCounter;
const turnId = randomUUID();
s.currentTraceId = flowId;
s.currentTurnId = turnId;
const turnStartedAt = new Date().toISOString();
let observedUnitType: string | undefined;
let observedUnitId: string | undefined;
let turnFinished = false;
const finishTurn = (
status: "completed" | "failed" | "paused" | "stopped" | "skipped" | "retry",
failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" | "closeout" | "git" = "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(),
});
s.currentTraceId = null;
s.currentTurnId = null;
};
deps.uokObserver?.onTurnStart({
traceId: flowId,
turnId,
iteration,
basePath: s.basePath,
startedAt: turnStartedAt,
});
if (iteration > MAX_LOOP_ITERATIONS) {
debugLog("autoLoop", {
@ -140,6 +179,7 @@ export async function autoLoop(
pi,
`Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`,
);
finishTurn("stopped", "manual-attention", "max-iterations");
break;
}
@ -157,22 +197,32 @@ 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;
}
try {
// ── Blanket try/catch: one bad iteration must not kill the session
const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
const uokFlags = resolveUokFlags(prefs);
// ── Check sidecar queue before deriveState ──
let sidecarItem: SidecarItem | undefined;
if (s.sidecarQueue.length > 0) {
if (uokFlags.executionGraph && s.sidecarQueue.length > 1) {
try {
s.sidecarQueue = await scheduleSidecarQueue(s.sidecarQueue);
} catch (err) {
logWarning("dispatch", `sidecar queue scheduling failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
sidecarItem = s.sidecarQueue.shift()!;
debugLog("autoLoop", {
phase: "sidecar-dequeue",
@ -256,27 +306,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 +375,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 +460,50 @@ 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") {
const finalizeFailureClass = finalizeResult.reason === "git-closeout-failure"
? "git"
: "closeout";
finishTurn("stopped", finalizeFailureClass, "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 +533,7 @@ export async function autoLoop(
pi,
`Infrastructure error (${infraCode}): not recoverable by retry`,
);
finishTurn("failed", "execution", msg);
break;
}
@ -429,6 +575,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 +602,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 +615,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);
}
}

View file

@ -12,6 +12,7 @@ import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from
import type { AutoSession, SidecarItem } from "./session.js";
import type { LoopDeps } from "./loop-deps.js";
import type { PostUnitContext, PreVerificationOpts } from "../auto-post-unit.js";
import type { Phase } from "../types.js";
import {
MAX_RECOVERY_CHARS,
BUDGET_THRESHOLDS,
@ -47,6 +48,9 @@ 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 { resolveUokFlags } from "../uok/flags.js";
import { UokGateRunner } from "../uok/gate-runner.js";
import { resetEvidence } from "../safety/evidence-collector.js";
import { createCheckpoint, cleanupCheckpoint, rollbackToCheckpoint } from "../safety/git-checkpoint.js";
import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js";
@ -77,6 +81,17 @@ export function _resolveDispatchGuardBasePath(
return s.originalBasePath || s.basePath;
}
const PLAN_V2_GATE_PHASES: ReadonlySet<Phase> = new Set([
"executing",
"summarizing",
"validating-milestone",
"completing-milestone",
]);
function shouldRunPlanV2Gate(phase: Phase): boolean {
return PLAN_V2_GATE_PHASES.has(phase);
}
/**
* Generate and write an HTML milestone report snapshot.
* Extracted from the milestone-transition block in autoLoop.
@ -202,14 +217,60 @@ export async function runPreDispatch(
loopState: LoopState,
): Promise<PhaseResult<PreDispatchData>> {
const { ctx, pi, s, deps, prefs } = ic;
const uokFlags = resolveUokFlags(prefs);
const runPreDispatchGate = async (input: {
gateId: string;
gateType: string;
outcome: "pass" | "fail" | "retry" | "manual-attention";
failureClass: "none" | "policy" | "input" | "execution" | "artifact" | "verification" | "closeout" | "git" | "timeout" | "manual-attention" | "unknown";
rationale: string;
findings?: string;
milestoneId?: string;
}): Promise<void> => {
if (!uokFlags.gates) return;
const gateRunner = new UokGateRunner();
gateRunner.register({
id: input.gateId,
type: input.gateType,
execute: async () => ({
outcome: input.outcome,
failureClass: input.failureClass,
rationale: input.rationale,
findings: input.findings ?? "",
}),
});
await gateRunner.run(input.gateId, {
basePath: s.basePath,
traceId: `pre-dispatch:${ic.flowId}`,
turnId: `iter-${ic.iteration}`,
milestoneId: input.milestoneId ?? s.currentMilestoneId ?? undefined,
unitType: "pre-dispatch",
unitId: `iter-${ic.iteration}`,
});
};
// Resource version guard
const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart);
if (staleMsg) {
await runPreDispatchGate({
gateId: "resource-version-guard",
gateType: "policy",
outcome: "fail",
failureClass: "policy",
rationale: "resource version guard blocked dispatch",
findings: staleMsg,
});
await deps.stopAuto(ctx, pi, staleMsg);
debugLog("autoLoop", { phase: "exit", reason: "resources-stale" });
return { action: "break", reason: "resources-stale" };
}
await runPreDispatchGate({
gateId: "resource-version-guard",
gateType: "policy",
outcome: "pass",
failureClass: "none",
rationale: "resource version guard passed",
});
deps.invalidateAllCaches();
s.lastPromptCharCount = undefined;
@ -225,6 +286,14 @@ export async function runPreDispatch(
);
}
if (!healthGate.proceed) {
await runPreDispatchGate({
gateId: "pre-dispatch-health-gate",
gateType: "execution",
outcome: "manual-attention",
failureClass: "manual-attention",
rationale: "pre-dispatch health gate blocked dispatch",
findings: healthGate.reason,
});
ctx.ui.notify(
healthGate.reason || "Pre-dispatch health check failed — run /gsd doctor for details.",
"error",
@ -233,7 +302,23 @@ export async function runPreDispatch(
debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
return { action: "break", reason: "health-gate-failed" };
}
await runPreDispatchGate({
gateId: "pre-dispatch-health-gate",
gateType: "execution",
outcome: "pass",
failureClass: "none",
rationale: "pre-dispatch health gate passed",
findings: healthGate.fixesApplied.length > 0 ? healthGate.fixesApplied.join(", ") : "",
});
} catch (e) {
await runPreDispatchGate({
gateId: "pre-dispatch-health-gate",
gateType: "execution",
outcome: "manual-attention",
failureClass: "manual-attention",
rationale: "pre-dispatch health gate threw unexpectedly",
findings: String(e),
});
logWarning("engine", "Pre-dispatch health gate threw unexpectedly", { error: String(e) });
}
@ -252,6 +337,32 @@ export async function runPreDispatch(
// Derive state
let state = await deps.deriveState(s.basePath);
if (prefs?.uok?.plan_v2?.enabled && shouldRunPlanV2Gate(state.phase)) {
const compiled = ensurePlanV2Graph(s.basePath, state);
if (!compiled.ok) {
const reason = compiled.reason ?? "Plan v2 compilation failed";
await runPreDispatchGate({
gateId: "plan-v2-gate",
gateType: "policy",
outcome: "manual-attention",
failureClass: "manual-attention",
rationale: "plan v2 compile gate failed",
findings: reason,
milestoneId: state.activeMilestone?.id ?? undefined,
});
ctx.ui.notify(`Plan gate failed-closed: ${reason}`, "error");
await deps.pauseAuto(ctx, pi);
return { action: "break", reason: "plan-v2-gate-failed" };
}
await runPreDispatchGate({
gateId: "plan-v2-gate",
gateType: "policy",
outcome: "pass",
failureClass: "none",
rationale: "plan v2 compile gate passed",
milestoneId: state.activeMilestone?.id ?? undefined,
});
}
deps.syncCmuxSidebar(prefs, state);
let mid = state.activeMilestone?.id;
let midTitle = state.activeMilestone?.title;
@ -297,7 +408,10 @@ export async function runPreDispatch(
s.basePath,
mid,
eligible,
{ maxWorkers: prefs.slice_parallel.max_workers ?? 2 },
{
maxWorkers: prefs.slice_parallel.max_workers ?? 2,
useExecutionGraph: uokFlags.executionGraph,
},
);
if (result.started.length > 0) {
ctx.ui.notify(
@ -1117,6 +1231,8 @@ export async function runUnitPhase(
// unit in the same Node process (see workflow-logger.ts module header).
_resetLogs();
s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
s.lastGitActionFailure = null;
s.lastGitActionStatus = null;
setCurrentPhase(unitType);
s.lastToolInvocationError = null; // #2883: clear stale error from previous unit
const unitStartSeq = ic.nextSeq();
@ -1625,11 +1741,15 @@ export async function runFinalize(
const preResult = preResultGuard.value;
if (preResult === "dispatched") {
const dispatchedReason = s.lastGitActionFailure
? "git-closeout-failure"
: "pre-verification-dispatched";
debugLog("autoLoop", {
phase: "exit",
reason: "pre-verification-dispatched",
reason: dispatchedReason,
gitError: s.lastGitActionFailure ?? undefined,
});
return { action: "break", reason: "pre-verification-dispatched" };
return { action: "break", reason: dispatchedReason };
}
if (preResult === "retry") {
if (sidecarItem) {

View file

@ -106,6 +106,8 @@ export class AutoSession {
// ── Current unit ─────────────────────────────────────────────────────────
currentUnit: CurrentUnit | null = null;
currentTraceId: string | null = null;
currentTurnId: string | null = null;
currentUnitRouting: UnitRouting | null = null;
currentMilestoneId: string | null = null;
@ -137,6 +139,10 @@ export class AutoSession {
/** Set when a GSD tool execution ends with isError due to malformed/truncated
* JSON arguments. Checked by postUnitPreVerification to break retry loops. */
lastToolInvocationError: string | null = null;
/** Set when turn-level git action fails during closeout. */
lastGitActionFailure: string | null = null;
/** Last turn-level git action status captured during finalize. */
lastGitActionStatus: "ok" | "failed" | null = null;
// ── Isolation degradation ────────────────────────────────────────────
/** Set to true when worktree creation fails; prevents merge of nonexistent branch. */
@ -219,6 +225,8 @@ export class AutoSession {
// Unit
this.currentUnit = null;
this.currentTraceId = null;
this.currentTurnId = null;
this.currentUnitRouting = null;
this.currentMilestoneId = null;
@ -250,6 +258,8 @@ export class AutoSession {
this.rewriteAttemptCount = 0;
this.consecutiveCompleteBootstraps = 0;
this.lastToolInvocationError = null;
this.lastGitActionFailure = null;
this.lastGitActionStatus = null;
this.isolationDegraded = false;
this.milestoneMergedInPhases = false;
this.checkpointSha = null;

View file

@ -822,7 +822,7 @@ export function serializePreferencesToFrontmatter(prefs: Record<string, unknown>
"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",

View file

@ -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,19 @@ 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. Keys:
- `enabled`: boolean — enable kernel wrappers and contract observers. Default: `true`.
- `legacy_fallback.enabled`: boolean — emergency release fallback that forces legacy orchestration behavior even when `uok.enabled` is `true`. Default: `false`.
- Runtime override: set `GSD_UOK_FORCE_LEGACY=1` (or `GSD_UOK_LEGACY_FALLBACK=1`) to force legacy behavior for the current process.
- `gates.enabled`: boolean — route checks through the unified gate runner and persist `gate_runs`.
- `model_policy.enabled`: boolean — enforce policy filtering before model capability scoring.
- `execution_graph.enabled`: boolean — enable DAG scheduler facade/adapters for execution.
- `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`.

View file

@ -34,6 +34,7 @@ import {
nativeAddPaths,
nativeResetSoft,
nativeCommitSubject,
_resetHasChangesCache,
} from "./native-git-bridge.js";
import { GSDError, GSD_MERGE_CONFLICT, GSD_GIT_ERROR } from "./errors.js";
import { getErrorMessage } from "./error-utils.js";
@ -93,6 +94,17 @@ export interface CommitOptions {
allowEmpty?: boolean;
}
export type TurnGitActionMode = "commit" | "snapshot" | "status-only";
export interface TurnGitActionResult {
action: TurnGitActionMode;
status: "ok" | "failed";
commitMessage?: string;
snapshotLabel?: string;
dirty?: boolean;
error?: string;
}
// ─── Meaningful Commit Message Generation ───────────────────────────────────
/** Context for generating a meaningful commit message from task execution results. */
@ -822,6 +834,62 @@ export function createGitService(basePath: string): GitServiceImpl {
return new GitServiceImpl(basePath, gitPrefs);
}
function buildTurnSnapshotLabel(unitType: string, unitId: string): string {
const raw = `${unitType}/${unitId}`.trim();
if (!raw) return "turn";
return raw
.replace(/[^a-zA-Z0-9._/-]/g, "-")
.replace(/\/{2,}/g, "/")
.replace(/-{2,}/g, "-")
.replace(/^[-/]+|[-/]+$/g, "") || "turn";
}
export function runTurnGitAction(args: {
basePath: string;
action: TurnGitActionMode;
unitType: string;
unitId: string;
taskContext?: TaskCommitContext;
}): TurnGitActionResult {
try {
// Force fresh working-tree status per turn; nativeHasChanges caches briefly.
_resetHasChangesCache();
if (args.action === "status-only") {
return {
action: args.action,
status: "ok",
dirty: nativeHasChanges(args.basePath),
};
}
const git = createGitService(args.basePath);
if (args.action === "snapshot") {
const label = buildTurnSnapshotLabel(args.unitType, args.unitId);
git.createSnapshot(label);
return {
action: args.action,
status: "ok",
snapshotLabel: label,
dirty: nativeHasChanges(args.basePath),
};
}
const commitMessage = git.autoCommit(args.unitType, args.unitId, [], args.taskContext) ?? undefined;
return {
action: args.action,
status: "ok",
commitMessage,
dirty: nativeHasChanges(args.basePath),
};
} catch (err) {
return {
action: args.action,
status: "failed",
error: getErrorMessage(err),
};
}
}
// ─── Commit Type Inference ─────────────────────────────────────────────────
/**

View file

@ -7,13 +7,144 @@
*/
import { logWarning } from "./workflow-logger.js";
import type { GraphQueryResult, GraphStatusResult } from "@gsd-build/mcp-server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
interface GraphNode {
id: string;
label: string;
type: string;
confidence: string;
description?: string;
}
interface GraphEdge {
from: string;
to: string;
type: string;
}
interface GraphQueryResult {
nodes: GraphNode[];
edges: GraphEdge[];
}
interface GraphStatusResult {
exists: boolean;
stale: boolean;
ageHours?: number;
}
interface GraphApi {
graphQuery: (projectDir: string, term: string, budget?: number) => Promise<GraphQueryResult>;
graphStatus: (projectDir: string) => Promise<GraphStatusResult>;
}
interface GraphFileShape {
nodes: GraphNode[];
edges: GraphEdge[];
builtAt?: string;
}
let cachedGraphApi: GraphApi | null = null;
let resolvedGraphApi = false;
export interface GraphSubgraphOptions {
/** Budget in tokens passed to graphQuery (1 node ≈ 20 tokens, 1 edge ≈ 10 tokens) */
budget: number;
}
function readGraphFile(projectDir: string): GraphFileShape | null {
try {
const graphPath = join(projectDir, ".gsd", "graphs", "graph.json");
const raw = readFileSync(graphPath, "utf-8");
const parsed = JSON.parse(raw) as Partial<GraphFileShape>;
const nodes = Array.isArray(parsed.nodes) ? parsed.nodes : [];
const edges = Array.isArray(parsed.edges) ? parsed.edges : [];
return { nodes, edges, builtAt: typeof parsed.builtAt === "string" ? parsed.builtAt : undefined };
} catch {
return null;
}
}
async function fallbackGraphQuery(projectDir: string, term: string, budget = 3000): Promise<GraphQueryResult> {
const graph = readGraphFile(projectDir);
if (!graph) return { nodes: [], edges: [] };
const needle = term.trim().toLowerCase();
const matches = graph.nodes.filter((node) => {
const hay = [node.id, node.label, node.description].filter(Boolean).join(" ").toLowerCase();
return hay.includes(needle);
});
const maxNodes = Math.max(1, Math.floor(Math.max(1, budget) / 20));
const selectedIds = new Set(matches.slice(0, maxNodes).map((node) => node.id));
const nodeById = new Map(graph.nodes.map((node) => [node.id, node] as const));
// Pull one-hop neighbors so relation context survives even when the term
// matches only one side of an edge.
for (const edge of graph.edges) {
if (selectedIds.size >= maxNodes) break;
const touchesSelection = selectedIds.has(edge.from) || selectedIds.has(edge.to);
if (!touchesSelection) continue;
if (selectedIds.has(edge.from) && !selectedIds.has(edge.to) && nodeById.has(edge.to)) {
selectedIds.add(edge.to);
} else if (selectedIds.has(edge.to) && !selectedIds.has(edge.from) && nodeById.has(edge.from)) {
selectedIds.add(edge.from);
}
}
const nodes = graph.nodes.filter((node) => selectedIds.has(node.id));
const remainingBudget = Math.max(0, budget - nodes.length * 20);
const maxEdges = Math.floor(remainingBudget / 10);
const edges = graph.edges
.filter((edge) => selectedIds.has(edge.from) && selectedIds.has(edge.to))
.slice(0, maxEdges);
return { nodes, edges };
}
async function fallbackGraphStatus(projectDir: string): Promise<GraphStatusResult> {
const graph = readGraphFile(projectDir);
if (!graph) return { exists: false, stale: false };
if (!graph.builtAt) return { exists: true, stale: false };
const builtAtMs = Date.parse(graph.builtAt);
if (!Number.isFinite(builtAtMs)) return { exists: true, stale: false };
const ageHours = (Date.now() - builtAtMs) / (1000 * 60 * 60);
return { exists: true, stale: ageHours > 24, ageHours };
}
function isGraphApi(mod: unknown): mod is GraphApi {
if (!mod || typeof mod !== "object") return false;
const candidate = mod as Record<string, unknown>;
return typeof candidate.graphQuery === "function" && typeof candidate.graphStatus === "function";
}
async function resolveGraphApi(): Promise<GraphApi> {
if (resolvedGraphApi && cachedGraphApi) return cachedGraphApi;
resolvedGraphApi = true;
try {
const imported = await import("@gsd-build/mcp-server");
if (isGraphApi(imported)) {
cachedGraphApi = imported;
return cachedGraphApi;
}
logWarning("prompt", "@gsd-build/mcp-server graph exports unavailable; using local graph fallback");
} catch {
// Fall back to local reader implementation.
}
cachedGraphApi = {
graphQuery: fallbackGraphQuery,
graphStatus: fallbackGraphStatus,
};
return cachedGraphApi;
}
/**
* Query the knowledge graph for nodes related to the given term and format
* the result as an inlined context block.
@ -33,18 +164,14 @@ export async function inlineGraphSubgraph(
if (!term || !term.trim()) return null;
try {
const { graphQuery, graphStatus } = await import("@gsd-build/mcp-server") as {
graphQuery: (projectDir: string, term: string, budget?: number) => Promise<GraphQueryResult>;
graphStatus: (projectDir: string) => Promise<GraphStatusResult>;
};
const result = await graphQuery(projectDir, term, opts.budget);
const graphApi = await resolveGraphApi();
const result = await graphApi.graphQuery(projectDir, term, opts.budget);
if (result.nodes.length === 0) return null;
// Check staleness for annotation
let staleAnnotation = "";
try {
const status = await graphStatus(projectDir);
const status = await graphApi.graphStatus(projectDir);
if (status.exists && status.stale && status.ageHours !== undefined) {
const hours = Math.round(status.ageHours);
staleAnnotation = `\n> ⚠ Graph last built ${hours}h ago — context may be outdated`;
@ -54,14 +181,14 @@ export async function inlineGraphSubgraph(
}
// Format nodes as a compact list
const nodeLines = result.nodes.map((n) => {
const desc = n.description ? `${n.description}` : "";
return `- **${n.label}** (\`${n.type}\`, ${n.confidence})${desc}`;
const nodeLines = result.nodes.map((node) => {
const desc = node.description ? `${node.description}` : "";
return `- **${node.label}** (\`${node.type}\`, ${node.confidence})${desc}`;
});
// Format edges as relations (only if present)
const edgeLines = result.edges.length > 0
? result.edges.map((e) => `- \`${e.from}\` →[${e.type}]→ \`${e.to}\``)
? result.edges.map((edge) => `- \`${edge.from}\` →[${edge.type}]→ \`${edge.to}\``)
: [];
const sections: string[] = [

View file

@ -54,8 +54,8 @@ let loadAttempted = false;
function suppressSqliteWarning(): void {
const origEmit = process.emit;
// @ts-expect-error overriding process.emit for warning filter
process.emit = function (event: string, ...args: unknown[]): boolean {
// Override via loose cast: Node's overloaded emit signature is not directly assignable.
(process as any).emit = function (event: string, ...args: unknown[]): boolean {
if (
event === "warning" &&
args[0] &&
@ -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<string, unknown>;
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<string, unknown>;
}): 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

View file

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

View file

@ -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

View file

@ -15,6 +15,8 @@
import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { gsdRoot } from "./paths.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -90,6 +92,34 @@ export function emitJournalEvent(basePath: string, entry: JournalEntry): void {
} catch {
// Silent failure — journal must never break auto-mode
}
if (!isUnifiedAuditEnabled()) return;
try {
const causedBy = entry.causedBy
? `${entry.causedBy.flowId}:${entry.causedBy.seq}`
: undefined;
const turnId =
typeof entry.data?.turnId === "string"
? entry.data.turnId
: undefined;
emitUokAuditEvent(
basePath,
buildAuditEnvelope({
traceId: entry.flowId,
turnId,
causedBy,
category: "orchestration",
type: `journal-${entry.eventType}`,
payload: {
seq: entry.seq,
rule: entry.rule,
data: entry.data ?? {},
},
}),
);
} catch {
// Best-effort: audit projection must never block journal writes.
}
}
// ─── Query ────────────────────────────────────────────────────────────────────

View file

@ -19,6 +19,8 @@ import { gsdRoot } from "./paths.js";
import { getAndClearSkills } from "./skill-telemetry.js";
import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
import { parseUnitId } from "./unit-id.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js";
// Re-export from shared — import directly from format-utils to avoid pulling
// in the full barrel (mod.js → ui.js → @gsd/pi-tui) which breaks when loaded
@ -143,6 +145,9 @@ export function snapshotUnitMetrics(
promptCharCount?: number;
baselineCharCount?: number;
autoSessionKey?: string;
traceId?: string;
turnId?: string;
causedBy?: string;
},
): UnitMetrics | null {
if (!ledger) return null;
@ -235,6 +240,27 @@ export function snapshotUnitMetrics(
}
saveLedger(basePath, ledger);
if (isUnifiedAuditEnabled()) {
emitUokAuditEvent(
basePath,
buildAuditEnvelope({
traceId: opts?.traceId ?? `metrics:${unitType}:${unitId}`,
turnId: opts?.turnId,
causedBy: opts?.causedBy,
category: "metrics",
type: "unit-metrics-snapshot",
payload: {
unitType,
unitId,
model,
tokens: unit.tokens,
cost: unit.cost,
toolCalls: unit.toolCalls,
},
}),
);
}
return unit;
}

View file

@ -42,6 +42,8 @@ import {
} from "./parallel-eligibility.js";
import { getErrorMessage } from "./error-utils.js";
import { logWarning } from "./workflow-logger.js";
import { resolveUokFlags } from "./uok/flags.js";
import { selectConflictFreeBatch } from "./uok/execution-graph.js";
// ─── Types ─────────────────────────────────────────────────────────────────
@ -69,6 +71,10 @@ export interface OrchestratorState {
let state: OrchestratorState | null = null;
function overlapKey(a: string, b: string): string {
return a < b ? `${a}::${b}` : `${b}::${a}`;
}
// ─── Persistence ──────────────────────────────────────────────────────────
const ORCHESTRATOR_STATE_FILE = "orchestrator.json";
@ -365,6 +371,7 @@ export async function startParallel(
}
const config = resolveParallelConfig(prefs);
const uokFlags = resolveUokFlags(prefs);
// Release any leftover state from a previous session before reassigning
if (state) {
@ -418,8 +425,40 @@ export async function startParallel(
const started: string[] = [];
const errors: Array<{ mid: string; error: string }> = [];
let filteredMilestoneIds = milestoneIds;
if (uokFlags.executionGraph && milestoneIds.length > 1) {
try {
const requestedIds = new Set(milestoneIds);
const candidates = await analyzeParallelEligibility(basePath);
const overlapPairs = new Set<string>();
for (const overlap of candidates.fileOverlaps) {
if (!requestedIds.has(overlap.mid1) || !requestedIds.has(overlap.mid2)) continue;
overlapPairs.add(overlapKey(overlap.mid1, overlap.mid2));
}
filteredMilestoneIds = selectConflictFreeBatch({
orderedIds: milestoneIds,
maxParallel: milestoneIds.length,
hasConflict: (candidate, existing) =>
overlapPairs.has(overlapKey(candidate, existing)),
});
if (filteredMilestoneIds.length < milestoneIds.length) {
const skipped = milestoneIds.filter((mid) => !filteredMilestoneIds.includes(mid));
logWarning(
"parallel",
`uok execution graph filtered ${skipped.length} conflicting milestone(s): ${skipped.join(", ")}`,
);
}
} catch (e) {
logWarning(
"parallel",
`uok execution graph overlap analysis failed; using legacy milestone selection: ${(e as Error).message}`,
);
filteredMilestoneIds = milestoneIds;
}
}
// Cap to max_workers
const toStart = milestoneIds.slice(0, config.max_workers);
const toStart = filteredMilestoneIds.slice(0, config.max_workers);
for (const mid of toStart) {
// Check budget ceiling before each spawn

View file

@ -355,7 +355,7 @@ export function resolveAutoSupervisorConfig(): AutoSupervisorConfig {
// ─── Token Profile Resolution ─────────────────────────────────────────────
const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["budget", "balanced", "quality"]);
const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["budget", "balanced", "quality", "burn-max"]);
/**
* Resolve profile defaults for a given token profile tier.
@ -400,6 +400,22 @@ export function resolveProfileDefaults(profile: TokenProfile): Partial<GSDPrefer
skip_reassess: true,
},
};
case "burn-max":
return {
// Quality-first profile: keep user-selected models, disable downgrade routing.
// Policy constraints still apply at dispatch time.
dynamic_routing: {
enabled: false,
},
context_selection: "full",
phases: {
skip_research: false,
skip_slice_research: false,
skip_reassess: false,
skip_milestone_validation: false,
reassess_after_slice: true,
},
};
}
}
@ -416,7 +432,7 @@ export function resolveEffectiveProfile(): TokenProfile {
/**
* Resolve the inline level from the active token profile.
* budget -> 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 {

View file

@ -83,6 +83,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"post_unit_hooks",
"pre_dispatch_hooks",
"dynamic_routing",
"uok",
"token_profile",
"phases",
"auto_visualize",
@ -208,6 +209,35 @@ export interface CmuxPreferences {
browser?: boolean;
}
export type UokTurnActionMode = "commit" | "snapshot" | "status-only";
export interface UokPreferences {
enabled?: boolean;
legacy_fallback?: {
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 +286,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<string, { capabilities?: Partial<ModelCapabilities> }>;
context_management?: ContextManagementConfig;

View file

@ -22,7 +22,12 @@ import {
type GSDSkillRule,
} from "./preferences-types.js";
const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["budget", "balanced", "quality"]);
const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["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,112 @@ 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<string, unknown>;
const valid: NonNullable<GSDPreferences["uok"]> = {};
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: "legacy_fallback" | "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<string, unknown>;
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("legacy_fallback");
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<string, unknown>;
const parsed: NonNullable<NonNullable<GSDPreferences["uok"]>["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",
"legacy_fallback",
"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`);
}
}

View file

@ -50,6 +50,8 @@ export type {
AutoSupervisorConfig,
RemoteQuestionsConfig,
CmuxPreferences,
UokTurnActionMode,
UokPreferences,
CodebaseMapPreferences,
GSDPreferences,
LoadedGSDPreferences,
@ -378,6 +380,32 @@ 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,
legacy_fallback: (base.uok?.legacy_fallback || override.uok?.legacy_fallback)
? { ...(base.uok?.legacy_fallback ?? {}), ...(override.uok?.legacy_fallback ?? {}) }
: undefined,
gates: (base.uok?.gates || override.uok?.gates)
? { ...(base.uok?.gates ?? {}), ...(override.uok?.gates ?? {}) }
: undefined,
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 ?? {}) }

View file

@ -52,6 +52,18 @@ export interface SessionLockStatus {
recovered?: boolean;
}
interface ProperLockfileApi {
lockSync(
path: string,
options?: {
realpath?: boolean;
stale?: number;
update?: number;
onCompromised?: () => void;
},
): () => void;
}
// ─── Module State ───────────────────────────────────────────────────────────
/** Release function from proper-lockfile — calling it releases the OS lock. */
@ -277,9 +289,9 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
unitStartedAt: new Date().toISOString(),
};
let lockfile: typeof import("proper-lockfile");
let lockfile: ProperLockfileApi;
try {
lockfile = _require("proper-lockfile") as typeof import("proper-lockfile");
lockfile = _require("proper-lockfile") as ProperLockfileApi;
} catch {
// proper-lockfile not available — fall back to PID-based check
return acquireFallbackLock(basePath, lp, lockData);

View file

@ -32,6 +32,7 @@ import {
} from "./session-status-io.js";
import { hasFileConflict } from "./slice-parallel-conflict.js";
import { getErrorMessage } from "./error-utils.js";
import { selectConflictFreeBatch } from "./uok/execution-graph.js";
// ─── Types ─────────────────────────────────────────────────────────────────
@ -61,6 +62,7 @@ export interface SliceOrchestratorState {
export interface StartSliceParallelOpts {
maxWorkers?: number;
budgetCeiling?: number;
useExecutionGraph?: boolean;
}
// ─── Module State ──────────────────────────────────────────────────────────
@ -118,7 +120,12 @@ export async function startSliceParallel(
const errors: Array<{ sid: string; error: string }> = [];
// Filter out conflicting slices (conservative: check all pairs)
const safeSlices = filterConflictingSlices(basePath, milestoneId, eligibleSlices);
const safeSlices = filterConflictingSlices(
basePath,
milestoneId,
eligibleSlices,
opts.useExecutionGraph === true,
);
// Limit to maxWorkers
const toSpawn = safeSlices.slice(0, maxWorkers);
@ -245,7 +252,19 @@ function filterConflictingSlices(
basePath: string,
milestoneId: string,
slices: Array<{ id: string }>,
useExecutionGraph: boolean,
): Array<{ id: string }> {
if (useExecutionGraph) {
const selectedIds = selectConflictFreeBatch({
orderedIds: slices.map((slice) => slice.id),
maxParallel: slices.length,
hasConflict: (candidate, existing) =>
hasFileConflict(basePath, milestoneId, candidate, existing),
});
const selected = new Set(selectedIds);
return slices.filter((slice) => selected.has(slice.id));
}
const safe: Array<{ id: string }> = [];
for (const candidate of slices) {

View file

@ -39,6 +39,24 @@ dynamic_routing:
budget_pressure:
cross_provider:
hooks:
uok:
enabled: true
legacy_fallback:
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:

View file

@ -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",
);
});

View file

@ -227,6 +227,26 @@ test("model change notify in selectAndApplyModel is gated behind verbose flag",
);
});
test("model policy resolves candidates from the policy-eligible pool", () => {
const src = readFileSync(join(__dirname, "..", "auto-model-selection.ts"), "utf-8");
assert.ok(
src.includes("const resolutionPool = uokFlags.modelPolicy ? routingEligibleModels : availableModels"),
"selectAndApplyModel should resolve model IDs against policy-eligible models when model policy is enabled",
);
});
test("model policy receives task metadata for requirement-vector decisions", () => {
const src = readFileSync(join(__dirname, "..", "auto-model-selection.ts"), "utf-8");
assert.ok(
src.includes("taskMetadata: taskMetadataForPolicy"),
"applyModelPolicyFilter should receive task metadata so requirement vectors are unit-aware",
);
assert.ok(
src.includes("extractTaskMetadata(unitId, basePath)"),
"execute-task dispatch should derive metadata before policy filtering",
);
});
test("resolveModelId: anthropic wins over claude-code when session provider is not claude-code", () => {
const availableModels = [
{ id: "claude-sonnet-4-6", provider: "claude-code" },

View file

@ -13,11 +13,15 @@ test("auto-mode captures GSD_PROJECT_ROOT before entering the dispatch loop", ()
const resumeCallIdx = source.indexOf("captureProjectRootEnv(s.originalBasePath || s.basePath);");
assert.ok(resumeCallIdx > -1, "auto.ts should capture GSD_PROJECT_ROOT before resume autoLoop");
const firstAutoLoopIdx = source.indexOf("await autoLoop(ctx, pi, s, buildLoopDeps());");
assert.ok(firstAutoLoopIdx > -1, "auto.ts should invoke autoLoop()");
const firstLoopIdxCandidates = [
source.indexOf("await runAutoLoopWithUok({"),
source.indexOf("await autoLoop(ctx, pi, s, buildLoopDeps());"),
].filter((idx) => idx > -1);
const firstAutoLoopIdx = firstLoopIdxCandidates.length > 0 ? Math.min(...firstLoopIdxCandidates) : -1;
assert.ok(firstAutoLoopIdx > -1, "auto.ts should invoke the auto dispatch loop");
assert.ok(
resumeCallIdx < firstAutoLoopIdx,
"auto.ts must set GSD_PROJECT_ROOT before the first autoLoop() call",
"auto.ts must set GSD_PROJECT_ROOT before the first loop call",
);
});

View file

@ -25,8 +25,12 @@ console.log("\n=== resume path refreshes resources and opens DB before rebuildSt
const resumeSectionStart = autoSrc.indexOf("if (s.paused) {", autoSrc.indexOf("// If resuming from paused state"));
assertTrue(resumeSectionStart > 0, "auto.ts has the paused-session resume block");
const resumeSectionEnd = autoSrc.indexOf("await autoLoop(", resumeSectionStart);
assertTrue(resumeSectionEnd > resumeSectionStart, "resume block reaches autoLoop");
const resumeSectionEndCandidates = [
autoSrc.indexOf("await runAutoLoopWithUok(", resumeSectionStart),
autoSrc.indexOf("await autoLoop(", resumeSectionStart),
].filter((idx) => idx > resumeSectionStart);
const resumeSectionEnd = resumeSectionEndCandidates.length > 0 ? Math.min(...resumeSectionEndCandidates) : -1;
assertTrue(resumeSectionEnd > resumeSectionStart, "resume block reaches the dispatch loop");
const resumeSection = autoSrc.slice(resumeSectionStart, resumeSectionEnd);

View file

@ -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();

View file

@ -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(

View file

@ -26,6 +26,14 @@ import { MAX_FINALIZE_TIMEOUTS } from "../auto/types.ts";
const { assertTrue, assertEq, report } = createTestContext();
function getRunFinalizeBody(phasesSource: string): string {
const fnIdx = phasesSource.indexOf("export async function runFinalize(");
assertTrue(fnIdx > 0, "runFinalize function should exist in phases.ts");
const nextExportIdx = phasesSource.indexOf("\nexport ", fnIdx + 1);
return phasesSource.slice(fnIdx, nextExportIdx > fnIdx ? nextExportIdx : undefined);
}
// ═══ Test: withTimeout resolves when inner promise resolves promptly ══════════
{
@ -145,11 +153,7 @@ const { assertTrue, assertEq, report } = createTestContext();
"utf-8",
);
// Find the runFinalize function body
const fnIdx = phasesSource.indexOf("export async function runFinalize(");
assertTrue(fnIdx > 0, "runFinalize function should exist in phases.ts");
const fnBody = phasesSource.slice(fnIdx, fnIdx + 8000);
const fnBody = getRunFinalizeBody(phasesSource);
// postUnitPreVerification must be wrapped in withTimeout
const preTimeoutIdx = fnBody.indexOf("withTimeout(");
@ -207,8 +211,7 @@ const { assertTrue, assertEq, report } = createTestContext();
"utf-8",
);
const fnIdx = phasesSource.indexOf("export async function runFinalize(");
const fnBody = phasesSource.slice(fnIdx, fnIdx + 8000);
const fnBody = getRunFinalizeBody(phasesSource);
// Both timeout handlers should increment consecutiveFinalizeTimeouts
const incrementCount = (fnBody.match(/consecutiveFinalizeTimeouts\+\+/g) || []).length;

View file

@ -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();

View file

@ -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', () => {
});
// ═══════════════════════════════════════════════════════════════════════════

View file

@ -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();
});

View file

@ -14,7 +14,7 @@ import { join } from "node:path";
import { runPostUnitVerification, type VerificationContext } from "../auto-verification.ts";
import { AutoSession } from "../auto/session.ts";
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask } from "../gsd-db.ts";
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, _getAdapter } from "../gsd-db.ts";
import { invalidateAllCaches } from "../cache.ts";
import { _clearGsdRootCache } from "../paths.ts";
@ -140,6 +140,43 @@ function createBasicTask(): void {
});
}
function createPostExecFailureTask(): void {
insertMilestone({ id: "M001" });
insertSlice({
id: "S01",
milestoneId: "M001",
title: "Test Slice",
risk: "low",
});
const srcDir = join(tempDir, "src");
mkdirSync(srcDir, { recursive: true });
writeFileSync(
join(srcDir, "broken.ts"),
"import { missing } from './does-not-exist.js';\nexport const ok = 1;\n",
"utf-8",
);
insertTask({
id: "T01",
sliceId: "S01",
milestoneId: "M001",
title: "Task with broken import",
status: "pending",
keyFiles: ["src/broken.ts"],
planning: {
description: "Task that introduces an unresolved import in key files",
estimate: "1h",
files: ["src/broken.ts"],
verify: "echo pass",
inputs: [],
expectedOutput: [],
observabilityImpact: "",
},
sequence: 0,
});
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("Post-execution blocking failure retry bypass", () => {
@ -249,6 +286,47 @@ describe("Post-execution blocking failure retry bypass", () => {
// This test mainly confirms the wiring is correct
assert.equal(result, "continue");
});
test("uok gate runner persists post-execution gate failures when enabled", async () => {
createPostExecFailureTask();
writePreferences({
enhanced_verification: true,
enhanced_verification_post: true,
verification_auto_fix: true,
verification_max_retries: 2,
uok: {
enabled: true,
gates: { enabled: true },
},
});
const ctx = makeMockCtx();
const pi = makeMockPi();
const pauseAutoMock = mock.fn(async () => {});
const s = makeMockSession(tempDir, { type: "execute-task", id: "M001/S01/T01" });
const vctx: VerificationContext = { s, ctx, pi };
const result = await runPostUnitVerification(vctx, pauseAutoMock);
assert.equal(result, "pause");
assert.equal(pauseAutoMock.mock.callCount(), 1);
const adapter = _getAdapter();
const row = adapter
?.prepare(
`SELECT gate_id, outcome, failure_class
FROM gate_runs
WHERE gate_id = 'post-execution-checks'
ORDER BY id DESC
LIMIT 1`,
)
.get() as { gate_id: string; outcome: string; failure_class: string } | undefined;
assert.ok(row, "post-execution gate run should be persisted when uok.gates is enabled");
assert.equal(row?.gate_id, "post-execution-checks");
assert.equal(row?.outcome, "fail");
assert.equal(row?.failure_class, "artifact");
});
});
describe("Post-execution retry behavior", () => {

View file

@ -21,7 +21,8 @@ test("postUnitPreVerification rebuilds STATE.md before worktree sync", () => {
const fnStart = source.indexOf("export async function postUnitPreVerification");
assert.ok(fnStart > 0, "postUnitPreVerification should exist");
const section = source.slice(fnStart, fnStart + 8000);
const fnEnd = source.indexOf("export async function postUnitPostVerification", fnStart);
const section = source.slice(fnStart, fnEnd > fnStart ? fnEnd : undefined);
const rebuildIdx = section.indexOf('await runSafely("postUnit", "state-rebuild"');
const syncIdx = section.indexOf('await runSafely("postUnit", "worktree-sync"');

View file

@ -17,7 +17,7 @@ import { join } from "node:path";
import { postUnitPostVerification, type PostUnitContext } from "../auto-post-unit.ts";
import { AutoSession } from "../auto/session.ts";
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask } from "../gsd-db.ts";
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, _getAdapter } from "../gsd-db.ts";
import { invalidateAllCaches } from "../cache.ts";
import { _clearGsdRootCache } from "../paths.ts";
@ -454,4 +454,43 @@ describe("Pre-execution checks → pauseAuto wiring", () => {
"postUnitPostVerification should return 'continue' when pre-execution checks are disabled"
);
});
test("uok gate runner persists pre-execution gate outcomes when enabled", async () => {
writePreferences({
enhanced_verification: true,
enhanced_verification_pre: true,
enhanced_verification_strict: true,
uok: {
enabled: true,
gates: { enabled: true },
},
});
createFailingTasks();
const ctx = makeMockCtx();
const pi = makeMockPi();
const pauseAutoMock = mock.fn(async () => {});
const s = makeMockSession(tempDir, { type: "plan-slice", id: "M001/S01" });
const pctx = makePostUnitContext(s, ctx, pi, pauseAutoMock);
const result = await postUnitPostVerification(pctx);
assert.equal(result, "stopped");
const adapter = _getAdapter();
const row = adapter
?.prepare(
`SELECT gate_id, outcome, failure_class
FROM gate_runs
WHERE gate_id = 'pre-execution-checks'
ORDER BY id DESC
LIMIT 1`,
)
.get() as { gate_id: string; outcome: string; failure_class: string } | undefined;
assert.ok(row, "pre-execution gate run should be persisted when uok.gates is enabled");
assert.equal(row?.gate_id, "pre-execution-checks");
assert.equal(row?.outcome, "fail");
assert.equal(row?.failure_class, "input");
});
});

View file

@ -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",
);
});

View file

@ -0,0 +1,101 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { emitJournalEvent } from "../journal.ts";
import { saveActivityLog } from "../activity-log.ts";
import { initMetrics, resetMetrics, snapshotUnitMetrics } from "../metrics.ts";
import { setLogBasePath, logWarning } from "../workflow-logger.ts";
import { setUnifiedAuditEnabled } from "../uok/audit-toggle.ts";
function readAuditEvents(basePath: string): Array<Record<string, unknown>> {
const file = join(basePath, ".gsd", "audit", "events.jsonl");
if (!existsSync(file)) return [];
const raw = readFileSync(file, "utf-8");
return raw
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
function makeMockContext(entries: unknown[]): any {
return {
sessionManager: {
getEntries: () => entries,
},
};
}
test("unified audit plane bridges journal/activity/metrics/workflow logger into audit envelope log", () => {
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-"));
setUnifiedAuditEnabled(true);
try {
emitJournalEvent(basePath, {
ts: new Date().toISOString(),
flowId: "trace-123",
seq: 1,
eventType: "iteration-start",
data: { turnId: "turn-123", unitId: "M001/S01/T01" },
});
const activityCtx = makeMockContext([
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "hello" }] } },
]);
const activityPath = saveActivityLog(activityCtx, basePath, "execute-task", "M001/S01/T01");
assert.ok(activityPath);
initMetrics(basePath);
const metricsCtx = makeMockContext([
{
type: "message",
message: {
role: "assistant",
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15, cost: 0.01 },
content: [],
},
},
]);
const unit = snapshotUnitMetrics(
metricsCtx,
"execute-task",
"M001/S01/T01",
Date.now() - 1000,
"openai/gpt-5.4",
{ traceId: "trace-123", turnId: "turn-123" },
);
assert.ok(unit);
resetMetrics();
setLogBasePath(basePath);
logWarning("engine", "audit bridge check", { id: "turn-123" });
const events = readAuditEvents(basePath);
const types = new Set(events.map((event) => String(event.type ?? "")));
assert.ok(types.has("journal-iteration-start"));
assert.ok(types.has("activity-log-saved"));
assert.ok(types.has("unit-metrics-snapshot"));
assert.ok(types.has("workflow-log-warn"));
} finally {
setUnifiedAuditEnabled(false);
resetMetrics();
rmSync(basePath, { recursive: true, force: true });
}
});
test("unified audit bridge is disabled when toggle is off", () => {
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-off-"));
setUnifiedAuditEnabled(false);
try {
emitJournalEvent(basePath, {
ts: new Date().toISOString(),
flowId: "trace-off",
seq: 1,
eventType: "iteration-start",
});
const events = readAuditEvents(basePath);
assert.equal(events.length, 0);
} finally {
rmSync(basePath, { recursive: true, force: true });
}
});

View file

@ -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");
});

View file

@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SidecarItem } from "../auto/session.ts";
import {
selectConflictFreeBatch,
selectReactiveDispatchBatch,
buildSidecarQueueNodes,
scheduleSidecarQueue,
} from "../uok/execution-graph.ts";
test("uok execution graph selects deterministic conflict-free IDs", () => {
const selected = selectConflictFreeBatch({
orderedIds: ["S01", "S02", "S03", "S04"],
maxParallel: 4,
hasConflict: (candidate, existing) =>
(candidate === "S02" && existing === "S01") ||
(candidate === "S01" && existing === "S02"),
});
assert.deepEqual(selected, ["S01", "S03", "S04"]);
});
test("uok execution graph reactive batch honors file conflicts and in-flight writes", () => {
const result = selectReactiveDispatchBatch({
graph: [
{ id: "T01", dependsOn: [], outputFiles: ["src/a.ts"] },
{ id: "T02", dependsOn: [], outputFiles: ["src/a.ts"] },
{ id: "T03", dependsOn: [], outputFiles: ["src/b.ts"] },
{ id: "T04", dependsOn: ["T03"], outputFiles: ["src/c.ts"] },
],
readyIds: ["T01", "T02", "T03", "T04"],
maxParallel: 3,
inFlightOutputs: new Set(["src/c.ts"]),
});
assert.deepEqual(result.selected, ["T01", "T03"]);
assert.ok(
result.conflicts.some((c) => c.nodeA === "T01" && c.nodeB === "T02" && c.file === "src/a.ts"),
"conflict list should include overlapping outputs",
);
});
test("uok execution graph sidecar nodes map queue kinds to supported DAG kinds", () => {
const queue: SidecarItem[] = [
{ kind: "hook", unitType: "execute-task", unitId: "M001/S01/T01", prompt: "hook" },
{ kind: "triage", unitType: "triage", unitId: "M001/S01", prompt: "triage" },
{ kind: "quick-task", unitType: "quick-task", unitId: "M001/S01/Q01", prompt: "quick" },
];
const nodes = buildSidecarQueueNodes(queue);
assert.equal(nodes[0]?.kind, "hook");
assert.equal(nodes[1]?.kind, "verification");
assert.equal(nodes[2]?.kind, "team-worker");
assert.equal(nodes[1]?.dependsOn.length, 1);
});
test("uok execution graph sidecar scheduler preserves deterministic queue order", async () => {
const queue: SidecarItem[] = [
{ kind: "quick-task", unitType: "quick-task", unitId: "M001/S01/Q01", prompt: "q1" },
{ kind: "hook", unitType: "hook", unitId: "M001/S01/H01", prompt: "h1" },
{ kind: "triage", unitType: "triage", unitId: "M001/S01/TR1", prompt: "t1" },
];
const scheduled = await scheduleSidecarQueue(queue);
assert.deepEqual(
scheduled.map((item) => item.unitId),
queue.map((item) => item.unitId),
);
});

View file

@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveUokFlags } from "../uok/flags.ts";
test("uok flags default to enabled when preference is unset", () => {
const flags = resolveUokFlags(undefined);
assert.equal(flags.enabled, true);
assert.equal(flags.legacyFallback, false);
});
test("uok legacy fallback preference forces legacy path", () => {
const flags = resolveUokFlags({
uok: {
enabled: true,
legacy_fallback: { enabled: true },
},
});
assert.equal(flags.enabled, false);
assert.equal(flags.legacyFallback, true);
});
test("uok legacy fallback env var forces legacy path", () => {
const previous = process.env.GSD_UOK_FORCE_LEGACY;
process.env.GSD_UOK_FORCE_LEGACY = "1";
try {
const flags = resolveUokFlags({
uok: {
enabled: true,
},
});
assert.equal(flags.enabled, false);
assert.equal(flags.legacyFallback, true);
} finally {
if (previous === undefined) delete process.env.GSD_UOK_FORCE_LEGACY;
else process.env.GSD_UOK_FORCE_LEGACY = previous;
}
});

View file

@ -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");
});

View file

@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { runTurnGitAction } from "../git-service.ts";
function run(cmd: string, cwd: string): string {
return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf-8" }).trim();
}
function makeRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "gsd-uok-gitops-"));
run("git init", repo);
run('git config user.email "test@example.com"', repo);
run('git config user.name "Test User"', repo);
writeFileSync(join(repo, "README.md"), "# Test\n", "utf-8");
run("git add README.md", repo);
run('git commit -m "chore: init"', repo);
return repo;
}
test("uok gitops turn action status-only reports working tree dirtiness", () => {
const repo = makeRepo();
try {
const clean = runTurnGitAction({
basePath: repo,
action: "status-only",
unitType: "execute-task",
unitId: "M001/S01/T01",
});
assert.equal(clean.status, "ok");
assert.equal(clean.dirty, false);
writeFileSync(join(repo, "README.md"), "# Dirty\n", "utf-8");
const dirty = runTurnGitAction({
basePath: repo,
action: "status-only",
unitType: "execute-task",
unitId: "M001/S01/T01",
});
assert.equal(dirty.status, "ok");
assert.equal(dirty.dirty, true);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
test("uok gitops turn action snapshot writes snapshot refs", () => {
const repo = makeRepo();
try {
const result = runTurnGitAction({
basePath: repo,
action: "snapshot",
unitType: "execute-task",
unitId: "M001/S01/T01",
});
assert.equal(result.status, "ok");
assert.ok(result.snapshotLabel?.includes("execute-task/M001/S01/T01"));
const refs = run("git for-each-ref refs/gsd/snapshots/ --format='%(refname)'", repo);
assert.ok(refs.includes("refs/gsd/snapshots/execute-task/M001/S01/T01/"));
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
test("uok gitops turn action commit creates commit with unit trailer", () => {
const repo = makeRepo();
try {
writeFileSync(join(repo, "feature.ts"), "export const x = 1;\n", "utf-8");
const result = runTurnGitAction({
basePath: repo,
action: "commit",
unitType: "execute-task",
unitId: "M001/S01/T02",
});
assert.equal(result.status, "ok");
assert.ok(result.commitMessage?.includes("chore: auto-commit after execute-task"));
const body = run("git log -1 --pretty=%B", repo);
assert.ok(body.includes("GSD-Unit: M001/S01/T02"));
} finally {
rmSync(repo, { recursive: true, force: true });
}
});

View file

@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const gsdDir = join(__dirname, "..");
test("post-unit pre-verification selects turn git action from UOK gitops flags", () => {
const source = readFileSync(join(gsdDir, "auto-post-unit.ts"), "utf-8");
assert.ok(
source.includes("const turnAction: TurnGitActionMode = uokFlags.gitops ? uokFlags.gitopsTurnAction : \"commit\""),
"postUnitPreVerification should derive turn action from uok.gitops.turn_action when enabled",
);
});
test("post-unit pre-verification routes git failures through closeout gate", () => {
const source = readFileSync(join(gsdDir, "auto-post-unit.ts"), "utf-8");
assert.ok(
source.includes('id: "closeout-git-action"') &&
source.includes('type: "closeout"') &&
source.includes('failureClass: "git"'),
"git failures should be persisted via a closeout gate with failureClass=git",
);
});
test("auto snapshot opts carry trace/turn IDs for turn closeout records", () => {
const source = readFileSync(join(gsdDir, "auto.ts"), "utf-8");
assert.ok(
source.includes("traceId: s.currentTraceId ?? undefined") &&
source.includes("turnId: s.currentTurnId ?? undefined"),
"buildSnapshotOpts should pass trace/turn IDs into closeout options",
);
});

View file

@ -0,0 +1,89 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
applyModelPolicyFilter,
buildRequirementVector,
} from "../uok/model-policy.ts";
import {
registerToolCompatibility,
resetToolCompatibilityRegistry,
} from "@gsd/pi-coding-agent";
test.afterEach(() => {
resetToolCompatibilityRegistry();
});
test("uok model policy builds requirement vectors from unit metadata", () => {
const requirements = buildRequirementVector("execute-task", {
tags: ["docs"],
fileCount: 8,
estimatedLines: 600,
});
assert.equal(requirements.instruction, 0.9);
assert.equal(requirements.coding, 0.3);
assert.equal(requirements.speed, 0.7);
});
test("uok model policy enforces provider/api/tool constraints and emits decision audit events", () => {
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-model-policy-"));
try {
mkdirSync(join(basePath, ".gsd"), { recursive: true });
registerToolCompatibility("screenshot", { producesImages: true });
const result = applyModelPolicyFilter(
[
{ id: "openai-image", provider: "openai", api: "openai-responses" },
{ id: "anthropic-ok", provider: "anthropic", api: "anthropic-messages" },
{ id: "gemini-api-deny", provider: "google", api: "google-generative-ai" },
{ id: "blocked-provider", provider: "blocked", api: "anthropic-messages" },
],
{
basePath,
traceId: "trace-model-policy-1",
turnId: "turn-model-policy-1",
unitType: "execute-task",
taskMetadata: { tags: ["docs"] },
allowCrossProvider: true,
requiredTools: ["screenshot"],
allowedApis: ["anthropic-messages", "openai-responses"],
deniedProviders: ["blocked"],
},
);
assert.deepEqual(
result.eligible.map((m) => m.id),
["anthropic-ok"],
"only the policy-compliant anthropic model should remain eligible",
);
assert.equal(result.decisions.length, 4);
assert.equal(result.decisions[0]?.allowed, false);
assert.match(result.decisions[0]?.reason ?? "", /tool policy denied/);
assert.equal(result.decisions[1]?.allowed, true);
assert.equal(result.decisions[2]?.allowed, false);
assert.match(result.decisions[2]?.reason ?? "", /transport\/api denied by policy/);
assert.equal(result.decisions[3]?.allowed, false);
assert.match(result.decisions[3]?.reason ?? "", /provider denied by policy/);
const auditLogPath = join(basePath, ".gsd", "audit", "events.jsonl");
const auditLines = readFileSync(auditLogPath, "utf-8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { type: string; payload?: { reason?: string } });
const decisionTypes = auditLines.map((event) => event.type);
assert.equal(auditLines.length, 4);
assert.ok(decisionTypes.includes("model-policy-allow"));
assert.ok(decisionTypes.includes("model-policy-deny"));
assert.ok(
auditLines.some((event) => (event.payload?.reason ?? "").includes("tool policy denied")),
"audit stream should include explicit deny reasons",
);
} finally {
rmSync(basePath, { recursive: true, force: true });
}
});

View file

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

View file

@ -0,0 +1,42 @@
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,
legacy_fallback: { enabled: false },
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?.legacy_fallback?.enabled, false);
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")));
});

View file

@ -112,4 +112,43 @@ describe("handleValidateMilestone write ordering (#2725)", () => {
).get();
assert.equal(row, undefined, "assessment row should be deleted after disk-write rollback");
});
it("persists milestone validation gate_runs rows when UOK gates are enabled", async () => {
base = makeTmpBase();
const dbPath = join(base, ".gsd", "gsd.db");
openDatabase(dbPath);
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
const result = await handleValidateMilestone(VALID_PARAMS, base, {
uokGatesEnabled: true,
traceId: "trace-val-1",
turnId: "turn-val-1",
});
assert.ok(!("error" in result), `unexpected error: ${"error" in result ? result.error : ""}`);
const adapter = _getAdapter()!;
const row = adapter.prepare(
`SELECT gate_id, outcome, failure_class, trace_id, turn_id
FROM gate_runs
WHERE gate_id = 'milestone-validation-gates'
ORDER BY id DESC
LIMIT 1`,
).get() as
| {
gate_id: string;
outcome: string;
failure_class: string;
trace_id: string;
turn_id: string;
}
| undefined;
assert.ok(row, "milestone validation gate row should be persisted");
assert.equal(row?.gate_id, "milestone-validation-gates");
assert.equal(row?.outcome, "pass");
assert.equal(row?.failure_class, "none");
assert.equal(row?.trace_id, "trace-val-1");
assert.equal(row?.turn_id, "turn-val-1");
});
});

View file

@ -430,11 +430,18 @@ export async function handleCompleteSlice(
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
try {
const graphMod = await import("@gsd-build/mcp-server") as {
const graphMod = await import("@gsd-build/mcp-server") as unknown as Partial<{
buildGraph: (dir: string) => Promise<{ nodes: unknown[]; edges: unknown[]; builtAt: string }>;
writeGraph: (gsdRoot: string, graph: unknown) => Promise<void>;
resolveGsdRoot: (basePath: string) => string;
};
}>;
if (
typeof graphMod.buildGraph !== "function"
|| typeof graphMod.writeGraph !== "function"
|| typeof graphMod.resolveGsdRoot !== "function"
) {
throw new Error("graph helpers unavailable from @gsd-build/mcp-server");
}
const g = await graphMod.buildGraph(basePath);
await graphMod.writeGraph(graphMod.resolveGsdRoot(basePath), g);
} catch (graphErr) {

View file

@ -23,6 +23,9 @@ import { invalidateStateCache } from "../state.js";
import { VALIDATION_VERDICTS, isValidMilestoneVerdict } from "../verdict-parser.js";
import { insertMilestoneValidationGates } from "../milestone-validation-gates.js";
import { logWarning } from "../workflow-logger.js";
import { UokGateRunner } from "../uok/gate-runner.js";
import { loadEffectiveGSDPreferences } from "../preferences.js";
import { resolveUokFlags } from "../uok/flags.js";
export interface ValidateMilestoneParams {
milestoneId: string;
@ -43,6 +46,12 @@ export interface ValidateMilestoneResult {
validationPath: string;
}
export interface ValidateMilestoneOptions {
uokGatesEnabled?: boolean;
traceId?: string;
turnId?: string;
}
function renderValidationMarkdown(params: ValidateMilestoneParams): string {
let md = `---
verdict: ${params.verdict}
@ -81,6 +90,7 @@ ${params.verdictRationale}
export async function handleValidateMilestone(
params: ValidateMilestoneParams,
basePath: string,
opts?: ValidateMilestoneOptions,
): Promise<ValidateMilestoneResult | { error: string }> {
if (!params.milestoneId || typeof params.milestoneId !== "string" || params.milestoneId.trim() === "") {
return { error: "milestoneId is required and must be a non-empty string" };
@ -108,6 +118,8 @@ export async function handleValidateMilestone(
// rendering can regenerate. The inverse (file exists, no DB row) is
// harder to detect and recover from (#2725).
const validatedAt = new Date().toISOString();
const slices = getMilestoneSlices(params.milestoneId);
const gateSliceId = slices.length > 0 ? slices[0].id : "_milestone";
transaction(() => {
insertAssessment({
@ -123,11 +135,9 @@ export async function handleValidateMilestone(
// #2945 Bug 4: persist quality_gates records alongside the assessment.
// Previously only the assessment was written, leaving M002+ milestones
// with zero quality_gate records despite passing validation.
const slices = getMilestoneSlices(params.milestoneId);
const sliceId = slices.length > 0 ? slices[0].id : "_milestone";
insertMilestoneValidationGates(
params.milestoneId,
sliceId,
gateSliceId,
params.verdict,
validatedAt,
);
@ -147,6 +157,41 @@ export async function handleValidateMilestone(
clearPathCache();
clearParseCache();
const prefs = loadEffectiveGSDPreferences()?.preferences;
const gatesEnabled = opts?.uokGatesEnabled ?? resolveUokFlags(prefs).gates;
if (gatesEnabled) {
try {
const gateRunner = new UokGateRunner();
const nonPassVerdict = params.verdict !== "pass";
gateRunner.register({
id: "milestone-validation-gates",
type: "verification",
execute: async () => ({
outcome: nonPassVerdict ? "manual-attention" : "pass",
failureClass: nonPassVerdict ? "manual-attention" : "none",
rationale: `milestone validation verdict: ${params.verdict}`,
findings: nonPassVerdict
? [params.verdictRationale, params.remediationPlan ?? ""].filter(Boolean).join("\n")
: "",
}),
});
await gateRunner.run("milestone-validation-gates", {
basePath,
traceId: opts?.traceId ?? `validate-milestone:${params.milestoneId}`,
turnId: opts?.turnId ?? `${params.milestoneId}:validate`,
milestoneId: params.milestoneId,
sliceId: gateSliceId,
unitType: "validate-milestone",
unitId: params.milestoneId,
});
} catch (err) {
logWarning(
"tool",
`validate_milestone — failed to persist UOK gate result: ${(err as Error).message}`,
);
}
}
return {
milestoneId: params.milestoneId,
verdict: params.verdict,

View file

@ -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";

View file

@ -46,8 +46,8 @@ let loadAttempted = false;
function suppressSqliteWarning(): void {
const origEmit = process.emit;
// @ts-expect-error overriding process.emit for warning filter
process.emit = function (event: string, ...args: unknown[]): boolean {
// Override via loose cast: Node's overloaded emit signature is not directly assignable.
(process as any).emit = function (event: string, ...args: unknown[]): boolean {
if (
event === "warning" &&
args[0] &&

View file

@ -0,0 +1,9 @@
const AUDIT_ENV_KEY = "GSD_UOK_AUDIT_UNIFIED";
export function setUnifiedAuditEnabled(enabled: boolean): void {
process.env[AUDIT_ENV_KEY] = enabled ? "1" : "0";
}
export function isUnifiedAuditEnabled(): boolean {
return process.env[AUDIT_ENV_KEY] === "1";
}

View file

@ -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<string, unknown>;
}): 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.
}
}

View file

@ -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<string, unknown>;
}
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<string, unknown>;
}>;
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<string, unknown>;
}
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<string, unknown>;
}
export interface UokTurnObserver {
onTurnStart(contract: TurnContract): void;
onPhaseResult(
phase: TurnPhase,
action: string,
data?: Record<string, unknown>,
): void;
onTurnResult(result: TurnResult): void;
}

View file

@ -0,0 +1,241 @@
import type { UokGraphNode } from "./contracts.js";
import type { DerivedTaskNode } from "../types.js";
import type { SidecarItem } from "../auto/session.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<void>;
export interface ConflictFreeBatchInput {
orderedIds: string[];
maxParallel: number;
hasConflict: (leftId: string, rightId: string) => boolean;
}
export interface ReactiveDispatchSelectionInput {
graph: Array<Pick<DerivedTaskNode, "id" | "dependsOn" | "outputFiles">>;
readyIds: string[];
maxParallel: number;
inFlightOutputs?: Set<string>;
}
export interface ReactiveDispatchSelectionResult {
selected: string[];
conflicts: Array<{ nodeA: string; nodeB: string; file: string }>;
}
export function selectConflictFreeBatch({
orderedIds,
maxParallel,
hasConflict,
}: ConflictFreeBatchInput): string[] {
if (maxParallel <= 0 || orderedIds.length === 0) return [];
const selected: string[] = [];
for (const candidate of orderedIds) {
if (selected.length >= maxParallel) break;
const conflictsExisting = selected.some((existing) => hasConflict(candidate, existing));
if (conflictsExisting) continue;
selected.push(candidate);
}
return selected;
}
function buildReactiveNodes(
graph: Array<Pick<DerivedTaskNode, "id" | "dependsOn" | "outputFiles">>,
): UokGraphNode[] {
return graph.map((node) => ({
id: node.id,
kind: "unit",
dependsOn: [...node.dependsOn],
writes: [...node.outputFiles],
}));
}
export function selectReactiveDispatchBatch(
input: ReactiveDispatchSelectionInput,
): ReactiveDispatchSelectionResult {
const nodeMap = new Map(buildReactiveNodes(input.graph).map((n) => [n.id, n]));
const readyNodes = input.readyIds
.map((id) => nodeMap.get(id))
.filter((node): node is UokGraphNode => !!node);
const conflicts = detectFileConflicts(readyNodes);
if (readyNodes.length === 0 || input.maxParallel <= 0) {
return { selected: [], conflicts };
}
const claimed = new Set(input.inFlightOutputs ?? []);
const selected: string[] = [];
const selectedSet = new Set<string>();
const readySet = new Set(input.readyIds);
for (const id of input.readyIds) {
if (selected.length >= input.maxParallel) break;
const node = nodeMap.get(id);
if (!node) continue;
const hasUnmetReadyDependency = node.dependsOn.some(
(dep) => readySet.has(dep) && !selectedSet.has(dep),
);
if (hasUnmetReadyDependency) continue;
const writes = node.writes ?? [];
const conflictsWithClaimed = writes.some((file) => claimed.has(file));
if (conflictsWithClaimed) continue;
selected.push(node.id);
selectedSet.add(node.id);
for (const file of writes) claimed.add(file);
}
return { selected, conflicts };
}
function sidecarToNodeKind(kind: SidecarItem["kind"]): UokGraphNode["kind"] {
if (kind === "hook") return "hook";
if (kind === "triage") return "verification";
return "team-worker";
}
export function buildSidecarQueueNodes(queue: SidecarItem[]): UokGraphNode[] {
return queue.map((item, index) => ({
id: `sidecar-${String(index).padStart(4, "0")}:${item.kind}:${item.unitType}:${item.unitId}`,
kind: sidecarToNodeKind(item.kind),
dependsOn: index > 0 ? [`sidecar-${String(index - 1).padStart(4, "0")}:${queue[index - 1].kind}:${queue[index - 1].unitType}:${queue[index - 1].unitId}`] : [],
metadata: { index },
}));
}
export async function scheduleSidecarQueue(queue: SidecarItem[]): Promise<SidecarItem[]> {
if (queue.length <= 1) return [...queue];
const nodes = buildSidecarQueueNodes(queue);
const scheduler = new ExecutionGraphScheduler();
const orderedIndexes: number[] = [];
const seenKinds = new Set<UokGraphNode["kind"]>(nodes.map((n) => n.kind));
for (const kind of seenKinds) {
scheduler.registerHandler(kind, async (node) => {
const idx = Number(node.metadata?.index);
if (Number.isInteger(idx) && idx >= 0) orderedIndexes.push(idx);
});
}
await scheduler.run(nodes, { parallel: false });
return orderedIndexes.map((idx) => queue[idx]).filter((item): item is SidecarItem => !!item);
}
export class ExecutionGraphScheduler {
private readonly handlers = new Map<string, ExecutionNodeHandler>();
registerHandler(kind: UokGraphNode["kind"], handler: ExecutionNodeHandler): void {
this.handlers.set(kind, handler);
}
async run(nodes: UokGraphNode[], options?: ExecutionGraphRunOptions): Promise<ExecutionGraphResult> {
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<string>();
const order: string[] = [];
while (remaining.size > 0) {
const ready = Array.from(remaining.values()).filter((node) =>
node.dependsOn.every((dep) => done.has(dep)),
);
ready.sort((a, b) => a.id.localeCompare(b.id));
if (ready.length === 0) {
throw new Error("Execution graph deadlock detected: no ready nodes and graph not complete");
}
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;
}

View file

@ -0,0 +1,45 @@
import type { GSDPreferences } from "../preferences.js";
import { loadEffectiveGSDPreferences } from "../preferences.js";
export interface UokFlags {
enabled: boolean;
legacyFallback: boolean;
gates: boolean;
modelPolicy: boolean;
executionGraph: boolean;
gitops: boolean;
gitopsTurnAction: "commit" | "snapshot" | "status-only";
gitopsTurnPush: boolean;
auditUnified: boolean;
planV2: boolean;
}
function envForcesLegacyFallback(): boolean {
const raw = process.env.GSD_UOK_FORCE_LEGACY ?? process.env.GSD_UOK_LEGACY_FALLBACK;
if (!raw) return false;
const normalized = raw.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
export function resolveUokFlags(prefs: GSDPreferences | undefined): UokFlags {
const uok = prefs?.uok;
const legacyFallback = uok?.legacy_fallback?.enabled === true || envForcesLegacyFallback();
const enabledByPreference = uok?.enabled ?? true;
return {
enabled: enabledByPreference && !legacyFallback,
legacyFallback,
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);
}

View file

@ -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<FailureClass, number> = {
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<string, GateExecutionInput>();
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<GateResult> {
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(),
};
}
}

View file

@ -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<string, unknown>;
}
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,
},
});
}

View file

@ -0,0 +1,105 @@
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 { setUnifiedAuditEnabled } from "./audit-toggle.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<void>;
}
function parityLogPath(basePath: string): string {
return join(gsdRoot(basePath), "runtime", "uok-parity.jsonl");
}
function writeParityEvent(basePath: string, event: Record<string, unknown>): 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
}
}
function resolveKernelPathLabel(flags: ReturnType<typeof resolveUokFlags>): "uok-wrapper" | "legacy-wrapper" | "legacy-fallback" {
if (flags.legacyFallback) return "legacy-fallback";
return flags.enabled ? "uok-wrapper" : "legacy-wrapper";
}
export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise<void> {
const { ctx, pi, s, deps, runLegacyLoop } = args;
const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
const flags = resolveUokFlags(prefs);
setUnifiedAuditEnabled(flags.auditUnified);
writeParityEvent(s.basePath, {
ts: new Date().toISOString(),
path: resolveKernelPathLabel(flags),
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: resolveKernelPathLabel(flags),
flags,
phase: "exit",
status: "ok",
});
} catch (err) {
writeParityEvent(s.basePath, {
ts: new Date().toISOString(),
path: resolveKernelPathLabel(flags),
flags,
phase: "exit",
status: "error",
error: err instanceof Error ? err.message : String(err),
});
throw err;
}
}

View file

@ -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;
},
};
}

View file

@ -0,0 +1,112 @@
import type { TaskMetadata } from "../complexity-classifier.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<Record<string, number>> {
if (!unitType) return {};
return computeTaskRequirements(unitType, taskMetadata) as unknown as Partial<Record<string, number>>;
}
export function applyModelPolicyFilter<T extends ModelCandidate>(
candidates: T[],
options: ModelPolicyOptions,
): {
eligible: T[];
decisions: ModelPolicyDecision[];
requirements: Partial<Record<string, number>>;
} {
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: T[] = [];
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,
};
}

View file

@ -0,0 +1,156 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { GSDState, Phase } from "../types.js";
import { gsdRoot, resolveMilestoneFile, resolveSliceFile } from "../paths.js";
import { isDbAvailable, getMilestoneSlices, getSliceTasks, type SliceRow } from "../gsd-db.js";
import type { UokGraphNode } from "./contracts.js";
const PLAN_V2_CLARIFY_ROUND_LIMIT = 3;
const EXECUTION_ENTRY_PHASES: ReadonlySet<Phase> = new Set([
"executing",
"summarizing",
"validating-milestone",
"completing-milestone",
]);
export interface PlanV2CompileResult {
ok: boolean;
reason?: string;
graphPath?: string;
nodeCount?: number;
clarifyRoundLimit?: number;
researchSynthesized?: boolean;
draftContextIncluded?: boolean;
finalizedContextIncluded?: boolean;
}
function graphOutputPath(basePath: string): string {
return join(gsdRoot(basePath), "runtime", "uok-plan-v2-graph.json");
}
function hasFileContent(path: string | null): boolean {
if (!path || !existsSync(path)) return false;
try {
return readFileSync(path, "utf-8").trim().length > 0;
} catch {
return false;
}
}
function countSliceResearchArtifacts(basePath: string, milestoneId: string, slices: SliceRow[]): number {
let count = 0;
for (const slice of slices) {
if (hasFileContent(resolveSliceFile(basePath, milestoneId, slice.id, "RESEARCH"))) {
count += 1;
}
}
return count;
}
function isExecutionEntryPhase(phase: Phase): boolean {
return EXECUTION_ENTRY_PHASES.has(phase);
}
export function compileUnitGraphFromState(basePath: string, state: GSDState): PlanV2CompileResult {
const mid = state.activeMilestone?.id;
if (!mid) return { ok: false, reason: "no active milestone" };
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[] = [];
const clarifyRoundLimit = PLAN_V2_CLARIFY_ROUND_LIMIT;
const draftContextIncluded = hasFileContent(resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"));
const finalizedContextIncluded = hasFileContent(resolveMilestoneFile(basePath, mid, "CONTEXT"));
const researchSynthesized = hasFileContent(resolveMilestoneFile(basePath, mid, "RESEARCH"))
|| countSliceResearchArtifacts(basePath, mid, slices) > 0;
if (isExecutionEntryPhase(state.phase) && !finalizedContextIncluded) {
const reason = draftContextIncluded
? "milestone context draft exists but finalized CONTEXT.md is missing"
: "missing milestone CONTEXT.md";
return {
ok: false,
reason,
clarifyRoundLimit,
researchSynthesized,
draftContextIncluded,
finalizedContextIncluded,
};
}
for (const slice of slices) {
const sid = slice.id;
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,
pipeline: {
clarifyRoundLimit,
researchSynthesized,
draftContextIncluded,
finalizedContextIncluded,
sourcePhase: state.phase,
},
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,
clarifyRoundLimit,
researchSynthesized: output.pipeline.researchSynthesized,
draftContextIncluded: output.pipeline.draftContextIncluded,
finalizedContextIncluded: output.pipeline.finalizedContextIncluded,
};
}
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;
}

View file

@ -20,6 +20,8 @@ import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { appendNotification } from "./notification-store.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js";
// ─── Types ──────────────────────────────────────────────────────────────
@ -275,6 +277,29 @@ function _push(
_buffer.shift();
}
if (_auditBasePath && isUnifiedAuditEnabled()) {
try {
emitUokAuditEvent(
_auditBasePath,
buildAuditEnvelope({
traceId: `workflow-log:${component}`,
turnId: context?.id,
causedBy: context?.fn ?? context?.tool,
category: "orchestration",
type: severity === "error" ? "workflow-log-error" : "workflow-log-warn",
payload: {
component,
message,
context: context ?? {},
},
}),
);
} catch (auditEmitErr) {
// Best-effort: unified audit projection must never block workflow logger.
_writeStderr(`[gsd:workflow-logger] unified-audit emit failed: ${(auditEmitErr as Error).message}\n`);
}
}
// Persist errors to .gsd/audit-log.jsonl so they survive context resets.
// Only error-severity entries are persisted — warnings are ephemeral (stderr + buffer)
// to avoid log amplification from expected-control-flow catch paths.

View file

@ -9,9 +9,14 @@
* available, testing all patterns in a single DFA pass. Falls back to
* per-rule JS RegExp iteration when the native module is not loaded.
*/
import picomatch from "picomatch";
import { createRequire } from "node:module";
import { debugTime, debugCount, debugPeak } from "../gsd/debug-logger.js";
const _require = createRequire(import.meta.url);
type PicomatchMatcher = (input: string) => boolean;
type PicomatchFn = (pattern: string) => PicomatchMatcher;
const picomatch = _require("picomatch") as PicomatchFn;
// ── Native TTSR engine (optional) ─────────────────────────────────────
let nativeTtsr: {
ttsrCompileRules: (rules: { name: string; conditions: string[] }[]) => number;
@ -65,7 +70,7 @@ export interface TtsrSettings {
interface ToolScope {
toolName?: string;
pathMatcher?: picomatch.Matcher;
pathMatcher?: PicomatchMatcher;
pathPattern?: string;
}
@ -80,7 +85,7 @@ interface TtsrEntry {
rule: Rule;
conditions: RegExp[];
scope: TtsrScope;
globalPathMatchers?: picomatch.Matcher[];
globalPathMatchers?: PicomatchMatcher[];
}
/** Tracks when a rule was last injected (for repeat gating). */
@ -147,7 +152,7 @@ export class TtsrManager {
return compiled;
}
#compileGlobalPathMatchers(globs: Rule["globs"]): picomatch.Matcher[] | undefined {
#compileGlobalPathMatchers(globs: Rule["globs"]): PicomatchMatcher[] | undefined {
if (!globs || globs.length === 0) return undefined;
const matchers = globs
.map((g) => g.trim())
@ -239,7 +244,7 @@ export class TtsrManager {
return pathValue.replaceAll("\\", "/");
}
#matchesGlob(matcher: picomatch.Matcher, filePaths: string[] | undefined): boolean {
#matchesGlob(matcher: PicomatchMatcher, filePaths: string[] | undefined): boolean {
if (!filePaths || filePaths.length === 0) return false;
for (const filePath of filePaths) {
const normalized = this.#normalizePath(filePath);