diff --git a/mods/model-router/hooks/register.test.ts b/mods/model-router/hooks/register.test.ts index d85b9a2..bebc952 100644 --- a/mods/model-router/hooks/register.test.ts +++ b/mods/model-router/hooks/register.test.ts @@ -23,6 +23,8 @@ async function boot( on('session.model', () => ({ value: model })) on('classic.StopFailure', () => ({})) on('classic.PostModelSwitch', () => ({})) + on('skill.prompt', ($, e) => ({ text: e.text })) + on('agent.offer', () => ({ isOffered: true })) if (tokens !== null) usageOf(on, tokens) on('session.start', ($, e) => ({ cwd: e.cwd })) await $.session.start({ cwd: '/tmp', surface: null, isInteractive: false }) @@ -321,30 +323,14 @@ test('floor: ultrathink survives a model route', async ($, on) => { expect(await stepEffort($, seen)).toBe('max') }) -test('floor: typed /effort-medium clamps low, lets max pass', async ( +test('floor: survives a skill load; an unrowed skill keeps the route', async ( $, on) => { - const seen = await bootFloor($, on) - await $.skill.prompt({ skill: 'effort-medium', text: 'x' }) - await $.tool.call({ tool: ROUTE_TOOL, phase: 'mechanical' }) - expect(await stepEffort($, seen)).toBe('medium') - await $.tool.call({ tool: ROUTE_TOOL, phase: 'escalate' }) - expect(await stepEffort($, seen)).toBe('max') -}) - -test('floor: typed /effort-low lowers an unrouted turn', async ($, on) => { - const seen = await bootFloor($, on) - const out = await $.skill.prompt({ skill: 'effort-low', text: 'x' }) - expect(out.text).toContain('minimum') - expect(await stepEffort($, seen)).toBe('low') -}) - -test('floor: survives a skill load', async ($, on) => { const seen = await bootFloor($, on) await ultrathink($) await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' }) await $.tool.call({ tool: 'Skill', skill: 'other' }) expect(await stepEffort($, seen)).toBe('max') - expect(mainLine(await route($, 'show'))).not.toContain('orchestrate') + expect(mainLine(await route($, 'show'))).toContain('model orchestrate') }) test('floor: lifts a lower sticky, then ends with the turn', async ( @@ -439,35 +425,6 @@ test('reload with an unreadable override keeps the previous config', async ( expect(await route($, 'show')).toContain('switch: on') }) -test('typed marker: a preload in a live agent is ignored', async ($, on) => { - const seen = await bootFloor($, on) - await $.agent.spawn(spawnInput()) - const out = await $.skill.prompt({ skill: 'effort-max', text: 'x' }) - expect(out.text?.startsWith('model-router: effort-max preload')).toBe(true) - expect(mainLine(await route($, 'show'))).not.toContain('floor') - expect(await stepEffort($, seen)).not.toBe('max') -}) - -test('typed marker: /effort-max seen at submit writes the floor', async ( - $, on) => { - await bootFloor($, on) - await $.agent.spawn(spawnInput()) - await $.prompt.submit({ - text: '/effort-max go', - wait: false, - origin: { kind: 'composer' }, - }) - await $.skill.prompt({ skill: 'effort-max', text: 'x' }) - expect(mainLine(await route($, 'show'))).toContain('floor max') -}) - -test('typed slash with no agent and no marker writes the floor', async ( - $, on) => { - await bootFloor($, on) - await $.skill.prompt({ skill: 'effort-max', text: 'x' }) - expect(mainLine(await route($, 'show'))).toContain('floor max') -}) - // ---- tiers, breaker, derived phases ------------------------------------- type Rig = { seen: Seen[]; clock: MockClock; specs: (string | undefined)[] } @@ -766,16 +723,6 @@ test('default rule: a typed slash command gets no default rule', async ( expect(mainLine(await route($, 'show'))).toContain('session defaults') }) -test('default rule: /effort-low pourquoi sets the floor, no default', async ( - $, on) => { - await bootRig($, on) - await typed($, '/effort-low pourquoi ça plante') - await $.skill.prompt({ skill: 'effort-low', text: 'x' }) - const line = mainLine(await route($, 'show')) - expect(line).toContain('floor low') - expect(line).not.toContain('reflect') -}) - test('per axis: a model-less sticky never hides a turn route tier', async ( $, on) => { const rig = await bootRig($, on, HAIKU) @@ -844,3 +791,466 @@ test('text: show names the model a floor upgrade moves to', async ( const line = mainLine(await route($, 'show')) expect(line).toContain(`${FABLE} (upgrade)`) }) + +// ---- wave 2: skill rows, run slot, agent rows --------------------------- + +const skillPrompt = ($: Engine, skill: string) => + $.skill.prompt({ skill, text: 'x' }) + +const loadSkill = ($: Engine, skill: string, agentId?: string) => + $.tool.call({ + tool: 'Skill', + skill, + ...(agentId === undefined ? {} : { agentId }), + }) + +/** bootRig plus a bottom Skill tool, so a model-loaded skill goes through. */ +async function bootRun($: Engine, on: On, model: string = FABLE) { + on('tool.call', { tool: 'Skill' }, () => ({ + result: { success: true, commandName: 'bottom' }, + })) + return bootRig($, on, model) +} + +const spawnOf = (agent: string, extra: object = {}) => + ({ ...spawnInput(), subagentType: agent, ...extra }) + +const offerOf = ($: Engine, agent: string, source: string) => + $.agent.offer({ + agent, + description: 'd', + source, + provider: { plugin: 'engine', tier: 'core' }, + }) + +/** + * A ui.log recorder. The kit refuses a bottom hook for a void event, so the + * hook sits on top and swallows the missing implementation below it. + */ +function recordLogs(on: On): string[] { + const lines: string[] = [] + on('ui.log', async ($, e, next) => { + lines.push(e.text) + try { + await next(e) + } catch { + // nothing implements ui.log below the recorder in the kit + } + }) + return lines +} + +test('typed /feat routes main to its row, best tier', async ($, on) => { + await bootRun($, on) + await typed($, '/feat add a thing') + await skillPrompt($, 'feat') + const line = mainLine(await route($, 'show')) + expect(line).toContain('skill reflect') + expect(line).toContain('effort high') + expect(line).toContain('[tier best]') +}) + +test('typed slash: a foreign origin arms nothing, fallback refused', async ( + $, on) => { + await bootRun($, on) + await $.prompt.submit({ + text: '/feat x', + wait: false, + origin: { kind: 'channel', server: 'slack' }, + }) + await skillPrompt($, 'feat') + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('typed slash: a preload in a live sub-agent leaves main alone', async ( + $, on) => { + await bootRun($, on) + await typed($, 'hello') + await $.agent.spawn(spawnOf('feater')) + await skillPrompt($, 'feat') + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('typed slash: no live loop and an allowed origin still routes', async ( + $, on) => { + await bootRun($, on) + await typed($, 'hello') + await skillPrompt($, 'feat') + expect(mainLine(await route($, 'show'))).toContain('skill reflect') +}) + +test('typed slash: an sdk origin arms the marker', async ($, on) => { + await bootRun($, on) + await $.prompt.submit({ + text: '/status', + wait: false, + origin: { kind: 'sdk' }, + }) + await $.agent.spawn(spawnOf('feater')) + await skillPrompt($, 'status') + expect(mainLine(await route($, 'show'))).toContain('skill mechanical') +}) + +test('typed slash: a mid-turn /status waits for the turn end', async ( + $, on) => { + await bootRun($, on) + await $.prompt.submit({ + text: '/status', + wait: true, + origin: { kind: 'composer' }, + turnId: 'u1', + }) + await skillPrompt($, 'status') + expect(mainLine(await route($, 'show'))).toContain('session defaults') + await endTurn($) + await skillPrompt($, 'status') + expect(mainLine(await route($, 'show'))).toContain('skill mechanical') +}) + +test('skill without a row keeps the route; a rowed one replaces it', async ( + $, on) => { + await bootRun($, on) + await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' }) + await loadSkill($, 'find-docs') + expect(mainLine(await route($, 'show'))).toContain('model plan') + await loadSkill($, 'gitflow') + expect(mainLine(await route($, 'show'))).toContain('skill implement') +}) + +test('run slot: survives route calls and the turn end', async ($, on) => { + await bootRun($, on) + await loadSkill($, 'feat') + await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' }) + await endTurn($) + expect(mainLine(await route($, 'show'))).toContain('run reflect') + await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' }) + await endTurn($) + expect(mainLine(await route($, 'show'))).toContain('run reflect') +}) + +test('run slot: a model-loaded helper skill keeps it', async ($, on) => { + await bootRun($, on) + await loadSkill($, 'feat') + await loadSkill($, 'using-git-worktrees') + expect(mainLine(await route($, 'show'))).toContain('skill mechanical') + await endTurn($) + expect(mainLine(await route($, 'show'))).toContain('run reflect') +}) + +test('run slot: a typed non-best skill drops it', async ($, on) => { + await bootRun($, on) + await loadSkill($, 'feat') + await endTurn($) + await typed($, '/status') + await skillPrompt($, 'status') + await endTurn($) + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('run slot: /route clear and /route off drop it', async ($, on) => { + await bootRun($, on) + await loadSkill($, 'feat') + expect(await route($, 'clear')).toContain('run slot dropped') + await endTurn($) + expect(mainLine(await route($, 'show'))).toContain('session defaults') + await loadSkill($, 'feat') + await route($, 'off') + await route($, 'on') + await endTurn($) + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('run slot: a user /model switch drops it, an auto one does not', async ( + $, on) => { + await bootRun($, on) + await loadSkill($, 'feat') + await endTurn($) + await switchTo($, FABLE, OPUS, 'auto') + expect(mainLine(await route($, 'show'))).toContain('run reflect') + await switchTo($, FABLE, OPUS, 'command') + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('run slot: a Skill call inside a sub-agent never touches it', async ( + $, on) => { + await bootRun($, on) + await loadSkill($, 'feat', 'a1') + await endTurn($) + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('run slot: route(clear) clears the turn and names the run', async ( + $, on) => { + await bootRun($, on) + await loadSkill($, 'feat') + const out = await $.tool.call({ tool: ROUTE_TOOL, clear: true }) + expect(JSON.stringify(out)).toContain('run reflect still holds') +}) + +test('Skill(effort-low) bridge is not sticky', async ($, on) => { + await bootRun($, on) + await loadSkill($, 'effort-low') + expect(mainLine(await route($, 'show'))).toContain('effort low') + await endTurn($) + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('route answer names the id even with a floor in force', async ( + $, on) => { + await bootRun($, on) + await ultrathink($) + const out = await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' }) + const text = JSON.stringify(out) + expect(text).toContain(`model ${FABLE}`) + expect(text).toContain('user floor max') +}) + +test('agent row: feater spawns on sonnet and steps at medium', async ( + $, on) => { + const rig = await bootRun($, on) + const out = await $.agent.spawn(spawnOf('feater')) + expect(rig.specs).toEqual([SONNET]) + expect(out.model).toBe(SONNET) + await runStep($, { ...highStep('a1'), model: SONNET }) + expect(rig.seen[rig.seen.length - 1]).toEqual({ + model: SONNET, + effort: 'medium', + }) +}) + +/** Spawn feater, load a skill inside it, return the effort of its step. */ +async function effortAfterSkill($: Engine, on: On, skill: string) { + const rig = await bootRun($, on) + await $.agent.spawn(spawnOf('feater')) + await loadSkill($, skill, 'a1') + await runStep($, { ...highStep('a1'), model: SONNET }) + return rig.seen[rig.seen.length - 1].effort +} + +test('agent skill: an unrowed one leaves the loop effort', async ($, on) => { + expect(await effortAfterSkill($, on, 'find-docs')).toBe('medium') +}) + +test('agent skill: a rowed one writes the loop effort', async ($, on) => { + expect(await effortAfterSkill($, on, 'status')).toBe('low') +}) + +test('agent row: judge moves up to fable when opus is down', async ( + $, on) => { + const rig = await bootRun($, on) + await switchTo($, OPUS, SONNET, 'auto') + await $.agent.spawn(spawnOf('plan-challenger')) + expect(rig.specs).toEqual([FABLE]) +}) + +test('agent row: opus and fable down, no write and one log line', async ( + $, on) => { + const logs = recordLogs(on) + const rig = await bootRun($, on) + await switchTo($, OPUS, SONNET, 'auto') + await switchTo($, FABLE, SONNET, 'auto') + await $.agent.spawn(spawnOf('plan-challenger')) + await $.agent.spawn(spawnOf('plan-challenger')) + expect(rig.specs).toEqual([undefined, undefined]) + const down = logs.filter(l => l.includes('frontmatter model kept')) + expect(down).toEqual([ + 'model-router: plan-challenger tier big down, frontmatter model kept', + ]) +}) + +test('agent row: explicit model, explicit effort and fork win', async ( + $, on) => { + const rig = await bootRun($, on) + await $.agent.spawn(spawnOf('feater', { model: 'opus' })) + await $.agent.spawn(spawnOf('feater', { fork: true })) + expect(rig.specs).toEqual(['opus', undefined]) + await $.tool.call({ + tool: 'Agent', + description: 'd', + prompt: 'p', + effort: 'low', + tool_use_id: 't1', + }) + await $.agent.spawn(spawnOf('feater')) + await runStep($, { ...highStep('a1'), model: SONNET }) + expect(rig.seen[rig.seen.length - 1]?.effort).toBe('high') +}) + +test('agent row: a project definition keeps its own model', async ($, on) => { + const rig = await bootRun($, on) + await offerOf($, 'verifier', 'projectSettings') + await offerOf($, 'feater', 'userSettings') + await $.agent.spawn(spawnOf('verifier')) + await $.agent.spawn(spawnOf('feater')) + expect(rig.specs).toEqual([undefined, SONNET]) +}) + +test('agent row: an override null drops the row', async ($, on) => { + on('env.get', () => ({ value: '/home/t' })) + on('fs.exists', () => ({ value: true })) + on('fs.stat', () => ({ + value: { kind: 'file' as const, size: 40, mtimeMs: 0, isLink: false }, + })) + on('fs.read', () => ({ value: '{"agents":{"verifier":null}}' })) + const rig = await bootRun($, on) + await $.agent.spawn(spawnOf('verifier')) + await $.agent.spawn(spawnOf('feater')) + expect(rig.specs).toEqual([undefined, SONNET]) +}) + +test('show lists the write and apply phases', async ($, on) => { + await bootRun($, on) + const text = await route($, 'show') + expect(text).toContain('apply=work→claude-sonnet-5-5/low') + expect(text).toContain('write=work→claude-sonnet-5-5/high') +}) + +// ---- wave 2: typed-slash origins, spawning counter, spawn exclusions ---- + +/** A promise the test settles by hand, to hold a hook in flight. */ +function deferred() { + let release: () => void = () => {} + const gate = new Promise(r => { release = r }) + return { gate, release } +} + +test('typed slash: an in-flight spawn refuses the fallback', async ( + $, on) => { + const hold = deferred() + const entered = deferred() + on('prompt.submit', ($, e) => ({ text: e.text })) + on('agent.spawn', async ($, e) => { + entered.release() + await hold.gate + return { model: e.model ?? e.parentModel, agentId: 'a1' } + }) + await boot($, on) + await typed($, 'hello') + const spawned = $.agent.spawn(spawnOf('feater')) + await entered.gate + await skillPrompt($, 'feat') + expect(mainLine(await route($, 'show'))).toContain('session defaults') + hold.release() + await spawned +}) + +test('typed slash: a bridge origin arms the marker', async ($, on) => { + await bootRun($, on) + await $.prompt.submit({ + text: '/feat x', + wait: false, + origin: { kind: 'bridge' }, + }) + await $.agent.spawn(spawnOf('feater')) + await skillPrompt($, 'feat') + expect(mainLine(await route($, 'show'))).toContain('skill reflect') +}) + +test('typed slash: a promoted mid-turn marker routes past a live loop', async ( + $, on) => { + await bootRun($, on) + await $.agent.spawn(spawnOf('feater')) + await $.prompt.submit({ + text: '/status', + wait: true, + origin: { kind: 'composer' }, + turnId: 'u1', + }) + await endTurn($) + await skillPrompt($, 'status') + expect(mainLine(await route($, 'show'))).toContain('skill mechanical') +}) + +test('agent row: any provider plugin applies, a local definition not', async ( + $, on) => { + const rig = await bootRun($, on) + const provider = { plugin: 'some-plugin', tier: 'core' as const } + await $.agent.spawn(spawnOf('feater', { provider })) + await offerOf($, 'verifier', 'localSettings') + await $.agent.spawn(spawnOf('verifier', { provider })) + expect(rig.specs).toEqual([SONNET, undefined]) +}) + +test('agent row: a workflow spawn is untouched, model and effort', async ( + $, on) => { + const rig = await bootRun($, on) + await $.agent.spawn(spawnOf('feater', { workflow: {} })) + expect(rig.specs).toEqual([undefined]) + await runStep($, { ...highStep('a1'), model: SONNET }) + expect(rig.seen[rig.seen.length - 1]?.effort).toBe('high') +}) + +test('typed slash: the marker binds to its skill name only', async ( + $, on) => { + await bootRun($, on) + await typed($, '/status') + await $.agent.spawn(spawnOf('feater')) + await skillPrompt($, 'feat') + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('typed slash: the marker is one-shot', async ($, on) => { + await bootRun($, on) + await typed($, '/status') + await $.agent.spawn(spawnOf('feater')) + await skillPrompt($, 'status') + expect(mainLine(await route($, 'show'))).toContain('skill mechanical') + await route($, 'clear') + await skillPrompt($, 'status') + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('skill.prompt nested in a Skill call leaves the run slot', async ( + $, on) => { + on('tool.call', { tool: 'Skill' }, async (_api, e) => { + await skillPrompt($, 'status') + return { result: { success: true, commandName: String(e.skill) } } + }) + await bootRig($, on, FABLE) + await typed($, '/status') + await loadSkill($, 'feat') + await endTurn($) + expect(mainLine(await route($, 'show'))).toContain('run reflect') +}) + +/** Submits a prompt from `kind` over a running turn (mid-turn). */ +const queued = ($: Engine, kind: string, text: string) => + $.prompt.submit({ + text, + wait: true, + origin: { kind }, + turnId: 'u1', + }) + +test('idle fallback: a mid-turn foreign prompt never arms it', async ( + $, on) => { + await bootRun($, on) + await queued($, 'channel', 'hello') + await endTurn($) + await skillPrompt($, 'status') + expect(mainLine(await route($, 'show'))).toContain('session defaults') +}) + +test('idle fallback: a mid-turn composer prompt promotes the allowance', async ( + $, on) => { + await bootRun($, on) + await $.prompt.submit({ + text: 'hello', + wait: false, + origin: { kind: 'channel' }, + }) + await queued($, 'composer', 'hello') + await endTurn($) + await skillPrompt($, 'status') + expect(mainLine(await route($, 'show'))).toContain('skill mechanical') +}) + +test('spawning: a finished spawn no longer counts as a live loop', async ( + $, on) => { + await bootRun($, on) + await $.agent.spawn(spawnOf('feater')) + await agentEnds($) + await typed($, 'hello') + await skillPrompt($, 'status') + expect(mainLine(await route($, 'show'))).toContain('skill mechanical') +}) diff --git a/mods/model-router/hooks/register.ts b/mods/model-router/hooks/register.ts index 09f2d3b..8107cc5 100644 --- a/mods/model-router/hooks/register.ts +++ b/mods/model-router/hooks/register.ts @@ -21,7 +21,7 @@ type Config = { models: Record // alias -> full id windows: Record // full id -> context window (tokens) phases: Record - agents: Record // built-in subagentType -> phase + agents: Record // subagentType -> phase skills: Record // skill name -> phase prompt: PromptRule[] tiers: Record // tier -> alias preference, best first @@ -35,7 +35,7 @@ type Config = { enabled: boolean // false: every hook passes through (per machine) } type Rule = { re: RegExp; phase: string; mode: PromptMode } -type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash' | 'derived' +type Source = 'user' | 'model' | 'skill' | 'prompt' | 'run' | 'derived' type Routed = { phase: string; route: Route; source: Source } type Hold = { until: number; reason: string } // until: ms, Infinity = reload type Pushed = { prev: Routed | null; spawnIds: Set } @@ -50,10 +50,17 @@ type State = { userMain: Routed | null // /route by the user, sticky until /route clear turnMain: Routed | null // model route tool, skill table row, Skill(effort-*) // bridge, prompt default rule, derived orchestrate; dropped at turn end - turnFloor: Routed | null // user-explicit level for this turn (prompt rule, - // typed /effort-): a floor, main loop only + runMain: Routed | null // best-tier skill row: spans the turns of a run; + // only a skill, /route clear|off or a user /model write or drop it + turnFloor: Routed | null // user-explicit level for this turn (prompt rule): + // a floor, main loop only pendingPrompt: Routed | null // typed mid-turn: the next turn's floor - typedSlash: boolean // one-shot: prompt.submit saw a typed /effort- + typedSlash: string | null // the rowed skill the user typed at prompt.submit + pendingSlash: string | null // same, typed mid-turn: promoted at turn end + promptAllowed: boolean // this turn's prompt came from a typing origin + pendingAllowed: boolean // same, for the mid-turn prompt + spawning: number // agent.spawn hooks in flight (preloads fire inside) + offers: Map // subagentType -> definition source loops: Map // agentId -> that loop's routing explicitEffort: Map // Agent tool_use_id -> effort param skillCalls: number // Skill tool calls in flight @@ -89,6 +96,19 @@ const LEVELS: readonly Level[] = ['low', 'medium', 'high', 'xhigh', 'max'] const MODEL_ID = /^claude-[a-z0-9.-]+$/ const TOOL = 'mcp__model-router__route' const EFFORT_SKILL = /^effort-(low|medium|high|xhigh|max)$/ +const BEST = 'best' // the tier whose skill rows hold for a whole run +// Origins that are a person typing: only these arm a typed slash. +const TYPED_ORIGINS: ReadonlySet = new Set([ + 'composer', 'sdk', 'bridge', +]) +// Definition sources of a foreign repo's own agent: its row is skipped. +const PROJECT_SOURCES: ReadonlySet = new Set([ + 'projectSettings', 'localSettings', +]) +// PostModelSwitch sources a person chose (auto and resume are not). +const USER_SWITCH: ReadonlySet = new Set([ + 'command', 'picker', 'sdk', +]) const OVERRIDE = '.claude/model-router.json' const HAIKU = 'claude-haiku' const MAX_PATTERN = 200 // chars of a prompt-rule pattern @@ -132,14 +152,69 @@ const DEFAULT_CONFIG: Config = { escalate: { tier: 'best', effort: 'max' }, judge: { tier: 'big', effort: 'xhigh' }, implement: { tier: 'work', effort: 'medium' }, - write: { tier: 'work', effort: 'medium' }, + write: { tier: 'work', effort: 'high' }, verify: { tier: 'work', effort: 'xhigh' }, explore: { tier: 'work', effort: 'medium' }, + apply: { tier: 'work', effort: 'low' }, mechanical: { tier: 'cheap', effort: 'low' }, }, - // Built-ins only: repo agents keep their frontmatter pin (wave 2). - agents: { Explore: 'explore', Plan: 'judge' }, - skills: {}, + // phase = role; one row per routed repo skill/agent (wave 2); a + // project-level agent of the same name shadows its row (see spawnRoute). + agents: { + // explore + Explore: 'explore', + // judge + Plan: 'judge', 'plan-challenger': 'judge', 'plugin-advisor': 'judge', + 'seo-analyzer': 'judge', 'geo-analyzer': 'judge', analyzer: 'judge', + // implement + feater: 'implement', bugfixer: 'implement', 'code-cleaner': 'implement', + scaffolder: 'implement', onboarder: 'implement', + // write + 'commit-changer': 'write', 'doc-syncer': 'write', + 'handover-doc-writer': 'write', refactorer: 'write', + // apply + hotfixer: 'apply', 'release-executor': 'apply', 'plugin-probe': 'apply', + 'validator-analyzer': 'apply', + // verify + verifier: 'verify', 'security-auditor': 'verify', + // mechanical + 'status-reporter': 'mechanical', + }, + skills: { + // plan + 'ship-feature': 'plan', 'init-project': 'plan', onboard: 'plan', + tour: 'plan', 'audit-delta': 'plan', analyze: 'plan', + 'code-clean': 'plan', 'client-handover': 'plan', brainstorming: 'plan', + 'writing-plans': 'plan', 'requesting-code-review': 'plan', + '21st-ui-review': 'plan', + // reflect + feat: 'reflect', hotfix: 'reflect', bugfix: 'reflect', + refactor: 'reflect', 'web-validate': 'reflect', harden: 'reflect', + seo: 'reflect', geo: 'reflect', 'site-motion': 'reflect', + 'frontend-design': 'reflect', 'emil-design-eng': 'reflect', + 'design-motion-principles': 'reflect', '21st-ui-build': 'reflect', + 'scroll-world-storytelling': 'reflect', + 'build-threejs-scroll-worlds': 'reflect', + 'scroll-scrubbed-visual-sequence': 'reflect', + 'scroll-scrubbed-word-reveal': 'reflect', + 'scroll-progress-timeline': 'reflect', + 'subagent-driven-development': 'reflect', 'writing-skills': 'reflect', + 'deprecation-and-migration': 'reflect', '21st-ai': 'reflect', + '21st-ui-explore': 'reflect', + // implement + gitflow: 'implement', 'prune-memory': 'implement', + 'pdf-translate': 'implement', 'ci-cd-and-automation': 'implement', + 'observability-and-instrumentation': 'implement', + 'test-driven-development': 'implement', + // apply + 'commit-change': 'apply', 'release-candidate': 'apply', doc: 'apply', + capitalize: 'apply', close: 'apply', reconcile: 'apply', deploy: 'apply', + // mechanical + status: 'mechanical', profile: 'mechanical', 'plugin-check': 'mechanical', + 'skills-perso': 'mechanical', 'using-git-worktrees': 'mechanical', + '21st-cli-use': 'mechanical', '21st-registry': 'mechanical', + '21st-design-sync': 'mechanical', + }, // `floor` rules set the turn's minimum effort; `default` rules set the // turn's route, which a route call or a skill overrides. Neither lowers. prompt: [ @@ -386,6 +461,25 @@ function mergeRouting( } } +/** A row table: `name: null` in the override drops a default row. */ +function mergeRows( + base: Record, + user: unknown, + name: string, + ref: (key: string, value: unknown) => string | undefined, + log: Log, +): Record { + const entries = isRecord(user) ? Object.entries(user) : [] + const kept = isRecord(user) + ? Object.fromEntries(entries.filter(([, v]) => v !== null)) + : user + const rows = mergeTable(base, kept, name, ref, log) + for (const [key, value] of entries) { + if (value === null && key !== '__proto__') delete rows[key] + } + return rows +} + /** Defaults overlaid with the user's entries, each validated first. */ function mergeConfig(user: unknown, log: Log): Config { const base = structuredClone(DEFAULT_CONFIG) @@ -405,8 +499,8 @@ function mergeConfig(user: unknown, log: Log): Config { windows: mergeTable( base.windows, user.windows, 'windows', acceptWindow, log), phases, - agents: mergeTable(base.agents, user.agents, 'agents', ref, log), - skills: mergeTable(base.skills, user.skills, 'skills', ref, log), + agents: mergeRows(base.agents, user.agents, 'agents', ref, log), + skills: mergeRows(base.skills, user.skills, 'skills', ref, log), prompt: mergePrompt(base.prompt, user.prompt, phases, log), mainModelSwitch: pickBool(user.mainModelSwitch, base.mainModelSwitch), verbose: pickBool(user.verbose, base.verbose), @@ -487,9 +581,15 @@ function newState(cfg: Config, source: string): State { source, userMain: null, turnMain: null, + runMain: null, turnFloor: null, pendingPrompt: null, - typedSlash: false, + typedSlash: null, + pendingSlash: null, + promptAllowed: false, + pendingAllowed: false, + spawning: 0, + offers: new Map(), loops: new Map(), explicitEffort: new Map(), skillCalls: 0, @@ -511,7 +611,8 @@ function newState(cfg: Config, source: string): State { /** * /clear rebuilds the state but keeps what is account- or process-wide: - * the breaker (a model's quota outlives the conversation) and the model. + * the breaker (a model's quota outlives the conversation), the model and + * the agent definitions' sources (the listing is not offered again). */ function resetSession(st: State): void { const kept = { @@ -519,6 +620,7 @@ function resetSession(st: State): void { strikes: st.strikes, agentModels: st.agentModels, sessionModel: st.sessionModel, + offers: st.offers, } Object.assign(st, newState(st.cfg, st.source), kept) } @@ -788,10 +890,11 @@ function decideMain( : downgradeCall(st, cur, wanted, tokens) } -/** Model axis: sticky, then turn route, then the floor's own model. */ +/** Model axis: sticky, turn route, run slot, then the floor's own model. */ const mainModel = (st: State): string | undefined => routeName(st.userMain?.route) ?? routeName(st.turnMain?.route) ?? + routeName(st.runMain?.route) ?? routeName(st.turnFloor?.route) async function readTokens($: Api): Promise { @@ -827,8 +930,9 @@ function logCall($: Api, st: State, call: Call): void { } } -/** The main loop's effective route: user /route > latest turn route. */ -const mainRoute = (st: State): Routed | null => st.userMain ?? st.turnMain +/** The main loop's effective route: user /route > turn route > run slot. */ +const mainRoute = (st: State): Routed | null => + st.userMain ?? st.turnMain ?? st.runMain const rank = (l: Level | undefined): number => l === undefined ? -1 : LEVELS.indexOf(l) @@ -851,7 +955,8 @@ function higherFloor(cur: Routed | null, next: Routed): Routed { */ function mainEffort(st: State, engine: Effort): Decision { const sticky = st.userMain?.route.effort - const named = sticky ?? st.turnMain?.route.effort + const named = sticky ?? st.turnMain?.route.effort ?? + st.runMain?.route.effort const floor = st.turnFloor?.route.effort const base = named ?? floor ?? engine const effort = floored(base, floor) @@ -862,11 +967,8 @@ function mainEffort(st: State, engine: Effort): Decision { return { effort, by: sticky === undefined ? 'turn' : 'sticky' } } -const floorSource = (f: Routed): string => - f.source === 'prompt' ? `prompt rule ${f.phase}` : `typed /${f.phase}` - const floorWord = (f: Routed): string => - `user floor ${f.route.effort ?? '-'} (${floorSource(f)})` + `user floor ${f.route.effort ?? '-'} (prompt rule ${f.phase})` /** * Why main will not run at `asked`, or '' when it will. Truthful tail of @@ -903,6 +1005,7 @@ function writeLoop(loop: Loop, route: Route): void { function clearRoutes(st: State): void { st.userMain = null st.turnMain = null + st.runMain = null st.turnFloor = null st.pendingPrompt = null } @@ -1122,14 +1225,18 @@ async function handleCommand($: Api, st: State, args: string): Promise { case '': case 'show': return show($, st) - case 'clear': + case 'clear': { + const run = st.runMain !== null clearRoutes(st) refresh($, st) - return 'route cleared\n' + (await show($, st)) + return 'route cleared' + (run ? '; run slot dropped' : '') + + '\n' + (await show($, st)) + } case 'on': case 'off': st.off = head === 'off' st.offConfig = false + if (st.off) st.runMain = null refresh($, st) return show($, st) case 'reload': @@ -1218,20 +1325,28 @@ function clearLoop(st: State, agentId: string | undefined): string { const held = f && f.route.effort !== undefined ? `; ${floorWord(f)} still holds, /route clear drops it` : '' - return 'route cleared for main' + held + const run = st.runMain + ? `; run ${st.runMain.phase} still holds` + : '' + return 'route cleared for main' + held + run } const loop = st.loops.get(agentId) if (loop) writeLoop(loop, {}) return 'route cleared for this agent' } -/** What the main loop's model will do, in the words of the decision. */ +/** The id the next main step runs on, with the decision's reason. */ async function mainAnswer($: Api, st: State): Promise { - const { call, wanted } = await snapshot($, st) - if (call.moved) return `${idWord(st, call.model)} (${call.why})` - const quiet = call.why === 'unchanged' || - (wanted === undefined && !isFallback(call)) - return quiet ? 'unchanged' : `unchanged (${call.why})` + const { call } = await snapshot($, st) + return `${idWord(st, call.model)} (${call.why})` +} + +/** Main's answer: effort and model asked, the sticky/floor note appended. */ +async function mainRoutedText($: Api, st: State, p: Picked) { + const base = `routed main to ${p.phase}: effort ${ + p.route.effort ?? 'unchanged'}, model ${await mainAnswer($, st)}` + const note = mainNote(st, p.route.effort) + return note ? `${base}; but ${note}` : base } /** Truthful answer: states what the calling loop will actually do. */ @@ -1241,15 +1356,11 @@ async function routedText( agentId: string | undefined, p: Picked, ): Promise { - const note = agentId === undefined ? mainNote(st, p.route.effort) : '' - if (note) return `recorded ${p.phase} for this turn, but ${note}` - const loop = agentId === undefined ? undefined : st.loops.get(agentId) - const effort = loop?.explicitEffort ? undefined : p.route.effort - const model = agentId === undefined - ? await mainAnswer($, st) - : routeName(p.route) ? 'unchanged (fixed at spawn)' : 'unchanged' - return `routed ${agentId === undefined ? 'main' : 'this agent'} to ` + - `${p.phase}: effort ${effort ?? 'unchanged'}, model ${model}` + if (agentId === undefined) return mainRoutedText($, st, p) + const explicit = st.loops.get(agentId)?.explicitEffort + const model = routeName(p.route) ? 'unchanged (fixed at spawn)' : 'unchanged' + return `routed this agent to ${p.phase}: effort ${ + explicit ? 'unchanged' : p.route.effort ?? 'unchanged'}, model ${model}` } async function handleRouteTool($: Api, st: State, e: RouteInput) { @@ -1295,51 +1406,59 @@ function effortBridge(st: State, agentId: string | undefined, skill: string, `loop from the next request on; the ${skill} skill text was not loaded.`) } -/** A non-effort skill load: resets the loop's route, applies its table row. */ -function onSkillLoad(st: State, skill: string, agentId: string | undefined) { - const table = hasKey(st.cfg.skills, skill) ? st.cfg.skills[skill] : undefined - const route = table === undefined ? undefined : phaseRoute(st.cfg, table) - if (agentId === undefined) { - st.turnMain = null - if (table !== undefined && route) { - st.turnMain = { phase: table, route, source: 'skill' } - } - return - } - const loop = route ? loopOf(st, agentId) : st.loops.get(agentId) - if (loop) writeLoop(loop, { effort: route?.effort }) -} - -/** A user-typed /effort-: a floor for the turn, prepends one line. */ -function slashEffort(st: State, skill: string, text: string) { - const level = EFFORT_SKILL.exec(skill)?.[1] - if (!isLevel(level)) return undefined - const route: Route = { effort: level } - const slash: Routed = { phase: skill, route, source: 'slash' } - st.turnFloor = higherFloor(st.turnFloor, slash) - const note = mainNote(st, level) - const line = `Effort ${level} set by model-router for the main loop this ` + - 'turn (minimum; a higher route still applies).' + - (note ? ` But ${note}.` : '') - return { text: line + '\n' + text } +/** A skill's table row: its phase and that phase's route, if both exist. */ +function skillRow(st: State, skill: string): Picked | undefined { + const phase = hasKey(st.cfg.skills, skill) ? st.cfg.skills[skill] : undefined + const route = phase === undefined ? undefined : phaseRoute(st.cfg, phase) + return phase !== undefined && route ? { phase, route } : undefined } /** - * Floor write for a skill.prompt. Only a typed slash may write it: the - * marker from prompt.submit attests the typing. Without it, a live - * sub-agent means the prompt is a preload inside that agent: ignored. + * A rowed skill on main sets the turn route; a best-tier row also holds the + * run slot. A typed non-best row drops the run; one the model loads (a + * helper skill inside a run) leaves it. */ -function guardedSlash(st: State, skill: string, text: string) { - if (!EFFORT_SKILL.test(skill)) return undefined - if (st.typedSlash) { - st.typedSlash = false - } else if (st.loops.size > 0) { - return { - text: `model-router: ${skill} preload inside a live sub-agent is ` + - 'ignored on the main loop.\n' + text, - } +function routeMainBySkill(st: State, row: Picked, typed: boolean): void { + const turn: Routed = { phase: row.phase, route: { ...row.route }, + source: 'skill' } + st.turnMain = turn + if (row.route.tier === BEST) st.runMain = { ...turn, source: 'run' } + else if (typed) st.runMain = null +} + +/** A model-loaded skill: its row applies; an unrowed one changes nothing. */ +function onSkillLoad(st: State, skill: string, agentId: string | undefined) { + const row = skillRow(st, skill) + if (agentId === undefined) { + if (row) routeMainBySkill(st, row, false) + return } - return slashEffort(st, skill, text) + if (row) writeLoop(loopOf(st, agentId), row.route) +} + +type TypedPath = 'typed-marker' | 'typed-fallback' + +/** + * Whether a skill.prompt on main is the user's own typing. The marker from + * prompt.submit names the skill; failing that, an allowed-origin prompt with + * no live or spawning sub-agent (a preload fires inside one) still counts. + */ +function typedPath(st: State, skill: string): TypedPath | undefined { + if (st.typedSlash === skill) { + st.typedSlash = null + return 'typed-marker' + } + const idle = st.loops.size === 0 && st.spawning === 0 + return st.promptAllowed && idle ? 'typed-fallback' : undefined +} + +function applyTypedSkill($: Api, st: State, skill: string): void { + const row = skillRow(st, skill) + const path = typedPath(st, skill) + if (row === undefined || path === undefined) return + routeMainBySkill(st, row, true) + refresh($, st) + vlog($, st, `skill ${skill}: ${path} → ${row.phase}`) } // ---- agents ---------------------------------------------------------- @@ -1347,27 +1466,60 @@ function guardedSlash(st: State, skill: string, text: string) { type SpawnIn = { tool_use_id: string subagentType: string - provider: { plugin: string } model?: string fork: boolean workflow?: unknown } /** - * The table row of a built-in agent. Known limit: provider.plugin === - * 'engine' is the best built-in test at spawn; a user agent named Explore - * in a foreign project also matches (wave 1: sonnet/medium on it). + * The table row of an agent, for every provider. Skipped for a fork, a + * workflow agent, and an agent whose definition came from the project (a + * foreign repo's own verifier.md). Known limit: the source is recorded by + * name only, so an offer fired inside a sub-agent with another cwd + * overwrites it; no record = the row applies (fail-open on routing). */ -function spawnRoute(cfg: Config, e: SpawnIn, frozen: boolean) { - if (frozen || e.provider.plugin !== 'engine') return undefined - if (!hasKey(cfg.agents, e.subagentType)) return undefined - const phase = cfg.agents[e.subagentType] - return phase === undefined ? undefined : phaseRoute(cfg, phase) +function spawnRoute(st: State, e: SpawnIn, frozen: boolean) { + if (frozen || !hasKey(st.cfg.agents, e.subagentType)) return undefined + if (PROJECT_SOURCES.has(st.offers.get(e.subagentType) ?? '')) { + return undefined + } + const phase = st.cfg.agents[e.subagentType] + return phase === undefined ? undefined : phaseRoute(st.cfg, phase) +} + +/** Once per turn: a spawn whose tier has nothing at or above its head. */ +function tierDownLog($: Api, st: State, agent: string, tier: string): void { + const text = `model-router: ${agent} tier ${tier} down, ` + + 'frontmatter model kept' + const key = `spawn-down:${agent}:${tier}` + if (st.turnLogged.has(key)) return + st.turnLogged.add(key) + $.ui.log(text) +} + +/** + * The first model of the route's tier that is not down, only when it ranks + * at or above the tier's head: an agent moves UP from its frontmatter alias, + * never below. Nothing qualifies: no write, the frontmatter model runs. + */ +function inTier($: Api, st: State, agent: string, tier: string) { + const aliases = hasKey(st.cfg.tiers, tier) ? st.cfg.tiers[tier] : undefined + const head = aliases?.[0] + const headId = head === undefined ? undefined : st.cfg.models[head] + if (aliases === undefined || headId === undefined) return undefined + const id = availableIn(st, aliases) + if (id !== undefined && (id === headId || ranksAbove(st, id, headId))) { + return id + } + tierDownLog($, st, agent, tier) + return undefined } /** The model a spawn is rewritten to; an explicit `model` param wins. */ -function spawnTarget(st: State, e: SpawnIn, route: Route | undefined) { - return e.model === undefined ? resolveRoute(st, route) : undefined +function spawnTarget($: Api, st: State, e: SpawnIn, route: Route | undefined) { + if (e.model !== undefined || route === undefined) return undefined + if (route.tier === undefined) return resolveRoute(st, route) + return inTier($, st, e.subagentType, route.tier) } function trackLoop(st: State, e: SpawnIn, started: { @@ -1493,7 +1645,10 @@ function endMainTurn($: Api, st: State): void { st.turnMain = null st.pushed = null st.turnModel = undefined - st.typedSlash = false + st.typedSlash = st.pendingSlash + st.promptAllowed = st.pendingAllowed + st.pendingSlash = null + st.pendingAllowed = false st.explicitEffort.clear() st.spinner = '' st.turnLogged.clear() @@ -1554,6 +1709,7 @@ async function onAutoSwitch($: Api, st: State, e: SwitchIn): Promise { /** Any model change: keeps `sessionModel`; a user choice clears its mark. */ async function onModelSwitch($: Api, st: State, e: SwitchIn): Promise { st.sessionModel = e.to_model + if (USER_SWITCH.has(e.source)) st.runMain = null if (st.off || e.source === 'resume') return if (e.source === 'auto') await onAutoSwitch($, st, e) else clearBreaker(st, canonical(st.cfg, e.to_model)) @@ -1652,10 +1808,8 @@ function registerSkills(on: On, st: State): void { }) on('skill.prompt', async ($, e, next) => { if (st.off || st.skillCalls > 0) return next(e) - const out = guardedSlash(st, e.skill, e.text) - if (!out) return next(e) - refresh($, st) - return out + safely(st, $, 'skill.prompt', () => applyTypedSkill($, st, e.skill)) + return next(e) }).catch(($, e, next) => { warnOnce(st, $, 'skill.prompt', next.error.kind) return next(e) @@ -1679,26 +1833,47 @@ function registerAgents(on: On, st: State): void { }) } +/** Bookkeeping once the agent started: its loop, its model, a log line. */ +function afterSpawn( + $: Api, + st: State, + e: SpawnIn, + started: { agentId?: unknown; model: string }, + route: Route | undefined, +): void { + if (typeof started.agentId !== 'string') return + const agentId = started.agentId + safely(st, $, 'agent.spawn', () => { + trackLoop(st, e, { agentId }, route) + rememberAgent(st, agentId, started.model) + vlog($, st, + `spawn ${e.subagentType}: ${e.model ?? '-'} → ${started.model}`) + }) +} + function registerSpawn(on: On, st: State): void { + on('agent.offer', async ($, e, next) => { + if (!st.off) st.offers.set(e.agent, e.source) + return next(e) + }).catch(($, e, next) => { + warnOnce(st, $, 'agent.offer', next.error.kind) + return next(e) + }) on('agent.spawn', async ($, e, next) => { if (st.off) return next(e) - await prune($, st) - const frozen = e.fork || e.workflow !== undefined - const route = spawnRoute(st.cfg, e, frozen) - const wanted = spawnTarget(st, e, route) - const started = await next(wanted === undefined - ? e - : { ...e, model: wanted }) - if (typeof started.agentId === 'string') { - const agentId = started.agentId - safely(st, $, 'agent.spawn', () => { - trackLoop(st, e, started, route) - rememberAgent(st, agentId, started.model) - vlog($, st, - `spawn ${e.subagentType}: ${e.model ?? '-'} → ${started.model}`) - }) + st.spawning += 1 + try { + await prune($, st) + const route = spawnRoute(st, e, e.fork || e.workflow !== undefined) + const wanted = spawnTarget($, st, e, route) + const started = await next(wanted === undefined + ? e + : { ...e, model: wanted }) + afterSpawn($, st, e, started, route) + return started + } finally { + st.spawning = Math.max(0, st.spawning - 1) } - return started }).catch(($, e, next) => { warnOnce(st, $, 'agent.spawn', next.error.kind) return next(e) @@ -1767,14 +1942,41 @@ function routeFromPrompt(st: State, text: string, midTurn: boolean): void { floorFromPrompt(st, midTurn, routed) } const slash = text.trimStart().startsWith('/') - if (text.trimStart().startsWith('/effort-')) st.typedSlash = true if (!midTurn && !slash && !floor) defaultFromPrompt(st, scanned) } +/** The rowed skill a slash prompt names, else null. */ +function typedSkill(st: State, text: string): string | null { + const typed = text.trimStart() + if (!typed.startsWith('/')) return null + const name = typed.slice(1).split(/\s/, 1)[0] ?? '' + return hasKey(st.cfg.skills, name) ? name : null +} + +/** + * Records what skill.prompt needs to tell a typed slash from a preload: the + * skill named and whether a person typed it. A mid-turn prompt waits in the + * pending slots for the turn end; a foreign one leaves them alone. A later + * queued prompt replaces an earlier one (a shortcut: one pending slot). + */ +function noteTyped(st: State, kind: string, text: string, midTurn: boolean) { + const allowed = TYPED_ORIGINS.has(kind) + const slash = allowed ? typedSkill(st, text) : null + if (!midTurn) { + st.typedSlash = slash + st.promptAllowed = allowed + } else if (allowed) { + st.pendingSlash = slash + st.pendingAllowed = true + } +} + function registerPrompt(on: On, st: State): void { on('prompt.submit', async ($, e, next) => { - if (st.off || e.origin.kind !== 'composer') return next(e) - routeFromPrompt(st, e.text, e.turnId !== undefined) + if (st.off) return next(e) + const midTurn = e.turnId !== undefined + noteTyped(st, e.origin.kind, e.text, midTurn) + if (e.origin.kind === 'composer') routeFromPrompt(st, e.text, midTurn) refresh($, st) return next(e) }).catch(($, e, next) => {