11 KiB
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
vlogsites are DELETED (step line, "answered by", spawn line, route-tool JSON, skill-path line). Notracelevel.vlogstays the single gate;/route verbose on|off, argumentHint and show texts unchanged. - Main transition:
planStepreturns{ plan, why }wherewhy= per-axis cause words computed at decision time (no$call, no re-decision): effort cause frommainEffort().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 = theCall.whywhen it moved (upgrade,fallback: <alias> down,switch) else none.Routedgains an optionallabel: the skill name atrouteMainBySkill/confirmSkill(/feat), the rule's phase atfloorFromPrompt. Rendered: "model-router: main: / → / (: [, ])", e.g. "(plan: skill /ship-feature)", "(orchestrate: dispatch)", "(escalate: prompt floor)", "(reflect: prompt rule)" (turn/source prompt;defaultFromPromptsets the label), "(custom: sticky /route)", "(reflect: skill /feat, upgrade)"; no route in force → "(session[, ])". Model cause fromCall.why: upgrade →upgrade, fallback →fallback: <alias> down, downgrade →switch. Comparison:prev = st.lastPlan ?? {e.model, e.effort}, CAPTURED BEFOREnoteMainoverwriteslastPlan(after /clear or/route on|off, which nulllastPlan, the first step compares to the engine input: stated omission); emitted INSIDEsafely(st, $, 'turn.step log', …)BEFOREnext(a log failure never unroutes a step; own warn key); deduped per turn per (loop, from→to) throughturnLogged(derived orchestrate waves log once per turn). - Agent HAND-OFF line (r3, a dispatch is a model change): emitted in
afterSpawnfor every rowed or explicit spawn, deduped per turn per (type, to) throughturnLogged(three parallel challengers = one line): "model-router: main / → agent / (, )" where from = main'sst.lastPlan(or the engine's session model when none), to = the EFFECTIVE sent plan (started.modelcanonical; 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).Loopgains optional{ type, last: {model, effort}, cause }: set intrackLoopfrom the effective plan;writeLoopupdateseffortandcauseonly (route tool|skill <name>|explicit); each agent step comparesplantoloop.last ?? {e.model, e.effort}, logs "model-router: agent : → ()" on a difference, then setsloop.last = plan(haiku: effort undefined on both sides); no Loop or notype→ labelagent <id8>;endAgentdrops the entry; caps asMAX_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 passesclean(…, 40). - Answered-by anomaly (agents only; main's engine switch is already
logged by
onAutoSwitch): afternext, insidesafely, whenresult.usageis non-null andcanonical(usage.model) !== canonical(plan.model), log once per (agentId, model) viast.loggedkeyanswered:<id>:<model>: "model-router: agent answered by instead of ". - 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:
verboseinvalid ("ON", 1, null) → defaultfalse+ one log per load (mergeConfig falls back to the seed, stated).
D2 Residuals
- (a)
forgetWritecatch ANDaskCommandcatch (the latter gains theRebuildFailedbranch "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)
keptbecomes{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/Restorefields 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
changedscan 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 inassembleANDrefreshMemory;effortsOfandtargetPhaseuse it, so a machine-only phase is never offered nor written; the existing empty-efforts toast reads "no shipped phase uses : add one to routing.json". Tests: a null file phase (ignored, no crash), a file withoutphases(code defaults offered), an override-onlycustom(not offered, not written). - (g)
confirmFirst:answeredflag set afterdecide()returns; the catch deletes the key fromaskedonly when unset. Tests: a throw BETWEENasked.addand the answer (a throwingui.toaston 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); thefailOncededupe 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 = nullon/route on|off. - S2
transitionLine(main, in safely before next, turnLogged dedupe) + agent line inafterSpawn+ agent step comparison + answered-by anomaly;aliasOfcanonical; names cleaned. - S3 residuals (a)-(g).
- S4 docs: README (three
verbosesites: the/routebullet, the key list "default false", the commands table → "transitions only"), USAGE/routerow, 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 throwingui.logleaves 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 attrue(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 (
tracesurface) → removed; boolean kept. - simp 2, corr 2, rob 3/6 (new Map, missing type/cause) →
st.lastPlanLoop {type, last, cause}, dropped in endAgent, capped.
- simp 3/4, corr 1 BLOCKER/3/4/5, rob 3 (why not derivable; spawn move
invisible) →
planStepreturns why from the decision;Routed.label; agent line inafterSpawnwithparentModel. - simp 5, corr 6, rob 2 (answered-by) → agents only, canonical ids, null
usage skipped,
st.loggeddedupe. - 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) →
answeredflag; 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 rulecause + 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.