Files
claude/.claude/tasks/plans/2026-10-10-model-router-w3a-confirm-1201.md
T

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 reads from). projects[key] = the "This project only" exceptions. Key (r4) = the origin remote normalized: scheme and userinfo stripped, host lowercased, .git and trailing slash removed (github.com/acme/app); no remote → local:<root realpath> (meaningful on that machine only, stated in the toast); session.repo or the realpath failing → the projects layer is skipped and ASK2 offers Everywhere only. Resolved once per decision and re-resolved on classic.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.json is not a layer (a cloned repo must not route the user's agents). Layers, later wins: routing.json (phases, rows, then projects[key] rows) < ~/.claude/model-router.json (machine override; its ask: false also honored). Phases merge first, rows validate against the final table.
  • Phases: routing.json phases replaces DEFAULT_CONFIG.phases route by route; an invalid or missing phase falls back to DEFAULT_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 show prints config: routing.json once loaded. resetSession keeps its current set (cfg included); the layers are re-read at the next prompt.submit and 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: applyTypedSkill made async, awaited in the skill.prompt hook OUTSIDE safely: route FIRST (routeMainBySkill, typed=true), build the text from the route now in force, ask, and on a change re-run routeMainBySkill with the recomputed skillRow (the model's Skill path never asks). T2 rowed agent spawn: in registerSpawn before spawnTarget, awaited; guards e.parentAgentId === undefined, not frozen, not shadowed, and e.model === undefined (an explicit model is not a routing decision). T3 route tool phase on main (handleRouteTool, no agentId, and only when e.phase is 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/mainEffort like mainRoutedText ("First route for /feat: reflect, next step claude-fable-5-1 at high. Keep it?"); spawn → spawnTarget + the explicit effort param 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-checks ask AND "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> AND confirmed.phases[<phase>] = <phase> (a Keep endorses the phase: no duplicate T3 later); the row applies. "Decided" = confirmed equals the routing.json row, OR a projects[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} (from kept 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: recompute skillRow; T2: recompute spawnRoute and 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 → ask in 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, readCapped takes 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 …)".
  • Writers: dialog answers and composer /route ask ONLY. 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 from register.ts tiers (unchanged by this wave; the only register.ts read left); a new lock: DEFAULT_CONFIG.phases values == routing.json phases (the fallback never applies a stale route). The drift lock stays for every row EXCEPT one with a changed.<kind>.<name> entry whose from phase route equals the frontmatter AND whose to equals the current row: then one WARN 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 matching from still 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/changed empty, ask true; 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; reload reads 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 (bottom fs.exists/fs.stat (+ realPath)/fs.read/fs.write, env.get HOME, session.root, session.repo) serving an inline routing.json with ask: false unless 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.json in the project tree is NEVER read; machine ask:false honored); typed /feat first 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; explicit model param → 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 pending text; 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 off refused with a toast, file not created; a phase typo in routing.json → DEFAULT phase by name + log, alts filtered; /route switch on survives 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.from drift WARNs, empty model: FAILs.
  • S7 live checks after the user's /reload-plugins (EVIDENCE lines, answers committed on the branch before finish): /route show prints config: routing.json right 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-machine projects (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.from matches; 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 (ask only in the tracked file) → machine override ask honored; 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 from kept, 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.