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:
commit
c63f801412
74 changed files with 5028 additions and 111 deletions
497
docs/dev/ADR-009-IMPLEMENTATION-PLAN.md
Normal file
497
docs/dev/ADR-009-IMPLEMENTATION-PLAN.md
Normal 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.
|
||||
|
||||
401
docs/dev/ADR-009-orchestration-kernel-refactor.md
Normal file
401
docs/dev/ADR-009-orchestration-kernel-refactor.md
Normal 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
|
||||
|
||||
69
packages/pi-coding-agent/src/types/ambient-modules.d.ts
vendored
Normal file
69
packages/pi-coding-agent/src/types/ambient-modules.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? {}) }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
101
src/resources/extensions/gsd/tests/uok-audit-unified.test.ts
Normal file
101
src/resources/extensions/gsd/tests/uok-audit-unified.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
85
src/resources/extensions/gsd/tests/uok-contracts.test.ts
Normal file
85
src/resources/extensions/gsd/tests/uok-contracts.test.ts
Normal 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");
|
||||
});
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
39
src/resources/extensions/gsd/tests/uok-flags.test.ts
Normal file
39
src/resources/extensions/gsd/tests/uok-flags.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
|
||||
70
src/resources/extensions/gsd/tests/uok-gate-runner.test.ts
Normal file
70
src/resources/extensions/gsd/tests/uok-gate-runner.test.ts
Normal 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");
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
35
src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts
Normal file
35
src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
89
src/resources/extensions/gsd/tests/uok-model-policy.test.ts
Normal file
89
src/resources/extensions/gsd/tests/uok-model-policy.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
167
src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts
Normal file
167
src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
closeDatabase,
|
||||
insertMilestone,
|
||||
insertSlice,
|
||||
insertTask,
|
||||
openDatabase,
|
||||
} from "../gsd-db.ts";
|
||||
import type { GSDState, Phase } from "../types.ts";
|
||||
import { ensurePlanV2Graph } from "../uok/plan-v2.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const gsdDir = join(__dirname, "..");
|
||||
const MILESTONE_ID = "M001";
|
||||
const SLICE_ID = "S01";
|
||||
const TASK_ID = "T01";
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
function createBasePath(): string {
|
||||
const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-planv2-"));
|
||||
mkdirSync(join(basePath, ".gsd", "milestones", MILESTONE_ID), { recursive: true });
|
||||
tempDirs.add(basePath);
|
||||
return basePath;
|
||||
}
|
||||
|
||||
function writeMilestoneFile(basePath: string, suffix: string, content: string): void {
|
||||
const milestoneDir = join(basePath, ".gsd", "milestones", MILESTONE_ID);
|
||||
mkdirSync(milestoneDir, { recursive: true });
|
||||
writeFileSync(join(milestoneDir, `${MILESTONE_ID}-${suffix}.md`), `${content}\n`, "utf-8");
|
||||
}
|
||||
|
||||
function writeSliceFile(basePath: string, suffix: string, content: string): void {
|
||||
const sliceDir = join(basePath, ".gsd", "milestones", MILESTONE_ID, "slices", SLICE_ID);
|
||||
mkdirSync(sliceDir, { recursive: true });
|
||||
writeFileSync(join(sliceDir, `${SLICE_ID}-${suffix}.md`), `${content}\n`, "utf-8");
|
||||
}
|
||||
|
||||
function seedGraphRows(): void {
|
||||
insertMilestone({ id: MILESTONE_ID, title: "Milestone", status: "active" });
|
||||
insertSlice({
|
||||
id: SLICE_ID,
|
||||
milestoneId: MILESTONE_ID,
|
||||
title: "Slice",
|
||||
status: "in_progress",
|
||||
sequence: 1,
|
||||
});
|
||||
insertTask({
|
||||
id: TASK_ID,
|
||||
milestoneId: MILESTONE_ID,
|
||||
sliceId: SLICE_ID,
|
||||
title: "Task",
|
||||
status: "pending",
|
||||
keyFiles: ["src/task.ts"],
|
||||
sequence: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function buildState(phase: Phase): GSDState {
|
||||
return {
|
||||
phase,
|
||||
activeMilestone: { id: MILESTONE_ID, title: "Milestone" },
|
||||
activeSlice: null,
|
||||
activeTask: null,
|
||||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction: "dispatch",
|
||||
registry: [],
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
closeDatabase();
|
||||
const opened = openDatabase(":memory:");
|
||||
assert.equal(opened, true);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
closeDatabase();
|
||||
for (const path of tempDirs) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
test("guided flow enforces plan-v2 gate before execution-oriented dispatch", () => {
|
||||
const source = readFileSync(join(gsdDir, "guided-flow.ts"), "utf-8");
|
||||
assert.ok(
|
||||
source.includes("needsPlanV2Gate") &&
|
||||
source.includes("ensurePlanV2Graph") &&
|
||||
source.includes("Plan gate failed-closed"),
|
||||
"guided flow should fail-closed when plan-v2 graph compilation fails",
|
||||
);
|
||||
});
|
||||
|
||||
test("plan-v2 gate fails closed for execution phase when finalized context is missing", () => {
|
||||
const basePath = createBasePath();
|
||||
seedGraphRows();
|
||||
|
||||
writeMilestoneFile(basePath, "CONTEXT-DRAFT", "Draft context only.");
|
||||
|
||||
const compiled = ensurePlanV2Graph(basePath, buildState("executing"));
|
||||
assert.equal(compiled.ok, false);
|
||||
assert.match(compiled.reason ?? "", /CONTEXT\.md/i);
|
||||
});
|
||||
|
||||
test("plan-v2 compiler writes pipeline metadata for clarify/research/draft stages", () => {
|
||||
const basePath = createBasePath();
|
||||
seedGraphRows();
|
||||
|
||||
writeMilestoneFile(basePath, "CONTEXT", "Finalized context.");
|
||||
writeMilestoneFile(basePath, "CONTEXT-DRAFT", "Draft context retained.");
|
||||
writeMilestoneFile(basePath, "RESEARCH", "Milestone research synthesis.");
|
||||
writeSliceFile(basePath, "RESEARCH", "Slice research detail.");
|
||||
|
||||
const compiled = ensurePlanV2Graph(basePath, buildState("executing"));
|
||||
assert.equal(compiled.ok, true);
|
||||
assert.equal(compiled.clarifyRoundLimit, 3);
|
||||
assert.equal(compiled.researchSynthesized, true);
|
||||
assert.equal(compiled.draftContextIncluded, true);
|
||||
assert.equal(compiled.finalizedContextIncluded, true);
|
||||
|
||||
const graphPath = compiled.graphPath ?? "";
|
||||
const graphRaw = readFileSync(graphPath, "utf-8");
|
||||
const graph = JSON.parse(graphRaw) as {
|
||||
pipeline?: Record<string, unknown>;
|
||||
nodes?: unknown[];
|
||||
};
|
||||
|
||||
assert.equal(graph.pipeline?.["clarifyRoundLimit"], 3);
|
||||
assert.equal(graph.pipeline?.["researchSynthesized"], true);
|
||||
assert.equal(graph.pipeline?.["draftContextIncluded"], true);
|
||||
assert.equal(graph.pipeline?.["finalizedContextIncluded"], true);
|
||||
assert.equal(Array.isArray(graph.nodes), true);
|
||||
});
|
||||
|
||||
test("plan-v2 graph may compile during planning even without finalized context", () => {
|
||||
const basePath = createBasePath();
|
||||
seedGraphRows();
|
||||
|
||||
writeMilestoneFile(basePath, "CONTEXT-DRAFT", "Planning draft context.");
|
||||
const compiled = ensurePlanV2Graph(basePath, buildState("planning"));
|
||||
assert.equal(compiled.ok, true);
|
||||
});
|
||||
|
||||
test("plan-v2 ensure rejects empty executable graph", () => {
|
||||
const basePath = createBasePath();
|
||||
writeMilestoneFile(basePath, "CONTEXT", "Finalized context.");
|
||||
|
||||
insertMilestone({ id: MILESTONE_ID, title: "Milestone", status: "active" });
|
||||
insertSlice({
|
||||
id: SLICE_ID,
|
||||
milestoneId: MILESTONE_ID,
|
||||
title: "Slice",
|
||||
status: "pending",
|
||||
sequence: 1,
|
||||
});
|
||||
|
||||
const compiled = ensurePlanV2Graph(basePath, buildState("executing"));
|
||||
assert.equal(compiled.ok, false);
|
||||
assert.match(compiled.reason ?? "", /compiled graph is empty/i);
|
||||
});
|
||||
42
src/resources/extensions/gsd/tests/uok-preferences.test.ts
Normal file
42
src/resources/extensions/gsd/tests/uok-preferences.test.ts
Normal 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")));
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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] &&
|
||||
|
|
|
|||
9
src/resources/extensions/gsd/uok/audit-toggle.ts
Normal file
9
src/resources/extensions/gsd/uok/audit-toggle.ts
Normal 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";
|
||||
}
|
||||
51
src/resources/extensions/gsd/uok/audit.ts
Normal file
51
src/resources/extensions/gsd/uok/audit.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
135
src/resources/extensions/gsd/uok/contracts.ts
Normal file
135
src/resources/extensions/gsd/uok/contracts.ts
Normal 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;
|
||||
}
|
||||
241
src/resources/extensions/gsd/uok/execution-graph.ts
Normal file
241
src/resources/extensions/gsd/uok/execution-graph.ts
Normal 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;
|
||||
}
|
||||
45
src/resources/extensions/gsd/uok/flags.ts
Normal file
45
src/resources/extensions/gsd/uok/flags.ts
Normal 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);
|
||||
}
|
||||
146
src/resources/extensions/gsd/uok/gate-runner.ts
Normal file
146
src/resources/extensions/gsd/uok/gate-runner.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
75
src/resources/extensions/gsd/uok/gitops.ts
Normal file
75
src/resources/extensions/gsd/uok/gitops.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
105
src/resources/extensions/gsd/uok/kernel.ts
Normal file
105
src/resources/extensions/gsd/uok/kernel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
162
src/resources/extensions/gsd/uok/loop-adapter.ts
Normal file
162
src/resources/extensions/gsd/uok/loop-adapter.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
112
src/resources/extensions/gsd/uok/model-policy.ts
Normal file
112
src/resources/extensions/gsd/uok/model-policy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
156
src/resources/extensions/gsd/uok/plan-v2.ts
Normal file
156
src/resources/extensions/gsd/uok/plan-v2.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue