feat: add guide steering loop to spike protocol - #312
Conversation
The guide role was vestigial — got a kickoff notification and nothing else. Now the guide reviews each checkpoint and can continue, redirect, or wrap up the investigation. New phase: `steering` (guide, 5m window) sits between exploring and reporting. Explorer checkpoints land in steering; guide verdicts (continue/redirect/wrap_up) route back to exploring or forward to reporting. Steering timeout defaults to continue so the explorer is never blocked by guide silence. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| }, | ||
| steering: { | ||
| actor: 'guide', half: 'bottom', | ||
| on: { continue: 'exploring', redirect: 'exploring', wrap_up: 'reporting', timeout: 'exploring', cancel: 'cancelled' }, |
There was a problem hiding this comment.
Should-fix (flagged by both reviewers): Unbounded explore↔steer loop. When the guide is absent, the cycle is: explore 60m → steer 5m (timeout) → explore 60m → steer 5m (timeout) → … indefinitely. advancePhase resets _phaseStartedAt on each re-entry to exploring (runner:575), so each cycle gets a fresh 60m window.
The old design had a single 60m exploring window that timed out to reporting. This PR removes that natural backstop.
Consider either: (a) a max-steering-timeouts counter that auto-wraps after N consecutive timeouts, (b) a total run duration backstop, or (c) an explicit comment that this is intentional because an absent guide means the explorer will eventually stop checkpointing and hit the 60m exploring timeout.
| }, | ||
| explorer: () => null, | ||
| }, | ||
| onTurn: (run, prevContent) => { |
There was a problem hiding this comment.
Should-fix (flagged by both reviewers): notifications.onTurn is not read by the runner — it reads top-level turnNotification (runner:817-818), not notifications.onTurn. The builder copies spec.turnNotification (dsl:303) but never touches notifications.onTurn.
This means all the new steering/redirect/continue notification branches (lines 92-160) will never execute. The guide will get generic Your turn messages instead of the checkpoint steering prompt, and the explorer won't see redirect/continue context.
Note: The wiring mismatch is pre-existing on main (same issue with the old onTurn), but this PR adds ~70 lines of notification logic that depends on it working. The feature's core UX — telling the guide what to steer and telling the explorer what changed — is inert as written.
Fix: Move onTurn to top-level turnNotification, or update the runner/DSL to support notifications.onTurn.
| }, | ||
| }, | ||
|
|
||
| summaryFormat: (run) => [ |
There was a problem hiding this comment.
Nit: summaryFormat accepts run but returns a static template — no run-specific data (checkpoint count, redirect count, total steering rounds). Compare with review protocol which generates per-round arc placeholders. Not blocking, but a missed opportunity to give the guide useful context in the summary prompt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| }, | ||
|
|
||
| notifications: { | ||
| onKickoff: { |
There was a problem hiding this comment.
Blocker (flagged by both reviewers): onKickoff shape mismatch — will crash at runtime.
The ProtocolSpec type expects onKickoff?: (run: RunState) => string (a single function). This PR passes a per-role object { guide: (run) => ..., explorer: () => null }. The runner's notifyKickoff calls onKickoff?.(run) — invoking a non-function object will throw TypeError. Additionally, the explorer branch returns null where string is expected.
This is distinct from the already-flagged onTurn wiring issue. onTurn is dead code (never consumed); onKickoff IS consumed but with the wrong shape.
Fix: Either update the DSL/runner to support per-role kickoff dispatch, or rewrite as a single function that inspects the recipient role internally.
| } | ||
| if (run.phase === 'exploring') { | ||
| const lastSteer = [...run.decisions].reverse().find(d => | ||
| d.phase === 'steering' && ['continue', 'redirect', 'wrap_up'].includes(d.value), |
There was a problem hiding this comment.
Should-fix: lastSteer lookup is fragile in two ways:
-
Unreachable value: Including
'wrap_up'in the filter is dead code —wrap_uptransitions toreporting, never toexploring, so this branch can't fire inside therun.phase === 'exploring'guard. Remove it to avoid confusion. -
Stale decision after timeout: When steering times out (no decision recorded),
lastSteerpicks up a decision from a previous steering round. If round 1 wasredirectand round 2 times out, the explorer gets the "Guide redirected" message with stale content from round 1 instead of the timeout message. The!prevContentcheck at line 124 may catch the timeout case (if the runner passes no content on timeout), but this depends on runner behavior — consider scoping the lookup to the current round or checking recency.
| options: ['done'] as const, | ||
| descriptions: { done: 'your summary' }, | ||
| events: { done: 'wrap_up' }, | ||
| guide_steer: { |
There was a problem hiding this comment.
Should-fix (flagged by both reviewers): Round counter increments on every continue/redirect with no cap.
The runner's onRunAdvance bumps currentRound whenever a verdict-bearing advance lands on initialPhase with no finalEvent. Since guide_steer has no finalEvent, every continue/redirect increments the round — producing badges like "round 7/3" with no enforcement.
This is a distinct symptom from the unbounded loop already flagged at line 24. Consider adding finalEvent: 'wrap_up' to guide_steer so the runner auto-fires wrap_up when currentRound >= rounds, giving the loop a natural bound.
| exploring: { | ||
| actor: 'explorer', half: 'top', | ||
| on: { checkpoint: 'exploring', wrap_up: 'reporting', timeout: 'reporting', cancel: 'cancelled' }, | ||
| on: { checkpoint: 'steering', timeout: 'reporting', cancel: 'cancelled' }, |
There was a problem hiding this comment.
Should-fix: Explorer has no self-exit path.
The old exploring phase had wrap_up: 'reporting' reachable via the explorer_done decision. This PR removes both — the only path to reporting is through the guide's wrap_up verdict. If the explorer exhausts its investigation but the guide is slow (5m steering timeout defaults back to exploring), the explorer is forced into another 60m investigation cycle.
Consider preserving an explorer-initiated wrap-up path (e.g. a second decision or a wrap_up event on exploring) so the explorer can signal "I'm done" without waiting for the guide.
Summary
steeringphase to the spike protocol so the guide can review explorer checkpoints and steer the investigation mid-flightcontinue(keep going),redirect(change focus),wrap_up(move to reporting)continueso the explorer is never blocked by guide silenceonTurnnotifications distinguish between continue and redirect, carrying the guide's content as contextPhase machine
Test plan
requiredwith 3 options, exploring isnone(updated DSL + runner tests)🤖 Generated with Claude Code