A rewrite is not finished when the tests pass. It is finished when the shape of the new system makes the next five features trivial to add. That is a stricter criterion. It requires the data types, the seam boundaries, and the composition points to be in the right places before the features arrive — not retrofitted in afterward, when retrofitting means touching everything that was built on top of the wrong shape. The shape audit is the tool the rewrite uses to apply that criterion. Chapter 2 used it outward — reading claude-code and codex-rs before a line of v2 production code was written, establishing whether the primitive design was correct by checking what two independent teams converged on independently. This chapter turns the same lens inward: it cross-checks the v2 runtime's own type contracts against the implementation, finds the places where the shape is already correct, and names the gaps where the implementation has not yet caught up to what the contracts already promise.
The audit verdict is clear: both independent analyses agree that zero dimensions require re-shaping the RunAgent primitive. The recursion model is correct. The seam boundaries are right. What the audit found is not a structural problem — it found seven specific places where the contracts say one thing and the implementation does another. Those are shape gaps. This chapter works through them, not as a to-do list, but as a reading of what the current shape is already promising and has not yet delivered.
A shape gap is not a missing feature. The distinction matters more than it might seem. A missing feature means you need to design something new: decide on the interface, figure out how it fits with existing seams, add it at the right level of abstraction. A shape gap means the design is already done — the field is declared, the seam is named, the contract specifies the behavior — but the implementation has not caught up. The type says one thing; the code does another.
There are three concrete forms a shape gap takes. First, a field declared but never read: the type contract names the field and callers can set it, but no code in the runtime looks at it. Second, a constraint specified but never enforced: the contract says the runtime will stop at iteration_budget turns, but the budget field is never checked in the loop. Third, a seam present in the type but wired to nothing: approvals/__init__.py is pass — an empty module — so any code that imports approval gating imports a stub.
Shape gaps are cheaper to fix before the code that depends on them is written, and this is not a small difference in cost. Once a dozen features build on top of an unwired field — once the rest of the system establishes, in practice, that tools are always empty and skills are never loaded — wiring those fields requires changing every callsite that built on that assumption. The shape audit run before the feature tells you whether the move fits an existing seam or requires a new one. Run after, it tells you what you should have caught earlier. The goal of the audit in Chapter 2 was to establish whether the primitive was correct-shaped before building on it. This chapter is the result: seven gaps, ranked by cross-harness leverage, and a build order that follows from the ranking.
The ranking is what makes the audit a roadmap rather than a list. "How many features are blocked or complicated by each gap?" is a different question than "what is wrong?" Both audits — against claude-code and against codex-rs, run independently — found the same seven gaps and agreed on the same rough leverage ordering. When two analyses working from different reference harnesses in different languages converge on the same set, that convergence is evidence. The gaps are not debatable artifacts of one auditor's preferences. They are what the shape is saying.
Start with the cheapest gap, because the cheapest gap is also one of the most instructive. AgentProfile.tools is declared in contracts.py. It accepts an array of tool references. Every caller can pass it. Nothing in the runtime reads it.
# contracts.py — AgentProfile
@dataclass(frozen=True)
class AgentProfile:
model: ModelSpec
instructions: str
name: str | None = None
tools: tuple[ToolRef, ...] | None = None # declared, zero readers in the runtimeLook at build_toolset — the function that assembles the tool array before a run. It reads profile.subagents. It reads the tool factories registered on the context. It does not read profile.tools. The field exists in the type and is ignored in the behavior. The same is true for skills: the profile shape includes a skills dimension, but no skill loader reads from the profile at runtime. The contracts say the agent is configured with tools and skills; the code configures the agent without them.
The reason G0 is ranked first is not that it is the most impactful gap in isolation — G2 blocks more features — but that the fix is the cheapest of any gap in the set. This is "impl behind existing shape." The contract does not need to change. The interface is already correct. What is missing is a ToolRef-string-to-AgentTool resolver that reads profile.tools and a SkillLoader that reads the skills dimension. Wiring those two things re-activates declarative tool and skill composition, enables per-subagent tool scoping, and lands the mount-half of the entire Skills dimension at once. One implementation change, three unlocks, zero contract changes.
The approvals/ module is in the same category. Currently approvals/__init__.py is pass — an empty file. Many composable recipes in the runtime depend on approval gating: a tool that modifies state should be gateable, a subagent that escalates should be able to request human confirmation. Those recipes depend on a real approvals edge. The current import resolves to a stub, so anything that calls into approvals is silently doing nothing. Fixing G0 means landing the real approvals edge at the same time. The shape already has a slot for it; the behavior just has not arrived.
The second gap is also an inert declaration, but it touches a different part of the system. limits.iteration_budget is declared in contracts.py at line 84 — a maximum number of turns the agent is allowed to take in a single run. The runtime's turn loop does not read it. An agent can run for as many turns as the model wants, regardless of what the caller specified.
The companion problem is on the output side. RunResult carries the conversation output but nothing about why the run ended:
# G1: what exists
@dataclass(frozen=True)
class RunResult:
content: str
# G1: what's needed
@dataclass(frozen=True)
class RunResult:
content: str
stop_reason: Literal[
'completed', 'max_turns', 'aborted', 'overflow',
'model_error', 'result_unavailable', 'max_depth',
]Without stop_reason, a workflow node that hits max iterations is indistinguishable from one that completed successfully but returned an empty string. Both produce a RunResult with a content field. The caller — a workflow orchestrator, a test harness, a retry policy — cannot tell them apart. A workflow that wants to retry on model_error but not on completed cannot implement that logic. A budget guard that wants to surface a timeout to the user cannot distinguish it from a normal completion.
The fix here is two connected changes. Add stop_reason to RunResult and read limits.iteration_budget in the turn loop. They are connected because the stop reason max_turns only makes sense if the budget is actually being enforced. Running them together means the output type becomes informative at the same time the loop becomes bounded. This is low-cost and high-leverage: adding stop_reason unblocks max-turns and max-budget guards throughout the system, and both the claude-code and codex-rs audits independently flagged its absence as a gap. Terminal semantics — what it means for a run to end — are what the rest of the system builds routing and retry logic on top of.
This is the largest shape gap in the set. It is the one that blocks the most downstream features, and it is the one where the distance between what the contract implies and what the runtime delivers is widest.
The current design captures instructions once, at Agent construction time, as a static string. That string goes into every model call, unchanged, for the entire run:
Current:
Agent constructor ← static instructions (captured once)
turn 1 → model call with instructions
turn 2 → model call with same instructions
(no channel for: memory recall, skill grants, steering messages, mid-turn model fallback)
The pi framework that the runtime sits on top of already anticipates the right design. @earendil-works/pi-agent-core exposes transformContext, prepareNextTurn, and getSteeringMessages. These are the hooks for per-turn context assembly — the place where memory recall injects retrieved chunks, where skill grants add dynamically discovered instructions, where steering messages push mid-turn corrections. The runtime wires none of them. The hooks exist in the dependency; the runtime never calls them.
The fix is a new seam on AgentContext: a recall function and a contextProviders list, both optional. Before each model call, the turn loop calls them to assemble the full context for that turn:
G2 fix:
Agent constructor ← profile + context (with recall/contextProviders seam)
turn N → per-turn context assembly
↓
instructions + recall() + contextProviders + steeringMessages
↓
model call
The reason G2 is ranked above G3 and G4 is that this single missing seam is the bottleneck for seven distinct features: Skills (the L1 budgeted index, dynamic discovery, compaction-survival), Hooks (context injection, end-turn continuation), memory recall, microcompaction, the token-budget keep-working loop, mid-turn model fallback, and prompt-cache prefix discipline. Every one of those features needs a way to inject content into the model's next turn at assembly time. They are all blocked by the same missing seam. G2 is not seven separate problems. It is one seam that seven features depend on.
The shape already contains the right idea. AgentContext is the right owner — it already carries the capability edges (sandbox, model, store, chat), and recall/contextProviders are capability edges of the same kind. The composition root (build_root_context) is the right wiring point. This is the same "impl behind existing shape" pattern as G0, but at larger scale. The contracts say the agent has an injection channel; the implementation has not built it yet.
Chapter 2 described claude-code's hooks system: the HOOK_EVENTS registry fires at lifecycle events with matcher-based routing and workspace-trust gating. An observer can register to see every tool dispatch, every turn decision, every context assembly step, without touching the loop. The loop is stable; the observability attaches at seams.
The current runtime's event contract is minimal:
@dataclass(frozen=True)
class TextDelta:
t: Literal['text_delta'] = 'text_delta'
text: str
@dataclass(frozen=True)
class Message:
t: Literal['message'] = 'message'
content: str
@dataclass(frozen=True)
class ToolCall:
t: Literal['tool_call'] = 'tool_call'
name: str
@dataclass(frozen=True)
class Error:
t: Literal['error'] = 'error'
message: str
type RunEvent = TextDelta | Message | ToolCall | ErrorThis is sufficient for rendering progress to a web client. It is not sufficient for the runtime's own loop hooks. A hook that wants to observe turn decision points — to decide whether to inject context, whether to trigger end-turn continuation, whether to apply a budget check — needs to see the loop's internal structure. The current event type exposes the outputs (text, messages, tool names) but not the decision points (turn started, context assembled, tool dispatched, turn ended). External renderers need the former; internal hooks need the latter.
G3 is about widening the event and decision contract to expose the loop's internal structure to registered hooks. The specific addition is a set of lifecycle events — turn_start, context_assembled, tool_dispatched, turn_end — that carry enough state for a hook to act on them. One contract change collapses four pending gaps in the shape audit checklist. This is the same principle as G2: a single missing seam that blocks multiple downstream features. The difference is that G2 is about context flowing in to the model, and G3 is about events flowing out of the loop to observers.
G4 is different in kind from the gaps above. G0, G1, and G3 are fixes to existing seams — places where the shape already names something the behavior has not delivered. G2 requires a new seam (the context-assembly call site), which is why it sits between the additive fixes and the new edges. G4 is a new edge. It did not exist in v1 and is not implied by the current contracts. It is the feature that justifies the product thesis behind the rewrite.
The Cy thesis is that an agent can write code, execute it, observe the output, and revise — iteratively, in a loop, until the code does what it was supposed to do. The model writes a Python script. The script runs in the sandbox. The output comes back. The model reads the output and revises the script. This is not a complex design in the abstract. The execute_code tool already exists. The sandbox already runs code. What does not exist is the reverse transport: a mechanism for the tool's output to flow back into the model's next turn as first-class content, not just as a text blob in the conversation history.
The codex-rs audit recommends building G4 as an atomic cell-actor protocol: CellId + nested-tool-call + wait/yield as one mechanism, not a bare RPC. The reason is subtle but important. If the reverse transport is a bare RPC — the tool calls the sandbox, waits, returns the output — then long-running scripts block the turn loop. The loop cannot do anything while the script runs. With a cell-actor protocol, the loop yields at the tool boundary, the cell runs independently, and the loop resumes when the cell delivers its result. This keeps the turn loop non-blocking and lets the runtime handle multiple concurrent tool calls correctly. It is a new edge, but it needs to be built with the right shape from the start — the shape that lets iteration work for multi-second scripts, not just fast computations.
Both audits agree on the current subagent model: the synchronous tree — delegate → narrow_for_subagent → runAgent, depth-capped — is correctly shaped and better than either reference harness for deterministic fan-out. The recursion primitive is right. The tree is bounded. The child isolation (chat: null, denyAll approvals) is enforced at the single seam where subagents are narrowed, not distributed across callers. This is not a gap; it is a strength.
What is missing is the peer graph: detached background runs, per-child stop signals, peer messaging, worktree isolation per child. A root agent that spawns a child and waits synchronously for it to complete is a tree. A root agent that spawns a child, continues its own work, and later inspects the child's result is a peer graph. Those are different shapes, and converting one to the other is not a local change. It alters the fundamental topology of the primitive from a tree with one execution frontier to a graph with multiple concurrent frontiers.
Both audits recommend deferring G5 unless cross-agent coordination is a hard product requirement, and the shape audit synthesis agrees. The reason is not that peer graphs are hard to build — it is that building one before it is required introduces complexity that all subsequent features have to navigate. The completion-injection seam already exists (deliver_trigger → role: 'signal'); only the producer side is missing. If G5 becomes necessary, the right move is to ship the cheap half first: a background-run handle, per-child stop signals, and a durable spawn-edge store. Defer the live peer-mailbox until the product genuinely requires it. The recursion primitive is already correct; do not reshape it for a feature the product may never need.
G6 is a catch-all for small, independent changes that do not justify their own gap number but that are natural extension points as the system grows. Three examples from the audit:
Adding fields to SessionRef for affinity routing: storageKey is present, affinityKey is declared optional but not consistently used. A storage backend that routes sessions to specific nodes based on affinity needs this field to be populated and read. Widening MessageContent for richer payload types: the current union is string | { markdown: string }. As the runtime handles more platform types — Slack blocks, Discord embeds, structured tool output — the content union needs to grow. Exposing structured tool-call metadata on RunEvent: currently a tool call event carries only the tool name. A hook that wants to log arguments, measure latency, or route on tool identity needs more.
None of these widenings require a contract redesign. They are additive changes — new optional fields, new union members — that fit naturally into the existing seams. Their leverage is low individually; they are not blocking any specific feature. They are worth naming because they are predictable: every system of this kind eventually needs affinity routing, richer content types, and structured observability. Knowing that those extension points exist and are already roughly in the right place means you can add them when the need arrives without reopening a design discussion.
The gap roadmap is the rewrite's success criterion stated concretely. Not "the tests pass" and not "the architecture is clean" — but "the shape makes the next five features trivial to add." The audit's verdict is that this criterion is almost met. The primitive is correctly defined. The recursion model is correct. The seven gaps are not structural problems; they are places where the contracts already promise something the implementation has not yet delivered.
Two independent audits, against two different reference harnesses (claude-code and codex-rs), each across nine dimensions, arrived at the same list. That independence is the methodology's value. A single audit can reflect the auditor's priors as much as the code's actual shape. Two audits that converge — without sharing notes — are evidence that the gaps are real and the ranking is right. Every load-bearing claim in the gap list is a concrete, greppable fact: profile.tools has zero readers, approvals/__init__.py is pass, limits.iteration_budget is never checked in the loop, prepareNextTurn is exposed by pi but never called.
The build order that follows from the leverage ranking:
G0 → G1 → G2 → G3 → G4 → (G5 if required) → G6 as needed
G0 and G1 are free wins: no contract changes, cheap implementation, immediate unlocks. G2 is the single highest-leverage move in the set — one seam that unblocks seven features — and should come immediately after. G3 extends the event contract to support the hooks that G2 enables. G4 builds the new edge that the product thesis requires. G5 reshapes the primitive and should wait until the product explicitly requires peer-graph semantics. G6 grows in naturally as the system reaches each extension point.
A gap list is not a backlog. A backlog is a list of things to build. A gap list is a reading of what the current shape already promises. The work is not to add these features to the runtime — it is to bring the runtime's behavior into alignment with contracts it already declares. That is a different kind of work, and it is cheaper than it looks, because the shape is already right.