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": {},