Files
claude/.claude/tasks/plans/2026-10-11-model-router-w4a-logs-residuals-1637.md
T

184 lines
11 KiB
Markdown

# PLAN r3 — model-router wave 4-A: transition logs + residuals (2026-10-11)
r1 → r2 after simplicity CONCERNS(4), robustness CONCERNS(5), correctness
FATAL(5) (BLOCKER: an agent's model move happens at spawn, invisible at
turn.step); r2 → r3 after the confirmation (correctness CONCERNS(5)).
Precedence r3 > r2 > r1. Contract
`.claude/tasks/contracts/2026-10-11-model-router-w4a-logs-residuals-*.md`.
Base: develop f1cea54. Corrected facts: `Plan` = {model, effort} and
`mainPlan` discards the `Call` (its `why`); `agentPlan` returns `e.model`
verbatim (the spawn hook rewrote it already); `TurnStepInput` carries only
`agentId`; `Loop` = {effort, explicitEffort}; `Routed` = {phase, route,
source} (no skill name, no rule word); `mainRoute` excludes `turnFloor`;
`mainEffort().by` ∈ sticky|turn|floor|engine; no `effortSource`; `st.lastPlan`
holds main's previous plan (nulled on /clear); `st.logged` / `turnLogged`
are the dedupe sets; `AgentSpawnInput.parentModel` exists; `askOr` swallows
a rejected ask into LATER; `forget not applied (${String(err)})` lives in
`forgetWrite` (and its twin in `askCommand`); `buildMemory` has no
models/tiers to validate phases; the user's override sets `verbose: true`.
## D1 Logs: `verbose` stays boolean; true = transitions only
- The four firehose `vlog` sites are DELETED (step line, "answered by",
spawn line, route-tool JSON, skill-path line). No `trace` level. `vlog`
stays the single gate; `/route verbose on|off`, argumentHint and show
texts unchanged.
- Main transition: `planStep` returns `{ plan, why }` where `why` =
per-axis cause words computed at decision time (no `$` call, no
re-decision): effort cause from `mainEffort().by` + its slot (`sticky
/route`, `route tool` (turn, source model), `skill <label>` (run/turn,
source skill|run), `prompt floor` (floor), `dispatch` (derived
orchestrate), `session`); model cause = the `Call.why` when it moved
(`upgrade`, `fallback: <alias> down`, `switch`) else none. `Routed`
gains an optional `label`: the skill name at `routeMainBySkill` /
`confirmSkill` (`/feat`), the rule's phase at `floorFromPrompt`.
Rendered: "model-router: main: <alias>/<effort> → <alias>/<effort>
(<phase>: <cause>[, <model cause>])", e.g. "(plan: skill /ship-feature)",
"(orchestrate: dispatch)", "(escalate: prompt floor)", "(reflect:
prompt rule)" (turn/source prompt; `defaultFromPrompt` sets the label),
"(custom: sticky /route)", "(reflect: skill /feat, upgrade)"; no route
in force → "(session[, <model cause>])". Model cause from `Call.why`:
upgrade → `upgrade`, fallback → `fallback: <alias> down`, downgrade →
`switch`. Comparison: `prev = st.lastPlan ?? {e.model, e.effort}`,
CAPTURED BEFORE `noteMain` overwrites `lastPlan` (after /clear or
`/route on|off`, which null `lastPlan`, the first step compares to the
engine input: stated omission); emitted INSIDE `safely(st, $,
'turn.step log', …)` BEFORE `next` (a log failure never unroutes a
step; own warn key); deduped per turn per (loop, from→to) through
`turnLogged` (derived orchestrate waves log once per turn).
- Agent HAND-OFF line (r3, a dispatch is a model change): emitted in
`afterSpawn` for every rowed or explicit spawn, deduped per turn per
(type, to) through `turnLogged` (three parallel challengers = one
line): "model-router: main <alias>/<effort> → agent <type>
<alias>/<effort> (<model cause>, <effort cause>)" where from = main's
`st.lastPlan` (or the engine's session model when none), to = the
EFFECTIVE sent plan (`started.model` canonical; effort = explicit ??
row effort, stripped on haiku), model cause = `row <phase>` | `explicit`
| `fallback: <alias> down` (in-tier move) | `frontmatter` (tier down,
nothing written), effort cause = `row` | `explicit` | `engine` (no row).
`Loop` gains optional `{ type, last: {model, effort}, cause }`: set in
`trackLoop` from the effective plan; `writeLoop` updates `effort` and
`cause` only (`route tool` | `skill <name>` | `explicit`); each agent
step compares `plan` to `loop.last ?? {e.model, e.effort}`, logs
"model-router: agent <type>: <from> → <to> (<cause>)" on a difference,
then sets `loop.last = plan` (haiku: effort undefined on both sides);
no Loop or no `type` → label `agent <id8>`; `endAgent` drops the entry;
caps as `MAX_AGENT_MODELS`.
- Aliases: `aliasOf(cfg, canonical(cfg, id))` with a `[1m]` marker kept,
the id itself when no alias matches; effort `-` when undefined; every
name in a line passes `clean(…, 40)`.
- Answered-by anomaly (agents only; main's engine switch is already
logged by `onAutoSwitch`): after `next`, inside `safely`, when
`result.usage` is non-null and `canonical(usage.model) !==
canonical(plan.model)`, log once per (agentId, model) via `st.logged`
key `answered:<id>:<model>`: "model-router: agent <type> answered by
<alias> instead of <alias>".
- Unconditional lines stay as today and count as warnings: `logCall`
(upgrade skipped / no fit), `tierDownLog`, `onAutoSwitch`, `warnOnce`,
config drops, toasts. The spinner suffix unchanged.
- Config: `verbose` invalid (`"ON"`, 1, null) → default `false` + one
log per load (mergeConfig falls back to the seed, stated).
## D2 Residuals
- (a) `forgetWrite` catch AND `askCommand` catch (the latter gains the
`RebuildFailed` branch "ask saved, config not rebuilt: /route reload"):
`$.ui.log("model-router: <cmd> failed: " + String(err))`, answers
"forget not applied, see the log" / "ask not applied, see the log".
- (b) `kept` becomes `{kind, name, reason}[]`; `clean(x, 40)` applied to
the file-sourced fragments (name, from, to) at RENDER time only
(`forgetAnswer`, `restoreClause`, `idleAnswer`, kept rendering); the
fixed reason texts untouched; `ForgetPlan`/`Restore` fields untouched
(a 45-char row name is restored byte-exact in the file; test).
- (c) `floorFile`: `SKILL_NAME.test(name)` else undefined.
- (d) `restoreOf`: `name === '__proto__'` → refused ("reserved name").
- (e) the `changed` scan iterates ROWS only.
- (f) `mem.filePhases` = exactly the seeded layer: `mergeConfig({
phases: file.phases }, noop).phases` (code defaults + file phases
validated against the DEFAULT models and tiers, override excluded),
computed in `assemble` AND `refreshMemory`; `effortsOf` and
`targetPhase` use it, so a machine-only phase is never offered nor
written; the existing empty-efforts toast reads "no shipped phase uses
<alias>: add one to routing.json". Tests: a null file phase (ignored,
no crash), a file without `phases` (code defaults offered), an
override-only `custom` (not offered, not written).
- (g) `confirmFirst`: `answered` flag set after `decide()` returns; the
catch deletes the key from `asked` only when unset. Tests: a throw
BETWEEN `asked.add` and the answer (a throwing `ui.toast` on an
unrecognised answer) → key unasked (fails if the delete is removed);
`world.failWrite` (post-answer) → key stays asked, one log; the three
existing tests asserting the old post-answer deletion are inverted
(authorized); the `failOnce` dedupe test rewritten with two different
rows failing.
## Steps
- [ ] S1 delete the four vlog sites; `planStep` → `{plan, why}`;
`Routed.label` (skill name, prompt rule phase); `Loop {type?, last?,
cause?}`; `lastPlan = null` on `/route on|off`.
- [ ] S2 `transitionLine` (main, in safely before next, turnLogged dedupe)
+ agent line in `afterSpawn` + agent step comparison + answered-by
anomaly; `aliasOf` canonical; names cleaned.
- [ ] S3 residuals (a)-(g).
- [ ] S4 docs: README (three `verbose` sites: the `/route` bullet, the key
list "default false", the commands table → "transitions only"),
USAGE `/route` row, CHANGELOG Unreleased (Changed: logs; Fixed: the
residuals). argumentHint unchanged.
- [ ] S5 kit tests (recordLogs recorder; a `usage`-bearing bottom step hook
where needed), one per clause: main unchanged over 3 steps → 0
lines; route tool plan → exactly one line with "(plan: route tool)";
typed `/feat` → "(reflect: skill /feat)"; derived orchestrate → one
line per turn even with two dispatch waves; ultrathink → "(escalate:
prompt floor)"; sticky `/route xhigh` → "(custom: sticky /route)";
sonnet session + typed `/feat` → ", upgrade"; feater spawn → one
agent line "agent feater: fable/high → sonnet/medium (row: feater
implement)" and 0 lines on its next steps; explicit model/effort
spawn words; in-agent route tool effort move → one agent line
"(route tool)"; answered-by anomaly once (usage hook), none when
usage null, none on a `[1m]` plan; a throwing `ui.log` leaves the
step routed; `verbose: false` → nothing; invalid override values
→ default + one log; /clear → first step compared to engine input;
the legacy four lines never appear at `true` (typed skill, route
tool, spawn, step); "explain why" prompt → "(reflect: prompt
rule)"; explicit-effort spawn and haiku spawn → one hand-off line
then 0 lines on their next steps; three parallel challengers → one
hand-off line; tier-down spawn → "frontmatter" cause; (a)-(g) each
with a mutation-sensitive test.
- Disposition: honors BDR-115/116 (no new writer; logs carry ids and
cleaned names only), LRN-210, LRN-212 (live mod: the terminal changes
as soon as the feater saves).
## Challenge ledger (r1 → r2)
- simp 1, corr 11/12 (`trace` surface) → removed; boolean kept.
- simp 2, corr 2, rob 3/6 (new Map, missing type/cause) → `st.lastPlan`
+ `Loop {type, last, cause}`, dropped in endAgent, capped.
- simp 3/4, corr 1 BLOCKER/3/4/5, rob 3 (why not derivable; spawn move
invisible) → `planStep` returns why from the decision; `Routed.label`;
agent line in `afterSpawn` with `parentModel`.
- simp 5, corr 6, rob 2 (answered-by) → agents only, canonical ids, null
usage skipped, `st.logged` dedupe.
- rob 1 (log failure unroutes) → safely before next, own warn key.
- rob 7 (flapping) → per-turn dedupe via turnLogged.
- rob 8 (names) → clean(40) on labels and causes.
- rob 9, corr 12 (invalid verbose) → default + one log, stated.
- rob 10 (/clear) → stated omission; reset at /route on.
- rob 11 (legacy lines untested) → S5 cases.
- simp 6, corr 9, rob 5 ((f) unvalidated / dead toast) → validated seed
layer in assemble + refreshMemory; toast folded.
- simp 7, corr 8 ((g) premise) → `answered` flag; tests inverted.
- rob 4 ((b) clean on plan fields) → render-time only.
- corr 10, rob 12 ((a) site + twin) → forgetWrite + askCommand.
## Confirmation ledger (r2 → r3)
- conf 1 (agent from-effort unsourced, non-changes) → hand-off line from
main's lastPlan, per-axis causes, per-turn dedupe per (type, to).
- conf 2 (loop.last bookkeeping) → last = effective sent plan, writeLoop
updates cause/effort only, each step sets last after comparing; tests.
- conf 3 (prompt rules unlabelled) → `prompt rule` cause + label.
- conf 4 ((b) kept strings) → kept as records, fragments cleaned at render.
- conf 5 ((g) pre-answer test) → throw between asked.add and the answer.
- conf 6 (prev before noteMain) → stated.
- conf 7 (why words, no-route render, alias fallback) → mapping spelled out.
- conf 8 ((f) formula) → `mergeConfig({phases}, noop).phases`.
- conf 9 ((a) askCommand) → RebuildFailed branch added.
- conf 10 (/route on reset) → S1.
- conf 11 (loop without type) → optional fields, id8 label.
- conf 12 (failOnce test) → two rows.