feat(model-router): wave 3-B — first-use dialog with context, model then effort on change
The dialog now explains what it asks about: the skill's description (from its SKILL.md frontmatter, five YAML forms, first sentence, cleaned), the agent's description (from agent.offer), the phase's new 'about' line in routing.json, and the real model id and effort the next step runs on. Options Later / Keep / Change; Change asks the model (fable, opus, sonnet, haiku with their tier role), then the effort among those the phases of that model offer, then the scope; the pair maps to an existing phase (rows stay phase names; the row's current phase wins a tie; same phase = Keep). A main-loop phase (T3) is Later / Keep only. A main-row change toasts the real decision and the /route switch hint when the pick is a downgrade. Descriptions pass a hardened read (name allowlist, stat kind, size cap) and clean(). Kit suite 190 → 232; W3-A dialog tests migrated. Contract .claude/tasks/contracts/2026-10-11-model-router-w3b-dialog-1240.md, plan r3: 3 lenses (2 BLOCKERs: inline rows, phase edits) + 1 confirmation, feater + 2 rounds, GATE 0 MET, verifier CONFORME (3rd pass), security PASS.
This commit is contained in:
+2
-2
@@ -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).
|
typed marker (unverified live 2026-10-10).
|
||||||
- Headless (`-p`, SDK) runs the hooks, so routing works there too.
|
- Headless (`-p`, SDK) runs the hooks, so routing works there too.
|
||||||
- Mod off: typed agents fall back to their `model:`/`effort:` frontmatter.
|
- Mod off: typed agents fall back to their `model:`/`effort:` frontmatter.
|
||||||
- First use of a row asks once (Keep, Later or another phase, then
|
- First use of a row asks once, with context (Later, Keep or Change: model,
|
||||||
Everywhere or this project only); the answer is kept in
|
then effort, then Everywhere or this project only); the answer is kept in
|
||||||
`mods/model-router/routing.json`. `/route pending` lists what is still
|
`mods/model-router/routing.json`. `/route pending` lists what is still
|
||||||
unconfirmed, `/route ask off|on` toggles the dialog.
|
unconfirmed, `/route ask off|on` toggles the dialog.
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ const REAL_ROOT = '/real/work/app'
|
|||||||
const REMOTE = 'https://github.com/acme/app.git'
|
const REMOTE = 'https://github.com/acme/app.git'
|
||||||
const KEY = 'github.com/acme/app'
|
const KEY = 'github.com/acme/app'
|
||||||
const REJECT = '<rejected>' // an answer the dialog never gets: headless
|
const REJECT = '<rejected>' // 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. */
|
/** What the bottom `fs`, `env`, `session.repo` and dialog hooks serve. */
|
||||||
type World = {
|
type World = {
|
||||||
@@ -31,6 +38,8 @@ type World = {
|
|||||||
onAsk: (() => void) | undefined // called when a dialog opens
|
onAsk: (() => void) | undefined // called when a dialog opens
|
||||||
onWrite: (() => void) | undefined // called when an fs.write starts
|
onWrite: (() => void) | undefined // called when an fs.write starts
|
||||||
repo: { root: string; remote: string | null } | null | 'fail'
|
repo: { root: string; remote: string | null } | null | 'fail'
|
||||||
|
env: string | undefined // HOME as env.get answers it (undefined = unset)
|
||||||
|
kinds: Map<string, 'file' | 'dir' | 'other'> // per-path stat kind override
|
||||||
}
|
}
|
||||||
|
|
||||||
type Fixture = {
|
type Fixture = {
|
||||||
@@ -40,6 +49,7 @@ type Fixture = {
|
|||||||
home?: Rec | 'broken' // ~/.claude/model-router.json
|
home?: Rec | 'broken' // ~/.claude/model-router.json
|
||||||
answers?: string[]
|
answers?: string[]
|
||||||
repo?: World['repo']
|
repo?: World['repo']
|
||||||
|
noHome?: boolean // HOME unset: env.get answers undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,11 +113,18 @@ function newWorld(fx: Fixture = {}): World {
|
|||||||
onAsk: undefined,
|
onAsk: undefined,
|
||||||
onWrite: undefined,
|
onWrite: undefined,
|
||||||
repo: fx.repo === undefined ? { root: ROOT, remote: REMOTE } : fx.repo,
|
repo: fx.repo === undefined ? { root: ROOT, remote: REMOTE } : fx.repo,
|
||||||
|
env: fx.noHome ? undefined : HOME,
|
||||||
|
kinds: new Map(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const slot = (path: string): string =>
|
const slot = (path: string): string => {
|
||||||
path.endsWith('/routing.json') ? 'routing' : path
|
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. */
|
/** routing.json as the world holds it now. */
|
||||||
const routingOf = (w: World): Rec =>
|
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").
|
* no hook answers is refused, never sent to the disk: see "writes: kit").
|
||||||
*/
|
*/
|
||||||
function installWorld(on: On, w: World): void {
|
function installWorld(on: On, w: World): void {
|
||||||
on('env.get', () => ({ value: HOME }))
|
on('env.get', () => ({ value: w.env }))
|
||||||
on('fs.exists', (_a, e) => ({
|
on('fs.exists', (_a, e) => ({
|
||||||
value: w.files.has(slot(e.path)) || e.path === ROOT,
|
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')
|
if (text === undefined && e.path !== ROOT) throw new Error('ENOENT')
|
||||||
return {
|
return {
|
||||||
value: {
|
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,
|
size: text?.length ?? 0,
|
||||||
mtimeMs: 0,
|
mtimeMs: 0,
|
||||||
isLink: false,
|
isLink: false,
|
||||||
@@ -991,10 +1009,15 @@ async function bootRun(
|
|||||||
const spawnOf = (agent: string, extra: object = {}) =>
|
const spawnOf = (agent: string, extra: object = {}) =>
|
||||||
({ ...spawnInput(), subagentType: agent, ...extra })
|
({ ...spawnInput(), subagentType: agent, ...extra })
|
||||||
|
|
||||||
const offerOf = ($: Engine, agent: string, source: string) =>
|
const offerOf = (
|
||||||
|
$: Engine,
|
||||||
|
agent: string,
|
||||||
|
source: string,
|
||||||
|
description = 'd',
|
||||||
|
) =>
|
||||||
$.agent.offer({
|
$.agent.offer({
|
||||||
agent,
|
agent,
|
||||||
description: 'd',
|
description,
|
||||||
source,
|
source,
|
||||||
provider: { plugin: 'engine', tier: 'core' },
|
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 (
|
test('layers: no remote gives no project key, no local: string', async (
|
||||||
$, on) => {
|
$, on) => {
|
||||||
const w = asks(['implement', 'This project only'],
|
const w = asks([...TO_IMPLEMENT, 'This project only'],
|
||||||
{ repo: { root: ROOT, remote: null } })
|
{ repo: { root: ROOT, remote: null } })
|
||||||
await bootRun($, on, FABLE, w)
|
await bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
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.written).toEqual([])
|
||||||
expect(w.files.get('routing')).not.toContain('local:')
|
expect(w.files.get('routing')).not.toContain('local:')
|
||||||
expect(w.files.get('routing')).not.toContain(REAL_ROOT)
|
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. */
|
/** The project keys "This project only" stores for a remote. */
|
||||||
async function keyedBy($: Engine, on: On, remote: string) {
|
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 } })
|
{ repo: { root: ROOT, remote } })
|
||||||
await bootRun($, on, FABLE, w)
|
await bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
await typeFeat($)
|
||||||
@@ -1710,7 +1733,7 @@ for (const bad of [
|
|||||||
test(`layers: credentials never become a key: ${bad}`, async ($, on) => {
|
test(`layers: credentials never become a key: ${bad}`, async ($, on) => {
|
||||||
const { w, keys } = await keyedBy($, on, bad)
|
const { w, keys } = await keyedBy($, on, bad)
|
||||||
expect(keys).toEqual([])
|
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@')
|
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) => {
|
test(`layers: ${guard} guard rejects ${bad}`, async ($, on) => {
|
||||||
const { w, keys } = await keyedBy($, on, bad)
|
const { w, keys } = await keyedBy($, on, bad)
|
||||||
expect(keys).toEqual([])
|
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('/srv/')
|
||||||
expect(w.files.get('routing')).not.toContain('ss@')
|
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
|
const first = w.asked[0] as Asked
|
||||||
expect(first.text).toContain('/feat')
|
expect(first.text).toContain('/feat')
|
||||||
expect(first.text).toContain('claude-fable-5-1 at high')
|
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')
|
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')
|
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) => {
|
$, on) => {
|
||||||
const w = asks(['implement', 'Everywhere'])
|
const w = asks([...TO_IMPLEMENT, 'Everywhere'])
|
||||||
await bootRun($, on, FABLE, w)
|
await bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
await typeFeat($)
|
||||||
const file = routingOf(w)
|
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) => {
|
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 bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
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 (
|
test('T1: This project only writes the exception, not the row', async (
|
||||||
$, on) => {
|
$, on) => {
|
||||||
const w = asks(['implement', 'This project only'])
|
const w = asks([...TO_IMPLEMENT, 'This project only'])
|
||||||
await bootRun($, on, FABLE, w)
|
await bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
await typeFeat($)
|
||||||
const file = routingOf(w)
|
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) => {
|
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 bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
await typeFeat($)
|
||||||
w.repo = { root: '/work/two', remote: 'https://github.com/acme/two.git' }
|
w.repo = { root: '/work/two', remote: 'https://github.com/acme/two.git' }
|
||||||
await $.classic.CwdChanged({ old_cwd: ROOT, new_cwd: '/work/two' })
|
await $.classic.CwdChanged({ old_cwd: ROOT, new_cwd: '/work/two' })
|
||||||
await typeFeat($)
|
await typeFeat($)
|
||||||
expect(w.asked).toHaveLength(2)
|
expect(w.asked).toHaveLength(SCOPE_ASK + 1)
|
||||||
expect(await mainNow($)).toContain('skill reflect')
|
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 (
|
test('T1: a second change keeps the first shipped phase in changed', async (
|
||||||
$, on) => {
|
$, on) => {
|
||||||
on('session.end', ($, e) => ({ sessionId: e.sessionId }))
|
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 bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
await typeFeat($)
|
||||||
const file = routingOf(w)
|
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 (
|
test('T1: Later applies the default, writes nothing, asks no more', async (
|
||||||
$, on) => {
|
$, on) => {
|
||||||
const w = asks(['Later'])
|
const w = asks(['Later'])
|
||||||
@@ -1905,18 +1912,19 @@ test('T1: a dismissed dialog is a Later', async ($, on) => {
|
|||||||
expect(await mainNow($)).toContain('skill reflect')
|
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 toasts = recordToasts(on)
|
||||||
const w = asks(['banana'])
|
const w = asks(['banana'])
|
||||||
await bootRun($, on, FABLE, w)
|
await bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
await typeFeat($)
|
||||||
expect(w.written).toEqual([])
|
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')
|
expect(await mainNow($)).toContain('skill reflect')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('T1: a scope answer that is not a label is a Later', async ($, on) => {
|
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 bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
await typeFeat($)
|
||||||
expect(w.written).toEqual([])
|
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) => {
|
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 bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
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.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 (
|
test('T1: a rebuild failing after the write leaves the key unasked', async (
|
||||||
$, on) => {
|
$, on) => {
|
||||||
const logs = recordLogs(on)
|
const logs = recordLogs(on)
|
||||||
const w = asks(['implement', 'Everywhere'])
|
const w = asks([...TO_IMPLEMENT, 'Everywhere'])
|
||||||
await bootRun($, on, FABLE, w)
|
await bootRun($, on, FABLE, w)
|
||||||
w.onWrite = () => { w.files.set(HOME_FILE, '{nope') }
|
w.onWrite = () => { w.files.set(HOME_FILE, '{nope') }
|
||||||
await typeFeat($)
|
await typeFeat($)
|
||||||
@@ -2129,9 +2137,9 @@ test('writes: the route tool never writes', async ($, on) => {
|
|||||||
expect(w.written).toEqual([])
|
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) {
|
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 bootRun($, on, FABLE, w)
|
||||||
await typeFeat($)
|
await typeFeat($)
|
||||||
return w
|
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
|
const q = w.asked[0] as Asked
|
||||||
expect(q.text).toContain('First dispatch of feater')
|
expect(q.text).toContain('First dispatch of feater')
|
||||||
expect(q.text).toContain('claude-sonnet-5-5 at medium')
|
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) => {
|
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 (
|
test('T2: a change routes this very spawn with the new row', async (
|
||||||
$, on) => {
|
$, on) => {
|
||||||
const w = asks(['judge', 'Everywhere'])
|
const w = asks([...TO_JUDGE, 'Everywhere'])
|
||||||
const rig = await bootRun($, on, FABLE, w)
|
const rig = await bootRun($, on, FABLE, w)
|
||||||
await $.agent.spawn(spawnOf('feater'))
|
await $.agent.spawn(spawnOf('feater'))
|
||||||
expect(rig.specs).toEqual([OPUS])
|
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 route($, 'show')).toContain('router: off (config)')
|
||||||
expect(await featerModel($, rig)).toBeUndefined()
|
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<string, Rec>
|
||||||
|
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', 'ab\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: 'ab\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' })
|
||||||
|
})
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ type State = {
|
|||||||
pendingAllowed: boolean // same, for the mid-turn prompt
|
pendingAllowed: boolean // same, for the mid-turn prompt
|
||||||
spawning: number // agent.spawn hooks in flight (preloads fire inside)
|
spawning: number // agent.spawn hooks in flight (preloads fire inside)
|
||||||
offers: Map<string, string> // subagentType -> definition source
|
offers: Map<string, string> // subagentType -> definition source
|
||||||
|
descs: Map<string, string> // subagentType -> cleaned one-sentence offer line
|
||||||
loops: Map<string, Loop> // agentId -> that loop's routing
|
loops: Map<string, Loop> // agentId -> that loop's routing
|
||||||
explicitEffort: Map<string, Level> // Agent tool_use_id -> effort param
|
explicitEffort: Map<string, Level> // Agent tool_use_id -> effort param
|
||||||
skillCalls: number // Skill tool calls in flight
|
skillCalls: number // Skill tool calls in flight
|
||||||
@@ -111,6 +112,7 @@ type Memory = {
|
|||||||
askLocal: boolean | undefined // the machine override's own `ask`
|
askLocal: boolean | undefined // the machine override's own `ask`
|
||||||
base: Record<Row, Table> // the file's rows, before any project layer
|
base: Record<Row, Table> // the file's rows, before any project layer
|
||||||
confirmed: Record<Kind, Table> // name -> the phase a Keep endorsed
|
confirmed: Record<Kind, Table> // name -> the phase a Keep endorsed
|
||||||
|
about: Table // phase -> its one-line meaning, shown in the dialog only
|
||||||
local: Record<Row, Set<string>> // rows of projects[key] or the override
|
local: Record<Row, Set<string>> // rows of projects[key] or the override
|
||||||
key: string | undefined // normalized repo key; undefined = no project layer
|
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[] = [
|
const ROUTING_ORDER: readonly string[] = [
|
||||||
'phases', 'skills', 'agents', 'projects', 'confirmed', 'changed', 'ask',
|
'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 LATER = 'Later'
|
||||||
const KEEP = 'Keep'
|
const KEEP = 'Keep'
|
||||||
|
const CHANGE = 'Change'
|
||||||
const EVERYWHERE = 'Everywhere'
|
const EVERYWHERE = 'Everywhere'
|
||||||
const THIS_PROJECT = 'This project only'
|
const THIS_PROJECT = 'This project only'
|
||||||
const ALTS: Record<Kind, readonly string[]> = {
|
const CHOICE_ALIASES: readonly string[] = ['fable', 'opus', 'sonnet', 'haiku']
|
||||||
skills: ['plan', 'reflect', 'implement', 'apply'],
|
const MAX_DESC = 140 // chars of a skill or agent description in a question
|
||||||
agents: ['judge', 'implement', 'verify', 'apply'],
|
const MAX_ABOUT = 120 // chars of a phase `about`
|
||||||
phases: [],
|
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 ROWS: readonly Row[] = ['skills', 'agents']
|
||||||
const KINDS: readonly Kind[] = ['skills', 'agents', 'phases']
|
const KINDS: readonly Kind[] = ['skills', 'agents', 'phases']
|
||||||
const HAIKU = 'claude-haiku'
|
const HAIKU = 'claude-haiku'
|
||||||
@@ -580,16 +583,34 @@ function emptyMemory(): Memory {
|
|||||||
askLocal: undefined,
|
askLocal: undefined,
|
||||||
base: { skills: {}, agents: {} },
|
base: { skills: {}, agents: {} },
|
||||||
confirmed: { skills: {}, agents: {}, phases: {} },
|
confirmed: { skills: {}, agents: {}, phases: {} },
|
||||||
|
about: {},
|
||||||
local: { skills: new Set(), agents: new Set() },
|
local: { skills: new Set(), agents: new Set() },
|
||||||
key: undefined,
|
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. */
|
/** What the file and the machine override say about decisions. */
|
||||||
function buildMemory(
|
function buildMemory(
|
||||||
file: Rec,
|
file: Rec,
|
||||||
key: string | undefined,
|
key: string | undefined,
|
||||||
override: Rec | undefined,
|
override: Rec | undefined,
|
||||||
|
log: Log,
|
||||||
): Memory {
|
): Memory {
|
||||||
const project = projectRows(file, key)
|
const project = projectRows(file, key)
|
||||||
const flag = override?.ask
|
const flag = override?.ask
|
||||||
@@ -608,6 +629,7 @@ function buildMemory(
|
|||||||
agents: strTable(sure.agents),
|
agents: strTable(sure.agents),
|
||||||
phases: strTable(sure.phases),
|
phases: strTable(sure.phases),
|
||||||
},
|
},
|
||||||
|
about: aboutOf(file.phases, log),
|
||||||
local: { skills: local('skills'), agents: local('agents') },
|
local: { skills: local('skills'), agents: local('agents') },
|
||||||
key,
|
key,
|
||||||
}
|
}
|
||||||
@@ -732,7 +754,7 @@ async function assemble(
|
|||||||
fileData && routingTables(fileData, key), override, log)
|
fileData && routingTables(fileData, key), override, log)
|
||||||
const mem = fileData === undefined
|
const mem = fileData === undefined
|
||||||
? emptyMemory()
|
? emptyMemory()
|
||||||
: buildMemory(fileData, key, override)
|
: buildMemory(fileData, key, override, log)
|
||||||
return { cfg, source: sourceWord(file, over), mem, override }
|
return { cfg, source: sourceWord(file, over), mem, override }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,6 +804,7 @@ function newState(cfg: Config, source: string): State {
|
|||||||
pendingAllowed: false,
|
pendingAllowed: false,
|
||||||
spawning: 0,
|
spawning: 0,
|
||||||
offers: new Map(),
|
offers: new Map(),
|
||||||
|
descs: new Map(),
|
||||||
loops: new Map(),
|
loops: new Map(),
|
||||||
explicitEffort: new Map(),
|
explicitEffort: new Map(),
|
||||||
skillCalls: 0,
|
skillCalls: 0,
|
||||||
@@ -823,6 +846,7 @@ function resetSession(st: State): void {
|
|||||||
agentModels: st.agentModels,
|
agentModels: st.agentModels,
|
||||||
sessionModel: st.sessionModel,
|
sessionModel: st.sessionModel,
|
||||||
offers: st.offers,
|
offers: st.offers,
|
||||||
|
descs: st.descs,
|
||||||
mem: st.mem,
|
mem: st.mem,
|
||||||
override: st.override,
|
override: st.override,
|
||||||
loaded: st.loaded,
|
loaded: st.loaded,
|
||||||
@@ -1531,7 +1555,8 @@ const wantsAsk = (st: State, req: Req): boolean =>
|
|||||||
async function refreshMemory($: Api, st: State): Promise<boolean> {
|
async function refreshMemory($: Api, st: State): Promise<boolean> {
|
||||||
const file = await readRouting($, text => $.ui.log(text))
|
const file = await readRouting($, text => $.ui.log(text))
|
||||||
if (typeof file === 'string') return false
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1547,26 +1572,217 @@ async function askOr(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionsFor(st: State, req: Req): string[] {
|
// What a question says about its row. Everything read from outside (a
|
||||||
const alts = ALTS[req.kind]
|
// SKILL.md, an agent's listing line, routing.json) is cleaned and capped.
|
||||||
.filter(p => p !== req.phase && hasKey(st.cfg.phases, p))
|
|
||||||
return req.kind === 'phases'
|
/**
|
||||||
? [LATER, KEEP]
|
* Text safe in a question: every whitespace folded to a space, then control
|
||||||
: [LATER, KEEP, ...alts.slice(0, 2)]
|
* 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. */
|
/** The first sentence of a description, cleaned; undefined when empty. */
|
||||||
function decide($: Api, st: State, req: Req, answer: string): Answer {
|
function oneSentence(text: string): string | undefined {
|
||||||
if (answer === KEEP) return { act: 'keep' }
|
const end = /\.(?:\s|$)/.exec(text)
|
||||||
if (answer === LATER || req.kind === 'phases') return { act: 'later' }
|
const line = clean(end ? text.slice(0, end.index) : text, MAX_DESC)
|
||||||
if (!hasKey(st.cfg.phases, answer)) {
|
return line === '' ? undefined : line
|
||||||
const shown = answer.slice(0, 40).replace(/[\u0000-\u001f]/g, ' ')
|
|
||||||
$.ui.toast(`unknown phase "${shown}", default kept`)
|
|
||||||
return { act: 'later' }
|
|
||||||
}
|
}
|
||||||
return answer === req.phase
|
|
||||||
? { act: 'keep' }
|
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/
|
||||||
: { act: 'change', phase: answer }
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `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/<name>/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<string | undefined> {
|
||||||
|
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 <phase> (<about>)": 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<string | undefined> {
|
||||||
|
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<Answer> {
|
||||||
|
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(
|
async function askScope(
|
||||||
@@ -1577,10 +1793,10 @@ async function askScope(
|
|||||||
): Promise<Scope> {
|
): Promise<Scope> {
|
||||||
const key = st.mem.key
|
const key = st.mem.key
|
||||||
const text = `Use ${to} for ${req.name} everywhere, or only in this project?`
|
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])
|
[EVERYWHERE, key === undefined ? LATER : THIS_PROJECT])
|
||||||
if (answer === EVERYWHERE) return 'everywhere'
|
if (choice === EVERYWHERE) return 'everywhere'
|
||||||
return answer === THIS_PROJECT && key !== undefined ? 'project' : 'later'
|
return choice === THIS_PROJECT && key !== undefined ? 'project' : 'later'
|
||||||
}
|
}
|
||||||
|
|
||||||
function table(parent: Rec, key: string): Rec {
|
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. */
|
/** The dialog and what follows; true when routing.json was rewritten. */
|
||||||
async function runDialog($: Api, st: State, req: Req, text: string) {
|
async function runDialog($: Api, st: State, req: Req, text: string) {
|
||||||
const answer = await askOr($, text, optionsFor(st, req))
|
|
||||||
st.asked.add(keyOf(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 {
|
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. */
|
/** First-use question text: the id and level the next step really gets. */
|
||||||
async function mainQuestion($: Api, st: State, lead: string) {
|
async function mainQuestion($: Api, st: State, lead: string) {
|
||||||
const v = await snapshot($, st)
|
const v = await snapshot($, st)
|
||||||
return `${lead} ${idWord(st, v.call.model)} at ${
|
return `${lead}: ${idWord(st, v.call.model)} at ${effortWord(st, v)}. OK?`
|
||||||
effortWord(st, v)}. Keep it?`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnQuestion(
|
function spawnQuestion(
|
||||||
@@ -1735,21 +1949,44 @@ function spawnQuestion(
|
|||||||
): string {
|
): string {
|
||||||
const id = spawnTarget($, st, e, route) ?? 'its own model'
|
const id = spawnTarget($, st, e, route) ?? 'its own model'
|
||||||
const effort = st.explicitEffort.get(e.tool_use_id) ?? route.effort
|
const effort = st.explicitEffort.get(e.tool_use_id) ?? route.effort
|
||||||
return `First dispatch of ${req.name}: ${req.phase}, ${id} at ${
|
const lead = withDesc(`First dispatch of ${req.name}`,
|
||||||
effort ?? 'its own effort'}. Keep it?`
|
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. */
|
/** Typed skill: a changed row re-routes the turn at once. */
|
||||||
async function confirmSkill($: Api, st: State, skill: string, row: Picked) {
|
async function confirmSkill($: Api, st: State, skill: string, row: Picked) {
|
||||||
const req: Req = { kind: 'skills', name: skill, phase: row.phase }
|
const req: Req = { kind: 'skills', name: skill, phase: row.phase }
|
||||||
const lead = `First route for /${skill}: ${row.phase}, next step`
|
const say = () => skillQuestion($, st, skill, row.phase)
|
||||||
if (!(await confirmFirst($, st, req, () => mainQuestion($, st, lead)))) {
|
if (!(await confirmFirst($, st, req, say))) return
|
||||||
return
|
|
||||||
}
|
|
||||||
const after = skillRow(st, skill)
|
const after = skillRow(st, skill)
|
||||||
if (after === undefined) return
|
if (after === undefined) return
|
||||||
routeMainBySkill(st, after, true)
|
routeMainBySkill(st, after, true)
|
||||||
refresh($, st)
|
refresh($, st)
|
||||||
|
if (after.phase !== row.phase) await toastChange($, st, skill)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rowed spawn: the route in force after the dialog (changed or not). */
|
/** 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. */
|
/** A phase declared through the route tool: confirm-only. */
|
||||||
async function confirmPhase($: Api, st: State, phase: string) {
|
async function confirmPhase($: Api, st: State, phase: string) {
|
||||||
const req: Req = { kind: 'phases', name: phase, phase }
|
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))
|
await confirmFirst($, st, req, () => mainQuestion($, st, lead))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2418,7 +2657,7 @@ function afterSpawn(
|
|||||||
|
|
||||||
function registerSpawn(on: On, st: State): void {
|
function registerSpawn(on: On, st: State): void {
|
||||||
on('agent.offer', async ($, e, next) => {
|
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)
|
return next(e)
|
||||||
}).catch(($, e, next) => {
|
}).catch(($, e, next) => {
|
||||||
warnOnce(st, $, 'agent.offer', next.error.kind)
|
warnOnce(st, $, 'agent.offer', next.error.kind)
|
||||||
|
|||||||
@@ -2,47 +2,58 @@
|
|||||||
"phases": {
|
"phases": {
|
||||||
"plan": {
|
"plan": {
|
||||||
"tier": "best",
|
"tier": "best",
|
||||||
"effort": "xhigh"
|
"effort": "xhigh",
|
||||||
|
"about": "design and architecture: the deepest thinking, before any code exists"
|
||||||
},
|
},
|
||||||
"reflect": {
|
"reflect": {
|
||||||
"tier": "best",
|
"tier": "best",
|
||||||
"effort": "high"
|
"effort": "high",
|
||||||
|
"about": "analysis, review and synthesis: reasoning about what already exists"
|
||||||
},
|
},
|
||||||
"orchestrate": {
|
"orchestrate": {
|
||||||
"tier": "best",
|
"tier": "best",
|
||||||
"effort": "medium"
|
"effort": "medium",
|
||||||
|
"about": "dispatching and coordinating sub-agents: mostly handing out work"
|
||||||
},
|
},
|
||||||
"escalate": {
|
"escalate": {
|
||||||
"tier": "best",
|
"tier": "best",
|
||||||
"effort": "max"
|
"effort": "max",
|
||||||
|
"about": "a stuck problem or a judged need: maximum effort, diagnosis only"
|
||||||
},
|
},
|
||||||
"judge": {
|
"judge": {
|
||||||
"tier": "big",
|
"tier": "big",
|
||||||
"effort": "xhigh"
|
"effort": "xhigh",
|
||||||
|
"about": "independent judgment of work: audits, challenges and verdicts"
|
||||||
},
|
},
|
||||||
"implement": {
|
"implement": {
|
||||||
"tier": "work",
|
"tier": "work",
|
||||||
"effort": "medium"
|
"effort": "medium",
|
||||||
|
"about": "writing the code of an already planned change"
|
||||||
},
|
},
|
||||||
"write": {
|
"write": {
|
||||||
"tier": "work",
|
"tier": "work",
|
||||||
"effort": "high"
|
"effort": "high",
|
||||||
|
"about": "writing prose and docs, or a careful edit that needs polish"
|
||||||
},
|
},
|
||||||
"verify": {
|
"verify": {
|
||||||
"tier": "work",
|
"tier": "work",
|
||||||
"effort": "xhigh"
|
"effort": "xhigh",
|
||||||
|
"about": "checking a result against its spec: tests, security review, gates"
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"tier": "work",
|
"tier": "work",
|
||||||
"effort": "medium"
|
"effort": "medium",
|
||||||
|
"about": "reading and searching the codebase to answer a question"
|
||||||
},
|
},
|
||||||
"apply": {
|
"apply": {
|
||||||
"tier": "work",
|
"tier": "work",
|
||||||
"effort": "low"
|
"effort": "low",
|
||||||
|
"about": "bookkeeping: commits, memory entries and other small edits"
|
||||||
},
|
},
|
||||||
"mechanical": {
|
"mechanical": {
|
||||||
"tier": "cheap",
|
"tier": "cheap",
|
||||||
"effort": "low"
|
"effort": "low",
|
||||||
|
"about": "scripted or trivial work needing no judgment, on the cheapest model"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
@@ -125,7 +136,7 @@
|
|||||||
"plugin-probe": "apply",
|
"plugin-probe": "apply",
|
||||||
"validator-analyzer": "apply",
|
"validator-analyzer": "apply",
|
||||||
"verifier": "verify",
|
"verifier": "verify",
|
||||||
"security-auditor": "verify",
|
"security-auditor": "judge",
|
||||||
"status-reporter": "mechanical"
|
"status-reporter": "mechanical"
|
||||||
},
|
},
|
||||||
"projects": {},
|
"projects": {},
|
||||||
@@ -133,12 +144,14 @@
|
|||||||
"agents": {
|
"agents": {
|
||||||
"verifier": "verify",
|
"verifier": "verify",
|
||||||
"feater": "implement",
|
"feater": "implement",
|
||||||
"doc-syncer": "write"
|
"doc-syncer": "write",
|
||||||
|
"security-auditor": "judge"
|
||||||
},
|
},
|
||||||
"phases": {
|
"phases": {
|
||||||
"verify": "verify",
|
"verify": "verify",
|
||||||
"implement": "implement",
|
"implement": "implement",
|
||||||
"write": "write"
|
"write": "write",
|
||||||
|
"orchestrate": "orchestrate"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"changed": {},
|
"changed": {},
|
||||||
|
|||||||
Reference in New Issue
Block a user