Skip to content

docs(agents): the program plan puts the front door first — a request routes itself to a preset, and the pi series waits behind it - #1019

Merged
justinhelmer merged 1 commit into
mainfrom
docs/plan-front-door-router
Sep 14, 2026
Merged

docs(agents): the program plan puts the front door first — a request routes itself to a preset, and the pi series waits behind it#1019
justinhelmer merged 1 commit into
mainfrom
docs/plan-front-door-router

Conversation

@justinhelmer

Copy link
Copy Markdown
Contributor

The orchestration program's plan now puts the "front door" — an @-mention with no agent: directive picks its own preset and says why on the card — ahead of the remaining pi harness steps, because the owner judged that people need it for the product to be useful to them. Three new units (U30 to U32) design it from record 0026; the pi series' second step (U26) waits behind them.

What & why

Record 0026 designed the router as its slice two: a filter after resolveRun, run only when the agent would otherwise be defaults.agent, on the deployment's fast model, answering a preset with a one-line reason, cheap, allowed to be wrong and harmless when wrong. The program plan (parent #821) listed that slice as a sibling board item owned elsewhere. On 2026-09-14 the owner reprioritized it over the pi series ("people really need that feature for it to be useful to them so it might make sense over pi as a priority"), so this amendment — in the shape of #957 and #1005 — absorbs it as Phase H with requirements R45 to R49, KD5, KTD14 and KTD15, three units, three board items, and the graph and phase table reordered so U26 waits on U32 as well as U25.

  • #1016 U30 — the router behind routing.auto, off by default, the offline replay (npm run load -- route) as its gate, and a thread-reply pause (go) before any write preset dispatches.
  • #1017 U31 — the default flips after a week of production routes at the bar; help says "describe what you want"; the one-word override on the card.
  • #1018 U32 — compound requests route to the conductor, one child per part, the pause rule at the parent, a checked-in set of twenty compounds in the replay.

The plan is proposed, so its body may be amended (decisions:check holds accepted bodies only). Record 0026 is accepted and untouched. Another agent is building U30's first pull request in parallel; both cite record 0026.

Tour

1. The owner's decision, as a key decision

KD5 records the reprioritization with the owner's words and the reasoning: the pi series is invisible to a person who mentions the bot, the front door is what that person meets, and the series loses only calendar because U25 and U30 touch disjoint code.

Look for: the claim that U25 is unaffected — the router touches no harness code and the proxy no routing code.

- KD5. **The front door before the pi series.** (Governs R45 to R49; reorders Phase G.) The owner's call, 2026-09-14: "people really need that feature for it to be useful to them so it might make sense over pi as a priority." Record 0026's slice two — the router — is the next work after the proxy (U25) lands; U26 to U29 wait behind U30 to U32. Chosen over finishing the pi series first because the series is invisible to a person who mentions the bot and the front door is what that person meets; the series loses nothing but calendar, since U25 touches no routing code and U30 touches no harness code.

2. Five requirements for the front door

R45 to R49 turn record 0026's Routing section into testable statements: when the router runs and when it is skipped (R45), what every routed run shows and how a routed thread stays sticky (R46), the pause for write presets (R47), the compound shape (R48) and the replay's bar (R49: at least 90 percent on review, coding and research; no coding or ship answer for a read-only truth).

Look for: R45's invariants — the router never emits a profile, never widens a boundary, never bypasses a gate; a directive always wins.

**The front door: a request routes itself** (record 0026 slice two)
- R45. The router runs only when the preset would otherwise be `defaults.agent` — a directive, the thread's sticky preset, a user `agent` or a channel `agent` each skip it — as a stage between `resolveRun` and the agent gate, so the agent gate, the profile gate and every repository gate judge the routed preset exactly as they judge a typed directive; it is off unless `routing.auto` is true (a boolean, validated at load: any other value or an unknown `routing` key fails the load by name), runs on the model `routing.model` names, else `defaults.models.general` (the fast model the record names), and is handed the request text, the thread's last directives and the preset table rendered from the registry — never hand-copied, and holding only the presets the requester may run — and answers a typed `{ preset, reason }`; a name outside the table, a parse failure, a provider error or a call past its deadline falls back to `defaults.agent` with `routed: fallback (…)`. The router never emits a profile, never widens a boundary (a routed preset's budget is clipped and its identity and class refused by the same gate as a typed one's) and never bypasses a gate; a directive always wins.
- R46. Every routed run says so where it is watched: the card's label carries `<preset> · routed: <reason>`, the record's `profile` carries `chosenBy` (`directive | sticky | user | channel | defaults | router`) and, for a route, `routedReason`, and the run stream carries a `run_note` of kind `routed`; the sticky scan reads a routed preset from the bot's own card label — a turn the adapter marked as the bot's own, which no user can author — so a routed thread stays on its preset until someone types `agent:`, and a thread reply carrying `agent:<preset>` overrides a route the way it overrides any sticky preset.
- R47. A route to a write preset (identity `write` in the registry: `coding` and `ship` today) never dispatches on the router's word alone: the run is admitted and its card posted — `<preset> · routed: <reason> · reply go to start` — and no repository is resolved, no memory read, no executor provisioned and no token minted until the thread answers `go`; a reply carrying `agent:<preset>` re-resolves the pending run as that preset through the same gates; any other reply is folded into the request as a follow-up (the inbox's own rule) and the wait continues; a stop (`runs stop`, the run page's Stop) or `ROUTE_CONFIRM_MS` of silence ends it `refused (route_unconfirmed)` with the card closed by name and the thread's slot released. `general`, `research`, `review`, `explore` and `conductor` route without a pause.
- R48. The router may answer that a request has several independent parts — `{ preset: "conductor", parts: [{ preset, prompt }, …] }`, accepted only when `conductor` and every part's preset are in the offered table and the parts are within `spawn.maxChildren` — and the run becomes a `conductor` run whose request names one child per part with the preset the router chose for it; each child is a `dispatch()` run through `spawnChild()` as the requester under every gate, its text beginning `agent:<preset>` so a child never routes; a compound with any write part pauses once, at the parent, under R47's rule; a deployment that restricts `conductor` never receives a compound answer, because the router is offered only the presets the requester may run.
- R49. The router is gated by an offline replay, `npm run load -- route`: it reads finished agent runs from the run history, takes the preset the run ran as for its truth, asks the router with the record's request text under the production prompt and scores preset accuracy per preset with a confusion table, the sample size per preset, the read-only-to-write count and the count of undirected mentions per day (record 0026's cost question). The bar: at least 90 percent on `review`, `coding` and `research`, and no `coding` or `ship` answer for a truth whose identity is `none` or `read`. The table is the receipt on the unit's board item; the same command over a week of production runs with `chosenBy: router` — a run on the same thread started within the correction window, chosen by a directive naming a different preset, counted as a misroute — gates the default flip (U31); a checked-in set of twenty compound requests gates the compound shape (U32).

3. The phase table: G waits on H

Phase H is a new row; Phase G's "waits on" gains U32 for U26. Phase H's own gate is U21 and U22 (both done), so its first unit can start now.

| G. The harness: pi replaces the native loop (record 0032) | U25 to U29 | record 0032 `accepted` (U10's flip, Justin's); U26 on U25 and on Phase H (U32 receipted — the front door first, the owner's call of 2026-09-14, KD5); U27 on U26; U28 on U27 and U15; U29 on U27 | the record's five gates in order, each on the tracker before the next step starts: the spike's driver through the proxy with equal tokens and the overhead measured; the detached re-attach proof, then five coding tasks on the switchboard resident with a bot kill and a resident roll mid-run, records complete, pull requests opened; a review on a real pull request and a 30-minute explore run on a cold sandbox; a plan run end to end on pi children; `ask` on general and research in production and a conductor run with two children — after which `src/runner.ts`, `src/providers/` and `src/tools/workspace.ts` are gone |
| H. The front door: a request routes itself (record 0026 slice two) | U30 to U32 | U21 and U22 (done); U31 on U30 receipted and a week of production routes at the bar; U32 on U31; runs beside U25 and ahead of U26 (KD5) | an undirected mention routes to a preset with its reason on the card and the record and a directive still wins; the offline replay at or above R49's bar, its table on the board item; no write preset dispatched from a route without `go`; `routing.auto` on by default with the week's production table posted; a two-part message runs as a conductor with one child per part |

4. The dependency graph

Five new edges: U21 and U22 into U30, the chain U30 → U31 → U32, and U32 → U26. The existing U25 → U26 edge stays, so U26 has two predecessors.

U21 --> U30[U30 the router behind a flag, the offline replay its gate]
U22 --> U30
U30 --> U31[U31 routing.auto on by default, help in plain language]
U31 --> U32[U32 compound requests route to the conductor]
U32 --> U26

5. U30 — the router behind a flag, the replay its gate

The unit names the seam (resolve() reports which layer named the agent; the stage sits between resolveRun and authorizeAgent, so both gates judge the routed preset), the flag's validation shape (ship.coordinator's rule before #1011 removed it), the prompt (the preset table rendered from the registry, filtered to the requester's grants), the fallbacks, where a route shows (card label, run_note routed, profile.chosenBy and routedReason, the own-label sticky read), the pause for write presets (before resolveTarget, so no GitHub call, no memory read, no executor before go) and the replay harness with its bar.

Look for: Approach step 5 — the replay's truth is the record's agent, because the input event's text is directive-stripped and the record carries no directive; general runs are excluded until chosenBy exists, and the sample sizes are on the table.

### U30. The router behind a flag, with the offline replay as its gate
- **Goal**: An undirected mention picks its own preset — the fast model reads the request and the preset table, names one with a one-line reason, and the card, the stream and the record say so — off by default, cheap, allowed to be wrong, harmless when wrong: a wrong read route costs one visibly labelled turn, and a write route never dispatches on the router's word alone.
- **Requirements**: R45, R46, R47, R49
- **Dependencies**: U21 and U22 (done: the profile a routed preset resolves through; `explore` and `budget:` in the table). Independent of U25 (the router touches no harness code, the proxy no routing code); the first unit of Phase H, and U26 waits on U32 (KD5).
- **Files**: `src/config.ts` and `src/config/validate.ts` (`AppConfig.routing?: { auto?: boolean; model?: string }`, `routing` in `CONFIG_KEYS`; `validateRouting` with `ROUTING_KEYS` held equal to the type: `auto` anything but `true` or `false` fails by name — the rule `validateShip` had for `ship.coordinator` before #1011 removed it — an unknown key fails by name, `model` must parse as `provider/model`; `ConfigStore.resolve()` returns `agentSource: "request" | "user" | "channel" | "defaults"` beside the triple, so the stage knows when only `defaults.agent` would have answered — `resolveRun` splits `request` into `directive` and `sticky` for `chosenBy`, since both enter the resolver as the request's own agent); new `src/core/dispatch/route.ts` (+ `.test.ts`): `routeRequest(deps, { msg, directives, sticky, resolved, history })` — runs only when `resolved.agentSource === "defaults"`, `directives.agent` and `sticky.agent` are unset and `routing.auto` is true; renders the preset table from the registry (`AGENTS`: name, description, machine, identity, `maxMinutes`), filtered to the presets `deps.config.canRunAgent(msg.userId, name)` admits; caps the request text; calls the provider behind `routing.model ?? defaults.models.general` once, under a `dispatch.route` span with the runner's token attrs and a deadline (`ROUTE_DEADLINE_MS`); parses `{ preset, reason }` the way `parseReflection` parses (fence-strip, shape, a preset outside the table is a fallback); re-resolves through `deps.config.resolve({ request: { agent: preset } })` so the routed preset takes the layers' model and effort; returns the `ResolvedRequest` with `{ chosenBy, routedReason }`; `src/core/dispatcher.ts` (the stage between `resolveRun` and `authorizeAgent`, so the agent gate and the profile gate judge the routed preset; the pause of Approach step 4 after admission and before `resolveTarget` — today the target resolution starts its GitHub round trip and the memory read before the ack card, so a pending run opens its ack card first, from what admission already holds, and starts neither until `go`); `src/core/dispatch/provision.ts` (the label suffix `routed: <reason>` appended the way `budgetClipLabel` is; the go affordance on a paused card; `RunProfileRecord.chosenBy` and `routedReason` on the record and the ledger meta — `src/core/runRecord.ts`, `src/core/runLedger/types.ts` — so a resume keeps them); `src/core/runEvents.ts` (`RunNoteKind` gains `routed`, `route_pending`, `route_confirmed`; the card's note filter unchanged, the run page shows them); `src/core/types.ts` (`HistoryItem.own?: true`), `src/channels/slack.ts` (history marks the bot's own messages `own`: today every `bot_id` reads as `assistant` and none is told apart), `src/directives.ts` (`lastThreadDirectives` reads a routed preset from an own assistant turn's card label — `<glyph> <preset> · routed:` — and from nothing else a bot wrote); `src/core/dispatch/admission.ts` and `src/core/dispatch/settle.ts` (the pause reads the thread's inbox, the path a steer takes; a pending run's slot is released on `route_unconfirmed` like a refusal's); `src/core/configAwareness.ts` (the block names a routed preset and its reason); new `src/load/routeReplay.ts` (+ `.test.ts`) and `scripts/load.ts` (`npm run load -- route`); `config/config.example.yaml` (a commented `routing:` block); `docs/reference/specs/routing-and-config.md` (a new item: the router; item 3 gains the own-label read, item 8 the routed line), `run-history.md` item 45 (`chosenBy`, `routedReason`), `slack-channel.md` (own turns in history; the card's routed label), `thread-admission.md` (a new item: the pause reads the inbox), `load-harness.md` (a new item: the replay), `docs/how-to/turn-features-on-and-off.md` (a `routing` row: what turns it on, what it changes, off is byte for byte today, and the limitation that a channel whose `agent` is set never routes), `docs/explanation/agents-and-toolsets.md`.
- **Approach**:
1. The seam: `resolve()` says which layer named the agent; `routeRequest` runs when that layer is `defaults` and the flag is on; a directive, the sticky preset, a user `agent` or a channel `agent` each skip it (a channel that set one never routes — record 0026's stated limitation, on the how-to row and in Appendix B). With `routing` absent the stage calls no provider and every request resolves byte for byte as today.
2. The call: one prompt, its static half (the preset table and the rules: name one preset, one reason under 120 characters, JSON only) first and cache-controlled, its dynamic half the request text and the thread's last directives; the answer `{ preset, reason }`, the reason capped and redacted like every label. The presets offered are the ones the requester may run, so the router never names a refusal, and the agent gate still runs on the answer (a grant revoked between the table and the gate refuses as a typed directive would). A name outside the table, malformed JSON, a provider error or the deadline → `defaults.agent` with `routed: fallback (<why>)`, one cheap turn on the default preset.
3. Where it shows: the card's label gains `· routed: <reason>`; a `run_note routed` on the stream; `profile.chosenBy` and `routedReason` on the record and the ledger meta; the config block names it; the sticky scan reads the preset from the bot's own card label so "continue" lands on it — only own turns count, so a user cannot forge a route by typing a card-shaped line, and a wrong route persists exactly as long as a wrong directive would, until someone types `agent:`.
4. The pause for write presets — identity `write` read from the registry, never a name list: after the agent gate, the profile gate and admission, the ack card posts `<preset> · routed: <reason> · reply go to start`, a `run_note route_pending`, and the dispatch waits on the thread's inbox before `resolveTarget` — no GitHub call, no memory read, no executor: `go``route_confirmed`, the run proceeds as if typed; a reply carrying `agent:<preset>` → the pending run re-resolves as that preset through the same gates (a read preset proceeds at once, another write preset waits again); any other reply is folded into the request as a follow-up and the wait continues; a stop (`runs stop` answers inline during a live run by thread-admission item 1; the run page's Stop) or `ROUTE_CONFIRM_MS` (ten minutes) of silence → `refuse("route_unconfirmed")`, the card closed by name, the slot released, no repository resolved, no executor, no token. Read presets and `conductor` route without a pause.
5. The replay: `npm run load -- route [--since <days>] [--limit <n>]` pages the state Worker's run history the way `load -- history` does (`WorkerRunStore`; `get` for each record's first `input` event), keeps finished agent runs whose input has text, takes the record's `agent` as the truth — a record written before `chosenBy` existed is taken as a person's or a scope's choice for every preset but the deployment's `defaults.agent`, whose runs are excluded from the score until `chosenBy: directive` says otherwise; a record with `chosenBy: router` is excluded from accuracy and counted for U31 — asks the stage's pure core with the record's text under the production prompt, and writes the receipt: accuracy per preset, a confusion table (truth by answer), the read-only-to-write count, the sample size per preset, the call's p50 and p95, the cache hit rate, and the count of undirected mentions per day in the window (record 0026's cost question). Offline, against the real provider with the operator's key; the scripted provider for the unit test.
6. The bar (R49): at least 90 percent on `review`, `coding` and `research`; zero `coding` or `ship` answers for a truth whose identity is `none` or `read`; the receipt on the board item before the flag is turned on in production.
- **Patterns to follow**: `authorizeAgent` in `src/core/dispatch/authorize.ts` (a gate's refusal by name — `route_unconfirmed` is one); `validateShip` as it stood before #1011 (`ship.coordinator must be true or false`; `SHIP_KEYS` and `unknownKeys` for the unknown-key refusal); `budgetClipLabel` in `src/core/dispatch/provision.ts` (a fact appended to the card label); `parseReflection` in `src/core/memory/reflection.ts` (one model call, one typed parse, a bounded output); `lastThreadDirectives` (derived from history on every message, never stored); `admit` and `steerRun` in `src/core/dispatch/admission.ts` (the inbox the pause reads); `scripts/load.ts` with `src/load/history.ts` (a load command with a receipt and a check); `scripts/agents-gen.ts` (a table rendered from the registry with a check that it matches).
- **Test scenarios**:
- `src/core/dispatch/route.test.ts`: the stage runs only when the agent's source is `defaults`, `routing.auto` is true and no directive or sticky agent is set; a directive, a sticky preset, a user `agent`, a channel `agent` and `auto: false` each skip it with no provider call; the rendered table holds every registered preset the requester may run and none they may not, and a preset added to the registry appears without a code change; a valid answer re-resolves with the layers' model for that preset; a name outside the table, malformed JSON, a provider throw and the deadline each fall back to `defaults.agent` with `fallback (…)`; the reason is capped and redacted; the static half of the prompt is cache-controlled.
- `src/config.test.ts`: `routing.auto: "yes"`, `routing.auto: 1`, `routing.frobnicate` and a `routing.model` without a slash fail the load by name; `routing` absent resolves byte for byte as today; `resolve()` names the agent's source for a directive, a user scope, a channel scope and the defaults.
- `src/core/dispatcher.test.ts` (factory stubbed, provider scripted): an undirected mention routed to `research` reaches the factory with `research`'s profile, the card's label carries `routed:`, the record's `profile.chosenBy` is `router` with the reason, the stream carries `run_note routed`; routed to `coding` posts the card with the go affordance and `route_pending`, resolves no repository, reads no memory, calls no factory and mints no token until a `go` reply, after which the run proceeds as `coding`; `agent:review` in the reply instead re-resolves as `review` and proceeds at once; a plain reply is folded into the request and the wait continues; a stop, or the confirm window elapsing, ends `refused (route_unconfirmed)` with no factory call and the thread's slot free; a routed preset the requester may not run (the grant revoked after the table was rendered) is refused at the agent gate; a routed `explore` in a channel bounded to 45 minutes is clipped like a typed one and a routed `coding` in a channel bounded to `read` is refused like a typed one — the router cannot widen; with `routing` absent the provider is never called by the stage and every existing dispatcher test passes unchanged.
- `src/directives.test.ts`: `lastThreadDirectives` reads `research` from an own assistant turn `⚡ research · routed: …`; the same text on a user turn or a non-own assistant turn sets nothing; a later user `agent:general` wins.
- `src/channels/slack.test.ts`: history marks the bot's own messages `own` and another bot's not.
- `src/load/routeReplay.test.ts`: over a fixture of records the accuracy per preset, the confusion counts, the read-only-to-write count and the sample sizes are exact; a `general` run without `chosenBy` is excluded; a `chosenBy: router` run is excluded from accuracy; the check fails at 89 percent and at one read-only-to-write.
- Edge: two undirected mentions in one thread — the second finds the first's card label and stays on its preset with no second router call.
- **Verification**: `npm test` green; `docs:check` green (the how-to row); the new spec items bound; the replay's receipt, human-gated on the board item: `npm run load -- route` over the last 30 days of production history at or above R49's bar, its table posted with the sample sizes; live, on the board item: an undirected "look at PR 42 in owner/repo" routed to `review` with the card's reason; an undirected "fix the flaky test in owner/repo" routed to `coding`, paused, released by `go`, the pull request opened; the same paused run re-routed by `agent:review`.

6. U31 — the default flips

Gated on a week of production routes scored by a correction signal (a same-thread run within thirty minutes, chosen by a directive naming a different preset, is a misroute). The flip is one default plus a migration note; help, the About block and the config block are rewritten; the one-word override (agent:<preset> in a thread reply) is shown to be the sticky path that already exists, so the unit adds words and a card pointer, not a path.

### U31. The default flips: `routing.auto` on, `help` in plain language, the override on the card
- **Goal**: A person who mentions the bot needs to know nothing about presets: routing is on unless a deployment turns it off, `help` says "describe what you want", and a wrong route is corrected with the one word the card names.
- **Requirements**: R45, R46, R49 (the week's table)
- **Dependencies**: U30 receipted, then a week of production routes on the switchboard deployment with `routing.auto: true` at or above the bar.
- **Files**: `src/config.ts` (`routing.auto` defaults to `true`; `routing: { auto: false }` turns it off); `config/config.example.yaml` (the block documents the default and the opt-out); `docs/reference/migrations.md` (the release's section: what changes for an undirected mention, the one-line opt-out); `docs/how-to/turn-features-on-and-off.md` (the `routing` row's default); `src/core/commands/help.ts` (`DIRECTIVES_HELP` and the agents section rewritten: "just describe what you want — Switchboard picks the agent and says why on the card; add `agent:<preset>` only to force one; reply `agent:<preset>` in the thread to change it"); `src/core/selfDescription.ts` (the About block: a message with no `agent:` is routed to a preset) and `src/core/configAwareness.ts`; `src/core/dispatch/provision.ts` (the routed label's trailer — `reply agent:<preset> to change` — when the route is a fallback or a pause, not on every card); `src/load/routeReplay.ts` (`--production`: the week's `chosenBy: router` runs scored by the correction signal); `docs/reference/specs/routing-and-config.md` (the router item: the default, the override), `docs/explanation/agents-and-toolsets.md` (you rarely need to name one).
- **Approach**:
1. The week: `routing.auto: true` on the switchboard deployment's config after U30's receipt; after seven days `npm run load -- route --production --since 7` scores the routed runs by the correction signal — a run on the same `threadKey` started within thirty minutes of the routed run's end, chosen by a directive naming a different preset, counts as a misroute of the routed run; one naming the same preset does not — and prints the misroute rate per preset beside the offline accuracy and the fallback rate; the bar is R49's; the table is posted on this unit's board item; the flip pull request opens only then.
2. The flip: the default is `true`; `routing: { auto: false }` is the opt-out; the migration note says what an operator sees (an undirected mention now routes, the card says so, `agent:` still wins, a write route waits for `go`) and the one line that keeps today's behaviour.
3. The words: `help` leads with the plain sentence and the directive line becomes the way to force a preset; the About block and the config block say when a route happened and why; the routed card carries the one-word override when the route was a fallback or is paused — `agent:<preset>` in a thread reply. The sticky path already gives the override for a finished run (routing-and-config item 2: a request directive beats the sticky preset, so the reply runs on the named preset with the thread's history, the original request in it, and re-sets stickiness); a paused run's re-resolution is U30's; U31 adds no path, only the card's pointer and the words.
- **Patterns to follow**: the `1.208.0` section of `docs/reference/migrations.md` (a key's change and the smallest edit); `src/core/commands/help.ts` as it stands (the catalogue's shape); `src/load/history.ts` (paging the store).
- **Test scenarios**:
- `src/config.test.ts`: `routing` absent → `auto` is true; `routing: { auto: false }` → off; the validation of U30 unchanged.
- `src/core/dispatcher.test.ts`: with no `routing` block an undirected mention is routed (the flip); `auto: false` restores the `defaults.agent` path with no provider call; every test that asserted `defaults.agent` for an undirected mention either sets `auto: false` or asserts the route, by name.
- `src/core/commands/help.test.ts`: the text leads with "describe what you want" and names `agent:<preset>` as the way to force one and the thread reply as the way to change one.
- `src/load/routeReplay.test.ts`: the correction signal — a directive-chosen run on the same thread within the window naming another preset counts as a misroute, one naming the same preset does not, a run on another thread never does; the fallback rate is counted.
- **Verification**: `npm test` and `docs:check` green; the migration section present; the week's production table on the board item at or above the bar (human-gated, Justin's read); live: `help` in production reads the new text; an undirected mention under a config with no `routing` block routes and its card says why.

7. U32 — compound requests route to the conductor

The router's second answer shape, accepted only when conductor and every part's preset were offered and the parts fit spawn.maxChildren; each child's text begins agent:<preset> so a child never routes; one pause at the parent for any write part; twenty hand-written compounds and ten look-alike singles checked into the harness, with the bar stated (18 of 20; at most 1 of 10 split). The history's conductor runs are few and the unit says so.

### U32. Compound requests route to the conductor
- **Goal**: A message with several independent parts becomes one conductor run that spawns one child per part, each on the preset the router chose for it, under the same pause rule for write presets — and the replay scores the split.
- **Requirements**: R47, R48, R49
- **Dependencies**: U31 (the router is on by default; the compound answer is a second shape of the same call); U23 and U24 (done: `spawnChild()`, `await_runs`, the conductor preset).
- **Files**: `src/core/dispatch/route.ts` (the answer's second shape `{ preset: "conductor", parts: [{ preset, prompt }] }`, accepted only when `conductor` and every part's preset are in the offered table and the parts are within `spawn.maxChildren`; a one-part compound is that part; anything else falls back to `defaults.agent` with `fallback (…)`; the conductor request's text composed from the original message and the parts, each part's text beginning `agent:<preset>` so a child never routes); `src/agents/registry.ts` (the conductor prompt: a request that arrives as a routed compound spawns exactly the listed parts, awaits them all, compiles one answer); `src/core/dispatch/provision.ts` (the label `conductor · routed: <n> parts — <reason>`; the pause when any part is a write preset, the parts listed on the card); `src/core/dispatch/spawn.ts` (read, not changed: a child's text carries its directive); `src/load/routeReplay.ts` (`--compound`: the conductor runs in history — few, the count printed — and the checked-in set) and new `src/load/routeCompound.fixtures.ts` (twenty hand-written compound requests, each with its expected parts and presets, and ten single requests that read as compound but are not); `docs/reference/specs/agent-conductor.md` (a new item: a routed compound), `routing-and-config.md` (the router item extended), `load-harness.md`.
- **Approach**:
1. The signal: the prompt's rules name the compound shape and when to use it — independent parts that need different presets or would each be a run of their own, never one task with steps — the cap `spawn.maxChildren`, and that without `conductor` in the table the router names one preset for the whole; the parse accepts the shape only when every named preset is in the offered table.
2. The run: a compound resolves as `conductor` through the same gates (a requester who may not run `conductor` never saw it in the table); its request text is composed — the original message, then the parts — so the conductor's first turn spawns them: each child's text begins `agent:<preset>` (a directive always wins, so the child never routes), each child through `spawnChild()` as the requester under every gate, depth 1, the cap; the parent awaits and compiles as agent-conductor item 7 says.
3. The pause: any write part → one pause at the parent (`conductor · routed: 3 parts, 1 writes · reply go to start`); `go` releases the whole; a reply carrying `agent:<preset>` collapses the compound to that preset for the whole message.
4. The replay: the compound set is checked in beside the harness and scored on every run of `load -- route --compound` on part count and per-part preset, and the singles on the no-split rate; the history's conductor runs — few, the count printed — are scored the same way where their input text lists parts.
- **Patterns to follow**: agent-conductor item 3 (the one path a child is born); U30's parse and fallback; `src/load/piTasks.ts` (a checked-in task set with expectations the harness scores).
- **Test scenarios**:
- `src/core/dispatch/route.test.ts`: a compound answer with `conductor` offered resolves `conductor` with its parts; a compound answer when `conductor` was not offered, a part naming a preset outside the table, and more parts than the cap each fall back to `defaults.agent` with `fallback (…)`; a one-part compound is that part; the composed conductor text carries `agent:<preset>` per part and the original message.
- `src/core/dispatcher.test.ts` (factory stubbed, spawn observed): a routed compound of two read parts reaches the factory as `conductor` with no pause, and its children carry the directives and never call the router; a compound with a `coding` part pauses at the parent and `go` releases it; the children run every gate (U23's child scenarios pass through the routed path); `agent:review` in the reply collapses the compound to one `review` run.
- `src/load/routeReplay.test.ts`: the compound fixtures are scored on part count and per-part preset; a single that reads as compound counts as a false split; the history's conductor count is printed.
- **Verification**: `npm test` green; the new agent-conductor item bound; the replay's compound table on the board item at the bar — at least 18 of the 20 compounds split into the expected parts with every part's preset right, at most 1 of the 10 singles split — and the history's conductor count printed; live, on the board item: a two-part undirected message in production ("summarize PR 42 in owner/repo and find who last touched its flaky test") runs as a conductor with two children in their own threads and one compiled answer.

8. Two technical decisions

KTD14: the switch is routing.auto (a boolean) beside routing.model, not the record's defaults.models.router doubling as the switch — the record's substance is unchanged and the deviation is flagged for the owner. KTD15: the pause is a thread reply because the Slack card has no interactive element and a thread reply is already heard without a mention.

- KTD14. **The routing switch is a boolean beside a model choice, not a model name that doubles as the switch.** Record 0026's rollout line names `defaults.models.router`, "off when unset"; the plan realizes it as `routing.auto` (a boolean, validated at load the way `ship.coordinator` was until #1011 removed it: anything but `true` or `false` fails by name, an unknown `routing` key fails by name) and `routing.model` (optional; default `defaults.models.general`, the fast model the record names). Chosen because a model name that is also a switch reads a typo as "off" and cannot say "on, with the default model"; the record's substance — off by default in U30, one fast model, a fallback to `defaults.agent` — is unchanged, and the record's open question on which model routes is answered by U30's replay rather than by a key name. Flagged for the owner.
- KTD15. **The pause for a write preset is a thread reply, not a card control.** The Slack card carries text and a link and no interactive element (the tree has no `block_actions` handler), the live page's Stop and Kill are the one write control and they stop, never start, and a thread reply in a thread the bot participates in is heard without a mention (slack-channel item 1); so a routed `coding` run waits on the thread's inbox — the path a steer takes — for `go`, and the run page shows the wait as a `route_pending` note. Chosen over Block Kit buttons (an interactivity endpoint, a second transport for one word) and over routing write presets without a pause (a pull request nobody asked for is the one route that is not harmless when wrong).

9. U26's dependency line agrees with the table

The unit's own Dependencies bullet now names U32, so the unit, the phase table and the graph say one thing. The board item #1007 carries the same note as a comment.

- **Dependencies**: U25 receipted; U32 receipted (Phase H, the front door, lands first — KD5, the owner's call of 2026-09-14); the detached re-attach proof (Approach step 1) before this unit's first pull request.

10. The verification contract gains the router's gate

A new row for npm run load -- route with its three modes (offline, --production, --compound) and the units it applies to; npm test, docs:check and the live-receipts rows gain U30 to U32.

| Router accuracy | `npm run load -- route` (offline, the operator's key; `--production` for the week's correction signal; `--compound` for the checked-in set) | U30 (the gate), U31 (the week), U32 (the compound set) | the receipt's table at or above R49's bar, posted on the unit's board item |

11. Definition of done

One bullet states what "slice two is live" means, including that the offline replay and the week's production table both met the bar before the default flipped.

- Record 0026 slice two is live: an undirected mention routes to a preset with its reason on the card, the stream and the record; a directive, a sticky preset and a scope `agent` each skip the router; a routed profile is clipped and refused by the same gates as a typed one's; no write preset dispatches from a route without `go`; the offline replay met R49's bar and the week's production table met it again before `routing.auto` became the default; `help` says "describe what you want"; a two-part message runs as a conductor with one child per part.

12. Appendix B: the reorder and a new deferral

The pi series' reorder is a row with disposition "decided by the owner 2026-09-14"; a per-channel routing.auto is deferred with its trigger. Two existing rows are updated: tool bundles no longer wait on the router (it routes over presets), and the force-versus-default split of a scope's agent moves from "slice two's concern" to deferred with a trigger.

| The pi series (U26 to U29) reordered behind the front door (U30 to U32) | the owner, 2026-09-14 ("people really need that feature for it to be useful to them so it might make sense over pi as a priority") | Decided by the owner 2026-09-14: U26 waits on U32 as well as U25; U25 is unaffected and may land first; Phase G's five gates and their order are unchanged (KD5). |
| Routing in a channel whose `agent` is set, and a per-channel `routing.auto` | record 0026, Routing; U30 | Deferred; `routing.auto` is deployment-level in U30 and U31 like `spawn.maxChildren`. Trigger: a deployment that wants the front door in some channels and a fixed preset in others. |

13. Record 0026's open question, tracked here

The record left "which model routes, and is its prompt cached" open until slice two; the plan's open-questions table now carries it with U30's replay as the resolver.

| Record 0026's open question: which model routes, and is its prompt cached per deployment? | maintainer | U30's replay: `defaults.models.general` is the default and `routing.model` the override; the receipt prints the call's p50 and p95 and the per-day count of undirected mentions, so the cost is a number before the flag is on; the prompt's static half (the preset table and the rules) is cache-controlled and its hit rate is on the same table | U31 |

14. Remaining changes

  • docs/plans/2026-09-10-001-feat-orchestration-program-plan.md L17–L18 and L22–L23 — Goal Capsule: the Objective names the front door and KD5; Authority says 0026 wins on the router; the Execution profile lists Phase H's specs; Tail ownership names the U31 flip as Justin's read.
  • L31 — Summary: one sentence on the reprioritization.
  • L73 — R26: slice two was a sibling board item until 2026-09-14.
  • L120 and L124 — Scope: slice two moves in scope; only record 0028 stays owned elsewhere.
  • L258 — Sequencing: "Nine phases" and where H sits.
  • L957 — Sources: the owner's call and record 0026's trace and hard case.

Decisions

  • routing.auto + routing.model, not defaults.models.router. Record 0026's rollout line names a model key that doubles as the switch. The plan realizes it as a boolean switch (validated like ship.coordinator was: anything but true/false fails by name) beside an optional model, defaulting to defaults.models.general — the fast model the record names. A model-name switch reads a typo as "off" and cannot say "on, with the default model". Substance unchanged; flagged here for the owner (KTD14).
  • The pause is a thread reply go, not a button. The Slack card carries text and a link and the tree has no block_actions handler; the live page's Stop/Kill stop, they do not start; a thread reply in a participated thread is heard without a mention. So a routed coding run opens its card as pending and waits on the thread's inbox before resolveTarget — no GitHub call, no memory read, no executor, no token. A plain reply folds into the request and the wait continues; a stop or ten minutes of silence ends it route_unconfirmed (KTD15). Chosen over cancelling on any non-go reply, which would kill a run because the user added detail.
  • The replay's truth is the record's agent. The input event is directive-stripped and the record has no directive field, so "the requester typed a directive" is not recoverable from today's history. The replay takes the run's preset as truth for every preset but defaults.agent, excludes general runs until the new chosenBy field says directive, and prints sample sizes. The two bar criteria are computable from today's records (identity from the registry).
  • U30 does not wait on U25. The router and the proxy touch disjoint code, so Phase H starts now; only U26 waits on U32. Phase G's five gates and their order are unchanged.
  • Not changed: record 0026 (accepted, immutable); the .github/ and .depot/ trees; any spec or code — this is the plan only. U30's implementation is another agent's pull request.

Validation

No code or visual changes; the criteria are the repository's checks on the amended plan plus the board state.

Criterion Receipt
Records and plans valid; accepted bodies unchanged npm run decisions:checkdecisions:check ok — 45 record(s) carry a valid status, every superseded_by resolves, accepted bodies unchanged against origin/main
Generated docs and spec proofs still resolve docs:check ok — 10 file(s) match…; specs:check ok — 44 spec(s), 2727 proof reference(s) checked
Public hygiene, project facts, prettier public-hygiene ok — 0 file(s), 0 hit(s); check:project-facts ok — 15 file(s) agree; All matched files use Prettier code style!
Whole gate on the pushed tree npm run verify exit 0 at 38b23098 — 373 test files, 6812 tests passed, 2 skipped; the CI checks on this pull request
PR title is a changelog line with a code-map scope check:pr-title ok — docs(agents): …
Board: three units on project 4, Status Todo, linked from the parent #1016, #1017, #1018 on project 4 with Status Todo; the amendment comment on #821; the dependency note on #1007
Every Tour anchor embeds the lines its step describes each range checked with git show 38b23098:<path> | sed -n before this body was written
Human-gated the owner's read of KTD14 (the config key's name diverges from record 0026's rollout line)

🤖 Generated with Claude Code

…routes itself to a preset, and the pi series waits behind it

Record 0026's slice two — the router — becomes Phase H of the orchestration program (U30 to U32) by the owner's call of 2026-09-14: U26 to U29 wait behind it. U30 is the router behind routing.auto with the offline replay as its gate and a thread-reply pause for write presets; U31 flips the default and rewrites help; U32 routes compound requests to the conductor. Requirements R45 to R49, KD5, KTD14 and KTD15, the phase table, the graph, the verification contract, the definition of done and Appendix B are amended.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM: Docs-only plan update adding Phase H (front-door router, U30–U32); internally consistent, all bookkeeping (phases, graph, gates, DoD, appendices) updated.

  • [nit] F1 docs/plans/2026-09-10-001-feat-orchestration-program-plan.md:139 — KD5 says Phase H comes 'after' U25 lands, while U30 and the phase table say it runs beside U25 independently

Approve — docs-only plan update adding Phase H (the front-door router, U30–U32) per the owner's 2026-09-14 call. The change is internally consistent: requirements R45–R49, KD5, KTD14/15, the reordered dependency graph (U32→U26), the nine-phase table, the verification contract, Definition of Done, and Appendix B dispositions all agree. specs:coverage and test-guard are clean.

One nit (F1): KD5 says slice two is "the next work after the proxy (U25) lands," while U30's Dependencies and the Phase H row say it's independent of U25 and runs beside it — the graph supports "beside"; a one-word tweak in KD5 would remove the ambiguity.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved: coreplane-switchboard[bot] reviewed this PR and posted an LGTM verdict (see its review). This repository opted in through its REVIEW_BOT_LOGIN and REVIEW_BOT_ID variables.

@justinhelmer

Copy link
Copy Markdown
Contributor Author

Review round 1: LGTM with one nit (F1, KD5's "after U25 lands" vs the phase table's "beside U25"). Gate is minor, so the nit is skipped this round; the graph and U30's Dependencies are the binding statements and both say the front door runs beside U25. Merging on the clean verdict.

@justinhelmer
justinhelmer merged commit ed08a9f into main Sep 14, 2026
30 checks passed
@justinhelmer
justinhelmer deleted the docs/plan-front-door-router branch September 14, 2026 05:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant