---
type: implementation-plan
status: active
created: 2026-05-07
completed: null
authored-by: Claude (Opus 4.7)
related: "[[wiki/Architecture/Specs/05-DESIGN-NORTH-STAR.md]]"
phase: A
wave: W23
---

# Phase A W23 — Execution-Engine Decomposition

**Goal:** Decompose `packages/runtime/src/execution-engine.ts` (4,663 LOC) using a phase-as-data architecture so each phase is a composable, testable, observable unit.

**Authority:** North Star v4.0 §6 Phase A.

**Baseline (2026-05-07 4:35 PM EDT):** 738 pass / 1 skip / 0 fail across 108 files in `packages/runtime`. This is the regression anchor.

---

## Architectural decision: phase-as-data

The current `ExecutionEngineLive` is one giant `Effect.gen` block (3,800+ LOC of body) where every phase is hardcoded into a sequential closure with shared local variables. The decomposition would otherwise require either:

- Threading 30+ closure variables through phase function signatures (exposes implementation detail), or
- A "god context" object that defeats the point of separation.

**Chosen design — phases as first-class typed values:**

```ts
// engine/phase.ts
export interface Phase {
  readonly name: ExecutionContext["phase"];
  readonly skip?: (ctx: ExecutionContext, deps: PhaseDeps) => boolean;
  readonly run: (ctx: ExecutionContext, deps: PhaseDeps) => Effect.Effect<ExecutionContext, RuntimeErrors>;
}

export interface PhaseDeps {
  readonly config: ReactiveAgentsConfig;
  readonly hooks: LifecycleHookRegistry;
  readonly obs: ObsLike | null;
  readonly eb: EbLike | null;
  readonly tools: ToolService | null;
  readonly memory: MemoryServices;
  readonly cost: CostService | null;
  readonly verification: VerificationService | null;
  // ... (~15 typed fields, one per service tag)
  readonly state: PhaseStateRefs;  // Refs.make<>() values shared across phases
}
```

```ts
// engine/pipeline.ts
export const runPipeline = (
  phases: readonly Phase[],
  initialCtx: ExecutionContext,
  deps: PhaseDeps,
): Effect.Effect<ExecutionContext, RuntimeErrors> =>
  Effect.reduce(phases, initialCtx, (ctx, phase) =>
    phase.skip?.(ctx, deps)
      ? Effect.succeed(ctx)
      : runObservablePhase(phase, ctx, deps),
  );
```

```ts
// execution-engine.ts (target ≤ 600 LOC)
const phases: readonly Phase[] = [
  bootstrap,
  guardrail,           // skip when no GuardrailService
  costRoute,           // skip when no cost router
  strategySelect,
  agentLoop,
  verify,              // skip when no VerificationService
  memoryFlush,
  costTrack,           // skip when no CostService
  audit,
  complete,
];

// Live layer just wires deps + runs the pipeline
const finalCtx = yield* runPipeline(phases, initialCtx, deps);
```

### Why this beats both alternatives

| Concern | Traditional split (closure-threaded) | Phase-as-data (this plan) |
|---|---|---|
| Closure breakage | Every phase signature lists 20+ params | Phases declare `PhaseDeps` once; opaque to phases |
| Adding a new phase | Edit pipeline + edit closures | Append to `phases` array |
| Phase reordering | Edit a 3,800-LOC `Effect.gen` body | Reorder one array literal |
| Compose API (Phase B) prep | Requires new infra layer | `.compose()` injects between phases — substrate already exists |
| Testing | Mock the entire engine | Test `phase.run(ctx, mockDeps)` as a pure function |
| Observability | Wrapping repeated 10× | Single `runObservablePhase` wraps all |

### Module count is incidental to the design

The North Star v4.0 W23 gate said "9 phase modules." That number was an estimate. The empirical structure has 10 named phases, two of which (`agent-loop` ~1,950 LOC, `complete` ~787 LOC) need internal sub-modules. Final layout will be ~13 files, but **the gate that matters** is:

- `execution-engine.ts` ≤ 600 LOC
- Every phase module ≤ 400 LOC
- Phase composition is declarative (one array literal in execution-engine.ts)

North Star §11 will be amended to reflect this refinement before extraction begins.

---

## Final file layout

```
packages/runtime/src/
├── execution-engine.ts                    target ≤ 600 LOC (orchestrator + Live layer wiring)
└── engine/
    ├── phase.ts                           Phase type, PhaseDeps, helper predicates  (~80 LOC)
    ├── pipeline.ts                        runPipeline + runObservablePhase           (~180 LOC, hoisted from current execution-engine.ts:377-510)
    ├── runtime-context.ts                 ExecutionContext utilities + state Refs    (~120 LOC)
    └── phases/
        ├── bootstrap.ts                   BOOTSTRAP — skill load, memory, tips       (~250 LOC)
        ├── guardrail.ts                   GUARDRAIL (optional)                       (~90 LOC)
        ├── cost-route.ts                  COST_ROUTE (optional)                      (~90 LOC)
        ├── strategy-select.ts             STRATEGY_SELECT — tool registry, budget    (~250 LOC)
        ├── agent-loop/
        │   ├── index.ts                   AGENT_LOOP orchestrator (composes sub-pipeline)  (~200 LOC)
        │   ├── tool-classifier.ts         LLM tool classification + literal mention  (~300 LOC)
        │   ├── reasoning-call.ts          Semantic cache + reasoning service execute (~350 LOC)
        │   ├── iteration-handler.ts       Per-iteration LLM call + episodic logging  (~350 LOC)
        │   └── tool-dispatcher.ts         Tool execution + ToolCallStarted/Completed (~400 LOC)
        ├── verify.ts                      VERIFY (optional) — output verification    (~380 LOC)
        ├── memory-flush.ts                MEMORY_FLUSH — episodic compaction         (~200 LOC)
        ├── cost-track.ts                  COST_TRACK (optional)                      (~50 LOC)
        ├── audit.ts                       AUDIT (optional)                           (~50 LOC)
        └── complete/
            ├── index.ts                   COMPLETE orchestrator                      (~200 LOC)
            ├── debrief.ts                 RunReport + AgentDebrief assembly          (~400 LOC)
            └── post-run-update.ts         Calibration + bandit + skill store update  (~300 LOC)
```

**Total:** ~17 files; orchestrator ~600 LOC; every phase module ≤ 400 LOC.

---

## Extraction order (smallest-first, validates the pattern)

Each step: extract → run `bun test` → commit. If LOC budget is exceeded by >50% on any extraction, stop and reassess.

| # | Module | LOC est. | Risk | Adds tests? |
|---|---|---|---|---|
| 1 | Infrastructure: `phase.ts` + `pipeline.ts` + `runtime-context.ts` | ~380 | low | yes (5 pipeline tests) |
| 2 | `audit.ts` (smallest — pattern validator) | ~50 | trivial | no |
| 3 | `cost-track.ts` | ~50 | trivial | no |
| 4 | `cost-route.ts` | ~90 | low | no |
| 5 | `guardrail.ts` | ~90 | low | no |
| 6 | `memory-flush.ts` | ~200 | medium | no |
| 7 | `bootstrap.ts` | ~250 | medium | no |
| 8 | `strategy-select.ts` | ~250 | medium | no |
| 9 | `verify.ts` | ~380 | high (decision logic) | yes (verify decision tree) |
| 10 | `complete/` (orchestrator + debrief + post-run-update) | ~900 across 3 files | high | yes (debrief assembler) |
| 11 | `agent-loop/` (orchestrator + 4 sub-modules) | ~1600 across 5 files | very high | yes (tool-classifier) |

**Stop conditions:**
- If extraction #2 (`audit.ts`) results in >75 LOC: pause, reassess closure-breakage cost.
- If any extraction breaks tests: revert, diagnose, do not advance.
- If `complete/` or `agent-loop/` extraction reveals shared mutable state we missed: stop, document, plan a separate state-extraction step.

---

## Closure-breakage protocol

Every closure variable currently in `ExecutionEngineLive` must be classified as one of:

1. **Config-derived (constant per task):** thread through `PhaseDeps.config` directly. Examples: `config.defaultModel`, `config.requiredTools`.

2. **Service tag (Effect Context):** declared on `PhaseDeps` once. Examples: `obs`, `eb`, `tools`, `memory`.

3. **Cross-phase mutable state:** hoisted to `PhaseStateRefs` via `Ref.make<T>()`. Examples: `cachedToolDefs`, `effectiveRequiredTools`, `runningContexts`. Phases mutate via `Ref.update`.

4. **Phase-local computation:** stays inside the phase. Example: `verifyPrompt` (local to verify phase).

**Rule:** if a value is set in phase X and read in phase Y, it goes in `PhaseStateRefs`. No exceptions. No bag-of-everything `metadata` blob.

---

## Validation gates

### Per-extraction gate (after each commit)

- [ ] `bun test` in `packages/runtime` returns 738+ pass / 0 fail
- [ ] `bunx tsc --noEmit` clean for affected packages
- [ ] LOC for the new module ≤ planned + 50%
- [ ] No new closure capture between phases (grep for `phase X reads variable defined in phase Y`)

### W23 completion gate

- [ ] `execution-engine.ts` ≤ 600 LOC
- [ ] Every phase module ≤ 400 LOC
- [ ] Phase pipeline is declarative: `phases` array literal in `execution-engine.ts`
- [ ] All 738 existing tests pass
- [ ] `bunx tsc --noEmit` clean across all 27 packages
- [ ] N=3 corpus run shows ≤ 5% variance from baseline (no behavioral regression)
- [ ] At least 3 new unit tests added for phases with decision logic (pipeline composition, tool-classifier, verify-or-debrief)

---

## Out of scope for W23

These belong to later waves:

- **W24** — Strategy RI-scaffolding helper extraction (`runStrategyRiScan` for `plan-execute.ts` and `tree-of-thought.ts`).
- **W26** — `builder.ts` decomposition.
- **Phase B** — Compose API integration (the phase pipeline this wave creates is the Phase B substrate).

---

## Risk register

| Risk | Likelihood | Mitigation |
|---|---|---|
| `agent-loop` shared state is too tangled to extract cleanly | Medium | Extract small phases first; use the validated pattern. If `agent-loop` resists, propose a smaller intermediate split. |
| Closure expansion blows phase LOC budgets | Medium | Calibrate on `audit.ts` first. If 50 LOC becomes 90 LOC, recalibrate all targets. |
| Hidden behavioral regression that integration tests miss | Low | N=3 corpus run validates end-to-end behavior. |
| Phase B (Compose API) needs structural changes we didn't anticipate | Low | Phase contract is explicitly designed to support `.compose()` interception between phases. |

---

## Amendment trigger

If the empirical extraction reveals the architecture needs adjustment (e.g., `agent-loop` truly cannot be cleanly decomposed), this plan is amended in place with a `## Amendments` section noting the date and reason. North Star §11 receives a corresponding row.

---

_Author: Claude (Opus 4.7) — 2026-05-07_
_Status: ACTIVE — partial completion_
_Next action: agent-loop extraction (deferred to next session — see Amendments below)._

---

## Amendments

### 2026-05-07 (session 1) — partial completion checkpoint

**Completed in this session (8 commits, 9 phase modules):**

| # | Phase | LOC | Tests added | Notes |
|---|---|---|---|---|
| 1 | Infrastructure (phase, pipeline, runtime-context, util) | 511 | — | Foundation, hoisted runPhase/runObservablePhase/checkLifecycle |
| 2 | audit | 38 | — | Pattern validator |
| 3 | guardrail | 79 | — | Safety check; fails with GuardrailViolationError |
| 4 | cost-route | 48 | — | Anthropic-only; falls back to defaultModel |
| 5 | cost-track | 53 | — | Reads cacheHit from ctx.metadata |
| 6 | bootstrap | 45 | — | Memory context fetch only; post-bootstrap inline code stays |
| 7 | memory-flush | 175 | — | Dispatch (trivial/moderate/complex) stays in orchestrator |
| 8 | strategy-select | 50 | — | StrategySelector service call |
| 9 | verify | 132 | **5** | TDD RED→GREEN; both call sites use same Phase value |
| 10 | complete | 25 | — | Trivial agentState transition |

**Result:** `execution-engine.ts` 4,663 → 4,232 LOC (-9.2%). All 738+ existing tests still pass; 5 new verify tests added (743 total).

### Findings during extraction

1. **The original 13-file estimate was over.** Actual count: 14 (10 phases + 4 infra). LOC ceilings (≤ 400 per phase) are met for everything extracted; the only file >175 LOC is `pipeline.ts` (313 LOC, infrastructure not a phase).

2. **`complete` phase split was overestimated.** The original plan called for a 3-way split (orchestrator + debrief + post-run-update). Empirical reality: the COMPLETE phase wrapper is 5 lines (just `agentState: "completed"`). The 700+ LOC of post-complete code is `ExecutionContext → TaskResult` orchestration (debrief synthesis, RunReport build, telemetry, calibration writes, lifecycle events). That's NOT a phase — it's a different shape transformation. It stays in the orchestrator. Future waves may extract it as standalone helper modules (NOT phases).

3. **Memory-flush dispatch (trivial/moderate/complex) stays in orchestrator.** The phase body extracted; the if/else dispatch (skip / forkDaemon / blocking) is execution-mode selection, not phase composition.

4. **Bootstrap post-init stays inline.** Post-bootstrap operations (skill application, experience-tip injection, MemorySnapshot publishing) don't fit the Phase contract cleanly. Future waves may promote them.

5. **Cache-hit forward-compat shim is unverified.** I added `ctx.metadata.cacheHit` write in agent-loop's existing inline code; the cost-track phase reads from there. Grep confirms NO test exercises an actual cache-hit path. **When agent-loop is extracted next session, verify cache-hit behavior preservation.**

### Agent-loop extraction — deferred to next session

**Reason for stop:** The agent-loop is qualitatively different from every phase extracted so far:

- **Not a phase, a sub-engine.** Contains ≥4 distinct concerns (LLM call, tool dispatch, semantic cache, classifier, iteration control). Doesn't fit the Phase contract; needs a sub-pipeline pattern decision.
- **Hidden-state inventory not done.** Closure variables not yet classified: `entropyLog`, `toolCallLog`, `cachedToolDefs`, `effectiveRequiredTools`, `resolvedCalibration`, `classifiedRelevantTools`, `cacheHit`, etc. Each needs to be classified as config-derived / service / cross-phase state / phase-local.
- **Risk grows non-linearly.** 1,950 LOC has many more closure-capture sites and a more complex error union than anything extracted so far.

**Next session prerequisites (write before any extraction begins):**

1. **State inventory.** Read agent-loop, list every variable declared in its scope, classify each.
2. **Sub-pipeline decision.** Either:
   - (a) `agent-loop/index.ts` exports a single `Phase` whose `run` body internally composes sub-modules (tool-classifier, reasoning-call, iteration-handler, tool-dispatcher); OR
   - (b) Fan agent-loop out into 3-4 top-level phases that the engine pipeline composes directly. Pros/cons for each before committing.
3. **Cache-hit test.** Add an integration test that exercises the cache-hit path so the cost-track forward-compat shim is verified before further extraction.
4. **Update tactical plan** (this file) with the chosen approach.
5. Then extract.

### W23 partial-completion gate status

The W23 completion gate requires `execution-engine.ts ≤ 600 LOC`. Current: 4,232 LOC. **W23 is not done.** Agent-loop extraction is the largest single remaining reduction (~1,950 LOC at risk of moving). Even after that, post-complete TaskResult assembly will leave the orchestrator at perhaps ~1,000 LOC, requiring a follow-up extraction pass for those helper modules.

This is documented honestly so future sessions don't conclude W23 prematurely.

---

## Amendments — 2026-05-07 (session 2: agent-loop prep)

### Step 1 done — cache-hit integration test (commit `33f072a3`)

Two tests added in `packages/runtime/tests/semantic-cache-hit.test.ts`:
- Cache-hit: LLM count stays 0, cost-track records `cachedHit: true`
- Cache-miss negative control: cost-track records `cachedHit: false`

Both pass on first run. Suite: 745 pass / 1 skip / 0 fail (was 743). **The cache-check sub-module's W23 extraction must keep these tests green.**

### Step 2 done — Cross-phase state inventory

The agent-loop scope (between `// ── Phase 5: AGENT_LOOP ──` and `// ── Phase 6: VERIFY ──`) declares ~50+ variables. Below is the classification per the closure-breakage protocol.

#### Cross-phase state → typed `AgentLoopState`

| Variable | Where set | Where read | Notes |
|---|---|---|---|
| `resolvedCalibration` | setup/calibration.ts | classifier, kernel-call, finalize | The Layer 1 builder substrate. **Per-sub-agent: each child run resolves its OWN.** |
| `cachedToolDefs` | setup/tools-registry.ts | classifier, kernel-call, tool-dispatch, post-kernel | Single fetch; reused everywhere. |
| `effectiveRequiredTools` | setup/classifier.ts | kernel-call (gate, available-tools filter), telemetry | |
| `effectiveRequiredToolQuantities` | setup/classifier.ts | setup/budget.ts, kernel-call | |
| `classifiedRelevantTools` | setup/classifier.ts | kernel-call (filter for available tools) | |
| `cacheHit` | cache-check.ts | kernel-call (skip), cost-track | **Already on `ctx.metadata.cacheHit` as forward-compat shim. Verified by step-1 tests.** |
| `entropyLog` | kernel-call (kernel runner emits ScoredEvent) | reflect, complete (debrief), cost-track, cost-route | Engine-level scope (declared above agent-loop), not just agent-loop. |
| `toolCallLog` | kernel-call (tool dispatch) | reflect, complete (debrief), telemetry | Same — engine-level scope. |
| `unsubscribeReasoningSteps` | kernel-call (subscribe) | finalize (cleanup) | Teardown handle; explicit cleanup needed. |

#### Phase-local (stays inside the relevant sub-module)

`fetchCommunity`, `cal`, `classifierReliability`, `wantsClassification`, `needsClassification`, `classifyResult`, `effectiveRequired`, `demoted`, `qHint`, `demotedRelevant`, `mergedRelevant`, `literalMentions`, `taskText`, `taskTextLower`, `allToolNames`, `autoMaxCallsPerTool`, `requiredQuantities`, `costOpt`, `cached`, `reasoningOpt`, `availableToolNames`, `availableToolSchemas`, `allToolSchemas`, `builtinsOpt`, `optedInBuiltins`, `taskTextForIntent`, `finalAnswerCtx`, `dynamicFinalAnswerDescription`, `dynamicFinalAnswerOutputDesc`, `requiredSet`, `filteredSet`, `taskTextForFilter`, `filtered`, `hiddenCount`, `capturedObs`, `capturedLogModelIO`, `capturedIsDebug`, `pass`, `indent`, `threadLines`, `sysLine`, `rawLine`, `sysPreview`, `userPreview`, `rawContent`, `prefix`, `content`, `memCtx`, `skillCatalogXml`, `episodes`, `cap`, `maxLine`, `meta`, `et`.

#### Config-derived (read from `deps.config`)

`config.{reactiveIntelligenceOptions, calibration, requiredTools, adaptiveToolFiltering, systemPrompt, allowedTools, parallelToolCalls, defaultModel, provider, contextProfile, builtins, logModelIO, maxIterations, observabilityVerbosity, outputValidator, minIterations, verificationStep, customTermination, ...}`. Immutable run-scoped — never goes on state.

#### Service tags (read via `Effect.serviceOption` lazily)

`CostService`, `ReasoningService`, `ObservableLogger`, `MemoryService`, `MemoryConsolidator`, `MemoryExtractor`, `SkillResolverService`, `ExperienceStore`, `StrategySelector`, `VerificationService`, `GuardrailService`, `KillSwitchService`, `BehavioralContractService`, `LLMService`, `ToolService`. Acquired lazily where used.

#### Decision: `AgentLoopState` shape

```typescript
// engine/phases/agent-loop/state.ts
export interface AgentLoopState {
  readonly resolvedCalibration: Ref.Ref<ModelCalibration | undefined>;
  readonly cachedToolDefs: Ref.Ref<readonly ToolDefinition[]>;
  readonly effectiveRequiredTools: Ref.Ref<readonly string[] | undefined>;
  readonly effectiveRequiredToolQuantities: Ref.Ref<Readonly<Record<string, number>> | undefined>;
  readonly classifiedRelevantTools: Ref.Ref<readonly string[] | undefined>;
  readonly unsubscribeReasoningSteps: Ref.Ref<(() => void) | null>;
}
```

`entropyLog` and `toolCallLog` stay at engine scope (declared in execution-engine.ts) since they're consumed by phases beyond agent-loop (debrief, telemetry). `cacheHit` stays on `ctx.metadata.cacheHit` (forward-compat shim, verified by step-1 tests).

#### Sub-agent calibration-aware design — confirmed natively supported

Per the calibration-aware sub-agent design (architecture exploration §10), each spawned ExecutionEngine instance has its own `PhaseStateRefs.AgentLoopState`. The parent's `resolvedCalibration` Ref is independent of any child's. **No changes needed to support per-sub-agent calibration** — it works automatically because state is per-engine-instance.

This is critical for small-model sub-agents: when a child uses qwen3:14b while the parent uses Sonnet, the child's `setup/calibration.ts` resolves `qwen3-14b.json` and Layer 1 builders use those traits — independent of parent's Sonnet calibration.

### Step 3 done — `direct` reasoning strategy (commit `25bc2a81`, plus `9aee5ced` to wire into registry)

- `packages/reasoning/src/strategies/direct.ts` (~210 LOC)
- `DirectInput` accepts `ModelCalibration` for per-sub-agent calibration cascade
- 5 unit tests cover: strategy:'direct' return, default maxIter:1, maxIter:2 with tool round, clamp at 3, memoryContext threading
- Registered in `StrategyRegistryLive` so `ReasoningService.execute({strategy: "direct"})` dispatches correctly

### Step 4 done — setup sub-modules (commits `8ea35b51`, `cbb4d60b`)

Three pure-function helpers extracted to `engine/phases/agent-loop/setup/`:

| Module | LOC | Notes |
|---|---|---|
| `calibration.ts` | 59 | `resolveCalibration(config)` — auto/skip/explicit modes; per-sub-agent works natively |
| `tools-registry.ts` | 103 | `fetchToolsRegistry(config, ctx, obs, isNormal)` — single tool-defs fetch + allowedTools warn + strategy summary log |
| `classifier.ts` | 257 | `classifyTools(params)` — three-branch decision tree (no-classify / low-reliability literal-mention fallback / LLM classify with hallucination demotion + sequential clamp + relevant merge) |

Plus `checkAllowedToolsMismatch` hoisted to `engine/util.ts` (single source; re-exported from execution-engine.ts for backward compat).

5 new unit tests for classifier (deterministic branches; LLMService stub fails-if-invoked confirms zero LLM calls on those paths).

### Step 5 done — cache-check sub-module (commit `563efed5`)

- `engine/phases/agent-loop/cache-check.ts` (~105 LOC)
- `checkSemanticCache(params)` returns `{ ctx, cacheHit }`
- Step-1 integration test passed without modification — regression anchor confirmed

### Cumulative session-2 progress (2026-05-07)

**execution-engine.ts: 4663 → 4014 LOC (-649, -13.9%)**

Tests:
- runtime: 738 → 750 pass (+12 new tests across cache-hit, classifier, verify)
- reasoning: 1119 → 1124 pass (+5 new direct strategy tests)
- 1 skip / 0 fail throughout

8 commits committed. All extracted modules under 400 LOC ceiling (largest: classifier at 257 LOC; pipeline.ts at 313 LOC is infra not a phase).

### Step 6 prep (2026-05-07 11:13pm) — typed `ReasoningService` import

Two prep changes to pave the way for Step 6a:

1. **Added `relevantTools?` and `maxCallsPerTool?` to `ReasoningService.execute` params** in `packages/reasoning/src/services/reasoning-service.ts`. The strategy fn at `strategy-registry.ts:47-50` already accepts them; this just makes the typed contract match. Required for swapping execution-engine's inline string-tag declaration to the proper Tag without losing fields.
2. **Replaced inline `Context.GenericTag<{...}>("ReasoningService")` (~60 LOC) in `execution-engine.ts:993-1054` with `import { ReasoningService } from "@reactive-agents/reasoning"`.** Same string identity (both Tags use `"ReasoningService"`), runtime-equivalent. Pure refactor — no behavior change.

**Result:** execution-engine.ts: 4014 → 3953 LOC (-61). Tests: runtime 750 / reasoning 1124 / 0 fail. The inline LLM-call path (lines ~2050-2700) is now the only ReasoningService-shape duplication left in the engine; once the conditional fallback lands in 6a, that path also collapses to `reasoningOpt.value.execute(...)`.

### Step 6 next — kernel-call sub-module (the big one)

The agent-loop has TWO parallel LLM-call paths (per architecture exploration §1):
- **ReasoningService delegation path** (lines ~1056-1850, ~800 LOC): when `ReasoningService` is wired
- **Inline LLM-call path** (lines ~2050-2700, ~650 LOC): when `ReasoningService` is NOT wired (test mode / minimal layer / no-reasoning fallback)

The `direct` strategy now exists and is registered. Step 6 has two sub-steps:

**Step 6a — migrate inline path to use `ReasoningService.execute({strategy: "reactive"})`.** This requires either:
- (A) Always-wire `ReasoningService` in default service stack (broader change; affects builder.ts wiring)
- (B) Conditional fallback: if `ReasoningService` not wired, instantiate a minimal one inline with the `reactive` strategy registered

Recommend **(B)** for W23 — keeps blast radius small; broader always-wire migration can be a separate follow-up.

> **Strategy choice corrected (2026-05-07 11:13pm):** Original plan said `direct`. Inspection showed `direct.ts:122` clamps `maxIterations` to `Math.min(Math.max(requestedMax, 1), 3)` (hard cap of 3), but the inline LLM-call path at `execution-engine.ts:1931` runs `while (!isComplete && ctx.iteration <= ctx.maxIterations)` where `config.maxIterations` is typically 10+. Using `direct` would silently truncate multi-iteration tasks. **`reactive` is the multi-iteration workhorse and is the correct fallback strategy.** `direct` stays right for chat/streaming UX (1-iteration use cases).

**Step 6b — extract the unified path to `engine/phases/agent-loop/kernel-call.ts`.** Once both paths use `ReasoningService.execute()`, the kernel-call sub-module is just:
1. Build `KernelInput` / `ReasoningInput` from PhaseDeps + ctx state
2. Call `ReasoningService.execute(...)` with the right strategy
3. Capture result into ctx.metadata

Estimated: ~400-500 LOC. With the inline path migrated, the duplication is gone; only one branch remains.

**Pre-step-6 prerequisites:**
1. Decide on (A) vs (B) above
2. Inventory the inline-path-only behaviors that would need preservation (provider fallback events, episodic memory logging shape, stream callback wiring)
3. Confirm cache-hit integration test still green (it should be — no path change yet)

**Risk profile:** medium-high. Each sub-step is invasive. Run full test suite + cross-package typecheck after each commit. If a single commit blows >100 LOC over budget or breaks tests, revert and reassess.

Estimated effort: 1-2 focused sessions for steps 6-9.
