18 KiB
PLAN r4 — model-router wave 3-A: first-use route confirmation + decision memory + project exceptions (2026-10-10)
r1 → r2 after simplicity CONCERNS(3) + correctness FATAL(8); r2 → r3 after
robustness CONCERNS(8) (fresh dispatch on r2 after the first died on a
network error); r3 → r4 after the confirmation pass (correctness
CONCERNS(6), no BLOCKER). Precedence: r4 > r3 > r2 > r1 where they differ. Contract
.claude/tasks/contracts/2026-10-10-model-router-w3a-confirm-1201.md.
Facts (kit types, checked 2026-10-10): $.ui.ask(question, {options, header}) opens the engine's AskUserQuestion dialog, resolves to the chosen
label or the "Other" free text, REJECTS when dismissed and in a -p run;
$.fs.read/stat/exists/write take absolute paths, write creates
directories; $.session.root() = the project root (follows /cd);
$.plugin.root = the plugin directory, absolute (routing.json =
${$.plugin.root}/routing.json, reached through the existing
~/.claude/skills/model-router link); $.ui.toast; $ calls stop the
hook budget clock. The kit has no fs: tests mock bottom fs.*, env.get,
session.root and tool.call AskUserQuestion hooks.
Data (one tracked source for rows AND phases; r3)
mods/model-router/routing.json(tracked):{ "phases": {11 full routes}, "skills": {56}, "agents": {23}, "projects": { "<repo key>": { "skills": {}, "agents": {} } }, "confirmed": { "skills": {}, "agents": {}, "phases": {} }, "changed": { "skills": {}, "agents": {} }, "ask": true }confirmed.<kind>.<name>= the routing.json phase endorsed by a Keep;changed.<kind>.<name>={ "from": <shipped phase>, "to": <phase> }for an "Everywhere" change (the census readsfrom).projects[key]= the "This project only" exceptions. Key (r4) = the origin remote normalized: scheme and userinfo stripped, host lowercased,.gitand trailing slash removed (github.com/acme/app); no remote →local:<root realpath>(meaningful on that machine only, stated in the toast);session.repoor the realpath failing → theprojectslayer is skipped and ASK2 offers Everywhere only. Resolved once per decision and re-resolved onclassic.CwdChanged(rebuild). No version key, no dates. Never a URL with credentials in the file.- NEVER read from the project tree:
<root>/.claude/model-router.jsonis not a layer (a cloned repo must not route the user's agents). Layers, later wins: routing.json (phases, rows, thenprojects[key]rows) <~/.claude/model-router.json(machine override; itsask: falsealso honored). Phases merge first, rows validate against the final table. - Phases: routing.json
phasesreplacesDEFAULT_CONFIG.phasesroute by route; an invalid or missing phase falls back toDEFAULT_CONFIG.phases [name]with one log line (a typo never removes a phase); alts offered by the dialog are filtered against the final table. - Loading: full read at session.start, and LAZILY once when the State was
never loaded (first prompt.submit / skill.prompt / agent.spawn / route
tool after a
/reload-plugins, which re-runs register() without session.start); a failed layer is skipped and logged at that first load only; afterwards any failed read, and a routing.json that went missing, keeps the WHOLE previous config (today's kill-switch rule), and no ask runs while routing.json is unreadable./route showprintsconfig: routing.jsononce loaded.resetSessionkeeps its current set (cfg included); the layers are re-read at the nextprompt.submitand the cfg swapped only after a fully successful read. Session toggles (/route switch,verbose) live in State, re-applied after any rebuild, and added to resetSession's kept set (they survive /clear as today). DEFAULT_CONFIG.skills/.agents={}; the file is the source. A missing routing.json → DEFAULT phases, no rows, one log, no asks, no writes (the writer never creates the file).
Dialog (main loop only; ask true; mod on; never inside an agent)
- Trigger sites and guards:
T1 typed skill with a row:
applyTypedSkillmade async, awaited in the skill.prompt hook OUTSIDEsafely: route FIRST (routeMainBySkill, typed=true), build the text from the route now in force, ask, and on a change re-runrouteMainBySkillwith the recomputedskillRow(the model'sSkillpath never asks). T2 rowed agent spawn: inregisterSpawnbeforespawnTarget, awaited; guardse.parentAgentId === undefined, not frozen, not shadowed, ande.model === undefined(an explicit model is not a routing decision). T3 route tool phase on main (handleRouteTool, no agentId, and only whene.phaseis a phase key — never for an effort-only call): confirm-only. - One dialog (ASK1), 4 options: question text built from the REAL decision:
main →
decideFor/mainEffortlikemainRoutedText("First route for /feat: reflect, next step claude-fable-5-1 at high. Keep it?"); spawn →spawnTarget+ the expliciteffortparam when given ("First dispatch of feater: implement, claude-sonnet-5-5 at medium. Keep it?"); T3 → "First use of orchestrate on the main loop: claude-fable-5-1 at medium. Keep it?" with options ["Later", "Keep"] only. Options T1/T2: ["Later", "Keep", , ] (Later FIRST: an idle auto-pick, if any surface does one, must never write), header "model-router"; alts = the first two of [plan, reflect, implement, apply] (skills) or [judge, implement, verify, apply] (agents) minus the current phase; "Other" free text = a phase key of the final table (else toast "unknown phase , default kept" → Later); an Other equal to the current phase = Keep. Any answer that is not exactly Keep, an alt, or a valid Other → Later (covers dismissed, rejected, auto-resolved idle answers). ASK2 accepts only its two labels; anything else = Later. Before opening any dialog the mod re-reads routing.json (the pre-ask read) and re-checksaskAND "decided" for the key from that fresh content, so a decision taken in another live session is seen. - Keep →
confirmed.<kind>.<name> = <routing.json phase>ANDconfirmed.phases[<phase>] = <phase>(a Keep endorses the phase: no duplicate T3 later); the row applies. "Decided" =confirmedequals the routing.json row, OR aprojects[key]/ machine-override row exists for that name (presence = decided; never compared across layers). - Change (alt/Other) → ASK2 scope ["Everywhere", "This project only"].
Everywhere → routing.json row +
changed.<kind>.<name> = {from, to}(fromkept from the FIRST entry when one exists,to= the new row) +confirmed.<kind>.<name> = to; This project only →projects[key] .<kind>.<name> = phase(routing.json row untouched) +confirmed.<kind> .<name> = the BASE routing.json row(so no other project asks again). One file, one serialized write. Then rebuild; the new route applies to the current decision at once (T1: recomputeskillRow; T2: recomputespawnRouteand pass it to the spawn bookkeeping). A write or rebuild failure → log once, default row applied, key left unasked (never escapes to the hook's.catch). - Later → default applies, nothing written.
- One dialog in flight per session, ever (
st.asking: string | null): a use that finds a dialog open (same key or another) applies its current route and stays unasked (asked later); the owner alone runs ASK1, ASK2 and the write. A parallel spawn never awaits a promise it did not create (hook budget: only the owner's$call stops the clock). Asked-this- session =Set<key>in State, dropped by resetSession. - Never on
turn.step. The ask is awaited in the owning hook with.catch(() => 'Later').
Commands (composer only, as /route today)
/route pending→ keys not yet confirmed (asked-this-session first)./route ask on|off→askin routing.json (write path below); the flag is re-read (one$.fs.read) right before each ask so another session's change is seen./route reload→ re-reads the three layers. (set,confirm, a show suffix: deferred to TODO; the dialog is the writer.)
Writes
writeRouting($, st, patch): read-modify-write of the whole JSON (2-space; key order phases, skills, agents, projects, confirmed, changed, ask), serialized through ONE promise chain in State; refuses (toast, answer treated as Later) when routing.json is absent or unparsable: never creates the file; size cap as the override,readCappedtakes the file label; after a write: rebuild (swap only on a successful read)- toast "routing.json updated: commit it from the config repo (chore
branch,
gitflow.sh start chore …)".
- toast "routing.json updated: commit it from the config repo (chore
branch,
- Writers: dialog answers and composer
/route askONLY. The route tool (model), prompt rules and sub-agents never write. Nothing is ever written into a project tree.
Census and floors (BLOCKER closed)
lib/tests/effort-routing.test.sh: rows AND phases read from routing.json (python3 json); tier heads still parsed fromregister.tstiers(unchanged by this wave; the only register.ts read left); a new lock:DEFAULT_CONFIG.phasesvalues == routing.json phases (the fallback never applies a stale route). The drift lock stays for every row EXCEPT one with achanged.<kind>.<name>entry whosefromphase route equals the frontmatter AND whosetoequals the current row: then oneWARN floor drift: <file> <value> vs row <to> (<value>)line, no FAIL. A Keep-only row, an empty or unknown frontmatter value, a hand edit after a change (to≠ row), or a drift not matchingfromstill FAILs.- BDR-115 amendment 2(a) "census-locked equal" → amended to "equal unless the row is a confirmed user decision (floor may lag; WARN)" at STEP 7.
Steps
- S1 routing.json from the current DEFAULT_CONFIG (phases + rows +
Explore/Plan),
projects/confirmed/changedempty,asktrue; DEFAULT rows →{};loadLayers(two files +projects[key]), first-load degrade then keep-previous rule, phase fallback by name, session toggles in State, resetSession kept set unchanged + re-read at the next prompt.submit. - S2 ask engine:
askFirst($, st, kind, name, text, options)with the single-dialog guard + asked set;decide(answer);applyDecision(Keep / change + scope) → serialized write → rebuild → recompute, wrapped so a failure logs once and applies the default. - S3 wiring T1 (async applyTypedSkill), T2 (registerSpawn), T3 (handleRouteTool, confirm-only).
- S4 commands
pending,ask on|off;reloadreads three layers. - S5 census (rows + phases from JSON, drift lock with the decision
exception) +
lib/effort-shift.md≤ 6 added lines (dialog,/route pending,/route ask). - S6 kit tests. First:
boot()/bootRun()gain a path-aware fixture (bottomfs.exists/fs.stat(+realPath)/fs.read/fs.write,env.getHOME,session.root,session.repo) serving an inline routing.json withask: falseunless a test opts in (boot($, on, {ask: true, project: {...}, home: {...}})); the existing{verifier: null}test becomes path-aware; all 86 tests green again. Then one test per clause: layer precedence (routing.json rows < projects[key] < machine override, null drop; a.claude/model-router.jsonin the project tree is NEVER read; machineask:falsehonored); typed/featfirst use → AskUserQuestion mock sees the id + "high" → "Keep" → fs.write of routing.json captured with confirmed.skills.feat = reflect AND confirmed.phases.reflect → second typed/feat→ no ask; alt "implement" → ASK2 → "Everywhere" → routing.json row + main routed implement now; "This project only" → project file written, routing.json row untouched, confirmed in routing.json; "Later"/reject/free text garbage → default, no write, no second ask this session; agent first spawn → ask → Keep → model written; explicitmodelparam → no ask; two parallel spawns → one ask, the second spawn routed on the current row without waiting; parentAgentId set → no ask; route tool phase first use → ask (Keep/Later) → Keep → confirmed.phases; phase already endorsed by a T1 Keep → no T3 ask;/route ask off→ no asks + write;/route pendingtext; routing.json unreadable at start → DEFAULT phases, empty rows, one log, no asks; unreadable at a later rebuild → previous cfg kept; missing file →/route ask offrefused with a toast, file not created; a phase typo in routing.json → DEFAULT phase by name + log, alts filtered;/route switch onsurvives a rebuild; writes are serialized (two decisions, one file, both present); row edited by hand after a Keep → asked again; census flip-tests: confirmed-only drift FAILs,changed.fromdrift WARNs, emptymodel:FAILs. - S7 live checks after the user's /reload-plugins (EVIDENCE lines,
answers committed on the branch before finish):
/route showprintsconfig: routing.jsonright after the reload (lazy load); one dialog at each of the three sites; a parallel same-agent dispatch answered after more than 10 s (the unowned spawn must be routed, not timed out); what an unanswered dialog resolves to on the terminal (and on sdk/ bridge if reachable): any auto-pick must land on "Later". - Disposition: honors BDR-115 (user writers only; full ids in texts; amendment 2(a) to be amended), BDR-107/108 (phases unchanged), LRN-206 (kit facts), LRN-210 (one clause per test), LRN-211 (typed path only).
- Deferred (TODO):
/route set,/route confirm, show suffix, dialog edits of phases, frontmatter auto-alignment, a session-start warning when routing.json is dirty, per-machineprojects(today one tracked map keyed by repo).
Challenge ledger (r1 → r2)
- correctness 1 BLOCKER + simplicity 3 (census red on a decision) → drift lock with the confirmed-decision exception (WARN), BDR-115 2(a) amended.
- correctness 2/5, simplicity 2 (partial phase overrides, blast radius) → phases move to routing.json as a full table, dialog never edits phases, T3 confirm-only, project layer rows only.
- correctness 3, simplicity 10 (kit loses rows) → S6 path-aware fixture first.
- correctness 4 (texts from the real decision) → ASK1 texts from decideFor/mainEffort and spawnTarget; T2 skipped on explicit model.
- correctness 6, simplicity 9 (sync site) → async applyTypedSkill awaited in the hook.
- correctness 7 (root == HOME) → layer 3 skipped, ASK2 skipped.
- correctness 8/9/10/16/17 (layers, /clear, validation order, stale root, concurrent writes) → re-read from disk on every rebuild, per-layer degrade, phases first, root re-resolved, one write chain.
- correctness 11/12 (Other, counts), simplicity 4/7 → one 4-option dialog, 2 alts, non-matching answers = Later.
- correctness 13 (confirmed location) → always routing.json.
- correctness 14/15 (parentAgentId, fs.stat label) → written in.
- correctness 18 (no live proof) → S7.
- simplicity 1 (commands) → pending + ask only; set/confirm deferred.
- simplicity 5/6/8 → confirmed = phase, no version, one Map, Keep endorses the phase.
- simplicity 11 → project layer rows only.
Challenge ledger (r2 → r3, robustness)
- rob 1/9 (shared promise burns the awaiters' budget; stacked dialogs) → one dialog in flight, owner-only; concurrent uses apply the current route unasked; S7 >10 s check.
- rob 2 (per-layer degrade wipes rows, reopens the kill switch) → degrade at first load only, then keep-previous; no asks while unreadable.
- rob 3 (a phase typo removes the phase, gate STOPs everywhere) → fallback to DEFAULT phase by name + log; alts filtered.
- rob 4 (writer creates a missing file) → writer refuses, never creates.
- rob 5 (global confirmed vs layered rows re-asks forever) → confirmed compared with the routing.json row only; layer rows = decided by presence; one file, one write.
- rob 6 (project-tree layer = security hole + foreign writes) → layer
removed; exceptions in routing.json
projects[key]; nothing written in a project tree. - rob 7 (drift exception too wide) → exemption only for
changed.frommatches; Keep-only, empty and unknown values FAIL. - rob 8 (/clear drops cfg/kill switch/breaker) → kept set unchanged; re-read at next prompt.submit, swap on success.
- rob 10 (session toggles reverted by rebuilds) → toggles in State.
- rob 11 (dirty tracked repo, S7 answers) → toast names the chore flow; S7 answers committed before finish; session-start warning deferred.
- rob 12 (idle auto-pick) → "Later" first; S7 verifies.
- rob 13 (
askonly in the tracked file) → machine overrideaskhonored; flag re-read before each ask. - rob 14 (write failure escapes) → applyDecision wrapped.
Confirmation ledger (r3 → r4, correctness)
- conf 1 (tier heads) → census keeps a tiers parser on register.ts; AC7 CHECK narrowed to the rows/phases parser.
- conf 2 (rows lost after /reload-plugins) → lazy load once; S7 line.
- conf 3 (T1 text before routing) → route first, text, ask, re-route.
- conf 4 (exception re-asks elsewhere) → confirmed = base row on "This project only"; test X then Y.
- conf 5 (multi-session re-ask) → pre-ask read re-checks decided.
- conf 6 (raw remote URL / machine path as key) → normalized key, no
userinfo,
local:fallback, key failure skips projects only. - conf 7 (leftovers, un-gated move of the exception file) → S6/contract fixed; named to the user at the gate.
- conf 8 (fixture) → session.repo + realPath mocked; key failure scoped.
- conf 9 (/cd) → rebuild on
classic.CwdChanged. - conf 10 (changed.from on a second change) → first
fromkept, exempt only when row == to. - conf 11 (unspecified answers) → ASK2 two labels only; Other == current = Keep.
- conf 12 (DEFAULT phases drift) → census lock DEFAULT == routing.json.
- conf 13 (effort-only route call) → T3 only for a phase key.
- conf 14 (later absence) → treated as failed, previous kept.
- conf 15 (toggles across /clear) → added to the kept set.