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

289 lines
18 KiB
Markdown

# 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", <altA>, <altB>] (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 <x>, 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.