diff --git a/memory/PLAN.md b/memory/PLAN.md index 3fa87723..1206cc7d 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -17,7 +17,7 @@ The interaction model is mature: four-phase interview, interviewer-autonomous qu The next product arc is the **Conversational Workspace Runtime** umbrella (`docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md`) plus a stronger semantic/generative substrate. The umbrella synthesizes MULTI_CHAT, SIDE_CHAT, PATCH_LEDGER, and CONTINUOUS_WORKSPACE_HYBRID into five sub-tracks: workspace shell (Track 1, shipped as `continuous-workspace` / FE-709), inline secondary-chat runtime over the existing chat/turn substrate (`chat-runtime-secondary-chats`), reconciliation runtime absorption (`reconciliation-runtime`), changeset ledger (`changeset-ledger`), and transcript-first chat context provision (`chat-context-provision`). The shell is now the stable host; schema-level `thread` is deferred until chat/turn proves insufficient. Secondary chats are the near-term runtime primitive for side, reconciliation, qa, and strategy conversations. The chat runtime is the critical unblocker for reconciliation absorption; chat context provision can proceed against chat/turn with explicit transcript snapshots and graph-item handles. The changeset ledger runs in parallel. The umbrella supersedes the independent side-chat V4a persistence horizon — persistent side-chat history becomes inline secondary chats in the workspace. The FE-705 branch contributes an integration substrate — a local agent capability CLI and external LLM-as-user probe harness — that should be reconciled into main before graph-review and scenario-options work depends on generated completed-spec fixtures. After that, the highest-coordination work is intent-graph semantics and the semantic changeset ledger; FE-701 should follow soon after the FE-705 reconciliation because the current schema already carries transitional multi-chat / reconciliation placeholders that only become coherent once `changeset` / `change` owns semantic mutation history. Lower-coordination provider, gitignore, and web-research work can proceed in parallel. -The **orchestrator / Petri-net execution substrate** is committed (2026-05-21) to Petri as the forward execution model, justified by parallelism, simulation, and resume value claims. Phases 0–2 are done: the dual-engine PoC (Phase 0, FE-730) validated the substrate and extracted the compiler/interpreter; Phase 1 (FE-738) added two-lane mechanical+semantic subnets, the compiler topology/wiring split, and §7 event vocabulary; Phase 2 (FE-743) added parallel firing policy with greedy token claiming, shared resource pool tokens bounding global concurrency, and worktree-per-slice isolation — the decision gate passed (parallel measurably beats serial on wall clock). Phases 3–4 (graph compilation, simulation oracle) are on the horizon pending `intent-graph-semantics` (FE-700) and relation-policy readiness. The north-star design is `docs/next/architecture/plan-graph-petri-orchestration.md`. +The **orchestrator / Petri-net execution substrate** is committed (2026-05-21) to Petri as the forward execution model, justified by parallelism, simulation, and resume value claims. Phases 0–2 are done: the dual-engine PoC (Phase 0, FE-730) validated the substrate and extracted the compiler/interpreter; Phase 1 (FE-738) added two-lane mechanical+semantic subnets, the compiler topology/wiring split, and §7 event vocabulary; Phase 2 (FE-743) added parallel firing policy with greedy token claiming, shared resource pool tokens bounding global concurrency, and worktree-per-slice isolation — the decision gate passed (parallel measurably beats serial on wall clock). Phase-3-prep `petri-declarative-routing` (FE-747) is done: typed Guard predicates on `HandlerDescriptor` plus `enumerateCandidateOutputs` make topology-only enumeration of reachable output places possible (I125-K). Phase 3 (graph compilation) remains blocked on `intent-graph-semantics` (FE-700) for relation-policy gates; Phase 4 (simulation oracle) now has its routing-side structural prerequisite satisfied but still needs Phase 3 for graph-derived gates. The north-star design is `docs/next/architecture/plan-graph-petri-orchestration.md`. The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agent-mutation design notes are reconciled into one direction. `docs/design/MULTI_CHAT.md` is the substrate document. `docs/design/SIDE_CHAT.md` describes side-chat V1 / V2 / V3.0 / V3.1 / V4 phasing on top of that substrate. `docs/design/PATCH_LEDGER.md` remains historical deeper design pressure for semantic mutation history, but canonical future-facing vocabulary is `changeset` / `change`. The product-layer ontology trajectory is split out as `docs/design/INTENT_GRAPH_SEMANTICS.md` and `docs/design/BEHAVIORAL_KERNELS.md`; broader synthesis lives in `docs/archive/design/INTENT_SPEC_EVOLUTION.md`. FE-705's branch-local strategy/proposal notes add scenario options, graph-review oracle, chat-local strategies, and concern/dependency mapping; those notes should become a canonical design doc when the branch is integrated. Coordination uses a substrate-strangler posture: keep existing frontend REST/SSE contracts stable while route adapters and capability adapters converge on shared server-owned handlers, then cut over UI flows only after parity and changeset-backed authority exist. The dev-layer self-tooling trajectory lives in `docs/design/ln-skills/EVOLUTION.md`. @@ -30,9 +30,9 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### Recently Completed +- `petri-declarative-routing` (FE-747) — `HandlerDescriptor` branching transitions now carry typed `Guard` predicates (`always`, `reportFieldTruthy`); `wireHandlers` consumes them via `evalGuard`; new `enumerateCandidateOutputs(transition)` exposes the topology-derived output-place set per transition. Establishes I125-K. Structural prerequisite for `petri-simulation-oracle` (Phase 4) and any static analysis; FE-700-independent. Halt paths and token transforms remain runtime concerns (separate follow-on slices). Follows FE-745. - `petri-epic-verification-merge` — `verify-epic` now runs against a freshly-merged `/__epic__//` built from completed slice worktrees (declaration-order wins on path collisions; conflicts surfaced via `epic-sandbox-merged` event). Unblocks multi-slice `cook` runs. Follows FE-743. - `petri-parallel-execution` (FE-743) — parallel firing policy, shared resource pool tokens, worktree-per-slice isolation. Decision gate passed: parallel measurably beats serial on wall clock for multi-slice plans. Follows `petri-semantic-lanes` (FE-738). -- `petri-semantic-lanes` (FE-738) — two-lane subnet, compiler topology/wiring split, engine factory, semantic rework budget, §7 events. PR #148. Criterion (5) stale-graph deferred → `petri-graph-compilation`. ### Next @@ -103,7 +103,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **Objective:** Compile Petri nets from workspace plan-graph nodes and relation-policy edges rather than from YAML plan fixtures. Relation kinds (`plan.depends_on`, `plan.verified_by_oracle`, `plan.introduces_design`, etc.) compile into topology-level requirements (prerequisite tokens, guard predicates, semantic-lane join conditions). Extends the FE-700 relation-policy registry. - **Why now / unlocks:** Without graph compilation, the Petri engine only runs hand-authored YAML plans. Graph compilation makes the engine a planning oracle (simulate before executing) and connects execution to the semantic workspace. - **Open design constraints (from PR #143 / FE-743 review):** - - **Declarative output arcs:** Current topology declares only input places; output routing lives in fire closures (conditional on report payloads). FE-738's `HandlerDescriptor` declares candidate outputs (`onTrue`/`onFalse`/`onPass`/`onFail`) but selection is runtime. This limits formal analyzability (reachability, deadlock detection, simulation) to input-side structure. Phase 3 should move conditional routing into the topology — explicit guard predicates + declared output arcs per branch — so the compiled net is formally analyzable end-to-end. + - **Declarative output arcs:** Extracted to its own frontier `petri-declarative-routing` (lands ahead of Phase 3; independent of FE-700). - **Token state enrichment:** Open question whether more metadata should move from reports into tokens (richer typed token payloads per spec §3). FE-738 added `reworkCount`, FE-743 added pool tokens with `agentPoolSize`, but the boundary between control state (tokens) and substantive handoff state (reports) is a design choice this frontier needs to resolve as the token taxonomy gets richer. - **Acceptance:** TBD — depends on FE-700 relation-policy shape. - **Verification:** Compiled-net topology tests against plan-graph fixtures; reachability assertions for relation-policy-derived gates; comparison of compiled vs hand-authored net shapes. @@ -124,6 +124,19 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **Traceability:** Requirements 46–50; spec §3 (token taxonomy — resource tokens), §4 (canonical slice-net terminal join). - **Design docs:** `docs/next/architecture/plan-graph-petri-orchestration.md`; `docs/design/orchestrator.md`; umbrella H-6476. +### petri-declarative-routing + +- **Name:** Petri declarative output arcs — topology-level routing for branching transitions +- **Linear:** FE-747 +- **Kind:** structural +- **Status:** done +- **Objective:** Move conditional output routing from `wireHandlers` fire closures into typed `Guard` predicates declared on `HandlerDescriptor`, so a topology-only consumer can enumerate every reachable output place per transition without invoking actions, reports, or the test runner. +- **Why now / unlocks:** First Phase-3 prep step that does not depend on FE-700. Today's `HandlerDescriptor` already names candidate output places (`onTrue`/`onFalse`, `onPass`/`onFail`, `onSatisfied`/`onRejected`) but the guard predicates that select among them live in runtime closures in `net-compiler.ts`. Without declarative guards, formal analyses (reachability, deadlock detection, simulation) can only see input-side structure — which makes `petri-simulation-oracle` (Phase 4) impossible regardless of whether Phase 3 graph compilation lands. Token transforms (`reportId` attach, budget decrement, retry/rework propagation) and budget-exhaustion halts stay in closures and become separate follow-on slices. +- **Acceptance:** (1) `HandlerDescriptor` branches that route conditionally carry typed `Guard` data; initial vocabulary covers `always` and `reportFieldTruthy`; extension shape is documented. (2) `wireHandlers` consumes guards via a pure `evalGuard(guard, report)` helper; conditional routing logic moves out of inline closure code. (3) A pure `enumerateCandidateOutputs(transition: TransitionSkeleton): Set` function returns the topology-derived output place set per transition without instantiating actions/reports/testRunner. (4) Engine contract suite passes unchanged across both engines. +- **Verification:** Existing engine-contract tests (12+ scenarios, both engines) prove runtime equivalence; new adapter tests pin `enumerateCandidateOutputs` and `evalGuard` against the `simplePlan` and `depPlan` fixtures; new tests run with topology-only inputs (no actions, no reports, no test runner). +- **Traceability:** Requirements 46, 47, 48; D156-K (Phase-3-prep refinement of FE-738 HandlerDescriptor design); candidate new invariant on build: "Topology output-place candidates are fully declared in `HandlerDescriptor`; `wireHandlers` introduces no new output places at fire time." +- **Design docs:** `docs/next/architecture/plan-graph-petri-orchestration.md` §6 (transition contracts), §10 (prototypes); umbrella H-6476. + ### continuous-workspace - **Name:** Continuous workspace / phase-addressable interview surface (Conversational Workspace Runtime — Track 1) @@ -506,9 +519,9 @@ orchestrator-poc (Phase 0: compiler extraction — done) └──→ petri-semantic-lanes (Phase 1: two-lane subnet + §7 events — done) └──→ petri-parallel-execution (Phase 2: concurrent firing + resource pools — done) ├──→ petri-epic-verification-merge (hardening: merge slice worktrees for verify-epic — done) - └──→ petri-graph-compilation (Phase 3: compile from plan-graph + relation policy) - ├──→ depends on intent-graph-semantics (FE-700) for relation-policy gates - └──→ petri-simulation-oracle (Phase 4: reachability, deadlock, resume) + └──→ petri-declarative-routing (Phase-3-prep: topology-level Guard predicates; FE-700-independent — done) + ├──→ petri-graph-compilation (Phase 3: compile from plan-graph + relation policy; needs FE-700) + └──→ petri-simulation-oracle (Phase 4: reachability, deadlock, resume; declarative-routing structural prerequisite now satisfied; Phase 3 still needed for graph-derived gates) LOWER-PRIORITY / DEFERRED side-chat-v4b-item-versioning (depends on changeset-ledger) diff --git a/memory/SPEC.md b/memory/SPEC.md index a9836038..c4cafc1c 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -257,6 +257,7 @@ Each invariant is a formalization candidate: the property is stated in human lan | I122-K | Orchestrator event content lives in `reports.jsonl`; petri engine tokens carry only `{ reportId, sliceId, epicId }` pointers. Proc engine may pass data through normal function calls — the shared seam is inputs and outputs. | contract tests | Requirement 48; D156-K | | I123-K | Worktree isolation holds — fixture directory and source repo are never mutated by an orchestrator run; worktree is cwd-scoped at `/.cook/runs//worktree/`. | integration tests, worktree.test.ts | Requirement 49; D159-K | | I124-K | Epic verification runs against a freshly-rebuilt `/__epic__//` dir holding the deterministic merge of its completed slices' worktrees (later slices in plan declaration order overwrite earlier ones on path collisions; collisions are reported via the `epic-sandbox-merged` event). Per-slice worktrees are not mutated by the merge. | epic-sandbox-merge.test.ts, engine-contract.test.ts | Requirement 49; D159-K | +| I125-K | Topology output-place candidates are fully declared in `HandlerDescriptor` via typed `Guard` predicates; `wireHandlers` introduces no new output places at fire time. Pure consumers can enumerate the reachable output-place set per transition from topology data alone via `enumerateCandidateOutputs(transition)`. Halt paths (budget exhaustion, verify-epic failure) and token transforms (reportId attach, retry/rework count propagation) remain runtime concerns and are explicitly not covered by this invariant. | topology.test.ts, engine-contract.test.ts | Requirements 46, 47, 48; D155-K (FE-747) | ## Future Direction Register diff --git a/src/orchestrator/src/net-blueprint.ts b/src/orchestrator/src/net-blueprint.ts index 70286e65..e8755d94 100644 --- a/src/orchestrator/src/net-blueprint.ts +++ b/src/orchestrator/src/net-blueprint.ts @@ -4,6 +4,32 @@ // --------------------------------------------------------------------------- import type { TransitionContract } from './petri-net.js'; +import type { ReportLine } from './types.js'; + +// --------------------------------------------------------------------------- +// RouteGuard — declarative routing predicate evaluated against a report payload +// +// Extension shape: add a new `kind` variant here and a matching case in +// evalRouteGuard. Keep guards pure data so a static analyzer can reason about +// reachable markings without executing fire closures. +// --------------------------------------------------------------------------- + +export type RouteGuard = { kind: 'always' } | { kind: 'reportFieldTruthy'; field: string }; + +export function evalRouteGuard(guard: RouteGuard, report: ReportLine | undefined): boolean { + switch (guard.kind) { + case 'always': + return true; + case 'reportFieldTruthy': { + const payload = report?.payload as Record | undefined; + return !!payload?.[guard.field]; + } + default: { + const unknown = guard as { kind: string }; + throw new Error(`Unsupported RouteGuard kind: ${unknown.kind}`); + } + } +} // --------------------------------------------------------------------------- // Token identity for initial token seeding and output routing @@ -27,7 +53,7 @@ type PassthroughDescriptor = { }; /** - * Call an action handler, optionally route on a report payload field. + * Call an action handler, route declaratively on guard evaluation. * Covers: evaluate, write-tests, write-code. */ type ActionDescriptor = { @@ -35,33 +61,37 @@ type ActionDescriptor = { actionKey: string; sliceId: string; epicId: string; - /** If set, read report.payload[routeField] to decide routing. */ - routeField?: string; - /** Places to emit to when routeField is truthy (or always, if no routeField). */ + /** RouteGuard evaluated against the action's report; selects onTrue vs onFalse. */ + guard: RouteGuard; + /** Places to emit to when guard evaluates true. */ onTrue: string[]; - /** Places to emit to when routeField is falsy. */ + /** Places to emit to when guard evaluates false. */ onFalse: string[]; /** Place to return a fresh agent-resource token to. */ agentReturnPlace?: string; }; -/** Test runner with retry budget — 3-way routing. */ +/** Test runner with retry budget — 3-way routing on declarative guard. */ type RunTestsDescriptor = { kind: 'run-tests'; sliceId: string; epicId: string; target: string; + /** RouteGuard evaluated against the tests-run report; selects onPass vs onFail. */ + passGuard: RouteGuard; onPass: string[]; onFail: string[]; budgetPlace: string; }; -/** Semantic assessment with rework budget. */ +/** Semantic assessment with rework budget; routing is declarative. */ type AssessSemanticDescriptor = { kind: 'assess-semantic'; actionKey: string; sliceId: string; epicId: string; + /** RouteGuard evaluated against the semantic-assessed report; selects onSatisfied vs onRejected. */ + satisfiedGuard: RouteGuard; onSatisfied: string[]; onRejected: string[]; budgetPlace: string; @@ -125,3 +155,45 @@ export type NetBlueprint = { transitions: TransitionSkeleton[]; initialTokens: { place: string; token: TokenSeed }[]; }; + +// --------------------------------------------------------------------------- +// enumerateCandidateOutputs — topology-only enumeration of reachable +// output places for one transition. Pure: no actions, no reports, no runner. +// Used by static analyzers (reachability, deadlock detection, simulation). +// --------------------------------------------------------------------------- + +export function enumerateCandidateOutputs(transition: TransitionSkeleton): Set { + const out = new Set(); + const h = transition.handler; + switch (h.kind) { + case 'passthrough': + for (const o of h.outputs) out.add(o.place); + return out; + case 'action': + for (const p of h.onTrue) out.add(p); + for (const p of h.onFalse) out.add(p); + if (h.agentReturnPlace) out.add(h.agentReturnPlace); + return out; + case 'run-tests': + for (const p of h.onPass) out.add(p); + for (const p of h.onFail) out.add(p); + out.add(h.budgetPlace); + return out; + case 'assess-semantic': + for (const p of h.onSatisfied) out.add(p); + for (const p of h.onRejected) out.add(p); + out.add(h.budgetPlace); + return out; + case 'complete-slice': + out.add(h.completedPlace); + for (const p of h.depSignals) out.add(p); + return out; + case 'complete-epic': + out.add(h.donePlace); + for (const p of h.depSignals) out.add(p); + return out; + case 'verify-epic': + for (const o of h.onPassOutputs) out.add(o.place); + return out; + } +} diff --git a/src/orchestrator/src/net-compiler.ts b/src/orchestrator/src/net-compiler.ts index 9a2100ce..ebf746a5 100644 --- a/src/orchestrator/src/net-compiler.ts +++ b/src/orchestrator/src/net-compiler.ts @@ -13,6 +13,7 @@ import { seedSliceSandboxFromDeps, sliceIdsForEpicVerifyMerge, } from './epic-sandbox-merge.js'; +import { evalRouteGuard } from './net-blueprint.js'; import type { NetBlueprint, TokenSeed, TransitionSkeleton } from './net-blueprint.js'; import { PetriNet } from './petri-net.js'; import type { Token } from './petri-net.js'; @@ -154,7 +155,7 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { actionKey: 'evaluate-done', sliceId: sid, epicId: epic.id, - routeField: 'done', + guard: { kind: 'reportFieldTruthy', field: 'done' }, onTrue: [p(sid, 'done-spec')], onFalse: [p(sid, 'needs-more')], agentReturnPlace: poolTestAgent, @@ -171,6 +172,7 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { actionKey: 'write-tests', sliceId: sid, epicId: epic.id, + guard: { kind: 'always' }, onTrue: [p(sid, 'failing-tests')], onFalse: [], agentReturnPlace: poolTestAgent, @@ -187,6 +189,7 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { actionKey: 'write-code', sliceId: sid, epicId: epic.id, + guard: { kind: 'always' }, onTrue: [p(sid, 'untested-code')], onFalse: [], agentReturnPlace: poolCodeAgent, @@ -208,6 +211,7 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { sliceId: sid, epicId: epic.id, target: slice.verification[0]?.target ?? '', + passGuard: { kind: 'reportFieldTruthy', field: 'passed' }, onPass: [p(sid, 'spec-ready')], onFail: [p(sid, 'failing-tests')], budgetPlace: p(sid, 'retry-budget'), @@ -230,6 +234,7 @@ export function compileTopology(plan: Plan, policy: RunPolicy): NetBlueprint { actionKey: 'assess-semantic', sliceId: sid, epicId: epic.id, + satisfiedGuard: { kind: 'reportFieldTruthy', field: 'satisfied' }, onSatisfied: [p(sid, 'semantic-satisfied')], onRejected: [p(sid, 'needs-more')], budgetPlace: p(sid, 'semantic-budget'), @@ -347,7 +352,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, } case 'action': { - const { actionKey, sliceId, epicId, routeField, onTrue, onFalse, agentReturnPlace } = h; + const { actionKey, sliceId, epicId, guard, onTrue, onFalse, agentReturnPlace } = h; const slice = plan.slices.find((s) => s.id === sliceId)!; const epic = plan.epics.find((e) => e.id === epicId)!; const baseToken: Token = { sliceId, epicId }; @@ -366,14 +371,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, ctx.reportIds.push(reportId); const tok: Token = { ...consumed[0]!, reportId }; - let route: string[]; - if (routeField) { - const report = reports.getById(reportId); - const val = !!(report?.payload as Record)?.[routeField]; - route = val ? onTrue : onFalse; - } else { - route = onTrue; - } + const route = evalRouteGuard(guard, reports.getById(reportId)) ? onTrue : onFalse; const outputs: { place: string; token: Token }[] = route.map((pl) => ({ place: pl, token: tok })); if (agentReturnPlace) { @@ -385,7 +383,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, } case 'run-tests': { - const { sliceId, epicId, target, onPass, onFail, budgetPlace } = h; + const { sliceId, epicId, target, passGuard, onPass, onFail, budgetPlace } = h; const baseToken: Token = { sliceId, epicId }; fire = async (consumed) => { @@ -407,7 +405,7 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, ctx.reportIds.push(reportId); const tok: Token = { ...consumed[0]!, reportId }; - if (result.passed) { + if (evalRouteGuard(passGuard, reports.getById(reportId))) { return [ ...onPass.map((pl) => ({ place: pl, token: tok })), { place: budgetPlace, token: { ...baseToken, retryCount: 0 } }, @@ -428,7 +426,16 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, } case 'assess-semantic': { - const { actionKey, sliceId, epicId, onSatisfied, onRejected, budgetPlace, maxReworks } = h; + const { + actionKey, + sliceId, + epicId, + satisfiedGuard, + onSatisfied, + onRejected, + budgetPlace, + maxReworks, + } = h; const slice = plan.slices.find((s) => s.id === sliceId)!; const epic = plan.epics.find((e) => e.id === epicId)!; const baseToken: Token = { sliceId, epicId }; @@ -448,10 +455,8 @@ export function wireHandlers(blueprint: NetBlueprint, input: OrchestratorInput, }; const reportId = await actions[actionKey]!(actCtx); ctx.reportIds.push(reportId); - const report = reports.getById(reportId); - const satisfied = !!(report?.payload as { satisfied?: boolean })?.satisfied; - if (satisfied) { + if (evalRouteGuard(satisfiedGuard, reports.getById(reportId))) { return onSatisfied.map((pl) => ({ place: pl, token: { ...consumed[0]!, reportId } })); } if (reworkCount >= maxReworks) { diff --git a/src/orchestrator/src/topology.test.ts b/src/orchestrator/src/topology.test.ts new file mode 100644 index 00000000..e7331dac --- /dev/null +++ b/src/orchestrator/src/topology.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; + +import { enumerateCandidateOutputs, evalRouteGuard, type RouteGuard } from './net-blueprint.js'; +import { compileTopology } from './net-compiler.js'; +import type { Plan, ReportLine } from './types.js'; + +// --------------------------------------------------------------------------- +// evalRouteGuard — pure interpreter for declarative routing guards +// --------------------------------------------------------------------------- + +function makeReport(payload: Record): ReportLine { + return { + id: 'rpt-x', + ts: '2026-05-26T00:00:00.000Z', + epicId: 'epic-1', + sliceId: 'slice-1', + actor: 'test', + event: 'test', + payload, + }; +} + +describe('evalRouteGuard', () => { + it('always returns true regardless of report', () => { + expect(evalRouteGuard({ kind: 'always' }, makeReport({ done: false }))).toBe(true); + expect(evalRouteGuard({ kind: 'always' }, makeReport({}))).toBe(true); + expect(evalRouteGuard({ kind: 'always' }, undefined)).toBe(true); + }); + + it('reportFieldTruthy reads the named field and coerces to boolean', () => { + const guard = { kind: 'reportFieldTruthy', field: 'done' } as const; + expect(evalRouteGuard(guard, makeReport({ done: true }))).toBe(true); + expect(evalRouteGuard(guard, makeReport({ done: false }))).toBe(false); + expect(evalRouteGuard(guard, makeReport({ done: 'yes' }))).toBe(true); + expect(evalRouteGuard(guard, makeReport({ done: 0 }))).toBe(false); + expect(evalRouteGuard(guard, makeReport({ other: true }))).toBe(false); + }); + + it('reportFieldTruthy returns false when the report is missing', () => { + const guard = { kind: 'reportFieldTruthy', field: 'done' } as const; + expect(evalRouteGuard(guard, undefined)).toBe(false); + }); + + it('throws on unsupported guard kinds', () => { + const guard = { kind: 'unknown' } as unknown as RouteGuard; + expect(() => evalRouteGuard(guard, makeReport({ done: true }))).toThrow( + 'Unsupported RouteGuard kind: unknown', + ); + }); +}); + +// --------------------------------------------------------------------------- +// enumerateCandidateOutputs — pure topology consumer +// --------------------------------------------------------------------------- + +const simplePlan: Plan = { + epics: [{ id: 'epic-1', summary: 'E', depends_on: [], verification: [] }], + slices: [ + { + id: 'slice-1', + epic_id: 'epic-1', + definition: 'D', + depends_on: [], + verification: [{ kind: 'unit-test', target: 't' }], + }, + ], +}; + +const depPlan: Plan = { + epics: [{ id: 'epic-1', summary: 'E', depends_on: [], verification: [] }], + slices: [ + { + id: 'slice-a', + epic_id: 'epic-1', + definition: 'A', + depends_on: [], + verification: [{ kind: 'unit-test', target: 'ta' }], + }, + { + id: 'slice-b', + epic_id: 'epic-1', + definition: 'B', + depends_on: ['slice-a'], + verification: [{ kind: 'unit-test', target: 'tb' }], + }, + ], +}; + +describe('enumerateCandidateOutputs', () => { + it('returns a non-empty output set for every transition in simplePlan', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + for (const transition of blueprint.transitions) { + const outputs = enumerateCandidateOutputs(transition); + expect(outputs.size, `${transition.id} has empty output set`).toBeGreaterThan(0); + } + }); + + it('returns only declared places (no runtime synthesis)', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const declaredPlaces = new Set(blueprint.places); + for (const transition of blueprint.transitions) { + for (const place of enumerateCandidateOutputs(transition)) { + expect(declaredPlaces.has(place), `${transition.id} emits to undeclared place ${place}`).toBe(true); + } + } + }); + + it('action transitions enumerate the union of onTrue, onFalse, and agentReturnPlace', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const writeTests = blueprint.transitions.find((t) => t.id.endsWith(':write-tests')); + expect(writeTests).toBeDefined(); + const handler = writeTests!.handler; + if (handler.kind !== 'action') throw new Error('expected action descriptor'); + + const expected = new Set([...handler.onTrue, ...handler.onFalse]); + if (handler.agentReturnPlace) expected.add(handler.agentReturnPlace); + + expect(enumerateCandidateOutputs(writeTests!)).toEqual(expected); + }); + + it('run-tests transitions enumerate onPass, onFail, and budgetPlace', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const runTests = blueprint.transitions.find((t) => t.id.endsWith(':run-tests')); + expect(runTests).toBeDefined(); + const handler = runTests!.handler; + if (handler.kind !== 'run-tests') throw new Error('expected run-tests descriptor'); + + const expected = new Set([...handler.onPass, ...handler.onFail, handler.budgetPlace]); + expect(enumerateCandidateOutputs(runTests!)).toEqual(expected); + }); + + it('assess-semantic transitions enumerate onSatisfied, onRejected, and budgetPlace', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const assess = blueprint.transitions.find((t) => t.id.endsWith(':assess-semantic')); + expect(assess).toBeDefined(); + const handler = assess!.handler; + if (handler.kind !== 'assess-semantic') throw new Error('expected assess-semantic descriptor'); + + const expected = new Set([...handler.onSatisfied, ...handler.onRejected, handler.budgetPlace]); + expect(enumerateCandidateOutputs(assess!)).toEqual(expected); + }); + + it('depPlan: dep-signal places are reachable from complete-slice topology', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + const completeA = blueprint.transitions.find((t) => t.id === 'slice-a:return-done'); + expect(completeA).toBeDefined(); + const outputs = enumerateCandidateOutputs(completeA!); + expect(outputs.has('slice:slice-a:dep-signal:slice-b')).toBe(true); + }); + + // Goldens — literal expected sets, not derived from descriptor fields. + // These catch silent lockstep drift in both the descriptor emitter and the enumerator. + it("golden: simplePlan 'slice-1:evaluate' enumerates to the action's two routes plus pool return", () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const evaluate = blueprint.transitions.find((t) => t.id === 'slice-1:evaluate'); + expect(evaluate).toBeDefined(); + expect(enumerateCandidateOutputs(evaluate!)).toEqual( + new Set(['slice:slice-1:done-spec', 'slice:slice-1:needs-more', 'pool:test-agent']), + ); + }); + + it("golden: simplePlan 'slice-1:run-tests' enumerates to pass, fail, and retry-budget", () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const runTests = blueprint.transitions.find((t) => t.id === 'slice-1:run-tests'); + expect(runTests).toBeDefined(); + expect(enumerateCandidateOutputs(runTests!)).toEqual( + new Set(['slice:slice-1:spec-ready', 'slice:slice-1:failing-tests', 'slice:slice-1:retry-budget']), + ); + }); + + it("golden: simplePlan 'slice-1:assess-semantic' enumerates to satisfied, rejected, and semantic-budget", () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const assess = blueprint.transitions.find((t) => t.id === 'slice-1:assess-semantic'); + expect(assess).toBeDefined(); + expect(enumerateCandidateOutputs(assess!)).toEqual( + new Set([ + 'slice:slice-1:semantic-satisfied', + 'slice:slice-1:needs-more', + 'slice:slice-1:semantic-budget', + ]), + ); + }); +});