# PLAN r3 — model-router wave 3-B: a dialog with context, model then effort on change (2026-10-11) r1 → r2 after simplicity CONCERNS(3), robustness FATAL(5), correctness FATAL(8): all three rejected inline-route rows and dialog edits of phases. r2 → r3 after the confirmation (correctness CONCERNS(2)). Precedence r3 > r2 > r1. Contract `.claude/tasks/contracts/2026-10-11-model-router-w3b-dialog-1240.md`. Base = W3-A (22455c0, plan r4, BDR-116). Facts: `agent.offer` payload carries `description` and `source`; `$.ui.ask` takes 2-4 labels + Other; `cfg.models` maps the four aliases to ids; each default alias heads one tier (best fable, big opus, work sonnet, cheap haiku); `readCapped` exists (size cap, stat). ## Decisions - D1 rows stay PHASE NAMES everywhere (routing.json, projects, override). No inline route rows. The dialog's model+effort choice maps to a phase. - D2 the dialog never edits a phase: T3 offers Later / Keep only, with context. Phases change by hand in routing.json + DEFAULT_CONFIG (census lock kept). BDR-116 unchanged. - D3 one description rule for every kind, hardened read, no cache. ## Texts (ASK1) - `clean(s, n)`: FIRST fold every `\s` and `\p{Z}` (incl. U+2028/2029) to a space, THEN strip `\p{Cc}` and `\p{Cf}`, collapse spaces, trim; when longer than n code points keep n-1 and append "…" (total ≤ n). Applied to every description and `about` (no answer echo anywhere). - Leads are KEPT as the first words (existing assertions stay valid): "First route for /feat — . Routed to reflect (): claude-fable-5-1 at high. OK?", "First dispatch of security-auditor — . Routed to verify (): claude-sonnet-5-5 at xhigh. OK?", "First use of orchestrate on the main loop — ; used by …: … OK?". Without a description the " — " part is omitted; without `about` the parenthesis is omitted. - Skill row desc = `skillDesc($, st, name)`: name must match `^[A-Za-z0-9][A-Za-z0-9._:-]*$` (else name only); `readCapped` of `${HOME}/.claude/skills//SKILL.md` (stat kind must be `file`, size cap as the override; HOME from `$.env.get`, unset → name only); parse the frontmatter `description:` in its five forms (plain, `|`, `>`/`>-`, single-quoted with `''`, double-quoted): unquote, join the block lines with spaces, cut at the first sentence end (`. ` / `.` at end), then clean(…, 140); any failure → name only. Read at ask time, once (the key is asked at most once per session). The kind check lives in a skill-specific wrapper around `readCapped` (config reads untouched). - Agent row: the offer description goes through the SAME rule (sentence cut + clean 140) WHEN STORED at `agent.offer` (bounded cache `Map`); absent → name only. - Phase (T3): "First use of orchestrate on the main loop — ; used by rows[, the derived route of dispatches][, prompt rule(s)]: claude-fable-5-1 at medium. OK?"; consumers derived: `orchestrate` → "the derived route of dispatches" (hardcoded, pushOrchestrate), prompt rules counted from `cfg.prompt.filter(r => r.phase === name)` (floor rules named "floor rule"). Options ["Later", "Keep"]. - Row options: ["Later", "Keep", "Change"]. At EVERY step the `askOr` LATER (dismissed, headless) is checked first and is silent; any other non-label answer = Later + toast "answer not recognised, default kept" (no echo). `ALTS`, the alt slice in `optionsFor` and the free-text phase branch of `decide` are removed. - Main-row texts keep the real-decision rule (decideFor/mainEffort); spawn texts keep spawnTarget + explicit effort. ## Change flow (rows only) - ASK2 "Which model for ``?": four FIXED labels from the default aliases, each with its tier role ("fable (best)", "opus (big)", "sonnet (work)", "haiku (cheap)"; role = the tier headed by the alias in `cfg.tiers`, "(custom)" when none); Other or an unknown label = Later + toast. - ASK3 "Which effort on ?": the efforts of the phases (in `cfg.phases`) whose tier head is the chosen alias, deduplicated and sorted ASCENDING by LEVELS (fable: medium high xhigh max; opus: xhigh; sonnet: low medium high xhigh; haiku: low) → at most 4 labels (the first 4); exactly one → ASK3 skipped; zero → Later + toast "no phase uses "; Other = Later. - Target phase = the row's CURRENT phase when it matches (alias, effort), else the first matching phase in `cfg.phases` order. Same phase as now → treated as Keep (no `changed` entry). - ASK4 scope as W3-A: ["Everywhere", "This project only"], or ["Everywhere", "Later"] when there is no repo key. - After a CHANGE on a MAIN row (T1, never on Keep), computed in `confirmSkill` from `skillRow` + `decideFor`: toast "`/` now runs at " plus ", main moves up only: `/route switch on` allows a downgrade" when the chosen alias ranks below the session model and the switch is off; the new route is re-picked into the current decision (T1/T2 as W3-A). - Storage unchanged from W3-A: `changed.. = {from (first), to}`, `confirmed.. = to` (strings), `projects[key]` rows. ## `about` - routing.json: `phases..about` (≤ 120 chars, clean) on the 11 phases; loaded into `mem.about: Record` (part of the memory rebuilt with the config, so it survives /clear with cfg; never on `Route`, never in DEFAULT_CONFIG; the census `code_phase` regex untouched); a non-string or overlong `about` is dropped + logged once; a phase without `about` → no parenthesis. acceptPhase ignores the key. ## Steps - [ ] S1 routing.json `about` ×11 (one line each from the W2 phase table); loader + map + validation. - [ ] S2 descriptions: offer cache `{source, description}`; `skillDesc` (allowlist, readCapped, kind check, YAML forms, sentence cut); `clean`. - [ ] S3 ASK1 texts + options Later/Keep/Change; Other → Later + toast; dead code removed (`ALTS`, alt slice, free-text branch). - [ ] S4 change flow ASK2 → ASK3 (derived efforts) → ASK4; target-phase rule; same-phase = Keep; main toast with the real decision. - [ ] S5 `lib/effort-shift.md` dialog lines (≤ 4 changed). - [ ] S6a migrate the W3-A dialog tests: every `asks([...])` answering a phase name at ASK1 becomes Keep or a Change chain (ASK1 → ASK2 → [ASK3] → ASK4; the `asked[n]` indexes shift accordingly); option-list assertions become Later/Keep/Change (T3 Later/Keep); the lead-text assertions (:2266 "First dispatch of feater", :2395 "First use of orchestrate on the main loop") stay valid by construction; :1914 expects the new toast "answer not recognised, default kept"; the two free-text tests (:1871 "a free-text phase counts as an alt", :1878 "equal to the current phase is a Keep") are DELETED (authorized: they test the removed branch); "a phase name answer is a Later" kept. World/helper extensions: `offerOf` takes a description; the World gains a per-path stat `kinds` override and a HOME override (unset). - [ ] S6b new tests, one per clause: skill text with each YAML form (plain, `|`, `>-`, 'sq' with `''`, "dq"), missing SKILL.md → name only, a bad name (`../x`) → name only, stat kind ≠ file → name only, a SKILL.md over the cap → name only, HOME unset → name only, a 500-char description with control, bidi and U+2028 chars → ≤ 140 clean code points ending in "…"; a Keep on a main row → no "now runs" toast; a Change → the toast with the switch hint; zero-effort alias → Later + toast; dismissed ASK2 → silent Later; agent text from the offer description; T3 text with `about` and consumers; ASK2 labels; ASK3 derived efforts per alias (fable 4, opus → skipped, sonnet 4, haiku → skipped); security-auditor Change → opus → (skipped) → Everywhere → row `judge`, spawn on claude-opus-5-5 at xhigh now; feater Change → sonnet → high → `write`; Explore Change → sonnet → medium → stays `explore` (current phase wins) → treated as Keep, no `changed`; `/feat` Change → sonnet → medium → `implement`, toast names "moves up only" with the switch off; Other at ASK1/ASK2/ASK3 → Later + toast, nothing written; project scope with the derived phase; `about` overlong → dropped + logged. - Disposition: honors BDR-116 rules (1), (3)-(6) unchanged (rows = phases, dialog never edits phases, writers/paths/caps as is); AMENDS its clause (2): options become Later/Keep/Change with a model-then-effort chain, Other = Later (successor amendment written at CAPITALIZE, append-only); BDR-115 (full ids from `cfg.models`), LRN-210 (one clause per test), LRN-212 (live mod: announce dialogs). - Docs (README/USAGE/CHANGELOG lines on the dialog) drift after this wave: STEP 6 doc-sync, out of this contract's scope. ## Challenge ledger (r1 → r2) - simp 1/2, rob 2/3/4/7/11/12, corr 1/2/3/9/10/12 (inline rows) → D1: rows stay phases; efforts derived from the alias's phases; target-phase rule with tie-break; same-phase = Keep. - simp 3, rob 1 BLOCKER/5/6/13, corr 4/5 (T3 phase edits) → D2: T3 Later/Keep with context; BDR-116 unchanged. - simp 6, rob 8/9, corr 8 (descriptions) → D3 single rule, five YAML forms, hardened read, allowlist, clean() with Cc/Cf and code points. - simp 7, corr 14 (`about` on Route) → separate map, validated at load. - simp 8, corr 13 (ASK2 Other, aliases) → fixed four labels, Other = Later. - simp 9 (dead code) → S3 removal. - rob 10, corr 6 (main model pick is a placebo) → toast with the real decision and the switch hint. - corr 7 (W3-A tests) → S6a migration step. - corr 11 (no-key scope) → W3-A behaviour kept ([Everywhere, Later]). - corr 15 (docs, AC5 oracle) → doc-sync noted; contract AC5 reworded. ## Confirmation ledger (r2 → r3) - conf 1 (S6a under-listed) → leads kept by construction; toast text, deleted free-text tests, `asked[n]` shifts listed. - conf 2 (read-hardening clauses untested) → S6b tests + World extensions. - conf 3 (ASK3 order) → ascending by LEVELS; tie-break in `cfg.phases` order. - conf 4 (clean order, length) → fold first, strip, n-1 + "…". - conf 5 (dismissed ASK2/3 toasted) → LATER checked first, silent. - conf 6 (zero efforts, texts, echo) → Later + toast; ASK2/ASK3 texts; no echo. - conf 7 (toast on Keep) → Change only, computed in confirmSkill. - conf 8 (BDR-116 clause 2) → amendment at CAPITALIZE. - conf 9 (`about` across /clear) → in `mem`, rebuilt with cfg. - conf 10 (agents vs skills rule) → same rule at offer time, bounded. - conf 11 (hardcoded consumers) → derived from cfg.prompt. - conf 12 (kind check placement) → skill wrapper around readCapped.