From 604a6c441126a3e63e5c50c6cca3d258c3334b76 Mon Sep 17 00:00:00 2001 From: bchanot Date: Sun, 11 Oct 2026 14:10:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(model-router):=20wave=203-B=20=E2=80=94=20?= =?UTF-8?q?first-use=20dialog=20with=20context,=20model=20then=20effort=20?= =?UTF-8?q?on=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog now explains what it asks about: the skill's description (from its SKILL.md frontmatter, five YAML forms, first sentence, cleaned), the agent's description (from agent.offer), the phase's new 'about' line in routing.json, and the real model id and effort the next step runs on. Options Later / Keep / Change; Change asks the model (fable, opus, sonnet, haiku with their tier role), then the effort among those the phases of that model offer, then the scope; the pair maps to an existing phase (rows stay phase names; the row's current phase wins a tie; same phase = Keep). A main-loop phase (T3) is Later / Keep only. A main-row change toasts the real decision and the /route switch hint when the pick is a downgrade. Descriptions pass a hardened read (name allowlist, stat kind, size cap) and clean(). Kit suite 190 → 232; W3-A dialog tests migrated. Contract .claude/tasks/contracts/2026-10-11-model-router-w3b-dialog-1240.md, plan r3: 3 lenses (2 BLOCKERs: inline rows, phase edits) + 1 confirmation, feater + 2 rounds, GATE 0 MET, verifier CONFORME (3rd pass), security PASS. --- lib/effort-shift.md | 4 +- mods/model-router/hooks/register.test.ts | 507 +++++++++++++++++++++-- mods/model-router/hooks/register.ts | 319 ++++++++++++-- mods/model-router/routing.json | 41 +- 4 files changed, 769 insertions(+), 102 deletions(-) diff --git a/lib/effort-shift.md b/lib/effort-shift.md index 52f685c..db781a0 100644 --- a/lib/effort-shift.md +++ b/lib/effort-shift.md @@ -48,8 +48,8 @@ Builtin `/effort` is NOT a lever inside a run: rows and routes outrank it. typed marker (unverified live 2026-10-10). - Headless (`-p`, SDK) runs the hooks, so routing works there too. - Mod off: typed agents fall back to their `model:`/`effort:` frontmatter. -- First use of a row asks once (Keep, Later or another phase, then - Everywhere or this project only); the answer is kept in +- First use of a row asks once, with context (Later, Keep or Change: model, + then effort, then Everywhere or this project only); the answer is kept in `mods/model-router/routing.json`. `/route pending` lists what is still unconfirmed, `/route ask off|on` toggles the dialog. diff --git a/mods/model-router/hooks/register.test.ts b/mods/model-router/hooks/register.test.ts index 8e0b884..8c4b626 100644 --- a/mods/model-router/hooks/register.test.ts +++ b/mods/model-router/hooks/register.test.ts @@ -17,6 +17,13 @@ const REAL_ROOT = '/real/work/app' const REMOTE = 'https://github.com/acme/app.git' const KEY = 'github.com/acme/app' const REJECT = '' // an answer the dialog never gets: headless +// A Change answered to the end: model, effort (no scope yet). /feat is on +// reflect (best, high); implement is the first work/medium phase, apply +// the work/low one, judge the only big one (opus has one effort: skipped). +const TO_IMPLEMENT = ['Change', 'sonnet (work)', 'medium'] +const TO_APPLY = ['Change', 'sonnet (work)', 'low'] +const TO_JUDGE = ['Change', 'opus (big)'] +const SCOPE_ASK = 3 // index of the scope question after a Change chain /** What the bottom `fs`, `env`, `session.repo` and dialog hooks serve. */ type World = { @@ -31,6 +38,8 @@ type World = { onAsk: (() => void) | undefined // called when a dialog opens onWrite: (() => void) | undefined // called when an fs.write starts repo: { root: string; remote: string | null } | null | 'fail' + env: string | undefined // HOME as env.get answers it (undefined = unset) + kinds: Map // per-path stat kind override } type Fixture = { @@ -40,6 +49,7 @@ type Fixture = { home?: Rec | 'broken' // ~/.claude/model-router.json answers?: string[] repo?: World['repo'] + noHome?: boolean // HOME unset: env.get answers undefined } /** @@ -103,11 +113,18 @@ function newWorld(fx: Fixture = {}): World { onAsk: undefined, onWrite: undefined, repo: fx.repo === undefined ? { root: ROOT, remote: REMOTE } : fx.repo, + env: fx.noHome ? undefined : HOME, + kinds: new Map(), } } -const slot = (path: string): string => - path.endsWith('/routing.json') ? 'routing' : path +const slot = (path: string): string => { + if (path.endsWith('/routing.json')) return 'routing' + // the engine resolves a relative path against its cwd: `undefined/...` + // (a HOME read as unset and not guarded) comes back absolute + const rel = /\/(undefined\/\.claude\/.*)$/.exec(path) + return rel?.[1] ?? path +} /** routing.json as the world holds it now. */ const routingOf = (w: World): Rec => @@ -118,7 +135,7 @@ const routingOf = (w: World): Rec => * no hook answers is refused, never sent to the disk: see "writes: kit"). */ function installWorld(on: On, w: World): void { - on('env.get', () => ({ value: HOME })) + on('env.get', () => ({ value: w.env })) on('fs.exists', (_a, e) => ({ value: w.files.has(slot(e.path)) || e.path === ROOT, })) @@ -127,7 +144,8 @@ function installWorld(on: On, w: World): void { if (text === undefined && e.path !== ROOT) throw new Error('ENOENT') return { value: { - kind: text === undefined ? 'dir' as const : 'file' as const, + kind: w.kinds.get(e.path) ?? + (text === undefined ? 'dir' as const : 'file' as const), size: text?.length ?? 0, mtimeMs: 0, isLink: false, @@ -991,10 +1009,15 @@ async function bootRun( const spawnOf = (agent: string, extra: object = {}) => ({ ...spawnInput(), subagentType: agent, ...extra }) -const offerOf = ($: Engine, agent: string, source: string) => +const offerOf = ( + $: Engine, + agent: string, + source: string, + description = 'd', +) => $.agent.offer({ agent, - description: 'd', + description, source, provider: { plugin: 'engine', tier: 'core' }, }) @@ -1668,11 +1691,11 @@ test('layers: a cwd change re-reads the layers for the new repo', async ( test('layers: no remote gives no project key, no local: string', async ( $, on) => { - const w = asks(['implement', 'This project only'], + const w = asks([...TO_IMPLEMENT, 'This project only'], { repo: { root: ROOT, remote: null } }) await bootRun($, on, FABLE, w) await typeFeat($) - expect(w.asked[1]?.options).toEqual(['Everywhere', 'Later']) + expect(w.asked[SCOPE_ASK]?.options).toEqual(['Everywhere', 'Later']) expect(w.written).toEqual([]) expect(w.files.get('routing')).not.toContain('local:') expect(w.files.get('routing')).not.toContain(REAL_ROOT) @@ -1696,7 +1719,7 @@ test('layers: a remote with credentials keys without them', async ($, on) => { /** The project keys "This project only" stores for a remote. */ async function keyedBy($: Engine, on: On, remote: string) { - const w = asks(['implement', 'This project only'], + const w = asks([...TO_IMPLEMENT, 'This project only'], { repo: { root: ROOT, remote } }) await bootRun($, on, FABLE, w) await typeFeat($) @@ -1710,7 +1733,7 @@ for (const bad of [ test(`layers: credentials never become a key: ${bad}`, async ($, on) => { const { w, keys } = await keyedBy($, on, bad) expect(keys).toEqual([]) - expect(w.asked[1]?.options).toEqual(['Everywhere', 'Later']) + expect(w.asked[SCOPE_ASK]?.options).toEqual(['Everywhere', 'Later']) expect(w.files.get('routing')).not.toContain('ss@') }) } @@ -1726,7 +1749,7 @@ for (const [guard, bad] of [ test(`layers: ${guard} guard rejects ${bad}`, async ($, on) => { const { w, keys } = await keyedBy($, on, bad) expect(keys).toEqual([]) - expect(w.asked[1]?.options).toEqual(['Everywhere', 'Later']) + expect(w.asked[SCOPE_ASK]?.options).toEqual(['Everywhere', 'Later']) expect(w.files.get('routing')).not.toContain('/srv/') expect(w.files.get('routing')).not.toContain('ss@') }) @@ -1766,7 +1789,7 @@ test('T1: a typed skill asks once, with the real id and effort', async ( const first = w.asked[0] as Asked expect(first.text).toContain('/feat') expect(first.text).toContain('claude-fable-5-1 at high') - expect(first.options).toEqual(['Later', 'Keep', 'plan', 'implement']) + expect(first.options).toEqual(['Later', 'Keep', 'Change']) expect(first.header).toBe('model-router') }) @@ -1797,9 +1820,9 @@ test('T1: Keep leaves the route as it was', async ($, on) => { expect(await mainNow($)).toContain('skill reflect') }) -test('T1: an alt then Everywhere rewrites the row and routes at once', async ( +test('T1: a Change then Everywhere rewrites the row, routes at once', async ( $, on) => { - const w = asks(['implement', 'Everywhere']) + const w = asks([...TO_IMPLEMENT, 'Everywhere']) await bootRun($, on, FABLE, w) await typeFeat($) const file = routingOf(w) @@ -1812,15 +1835,15 @@ test('T1: an alt then Everywhere rewrites the row and routes at once', async ( }) test('T1: the scope question names the two scopes', async ($, on) => { - const w = asks(['implement', 'Later']) + const w = asks([...TO_IMPLEMENT, 'Later']) await bootRun($, on, FABLE, w) await typeFeat($) - expect(w.asked[1]?.options).toEqual(['Everywhere', 'This project only']) + expect(w.asked[SCOPE_ASK]?.options).toEqual(['Everywhere', 'This project only']) }) test('T1: This project only writes the exception, not the row', async ( $, on) => { - const w = asks(['implement', 'This project only']) + const w = asks([...TO_IMPLEMENT, 'This project only']) await bootRun($, on, FABLE, w) await typeFeat($) const file = routingOf(w) @@ -1832,13 +1855,13 @@ test('T1: This project only writes the exception, not the row', async ( }) test('T1: after a project exception no other project asks', async ($, on) => { - const w = asks(['implement', 'This project only']) + const w = asks([...TO_IMPLEMENT, 'This project only']) await bootRun($, on, FABLE, w) await typeFeat($) w.repo = { root: '/work/two', remote: 'https://github.com/acme/two.git' } await $.classic.CwdChanged({ old_cwd: ROOT, new_cwd: '/work/two' }) await typeFeat($) - expect(w.asked).toHaveLength(2) + expect(w.asked).toHaveLength(SCOPE_ASK + 1) expect(await mainNow($)).toContain('skill reflect') }) @@ -1854,7 +1877,7 @@ function clearOf($: Engine) { test('T1: a second change keeps the first shipped phase in changed', async ( $, on) => { on('session.end', ($, e) => ({ sessionId: e.sessionId })) - const w = asks(['implement', 'Everywhere', 'apply', 'Everywhere']) + const w = asks([...TO_IMPLEMENT, 'Everywhere', ...TO_APPLY, 'Everywhere']) await bootRun($, on, FABLE, w) await typeFeat($) const file = routingOf(w) @@ -1868,22 +1891,6 @@ test('T1: a second change keeps the first shipped phase in changed', async ( }) }) -test('T1: a free-text phase counts as an alt', async ($, on) => { - const w = asks(['apply', 'Everywhere']) - await bootRun($, on, FABLE, w) - await typeFeat($) - expect((routingOf(w).skills as Rec).feat).toBe('apply') -}) - -test('T1: a free-text answer equal to the current phase is a Keep', async ( - $, on) => { - const w = asks(['reflect']) - await bootRun($, on, FABLE, w) - await typeFeat($) - expect(confirmedOf(w).skills).toEqual({ feat: 'reflect' }) - expect(w.asked).toHaveLength(1) -}) - test('T1: Later applies the default, writes nothing, asks no more', async ( $, on) => { const w = asks(['Later']) @@ -1905,18 +1912,19 @@ test('T1: a dismissed dialog is a Later', async ($, on) => { expect(await mainNow($)).toContain('skill reflect') }) -test('T1: an unknown phase is a Later and says so', async ($, on) => { +test('T1: an answer that is no label is a Later and says so', async ( + $, on) => { const toasts = recordToasts(on) const w = asks(['banana']) await bootRun($, on, FABLE, w) await typeFeat($) expect(w.written).toEqual([]) - expect(toasts.join('\n')).toContain('unknown phase "banana"') + expect(toasts).toContain('answer not recognised, default kept') expect(await mainNow($)).toContain('skill reflect') }) test('T1: a scope answer that is not a label is a Later', async ($, on) => { - const w = asks(['implement', 'whenever']) + const w = asks([...TO_IMPLEMENT, 'whenever']) await bootRun($, on, FABLE, w) await typeFeat($) expect(w.written).toEqual([]) @@ -1924,10 +1932,10 @@ test('T1: a scope answer that is not a label is a Later', async ($, on) => { }) test('T1: without a repo key only Everywhere is offered', async ($, on) => { - const w = asks(['implement', 'This project only'], { repo: 'fail' }) + const w = asks([...TO_IMPLEMENT, 'This project only'], { repo: 'fail' }) await bootRun($, on, FABLE, w) await typeFeat($) - expect(w.asked[1]?.options).toEqual(['Everywhere', 'Later']) + expect(w.asked[SCOPE_ASK]?.options).toEqual(['Everywhere', 'Later']) expect(w.written).toEqual([]) }) @@ -2062,7 +2070,7 @@ test('T1: two failed writes log the failure once', async ($, on) => { test('T1: a rebuild failing after the write leaves the key unasked', async ( $, on) => { const logs = recordLogs(on) - const w = asks(['implement', 'Everywhere']) + const w = asks([...TO_IMPLEMENT, 'Everywhere']) await bootRun($, on, FABLE, w) w.onWrite = () => { w.files.set(HOME_FILE, '{nope') } await typeFeat($) @@ -2129,9 +2137,9 @@ test('writes: the route tool never writes', async ($, on) => { expect(w.written).toEqual([]) }) -/** An alt answer, then `scope`: the world after the typed /feat. */ +/** A Change chain, then `scope`: the world after the typed /feat. */ async function changeFeat($: Engine, on: On, scope: string) { - const w = asks(['implement', scope]) + const w = asks([...TO_IMPLEMENT, scope]) await bootRun($, on, FABLE, w) await typeFeat($) return w @@ -2265,7 +2273,7 @@ test('T2: a rowed spawn asks with the model and effort it will get', async ( const q = w.asked[0] as Asked expect(q.text).toContain('First dispatch of feater') expect(q.text).toContain('claude-sonnet-5-5 at medium') - expect(q.options).toEqual(['Later', 'Keep', 'judge', 'verify']) + expect(q.options).toEqual(['Later', 'Keep', 'Change']) }) test('T2: the question carries an explicit effort param', async ($, on) => { @@ -2297,7 +2305,7 @@ test('T2: Keep writes the agent row and the spawn goes on', async ($, on) => { test('T2: a change routes this very spawn with the new row', async ( $, on) => { - const w = asks(['judge', 'Everywhere']) + const w = asks([...TO_JUDGE, 'Everywhere']) const rig = await bootRun($, on, FABLE, w) await $.agent.spawn(spawnOf('feater')) expect(rig.specs).toEqual([OPUS]) @@ -2541,3 +2549,410 @@ test('layers: enabled:false in the override survives /clear', async ( expect(await route($, 'show')).toContain('router: off (config)') expect(await featerModel($, rig)).toBeUndefined() }) + +// ---- wave 3-B: a dialog with context, model then effort on a change ------ + +const skillPath = (name: string) => `${HOME}/.claude/skills/${name}/SKILL.md` +const skillMd = (front: string, body = '# body') => + `---\nname: feat\n${front}\nallowed-tools: Read\n---\n${body}\n` +const ROUTED = 'Routed to reflect: claude-fable-5-1 at high. OK?' + +/** A typed /feat with `md` served as its SKILL.md; the world after. */ +async function featWith( + $: Engine, + on: On, + md: string | undefined, + fx: Fixture = {}, +) { + const w = asks(['Later'], fx) + if (md !== undefined) w.files.set(skillPath('feat'), md) + await bootRun($, on, FABLE, w) + await typeFeat($) + return w +} + +/** Edits one phase of the inline routing.json. */ +const setPhase = (phase: string, extra: Rec) => (file: Rec) => { + const phases = file.phases as Record + phases[phase] = { ...phases[phase], ...extra } +} + +for (const [form, front, desc] of [ + ['plain', 'description: Builds a thing. Then more words.', 'Builds a thing'], + ['literal block', 'description: |\n Line one\n runs on. Second.', + 'Line one runs on'], + ['folded block', 'description: >-\n Folded one\n two. Next.', + 'Folded one two'], + ['single-quoted', "description: 'It''s here. More.'", "It's here"], + ['double-quoted', 'description: "Say \\"hi\\" now. More."', 'Say "hi" now'], +] as const) { + test(`text: a ${form} SKILL.md description leads the skill question`, async ( + $, on) => { + const w = await featWith($, on, skillMd(front)) + expect(w.asked[0]?.text).toBe(`First route for /feat — ${desc}. ${ROUTED}`) + }) +} + +test('text: a skill without a SKILL.md is named alone', async ($, on) => { + const w = await featWith($, on, undefined) + expect(w.asked[0]?.text).toBe(`First route for /feat. ${ROUTED}`) +}) + +test('text: a name outside the allowlist reads no file', async ($, on) => { + const w = asks(['Later'], { + edit: f => { (f.skills as Rec)['../x'] = 'reflect' }, + }) + const md = skillMd('description: Escaped. Bad.') + w.files.set(`${HOME}/.claude/skills/../x/SKILL.md`, md) + w.files.set(`${HOME}/.claude/x/SKILL.md`, md) // the folded spelling + await bootRun($, on, FABLE, w) + await typed($, 'hello') + await skillPrompt($, '../x') + expect(w.asked[0]?.text).toBe(`First route for /../x. ${ROUTED}`) + expect(w.reads.filter(p => p.includes('SKILL.md'))).toEqual([]) +}) + +test('text: a SKILL.md that is not a regular file is not read', async ( + $, on) => { + const w = asks(['Later']) + w.files.set(skillPath('feat'), skillMd('description: Hidden. No.')) + w.kinds.set(skillPath('feat'), 'dir') + await bootRun($, on, FABLE, w) + await typeFeat($) + expect(w.asked[0]?.text).toBe(`First route for /feat. ${ROUTED}`) + expect(w.reads).not.toContain(skillPath('feat')) +}) + +test('text: a SKILL.md over the size cap is named alone', async ($, on) => { + const big = skillMd('description: Too big. No.', 'x'.repeat(70000)) + const w = await featWith($, on, big) + expect(w.asked[0]?.text).toBe(`First route for /feat. ${ROUTED}`) +}) + +test('text: HOME unset names the skill alone, reads nothing', async ( + $, on) => { + const w = asks(['Later'], { noHome: true }) + w.files.set(skillPath('feat'), skillMd('description: Hidden. No.')) + // served where an unguarded `${undefined}/.claude/...` would land + for (const root of ['undefined', '']) { + w.files.set(`${root}/.claude/skills/feat/SKILL.md`, + skillMd('description: Unguarded. No.')) + } + await bootRun($, on, FABLE, w) + await typeFeat($) + expect(w.asked[0]?.text).toBe(`First route for /feat. ${ROUTED}`) + expect(w.reads.filter(p => p.includes('SKILL.md'))).toEqual([]) +}) + +test('text: an empty HOME never reads from the root', async ($, on) => { + const w = asks(['Later']) + w.env = '' + w.files.set('/.claude/skills/feat/SKILL.md', + skillMd('description: Hidden. No.')) + await bootRun($, on, FABLE, w) + await typeFeat($) + expect(w.asked[0]?.text).toBe(`First route for /feat. ${ROUTED}`) + expect(w.reads.filter(p => p.includes('SKILL.md'))).toEqual([]) +}) + +test('text: a long description with control chars is cut and cleaned', async ( + $, on) => { + const raw = 'word \u0007‮
'.repeat(100) + const w = await featWith($, on, skillMd(`description: ${raw}`)) + const text = w.asked[0]?.text ?? '' + const desc = text.slice(text.indexOf(' — ') + 3, text.indexOf('. Routed')) + expect([...desc]).toHaveLength(140) + expect(desc.endsWith('…')).toBe(true) + expect(text).not.toMatch(/[\p{Cc}\p{Cf}]/u) +}) + +test('text: an agent question carries the offered description', async ( + $, on) => { + const w = asks(['Later']) + await bootRun($, on, FABLE, w) + await offerOf($, 'feater', 'userSettings', 'Writes the code.\nMore text.') + await $.agent.spawn(spawnOf('feater')) + expect(w.asked[0]?.text).toBe('First dispatch of feater — Writes the code. ' + + 'Routed to implement: claude-sonnet-5-5 at medium. OK?') +}) + +test('text: an agent description is cleaned like a skill one', async ( + $, on) => { + const w = asks(['Later']) + await bootRun($, on, FABLE, w) + await offerOf($, 'feater', 'userSettings', 'a‮b\u0007c'.repeat(100)) + await $.agent.spawn(spawnOf('feater')) + const text = w.asked[0]?.text ?? '' + expect(text).not.toMatch(/[\p{Cc}\p{Cf}]/u) + expect(text.indexOf('. Routed') - text.indexOf(' — ') - 3).toBe(140) +}) + +test('text: the phase about shows in the skill question', async ($, on) => { + const w = await featWith($, on, undefined, + { edit: setPhase('reflect', { about: 'deep thinking' }) }) + expect(w.asked[0]?.text).toBe('First route for /feat. Routed to reflect ' + + '(deep thinking): claude-fable-5-1 at high. OK?') +}) + +test('text: the about is cleaned before it enters a question', async ( + $, on) => { + const w = await featWith($, on, undefined, + { edit: setPhase('reflect', { about: 'a‮b\nc' }) }) + expect(w.asked[0]?.text).toContain('Routed to reflect (ab c):') +}) + +test('text: an astral description is cut by code point, not unit', async ( + $, on) => { + const w = asks(['Later']) + await bootRun($, on, FABLE, w) + await offerOf($, 'feater', 'userSettings', '\u{1F600}'.repeat(200)) + await $.agent.spawn(spawnOf('feater')) + const text = w.asked[0]?.text ?? '' + const desc = text.slice(text.indexOf(' — ') + 3, text.indexOf('. Routed')) + expect([...desc]).toHaveLength(140) + expect(desc.endsWith('…')).toBe(true) + expect(desc.isWellFormed()).toBe(true) +}) + +test('text: an agent question carries the phase about', async ($, on) => { + const w = asks(['Later'], { + edit: setPhase('implement', { about: 'writing code' }), + }) + await bootRun($, on, FABLE, w) + await $.agent.spawn(spawnOf('feater')) + expect(w.asked[0]?.text).toContain('Routed to implement (writing code):') +}) + +test('change: the effort question lists four labels at most', async ( + $, on) => { + const w = asks(['Change', 'sonnet (work)', 'Later'], { + edit: f => { + (f.phases as Rec).deep = { tier: 'work', effort: 'max' } + }, + }) + await bootRun($, on, FABLE, w) + await $.agent.spawn(spawnOf('feater')) + const q = w.asked.find(a => a.text.startsWith('Which effort')) + expect(q?.options).toEqual(['low', 'medium', 'high', 'xhigh']) +}) + +for (const [what, about] of [ + ['an overlong', 'x'.repeat(121)], + ['a non-string', 5], +] as const) { + test(`about: ${what} value is dropped and logged`, async ($, on) => { + const logs = recordLogs(on) + const w = await featWith($, on, undefined, + { edit: setPhase('reflect', { about }) }) + expect(w.asked[0]?.text).toBe(`First route for /feat. ${ROUTED}`) + expect(logs.join('\n')).toContain('phases.reflect.about ignored') + }) +} + +test('text: a phase question names its about and its consumers', async ( + $, on) => { + const w = asks(['Later'], { + edit: setPhase('orchestrate', { about: 'dispatch work' }), + }) + await bootRun($, on, FABLE, w) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' }) + expect(w.asked[0]?.text).toBe('First use of orchestrate on the main loop — ' + + 'dispatch work; used by 0 rows, the derived route of dispatches: ' + + 'claude-fable-5-1 at medium. OK?') +}) + +test('text: a phase question counts rows and prompt rules', async ($, on) => { + const w = asks(['Later']) + await bootRun($, on, FABLE, w) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'reflect' }) + expect(w.asked[0]?.text).toBe('First use of reflect on the main loop — ' + + 'used by 1 row, 1 prompt rule: claude-fable-5-1 at high. OK?') +}) + +test('text: a phase question names its floor rule', async ($, on) => { + const w = asks(['Later']) + await bootRun($, on, FABLE, w) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'escalate' }) + expect(w.asked[0]?.text).toBe('First use of escalate on the main loop — ' + + 'used by 0 rows, 1 floor rule: claude-fable-5-1 at max. OK?') +}) + +test('T3: a Change answer changes nothing and is said', async ($, on) => { + const toasts = recordToasts(on) + const w = asks(['Change']) + await bootRun($, on, FABLE, w) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' }) + expect(w.asked).toHaveLength(1) + expect(w.written).toEqual([]) + expect(toasts).toContain('answer not recognised, default kept') +}) + +test('change: the model question offers the four tiered aliases', async ( + $, on) => { + const w = asks(['Change', 'Later']) + await bootRun($, on, FABLE, w) + await typeFeat($) + expect(w.asked[1]?.text).toBe('Which model for feat?') + expect(w.asked[1]?.options).toEqual([ + 'fable (best)', 'opus (big)', 'sonnet (work)', 'haiku (cheap)', + ]) +}) + +for (const [label, alias, efforts] of [ + ['fable (best)', 'fable', ['medium', 'high', 'xhigh', 'max']], + ['sonnet (work)', 'sonnet', ['low', 'medium', 'high', 'xhigh']], +] as const) { + test(`change: ${alias} offers the efforts of its phases, ascending`, async ( + $, on) => { + const w = asks(['Change', label, 'Later']) + await bootRun($, on, FABLE, w) + await typeFeat($) + expect(w.asked[2]?.text).toBe(`Which effort on ${alias}?`) + expect(w.asked[2]?.options).toEqual(efforts) + }) +} + +for (const [label, phase] of [ + ['opus (big)', 'judge'], + ['haiku (cheap)', 'mechanical'], +] as const) { + test(`change: ${label} has one effort, the question is skipped`, async ( + $, on) => { + const w = asks(['Change', label, 'Later']) + await bootRun($, on, FABLE, w) + await typeFeat($) + expect(w.asked).toHaveLength(3) + expect(w.asked[2]?.text).toContain(`Use ${phase} for feat`) + expect(w.asked[2]?.options).toEqual(['Everywhere', 'This project only']) + }) +} + +test('change: an alias no phase uses is a Later and says so', async ( + $, on) => { + const toasts = recordToasts(on) + const w = asks(['Change', 'haiku (cheap)'], { + home: { phases: { mechanical: { tier: 'work', effort: 'low' } } }, + }) + await bootRun($, on, FABLE, w) + await typeFeat($) + expect(toasts).toContain('no phase uses haiku') + expect(w.asked).toHaveLength(2) + expect(w.written).toEqual([]) +}) + +test('change: a dismissed model question is a silent Later', async ( + $, on) => { + const toasts = recordToasts(on) + const w = asks(['Change', REJECT]) + await bootRun($, on, FABLE, w) + await typeFeat($) + expect(toasts).toEqual([]) + expect(w.written).toEqual([]) +}) + +for (const [step, answers] of [ + ['the first', ['whatever']], + ['the model', ['Change', 'mistral']], + ['the effort', ['Change', 'sonnet (work)', 'ludicrous']], +] as const) { + test(`change: another answer at ${step} question is a Later, said`, async ( + $, on) => { + const toasts = recordToasts(on) + const w = asks([...answers]) + await bootRun($, on, FABLE, w) + await typeFeat($) + expect(toasts).toContain('answer not recognised, default kept') + expect(w.asked).toHaveLength(answers.length) + expect(w.written).toEqual([]) + expect(toasts.join('\n')).not.toContain('mistral') + }) +} + +test('change: a Keep on a main row says nothing about a new route', async ( + $, on) => { + const toasts = recordToasts(on) + await bootRun($, on, FABLE, asks(['Keep'])) + await typeFeat($) + expect(toasts.join('\n')).not.toContain('now runs') +}) + +test('change: a downgrade names the real route and the switch', async ( + $, on) => { + const toasts = recordToasts(on) + const w = asks([...TO_IMPLEMENT, 'Everywhere']) + await bootRun($, on, FABLE, w) + await typeFeat($) + expect((routingOf(w).skills as Rec).feat).toBe('implement') + expect(toasts).toContain('/feat now runs claude-fable-5-1 at medium, ' + + 'main moves up only: /route switch on allows a downgrade') +}) + +test('change: with the switch on the toast carries no hint', async ( + $, on) => { + const toasts = recordToasts(on) + await bootRun($, on, FABLE, asks([...TO_IMPLEMENT, 'Everywhere'])) + await route($, 'switch on') + await typeFeat($) + expect(toasts).toContain('/feat now runs claude-sonnet-5-5 at medium') +}) + +test('change: an upgrade toast carries no hint', async ($, on) => { + const toasts = recordToasts(on) + const w = asks(['Change', 'fable (best)', 'xhigh', 'Everywhere']) + await bootRun($, on, SONNET, w) + await typeFeat($) + expect(toasts).toContain('/feat now runs claude-fable-5-1 at xhigh') +}) + +test('change: an upgrade the config holds back carries no hint', async ( + $, on) => { + const toasts = recordToasts(on) + const w = asks(['Change', 'fable (best)', 'xhigh', 'Everywhere'], + { home: { mainUpgrade: false } }) + await bootRun($, on, SONNET, w) + await typeFeat($) + expect(toasts).toContain('/feat now runs claude-sonnet-5-5 at xhigh') +}) + +test('change: a project scope stores the derived phase, keeps the row', async ( + $, on) => { + const w = asks([...TO_APPLY, 'This project only']) + await bootRun($, on, FABLE, w) + await typeFeat($) + const file = routingOf(w) + expect((file.skills as Rec).feat).toBe('reflect') + expect(file.projects).toEqual({ [KEY]: { skills: { feat: 'apply' } } }) +}) + +test('change: a security auditor moves to opus at xhigh, at once', async ( + $, on) => { + const w = asks([...TO_JUDGE, 'Everywhere'], { + edit: f => { (f.agents as Rec)['security-auditor'] = 'verify' }, + }) + const rig = await bootRun($, on, FABLE, w) + await $.agent.spawn(spawnOf('security-auditor')) + expect(w.asked[0]?.text).toContain('claude-sonnet-5-5 at xhigh') + expect(rig.specs).toEqual([OPUS]) + expect((routingOf(w).agents as Rec)['security-auditor']).toBe('judge') + await runStep($, { ...highStep('a1'), model: OPUS }) + expect(rig.seen[rig.seen.length - 1]?.effort).toBe('xhigh') +}) + +test('change: an agent picks sonnet then high, the write phase', async ( + $, on) => { + const w = asks(['Change', 'sonnet (work)', 'high', 'Everywhere']) + const rig = await bootRun($, on, FABLE, w) + await $.agent.spawn(spawnOf('feater')) + expect((routingOf(w).agents as Rec).feater).toBe('write') + expect(rig.specs).toEqual([SONNET]) +}) + +test('change: the current phase wins a tie, and counts as a Keep', async ( + $, on) => { + const w = asks(['Change', 'sonnet (work)', 'medium']) + await bootRun($, on, FABLE, w) + await $.agent.spawn(spawnOf('Explore')) + expect(w.asked).toHaveLength(3) + expect(routingOf(w).changed).toEqual({}) + expect(confirmedOf(w).agents).toEqual({ Explore: 'explore' }) +}) diff --git a/mods/model-router/hooks/register.ts b/mods/model-router/hooks/register.ts index 732dbda..2092106 100644 --- a/mods/model-router/hooks/register.ts +++ b/mods/model-router/hooks/register.ts @@ -62,6 +62,7 @@ type State = { pendingAllowed: boolean // same, for the mid-turn prompt spawning: number // agent.spawn hooks in flight (preloads fire inside) offers: Map // subagentType -> definition source + descs: Map // subagentType -> cleaned one-sentence offer line loops: Map // agentId -> that loop's routing explicitEffort: Map // Agent tool_use_id -> effort param skillCalls: number // Skill tool calls in flight @@ -111,6 +112,7 @@ type Memory = { askLocal: boolean | undefined // the machine override's own `ask` base: Record // the file's rows, before any project layer confirmed: Record // name -> the phase a Keep endorsed + about: Table // phase -> its one-line meaning, shown in the dialog only local: Record> // rows of projects[key] or the override key: string | undefined // normalized repo key; undefined = no project layer } @@ -137,16 +139,17 @@ const ROUTING = 'routing.json' // next to plugin.json: the tracked source const ROUTING_ORDER: readonly string[] = [ 'phases', 'skills', 'agents', 'projects', 'confirmed', 'changed', 'ask', ] -// First-use dialog: answers, and the phases offered as alternatives. +// First-use dialog: the answers, and what a question may carry of the outside. const LATER = 'Later' const KEEP = 'Keep' +const CHANGE = 'Change' const EVERYWHERE = 'Everywhere' const THIS_PROJECT = 'This project only' -const ALTS: Record = { - skills: ['plan', 'reflect', 'implement', 'apply'], - agents: ['judge', 'implement', 'verify', 'apply'], - phases: [], -} +const CHOICE_ALIASES: readonly string[] = ['fable', 'opus', 'sonnet', 'haiku'] +const MAX_DESC = 140 // chars of a skill or agent description in a question +const MAX_ABOUT = 120 // chars of a phase `about` +const MAX_DESCS = 256 // agent descriptions kept (oldest dropped) +const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ const ROWS: readonly Row[] = ['skills', 'agents'] const KINDS: readonly Kind[] = ['skills', 'agents', 'phases'] const HAIKU = 'claude-haiku' @@ -580,16 +583,34 @@ function emptyMemory(): Memory { askLocal: undefined, base: { skills: {}, agents: {} }, confirmed: { skills: {}, agents: {}, phases: {} }, + about: {}, local: { skills: new Set(), agents: new Set() }, key: undefined, } } +/** The phases' `about` lines; a non-string or overlong one is dropped, said. */ +function aboutOf(phases: unknown, log: Log): Table { + const out: Table = {} + if (!isRecord(phases)) return out + for (const [name, phase] of Object.entries(phases)) { + const about = isRecord(phase) ? phase.about : undefined + if (about === undefined || name === '__proto__') continue + if (typeof about === 'string' && about.length <= MAX_ABOUT) { + out[name] = about + } else { + log(`model-router: ${ROUTING} phases.${name}.about ignored`) + } + } + return out +} + /** What the file and the machine override say about decisions. */ function buildMemory( file: Rec, key: string | undefined, override: Rec | undefined, + log: Log, ): Memory { const project = projectRows(file, key) const flag = override?.ask @@ -608,6 +629,7 @@ function buildMemory( agents: strTable(sure.agents), phases: strTable(sure.phases), }, + about: aboutOf(file.phases, log), local: { skills: local('skills'), agents: local('agents') }, key, } @@ -732,7 +754,7 @@ async function assemble( fileData && routingTables(fileData, key), override, log) const mem = fileData === undefined ? emptyMemory() - : buildMemory(fileData, key, override) + : buildMemory(fileData, key, override, log) return { cfg, source: sourceWord(file, over), mem, override } } @@ -782,6 +804,7 @@ function newState(cfg: Config, source: string): State { pendingAllowed: false, spawning: 0, offers: new Map(), + descs: new Map(), loops: new Map(), explicitEffort: new Map(), skillCalls: 0, @@ -823,6 +846,7 @@ function resetSession(st: State): void { agentModels: st.agentModels, sessionModel: st.sessionModel, offers: st.offers, + descs: st.descs, mem: st.mem, override: st.override, loaded: st.loaded, @@ -1531,7 +1555,8 @@ const wantsAsk = (st: State, req: Req): boolean => async function refreshMemory($: Api, st: State): Promise { const file = await readRouting($, text => $.ui.log(text)) if (typeof file === 'string') return false - st.mem = buildMemory(file.data, await projectKey($), st.override) + st.mem = buildMemory( + file.data, await projectKey($), st.override, () => undefined) return true } @@ -1547,26 +1572,217 @@ async function askOr( } } -function optionsFor(st: State, req: Req): string[] { - const alts = ALTS[req.kind] - .filter(p => p !== req.phase && hasKey(st.cfg.phases, p)) - return req.kind === 'phases' - ? [LATER, KEEP] - : [LATER, KEEP, ...alts.slice(0, 2)] +// What a question says about its row. Everything read from outside (a +// SKILL.md, an agent's listing line, routing.json) is cleaned and capped. + +/** + * Text safe in a question: every whitespace folded to a space, then control + * and format characters (bidi included) stripped, cut to `n` code points + * with an ellipsis (total <= n). + */ +function clean(text: string, n: number): string { + const flat = text.replace(/[\s\p{Z}]+/gu, ' ') + .replace(/[\p{Cc}\p{Cf}]/gu, '').replace(/ {2,}/g, ' ').trim() + const points = [...flat] + return points.length <= n ? flat : `${points.slice(0, n - 1).join('')}…` } -/** Keep, a phase of the table (an alt or free text), else Later. */ -function decide($: Api, st: State, req: Req, answer: string): Answer { - if (answer === KEEP) return { act: 'keep' } - if (answer === LATER || req.kind === 'phases') return { act: 'later' } - if (!hasKey(st.cfg.phases, answer)) { - const shown = answer.slice(0, 40).replace(/[\u0000-\u001f]/g, ' ') - $.ui.toast(`unknown phase "${shown}", default kept`) - return { act: 'later' } +/** The first sentence of a description, cleaned; undefined when empty. */ +function oneSentence(text: string): string | undefined { + const end = /\.(?:\s|$)/.exec(text) + const line = clean(end ? text.slice(0, end.index) : text, MAX_DESC) + return line === '' ? undefined : line +} + +const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/ +const BLOCK_HEAD = /^[|>][+-]?\d*$/ + +/** A YAML scalar without its quotes: `''` and JSON escapes resolved. */ +function unquote(value: string): string { + const quote = value[0] + if (value.length < 2 || value[value.length - 1] !== quote) return value + if (quote === "'") return value.slice(1, -1).replace(/''/g, "'") + if (quote !== '"') return value + try { + const parsed: unknown = JSON.parse(value) + return typeof parsed === 'string' ? parsed : value.slice(1, -1) + } catch { + return value.slice(1, -1) } - return answer === req.phase - ? { act: 'keep' } - : { act: 'change', phase: answer } +} + +/** `description:` of a SKILL.md frontmatter: plain, block or quoted. */ +function frontDescription(text: string): string | undefined { + const front = FRONTMATTER.exec(text)?.[1] + const lines = front === undefined ? [] : front.split(/\r?\n/) + const at = lines.findIndex(line => line.startsWith('description:')) + if (at < 0) return undefined + const head = (lines[at] ?? '').slice('description:'.length).trim() + const body: string[] = [] + for (const line of lines.slice(at + 1)) { + if (line.trim() !== '' && !/^\s/.test(line)) break + body.push(line.trim()) + } + const first = BLOCK_HEAD.test(head) ? '' : head + return unquote([first, ...body].filter(w => w !== '').join(' ')) +} + +/** + * A skill's description from ${HOME}/.claude/skills//SKILL.md: a + * regular file under the size cap, a name from the allowlist. Any failure + * gives undefined: the question names the skill alone. + */ +async function skillDesc($: Api, name: string): Promise { + if (!SKILL_NAME.test(name)) return undefined + try { + const home = await $.env.get('HOME') + if (!home) return undefined + const path = `${home}/.claude/skills/${name}/SKILL.md` + if ((await $.fs.stat(path)).kind !== 'file') return undefined + const text = await readCapped($, path, 'SKILL.md', () => undefined) + const raw = text === undefined ? undefined : frontDescription(text) + return raw === undefined ? undefined : oneSentence(raw) + } catch { + return undefined + } +} + +/** The agent listing line, cleaned once at offer time; oldest dropped. */ +function rememberOffer( + st: State, + e: { agent: string; source: string; description: string }, +): void { + st.offers.set(e.agent, e.source) + const given: unknown = e.description + const line = typeof given === 'string' + ? oneSentence(given.slice(0, 4096)) + : undefined + st.descs.delete(e.agent) + if (line !== undefined) st.descs.set(e.agent, line) + if (st.descs.size <= MAX_DESCS) return + const oldest = st.descs.keys().next().value + if (oldest !== undefined) st.descs.delete(oldest) +} + +const plural = (n: number, word: string): string => + `${n} ${word}${n === 1 ? '' : 's'}` + +const withDesc = (lead: string, desc: string | undefined): string => + desc === undefined ? lead : `${lead} — ${desc}` + +function aboutLine(st: State, phase: string): string | undefined { + const raw = own(st.mem.about, phase) + const line = raw === undefined ? '' : clean(raw, MAX_ABOUT) + return line === '' ? undefined : line +} + +/** "Routed to ()": the parenthesis only when about is set. */ +function routedTo(st: State, phase: string): string { + const about = aboutLine(st, phase) + return `Routed to ${phase}${about === undefined ? '' : ` (${about})`}` +} + +/** Who uses a phase: its rows, the derived dispatch route, prompt rules. */ +function consumers(st: State, phase: string): string { + const { skills, agents, prompt } = st.cfg + const rows = [...Object.values(skills), ...Object.values(agents)] + .filter(p => p === phase).length + const rules = prompt.filter(r => r.phase === phase) + const floors = rules.filter(r => r.mode !== 'default').length + const parts = [plural(rows, 'row')] + if (phase === 'orchestrate') parts.push('the derived route of dispatches') + if (rules.length > floors) { + parts.push(plural(rules.length - floors, 'prompt rule')) + } + if (floors > 0) parts.push(plural(floors, 'floor rule')) + return `used by ${parts.join(', ')}` +} + +// ---- the dialog's steps: ASK1 Later/Keep/Change, then model, effort, scope + +const optionsFor = (req: Req): string[] => + isRowReq(req) ? [LATER, KEEP, CHANGE] : [LATER, KEEP] + +/** + * One step: the chosen label, or undefined (a Later). A dismissed or + * headless dialog is silent; any other answer is toasted, never echoed. + */ +async function askStep( + $: Api, + text: string, + labels: readonly string[], +): Promise { + const answer = await askOr($, text, labels) + if (answer === LATER) return undefined + if (labels.includes(answer)) return answer + $.ui.toast('answer not recognised, default kept') + return undefined +} + +/** The alias a phase's route leads with: its tier's head, or its model. */ +function headAlias(cfg: Config, route: Route): string | undefined { + if (route.tier !== undefined) { + const list = hasKey(cfg.tiers, route.tier) ? cfg.tiers[route.tier] : [] + return list?.[0] + } + if (route.model === undefined) return undefined + return hasKey(cfg.models, route.model) + ? route.model + : aliasOf(cfg, canonical(cfg, route.model)) +} + +/** "fable (best)": the alias and the tier it heads ("custom" if none). */ +function modelLabel(cfg: Config, alias: string): string { + const tier = Object.keys(cfg.tiers).find(t => cfg.tiers[t]?.[0] === alias) + return `${alias} (${tier ?? 'custom'})` +} + +async function askModel($: Api, st: State, req: RowReq) { + const labels = CHOICE_ALIASES.map(a => modelLabel(st.cfg, a)) + const label = await askStep($, `Which model for ${req.name}?`, labels) + return label === undefined ? undefined : CHOICE_ALIASES[labels.indexOf(label)] +} + +/** The efforts of the phases headed by `alias`, ascending, four at most. */ +function effortsOf(cfg: Config, alias: string): Level[] { + const found = Object.values(cfg.phases) + .filter(r => headAlias(cfg, r) === alias).map(r => r.effort) + return LEVELS.filter(level => found.includes(level)).slice(0, 4) +} + +/** One effort is no question; none is a Later, said. */ +async function askEffort($: Api, st: State, alias: string) { + const efforts = effortsOf(st.cfg, alias) + if (efforts.length === 0) $.ui.toast(`no phase uses ${alias}`) + if (efforts.length < 2) return efforts[0] + const label = await askStep($, `Which effort on ${alias}?`, efforts) + return efforts.find(effort => effort === label) +} + +/** The row's current phase when it matches, else the first that does. */ +function targetPhase(cfg: Config, now: string, alias: string, effort: Level) { + const fits = (name: string): boolean => { + const route = phaseRoute(cfg, name) + return route?.effort === effort && headAlias(cfg, route) === alias + } + return fits(now) ? now : Object.keys(cfg.phases).find(fits) +} + +/** Change: model, then effort, mapped to a phase of the table. */ +async function changeRoute($: Api, st: State, req: RowReq): Promise { + const alias = await askModel($, st, req) + const effort = alias === undefined ? undefined : await askEffort($, st, alias) + if (alias === undefined || effort === undefined) return { act: 'later' } + const phase = targetPhase(st.cfg, req.phase, alias, effort) + if (phase === undefined) return { act: 'later' } + return phase === req.phase ? { act: 'keep' } : { act: 'change', phase } +} + +async function decide($: Api, st: State, req: Req, text: string) { + const choice = await askStep($, text, optionsFor(req)) + if (choice === KEEP) return { act: 'keep' } as const + if (choice === CHANGE && isRowReq(req)) return changeRoute($, st, req) + return { act: 'later' } as const } async function askScope( @@ -1577,10 +1793,10 @@ async function askScope( ): Promise { const key = st.mem.key const text = `Use ${to} for ${req.name} everywhere, or only in this project?` - const answer = await askOr($, text, + const choice = await askStep($, text, [EVERYWHERE, key === undefined ? LATER : THIS_PROJECT]) - if (answer === EVERYWHERE) return 'everywhere' - return answer === THIS_PROJECT && key !== undefined ? 'project' : 'later' + if (choice === EVERYWHERE) return 'everywhere' + return choice === THIS_PROJECT && key !== undefined ? 'project' : 'later' } function table(parent: Rec, key: string): Rec { @@ -1679,9 +1895,8 @@ async function saveAnswer($: Api, st: State, req: Req, ans: Answer) { /** The dialog and what follows; true when routing.json was rewritten. */ async function runDialog($: Api, st: State, req: Req, text: string) { - const answer = await askOr($, text, optionsFor(st, req)) st.asked.add(keyOf(req)) - return saveAnswer($, st, req, decide($, st, req, answer)) + return saveAnswer($, st, req, await decide($, st, req, text)) } function failOnce($: Api, st: State, err: unknown): void { @@ -1722,8 +1937,7 @@ async function confirmFirst( /** First-use question text: the id and level the next step really gets. */ async function mainQuestion($: Api, st: State, lead: string) { const v = await snapshot($, st) - return `${lead} ${idWord(st, v.call.model)} at ${ - effortWord(st, v)}. Keep it?` + return `${lead}: ${idWord(st, v.call.model)} at ${effortWord(st, v)}. OK?` } function spawnQuestion( @@ -1735,21 +1949,44 @@ function spawnQuestion( ): string { const id = spawnTarget($, st, e, route) ?? 'its own model' const effort = st.explicitEffort.get(e.tool_use_id) ?? route.effort - return `First dispatch of ${req.name}: ${req.phase}, ${id} at ${ - effort ?? 'its own effort'}. Keep it?` + const lead = withDesc(`First dispatch of ${req.name}`, + st.descs.get(req.name)) + return `${lead}. ${routedTo(st, req.phase)}: ${id} at ${ + effort ?? 'its own effort'}. OK?` +} + +async function skillQuestion($: Api, st: State, skill: string, phase: string) { + const head = withDesc(`First route for /${skill}`, await skillDesc($, skill)) + return mainQuestion($, st, `${head}. ${routedTo(st, phase)}`) +} + +/** A downgrade the switch holds back: main keeps its model, said. */ +function downgradeHeld(st: State, v: Verdict, cur: string): boolean { + return v.wanted !== undefined && v.call.why === 'switch off' && + !ranksAbove(st, v.wanted, canonical(st.cfg, cur)) +} + +/** After a Change: what main will really run, and the switch hint. */ +async function toastChange($: Api, st: State, skill: string) { + const cur = st.turnModel ?? st.sessionModel + const v = await decideFor($, st, cur) + const hint = downgradeHeld(st, v, cur) + ? ', main moves up only: /route switch on allows a downgrade' + : '' + $.ui.toast(`/${skill} now runs ${idWord(st, v.call.model)} at ${ + effortWord(st, v)}${hint}`) } /** Typed skill: a changed row re-routes the turn at once. */ async function confirmSkill($: Api, st: State, skill: string, row: Picked) { const req: Req = { kind: 'skills', name: skill, phase: row.phase } - const lead = `First route for /${skill}: ${row.phase}, next step` - if (!(await confirmFirst($, st, req, () => mainQuestion($, st, lead)))) { - return - } + const say = () => skillQuestion($, st, skill, row.phase) + if (!(await confirmFirst($, st, req, say))) return const after = skillRow(st, skill) if (after === undefined) return routeMainBySkill(st, after, true) refresh($, st) + if (after.phase !== row.phase) await toastChange($, st, skill) } /** Rowed spawn: the route in force after the dialog (changed or not). */ @@ -1771,7 +2008,9 @@ async function routeSpawn($: Api, st: State, e: SpawnIn) { /** A phase declared through the route tool: confirm-only. */ async function confirmPhase($: Api, st: State, phase: string) { const req: Req = { kind: 'phases', name: phase, phase } - const lead = `First use of ${phase} on the main loop:` + const meaning = [aboutLine(st, phase), consumers(st, phase)] + .filter(part => part !== undefined).join('; ') + const lead = `First use of ${phase} on the main loop — ${meaning}` await confirmFirst($, st, req, () => mainQuestion($, st, lead)) } @@ -2418,7 +2657,7 @@ function afterSpawn( function registerSpawn(on: On, st: State): void { on('agent.offer', async ($, e, next) => { - if (!st.off) st.offers.set(e.agent, e.source) + if (!st.off) rememberOffer(st, e) return next(e) }).catch(($, e, next) => { warnOnce(st, $, 'agent.offer', next.error.kind) diff --git a/mods/model-router/routing.json b/mods/model-router/routing.json index eeb81e2..8760498 100644 --- a/mods/model-router/routing.json +++ b/mods/model-router/routing.json @@ -2,47 +2,58 @@ "phases": { "plan": { "tier": "best", - "effort": "xhigh" + "effort": "xhigh", + "about": "design and architecture: the deepest thinking, before any code exists" }, "reflect": { "tier": "best", - "effort": "high" + "effort": "high", + "about": "analysis, review and synthesis: reasoning about what already exists" }, "orchestrate": { "tier": "best", - "effort": "medium" + "effort": "medium", + "about": "dispatching and coordinating sub-agents: mostly handing out work" }, "escalate": { "tier": "best", - "effort": "max" + "effort": "max", + "about": "a stuck problem or a judged need: maximum effort, diagnosis only" }, "judge": { "tier": "big", - "effort": "xhigh" + "effort": "xhigh", + "about": "independent judgment of work: audits, challenges and verdicts" }, "implement": { "tier": "work", - "effort": "medium" + "effort": "medium", + "about": "writing the code of an already planned change" }, "write": { "tier": "work", - "effort": "high" + "effort": "high", + "about": "writing prose and docs, or a careful edit that needs polish" }, "verify": { "tier": "work", - "effort": "xhigh" + "effort": "xhigh", + "about": "checking a result against its spec: tests, security review, gates" }, "explore": { "tier": "work", - "effort": "medium" + "effort": "medium", + "about": "reading and searching the codebase to answer a question" }, "apply": { "tier": "work", - "effort": "low" + "effort": "low", + "about": "bookkeeping: commits, memory entries and other small edits" }, "mechanical": { "tier": "cheap", - "effort": "low" + "effort": "low", + "about": "scripted or trivial work needing no judgment, on the cheapest model" } }, "skills": { @@ -125,7 +136,7 @@ "plugin-probe": "apply", "validator-analyzer": "apply", "verifier": "verify", - "security-auditor": "verify", + "security-auditor": "judge", "status-reporter": "mechanical" }, "projects": {}, @@ -133,12 +144,14 @@ "agents": { "verifier": "verify", "feater": "implement", - "doc-syncer": "write" + "doc-syncer": "write", + "security-auditor": "judge" }, "phases": { "verify": "verify", "implement": "implement", - "write": "write" + "write": "write", + "orchestrate": "orchestrate" } }, "changed": {},