feat(model-router): wave 3-C — /route forget <name|all|projects>
Clears first-use decisions from the tracked routing.json through the existing writer: a name is forgotten in every table (confirmed, changed with the row restored to its recorded shipped phase under guards, project exceptions pruned) and asked again; all and projects ask a confirmation in the engine dialog (Cancel first) and hold the single-dialog slot; nothing is written when there is nothing to forget; the answer names what was restored and the frontmatter floor to realign when one was aligned; the route tool has no forget path. Docs name the new writer. Kit suite 232 → 284. Contract .claude/tasks/contracts/2026-10-11-model-router-w3c-forget-1457.md, plan r3: 3 lenses + 1 confirmation, feater + 3 rounds (one real defect), GATE 0 MET, verifier at the cap on coverage (user-accepted), security PASS.
This commit is contained in:
@@ -2956,3 +2956,577 @@ test('change: the current phase wins a tie, and counts as a Keep', async (
|
||||
expect(routingOf(w).changed).toEqual({})
|
||||
expect(confirmedOf(w).agents).toEqual({ Explore: 'explore' })
|
||||
})
|
||||
|
||||
// ---- wave 3-C: /route forget -----------------------------------------------
|
||||
|
||||
const TWO = 'github.com/acme/two'
|
||||
const FORGET_NOTE = 'other live sessions see it after /route reload'
|
||||
const MODEL_LOCK = 'lib/tests/model-routing.test.sh'
|
||||
|
||||
/** A feater moved to judge, as a Change Everywhere would have left it. */
|
||||
const movedFeater = (file: Rec): void => {
|
||||
(file.agents as Rec).feater = 'judge'
|
||||
file.changed = { agents: { feater: { from: 'implement', to: 'judge' } } }
|
||||
file.confirmed = { agents: { feater: 'judge' } }
|
||||
}
|
||||
|
||||
/** Edits the inline routing.json with `extra` keys laid over it. */
|
||||
const decisions = (extra: Rec) => (file: Rec): void => {
|
||||
Object.assign(file, extra)
|
||||
}
|
||||
|
||||
/** A world with decisions in the file, for the forget tests. */
|
||||
const forgetWorld = (extra: Rec, answers: string[] = []) =>
|
||||
newWorld({ edit: decisions(extra), answers })
|
||||
|
||||
test('forget: a confirmed skill is removed and asked again', async ($, on) => {
|
||||
const w = asks(['Keep', 'Later'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
await typeFeat($)
|
||||
expect(await route($, 'forget feat')).toContain('forgot feat: 1 confirmed')
|
||||
expect(confirmedOf(w).skills).toBeUndefined()
|
||||
await typeFeat($)
|
||||
expect(w.asked).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('forget: /route pending lists a forgotten key again', async ($, on) => {
|
||||
const w = asks(['Keep'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
await typeFeat($)
|
||||
await route($, 'forget feat')
|
||||
const lines = (await route($, 'pending')).split('\n')
|
||||
expect(lines[1]).toBe('asked this session: none')
|
||||
expect(lines[2]).toContain('skills:feat')
|
||||
})
|
||||
|
||||
test('forget: the answer closes on the other sessions\' reload', async (
|
||||
$, on) => {
|
||||
const w = forgetWorld({ confirmed: { skills: { feat: 'reflect' } } })
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget feat')).toMatch(new RegExp(`${FORGET_NOTE}$`))
|
||||
})
|
||||
|
||||
test('forget: a changed agent goes back to its shipped phase', async (
|
||||
$, on) => {
|
||||
const w = newWorld({ edit: movedFeater })
|
||||
const rig = await bootRun($, on, FABLE, w)
|
||||
expect(await featerModel($, rig)).toBe(OPUS)
|
||||
await route($, 'forget feater')
|
||||
const file = routingOf(w)
|
||||
expect((file.agents as Rec).feater).toBe('implement')
|
||||
expect(file.changed).toEqual({})
|
||||
expect(file.confirmed).toEqual({})
|
||||
expect(await featerModel($, rig)).toBe(SONNET)
|
||||
})
|
||||
|
||||
test('forget: a restored row names the frontmatter to realign', async (
|
||||
$, on) => {
|
||||
await bootRun($, on, FABLE, newWorld({ edit: movedFeater }))
|
||||
expect(await route($, 'forget feater')).toContain(
|
||||
' (agents.feater → implement: sonnet at medium; if agents/feater.md ' +
|
||||
'was aligned to judge, set model: sonnet, effort: medium and its lock in ' +
|
||||
`${MODEL_LOCK}, then \`make test\`)`)
|
||||
})
|
||||
|
||||
test('forget: a built-in agent restored names no file', async ($, on) => {
|
||||
const w = forgetWorld({
|
||||
agents: { Explore: 'write' },
|
||||
changed: { agents: { Explore: { from: 'explore', to: 'write' } } },
|
||||
})
|
||||
await bootRun($, on, FABLE, w)
|
||||
const out = await route($, 'forget Explore')
|
||||
expect(out).toContain('(agents.Explore → explore: sonnet at medium)')
|
||||
})
|
||||
|
||||
test('forget: the built-in Plan agent restored names no file', async (
|
||||
$, on) => {
|
||||
const w = forgetWorld({
|
||||
agents: { Plan: 'write' },
|
||||
changed: { agents: { Plan: { from: 'judge', to: 'write' } } },
|
||||
})
|
||||
await bootRun($, on, FABLE, w)
|
||||
const out = await route($, 'forget Plan')
|
||||
expect(out).toContain('(agents.Plan → judge:')
|
||||
expect(out).not.toContain('agents/Plan.md')
|
||||
})
|
||||
|
||||
test('forget all: a refused restore is kept, the rest removed', async (
|
||||
$, on) => {
|
||||
const w = forgetWorld({
|
||||
agents: { Explore: 'judge' },
|
||||
changed: { agents: { Explore: { from: 'explore', to: 'write' } } },
|
||||
confirmed: { skills: { feat: 'reflect' } },
|
||||
}, ['Forget'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
const out = await route($, 'forget all')
|
||||
expect(out).toContain('1 restore(s) kept: agents.Explore: the row differs')
|
||||
expect(confirmedOf(w).skills).toBeUndefined()
|
||||
expect(routingOf(w).changed).toEqual(
|
||||
{ agents: { Explore: { from: 'explore', to: 'write' } } })
|
||||
})
|
||||
|
||||
test('forget: a phase is removed and the route tool asks again', async (
|
||||
$, on) => {
|
||||
const w = asks(['Keep', 'Later'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
|
||||
expect(await route($, 'forget orchestrate')).toContain('1 confirmed')
|
||||
expect(confirmedOf(w).phases).toBeUndefined()
|
||||
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
|
||||
expect(w.asked).toHaveLength(2)
|
||||
})
|
||||
|
||||
const twoRepos = {
|
||||
projects: {
|
||||
[KEY]: { skills: { feat: 'plan' } },
|
||||
[TWO]: { skills: { feat: 'apply' }, agents: { feater: 'judge' } },
|
||||
},
|
||||
}
|
||||
|
||||
test('forget: a name loses its exception in every repo', async ($, on) => {
|
||||
const w = forgetWorld(twoRepos)
|
||||
await bootRun($, on, FABLE, w)
|
||||
const out = await route($, 'forget feat')
|
||||
expect(out).toContain('2 project exception(s) removed')
|
||||
expect(routingOf(w).projects).toEqual({
|
||||
[TWO]: { agents: { feater: 'judge' } },
|
||||
})
|
||||
})
|
||||
|
||||
test('forget: an undecided row has nothing to forget, no write', async (
|
||||
$, on) => {
|
||||
const w = newWorld()
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget status')).toBe('nothing to forget for status')
|
||||
expect(w.written).toEqual([])
|
||||
})
|
||||
|
||||
test('forget: an unknown name has nothing to forget, no write', async (
|
||||
$, on) => {
|
||||
const w = newWorld()
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget zzz')).toContain('nothing to forget for zzz')
|
||||
expect(w.written).toEqual([])
|
||||
})
|
||||
|
||||
test('forget: a key only asked this session is reset, no write', async (
|
||||
$, on) => {
|
||||
const w = asks(['Later', 'Later'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
await typeFeat($)
|
||||
const out = await route($, 'forget feat')
|
||||
expect(out).toContain('asked again at the next use, nothing was saved')
|
||||
expect(w.written).toEqual([])
|
||||
await typeFeat($)
|
||||
expect(w.asked).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('forget: an asked key the override decides is said so', async (
|
||||
$, on) => {
|
||||
const w = asks(['Later'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
await typeFeat($)
|
||||
w.files.set(HOME_FILE, '{"skills":{"feat":"plan"}}')
|
||||
await route($, 'reload')
|
||||
const out = await route($, 'forget feat')
|
||||
expect(out).toContain('still decided by ~/.claude/model-router.json')
|
||||
})
|
||||
|
||||
for (const args of ['forget', 'forget a b']) {
|
||||
test(`forget: "${args}" is a usage line, no write`, async ($, on) => {
|
||||
const w = newWorld()
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, args)).toBe(
|
||||
'usage: /route forget <name|all|projects>')
|
||||
expect(w.written).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('forget: a row named all is reserved, said', async ($, on) => {
|
||||
const w = newWorld({ edit: f => { (f.skills as Rec).all = 'plan' } })
|
||||
await bootRun($, on, FABLE, w)
|
||||
const out = await route($, 'forget all')
|
||||
expect(out).toContain('named all is reserved here: edit routing.json by hand')
|
||||
})
|
||||
|
||||
/** `/route forget <args>` typed while a first-use dialog is held open. */
|
||||
async function forgetDuringDialog($: Engine, w: World, args: string) {
|
||||
const held = deferred()
|
||||
const seen = deferred()
|
||||
w.gate = held.gate
|
||||
w.onAsk = seen.release
|
||||
const typing = typeFeat($)
|
||||
await seen.gate
|
||||
const out = await route($, args)
|
||||
held.release()
|
||||
await typing
|
||||
return out
|
||||
}
|
||||
|
||||
test('forget: refused while a dialog is open, nothing written', async (
|
||||
$, on) => {
|
||||
const w = asks(['Later'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
const out = await forgetDuringDialog($, w, 'forget feat')
|
||||
expect(out).toBe('answer the open dialog first')
|
||||
expect(w.written).toEqual([])
|
||||
})
|
||||
|
||||
const confirmedFeat = { confirmed: { skills: { feat: 'reflect' } } }
|
||||
|
||||
test('forget all asks first: Cancel, then Forget', async ($, on) => {
|
||||
const w = forgetWorld({ ...confirmedFeat, ...twoRepos }, ['Cancel'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
await route($, 'forget all')
|
||||
expect(w.asked[0]?.options).toEqual(['Cancel', 'Forget'])
|
||||
expect(w.asked[0]?.header).toBe('model-router')
|
||||
})
|
||||
|
||||
test('forget all: the question counts what it removes', async ($, on) => {
|
||||
const w = forgetWorld({ ...confirmedFeat, ...twoRepos }, ['Cancel'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
await route($, 'forget all')
|
||||
expect(w.asked[0]?.text).toBe('Forget 4 decisions: 0 row(s) restored, ' +
|
||||
'3 project exception(s) in 2 repos?')
|
||||
})
|
||||
|
||||
test('forget all: Cancel writes nothing', async ($, on) => {
|
||||
const w = forgetWorld(confirmedFeat, ['Cancel'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget all')).toContain('cancelled, nothing written')
|
||||
expect(w.written).toEqual([])
|
||||
})
|
||||
|
||||
for (const [what, answer] of [
|
||||
['a dismissed dialog', REJECT],
|
||||
['another answer', 'whenever'],
|
||||
] as const) {
|
||||
test(`forget all: ${what} is a cancel`, async ($, on) => {
|
||||
const w = forgetWorld(confirmedFeat, [answer])
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget all')).toContain('cancelled')
|
||||
expect(w.written).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('forget all: nothing to forget skips the question', async ($, on) => {
|
||||
const w = newWorld({ answers: ['Forget'] })
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget all')).toBe('nothing to forget')
|
||||
expect(w.asked).toEqual([])
|
||||
})
|
||||
|
||||
test('forget all: empties confirmed, changed and projects', async ($, on) => {
|
||||
const w = forgetWorld({
|
||||
agents: { feater: 'judge' },
|
||||
confirmed: { skills: { feat: 'reflect' }, agents: { feater: 'judge' } },
|
||||
changed: movedChange(),
|
||||
projects: twoRepos.projects,
|
||||
}, ['Forget'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
await route($, 'forget all')
|
||||
const file = routingOf(w)
|
||||
expect([file.confirmed, file.changed, file.projects]).toEqual([{}, {}, {}])
|
||||
})
|
||||
|
||||
/** The `changed` table of a feater moved to judge. */
|
||||
function movedChange(): Rec {
|
||||
return { agents: { feater: { from: 'implement', to: 'judge' } } }
|
||||
}
|
||||
|
||||
test('forget all: the rows come back, the counts are said', async ($, on) => {
|
||||
const w = forgetWorld({
|
||||
agents: { feater: 'judge' },
|
||||
confirmed: { skills: { feat: 'reflect' }, agents: { feater: 'judge' } },
|
||||
changed: movedChange(),
|
||||
projects: { [KEY]: { skills: { feat: 'plan' } } },
|
||||
}, ['Forget'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
const out = await route($, 'forget all')
|
||||
expect(out).toContain('forgot all: 2 confirmed, 1 row(s) restored, ' +
|
||||
'1 project exception(s) removed')
|
||||
expect((routingOf(w).agents as Rec).feater).toBe('implement')
|
||||
})
|
||||
|
||||
test('forget all: the asked set is cleared', async ($, on) => {
|
||||
const w = asks(['Keep', 'Forget', 'Later'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
await typeFeat($)
|
||||
await route($, 'forget all')
|
||||
await typeFeat($)
|
||||
expect(w.asked).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('forget all: the plan is recomputed on the file the write reads', async (
|
||||
$, on) => {
|
||||
const w = forgetWorld(confirmedFeat, ['Forget'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
w.onAsk = () => {
|
||||
const file = routingOf(w)
|
||||
file.confirmed = {
|
||||
skills: { feat: 'reflect' },
|
||||
agents: { verifier: 'verify' },
|
||||
}
|
||||
w.files.set('routing', JSON.stringify(file))
|
||||
}
|
||||
const out = await route($, 'forget all')
|
||||
expect(out).toContain('2 confirmed')
|
||||
expect(routingOf(w).confirmed).toEqual({})
|
||||
})
|
||||
|
||||
test('forget all: the file vanishing during the question saves nothing', async (
|
||||
$, on) => {
|
||||
const w = forgetWorld(confirmedFeat, ['Forget'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
w.onAsk = () => { w.files.delete('routing') }
|
||||
expect(await route($, 'forget all')).toBe('forget refused: nothing saved')
|
||||
expect(w.written).toEqual([])
|
||||
})
|
||||
|
||||
test('forget all: holds the dialog slot while it asks', async ($, on) => {
|
||||
const w = asks(['Forget'], { edit: decisions(confirmedFeat) })
|
||||
await bootRun($, on, FABLE, w)
|
||||
const held = deferred()
|
||||
const seen = deferred()
|
||||
w.gate = held.gate
|
||||
w.onAsk = seen.release
|
||||
const forgetting = route($, 'forget all')
|
||||
await seen.gate
|
||||
w.onAsk = undefined
|
||||
await typed($, '/status')
|
||||
await skillPrompt($, 'status')
|
||||
expect(w.asked).toHaveLength(1)
|
||||
held.release()
|
||||
await forgetting
|
||||
})
|
||||
|
||||
test('forget all: a second forget is refused while the first asks', async (
|
||||
$, on) => {
|
||||
const w = forgetWorld(confirmedFeat, ['Cancel'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
const held = deferred()
|
||||
const seen = deferred()
|
||||
w.gate = held.gate
|
||||
w.onAsk = seen.release
|
||||
const first = route($, 'forget all')
|
||||
await seen.gate
|
||||
expect(await route($, 'forget feat')).toBe('answer the open dialog first')
|
||||
held.release()
|
||||
await first
|
||||
})
|
||||
|
||||
test('forget projects: only the project exceptions go', async ($, on) => {
|
||||
const w = forgetWorld({ ...confirmedFeat, ...twoRepos }, ['Forget'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
await route($, 'forget projects')
|
||||
expect(routingOf(w).projects).toEqual({})
|
||||
expect(routingOf(w).confirmed).toEqual(confirmedFeat.confirmed)
|
||||
})
|
||||
|
||||
test('forget projects: nothing to forget skips the question', async (
|
||||
$, on) => {
|
||||
const w = forgetWorld(confirmedFeat, ['Forget'])
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget projects')).toBe('nothing to forget')
|
||||
expect(w.asked).toEqual([])
|
||||
})
|
||||
|
||||
/** A moved feater whose recorded change no longer matches the file. */
|
||||
const brokenRestore = (changed: Rec, row = 'judge') => (file: Rec) => {
|
||||
(file.agents as Rec).feater = row
|
||||
file.changed = { agents: { feater: changed } }
|
||||
}
|
||||
|
||||
for (const [why, changed, row, text] of [
|
||||
['the row differs', { from: 'implement', to: 'judge' }, 'write',
|
||||
'differs from the recorded change'],
|
||||
['from is no phase', { from: 'nosuch', to: 'judge' }, 'judge',
|
||||
'nosuch is not a known phase'],
|
||||
['from is not a string', { from: 5, to: 'judge' }, 'judge',
|
||||
'no phase recorded'],
|
||||
] as const) {
|
||||
test(`forget: a restore is refused when ${why}, entry kept`, async (
|
||||
$, on) => {
|
||||
const w = newWorld({ edit: brokenRestore(changed, row) })
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget feater')).toContain(text)
|
||||
expect(w.written).toEqual([])
|
||||
expect(routingOf(w).changed).toEqual({ agents: { feater: changed } })
|
||||
})
|
||||
}
|
||||
|
||||
test('forget: a refused restore reports it next to what was removed', async (
|
||||
$, on) => {
|
||||
const w = newWorld({ edit: f => {
|
||||
brokenRestore({ from: 'implement', to: 'judge' }, 'write')(f)
|
||||
f.confirmed = { agents: { feater: 'write' } }
|
||||
} })
|
||||
await bootRun($, on, FABLE, w)
|
||||
const out = await route($, 'forget feater')
|
||||
expect(out).toContain('1 confirmed, 0 row(s) restored')
|
||||
expect(out).toContain('1 restore(s) kept: agents.feater:')
|
||||
})
|
||||
|
||||
test('forget: the override still setting the row is said', async ($, on) => {
|
||||
const w = newWorld({
|
||||
edit: movedFeater,
|
||||
home: { agents: { feater: 'judge' } },
|
||||
})
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget feater')).toContain(
|
||||
'; ~/.claude/model-router.json still sets feater and wins here')
|
||||
})
|
||||
|
||||
test('forget: a restored skill the run still holds says /route clear', async (
|
||||
$, on) => {
|
||||
const w = newWorld({ edit: f => {
|
||||
(f.skills as Rec).feat = 'plan'
|
||||
f.changed = { skills: { feat: { from: 'reflect', to: 'plan' } } }
|
||||
f.confirmed = { skills: { feat: 'plan' } }
|
||||
} })
|
||||
await bootRun($, on, FABLE, w)
|
||||
await typed($, '/feat add a thing')
|
||||
await skillPrompt($, 'feat')
|
||||
expect(await route($, 'forget feat')).toContain(
|
||||
'; current run may keep plan: /route clear to apply')
|
||||
})
|
||||
|
||||
/** A feat moved to plan, as a Change Everywhere would have left it. */
|
||||
const movedFeat = (file: Rec): void => {
|
||||
(file.skills as Rec).feat = 'plan'
|
||||
file.changed = { skills: { feat: { from: 'reflect', to: 'plan' } } }
|
||||
file.confirmed = { skills: { feat: 'plan' } }
|
||||
}
|
||||
|
||||
const KEEPS = 'current run may keep'
|
||||
|
||||
for (const answer of ['Cancel', REJECT]) {
|
||||
test(`forget projects asks first: ${answer} saves nothing`, async (
|
||||
$, on) => {
|
||||
const w = forgetWorld(twoRepos, [answer])
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget projects')).toContain('cancelled')
|
||||
expect(w.asked[0]?.options).toEqual(['Cancel', 'Forget'])
|
||||
expect(w.written).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('forget: a restored agent or other-phase skill keeps no run', async (
|
||||
$, on) => {
|
||||
const w = newWorld({ edit: f => {
|
||||
movedFeat(f)
|
||||
f.changed = { ...(f.changed as Rec), agents: {
|
||||
feater: { from: 'implement', to: 'plan' } } }
|
||||
;(f.agents as Rec).feater = 'plan'
|
||||
;(f.skills as Rec).status = 'apply'
|
||||
;(f.changed as Rec).skills = { ...((f.changed as Rec).skills as Rec),
|
||||
status: { from: 'mechanical', to: 'apply' } }
|
||||
} })
|
||||
await bootRun($, on, FABLE, w)
|
||||
await typed($, '/feat add a thing')
|
||||
await skillPrompt($, 'feat')
|
||||
expect(await route($, 'forget feater')).not.toContain(KEEPS)
|
||||
expect(await route($, 'forget status')).not.toContain(KEEPS)
|
||||
})
|
||||
|
||||
test('forget: a phase the model routed is not a run to keep', async (
|
||||
$, on) => {
|
||||
const w = newWorld({ edit: f => {
|
||||
(f.skills as Rec).status = 'plan'
|
||||
f.changed = { skills: { status: { from: 'mechanical', to: 'plan' } } }
|
||||
f.confirmed = { skills: { status: 'plan' } }
|
||||
} })
|
||||
await bootRun($, on, FABLE, w)
|
||||
await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
|
||||
expect(await route($, 'forget status')).not.toContain(KEEPS)
|
||||
})
|
||||
|
||||
test('forget: no machine pin on the name, no "wins here"', async ($, on) => {
|
||||
const w = newWorld({
|
||||
edit: decisions(confirmedFeat),
|
||||
home: { skills: { hotfix: 'plan' } },
|
||||
})
|
||||
await bootRun($, on, FABLE, w)
|
||||
const out = await route($, 'forget feat')
|
||||
expect(out).toContain('forgot feat: 1 confirmed')
|
||||
expect(out).not.toContain('wins here')
|
||||
})
|
||||
|
||||
test('forget: a restored skill row names its SKILL.md to realign', async (
|
||||
$, on) => {
|
||||
await bootRun($, on, FABLE, newWorld({ edit: movedFeat }))
|
||||
expect(await route($, 'forget feat')).toContain(
|
||||
'if skills/feat/SKILL.md was aligned to plan, set model: ')
|
||||
})
|
||||
|
||||
test('forget: a missing routing.json saves nothing', async ($, on) => {
|
||||
const w = newWorld({ routing: 'absent' })
|
||||
await bootRun($, on, FABLE, w)
|
||||
expect(await route($, 'forget feat')).toContain('nothing saved')
|
||||
expect(w.written).toEqual([])
|
||||
})
|
||||
|
||||
test('forget: a rebuild failing after the write is said', async ($, on) => {
|
||||
const w = forgetWorld(confirmedFeat)
|
||||
await bootRun($, on, FABLE, w)
|
||||
w.onWrite = () => { w.files.set(HOME_FILE, '{nope') }
|
||||
expect(await route($, 'forget feat')).toBe(
|
||||
'saved, config not rebuilt: /route reload')
|
||||
expect(routingOf(w).confirmed).toEqual({})
|
||||
})
|
||||
|
||||
test('forget: a failing write is not applied', async ($, on) => {
|
||||
const w = forgetWorld(confirmedFeat)
|
||||
await bootRun($, on, FABLE, w)
|
||||
w.failWrite = true
|
||||
expect(await route($, 'forget feat')).toContain('forget not applied')
|
||||
expect(routingOf(w).confirmed).toEqual(confirmedFeat.confirmed)
|
||||
})
|
||||
|
||||
test('forget: the route tool cannot forget', async ($, on) => {
|
||||
const w = forgetWorld(confirmedFeat)
|
||||
await bootRun($, on, FABLE, w)
|
||||
await $.tool.call({ tool: ROUTE_TOOL, clear: true })
|
||||
expect(w.written).toEqual([])
|
||||
expect(routingOf(w).confirmed).toEqual(confirmedFeat.confirmed)
|
||||
})
|
||||
|
||||
test('forget: a forget input to the route tool is refused', async (
|
||||
$, on) => {
|
||||
const w = forgetWorld(confirmedFeat)
|
||||
await bootRun($, on, FABLE, w)
|
||||
for (const input of [{ forget: 'feat' }, { phase: 'forget' }]) {
|
||||
const out = await $.tool.call({ tool: ROUTE_TOOL, ...input })
|
||||
expect(JSON.stringify(out)).toContain('"deny"')
|
||||
}
|
||||
expect(w.written).toEqual([])
|
||||
expect(routingOf(w).confirmed).toEqual(confirmedFeat.confirmed)
|
||||
})
|
||||
|
||||
test('forget projects: the asked set is kept, no re-ask', async ($, on) => {
|
||||
const w = newWorld({
|
||||
ask: true,
|
||||
edit: decisions({ projects: { [TWO]: twoRepos.projects[TWO] } }),
|
||||
answers: ['Later', 'Forget'],
|
||||
})
|
||||
await bootRun($, on, FABLE, w)
|
||||
await typeFeat($)
|
||||
await route($, 'forget projects')
|
||||
await typeFeat($)
|
||||
expect(w.asked).toHaveLength(2)
|
||||
expect(w.asked[1]?.options).toEqual(['Cancel', 'Forget'])
|
||||
})
|
||||
|
||||
test('forget: a non-composer origin is refused, nothing written', async (
|
||||
$, on) => {
|
||||
const w = forgetWorld(confirmedFeat)
|
||||
await bootRun($, on, FABLE, w)
|
||||
const out = await $.command.run({
|
||||
command: 'route',
|
||||
args: 'forget feat',
|
||||
origin: { kind: 'plugin', name: 'x' },
|
||||
presentation: { isFullscreen: false, columns: 80 },
|
||||
})
|
||||
expect(out.text).toContain('user-only')
|
||||
expect(w.written).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1506,6 +1506,8 @@ async function handleCommand($: Api, st: State, args: string): Promise<string> {
|
||||
return pendingText(st)
|
||||
case 'ask':
|
||||
return askCommand($, st, rest[0])
|
||||
case 'forget':
|
||||
return forgetCommand($, st, rest)
|
||||
case 'switch':
|
||||
case 'verbose':
|
||||
return toggle($, st, head, rest[0])
|
||||
@@ -1517,8 +1519,9 @@ async function handleCommand($: Api, st: State, args: string): Promise<string> {
|
||||
// ---- first use: one dialog per row, the answer kept in routing.json -----
|
||||
// The first time a row routes (a typed skill, an agent spawn, a phase
|
||||
// declared through the route tool) the user confirms it once, for every
|
||||
// project; a change can be a project exception. Only a dialog answer or
|
||||
// `/route ask` writes; nothing is asked on the step path or in an agent.
|
||||
// project; a change can be a project exception. Only a dialog answer,
|
||||
// `/route ask` or `/route forget` writes; nothing is asked on the step path
|
||||
// or in an agent.
|
||||
|
||||
const HEADER = 'model-router'
|
||||
const UPDATED = 'routing.json updated: commit it from the config repo ' +
|
||||
@@ -1853,6 +1856,9 @@ function serializeRouting(file: Rec): string {
|
||||
return JSON.stringify(ordered, null, 2) + '\n'
|
||||
}
|
||||
|
||||
/** The write landed but the config could not be rebuilt from it. */
|
||||
class RebuildFailed extends Error {}
|
||||
|
||||
/**
|
||||
* Read-modify-write of routing.json, one at a time (a chain). Refuses a
|
||||
* missing or unparsable file and never creates it; rebuilds the config
|
||||
@@ -1874,7 +1880,7 @@ async function writeNow($: Api, st: State, patch: Patch): Promise<boolean> {
|
||||
await $.fs.write(file.path, out)
|
||||
const rebuilt = await reloadConfig($, st)
|
||||
$.ui.toast(UPDATED)
|
||||
if (!rebuilt) throw new Error('rebuild failed after the write')
|
||||
if (!rebuilt) throw new RebuildFailed('rebuild failed after the write')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2051,6 +2057,261 @@ async function askCommand($: Api, st: State, arg: string | undefined) {
|
||||
return `ask ${arg}${note}`
|
||||
}
|
||||
|
||||
// ---- /route forget: the user takes decisions back -------------------------
|
||||
// Removes `confirmed` and `projects` entries and restores a `changed` row to
|
||||
// its shipped phase. The frontmatter floors are never touched. `all` and
|
||||
// `projects` ask first. Reserved words: a row named like them is edited by
|
||||
// hand.
|
||||
|
||||
type Target = { label: string; name?: string; only?: 'projects' }
|
||||
type Restore = { kind: Kind; name: string; from: string; to: string }
|
||||
type ForgetPlan = {
|
||||
confirmed: [Kind, string][]
|
||||
restores: Restore[]
|
||||
kept: string[] // refused restores, with the reason
|
||||
projects: [string, Kind, string][] // repo key, kind, name
|
||||
}
|
||||
|
||||
const REFUSED = 'forget refused: nothing saved'
|
||||
const FORGET_USAGE = 'usage: /route forget <name|all|projects>'
|
||||
const BUILTIN_AGENTS: ReadonlySet<string> = new Set(['Explore', 'Plan'])
|
||||
const isKind = (v: string): v is Kind => KINDS.some(kind => kind === v)
|
||||
|
||||
function targetOf(arg: string): Target {
|
||||
if (arg === 'all') return { label: 'all' }
|
||||
if (arg === 'projects') return { label: 'projects', only: 'projects' }
|
||||
return { label: arg, name: arg }
|
||||
}
|
||||
|
||||
/** The [kind, name, value] entries of a kind-keyed table the target picks. */
|
||||
function decided(tables: unknown, t: Target): [Kind, string, unknown][] {
|
||||
if (!isRecord(tables)) return []
|
||||
return Object.entries(tables).filter(([kind]) => isKind(kind))
|
||||
.flatMap(([kind, rows]) => isRecord(rows) ? Object.keys(rows)
|
||||
.filter(name => t.name === undefined || name === t.name)
|
||||
.map(name => [kind as Kind, name, rows[name]] as [Kind, string, unknown])
|
||||
: [])
|
||||
}
|
||||
|
||||
/** The restore a `changed` entry allows, or why it is refused. */
|
||||
function restoreOf(
|
||||
file: Rec,
|
||||
kind: Kind,
|
||||
name: string,
|
||||
entry: unknown,
|
||||
): Restore | string {
|
||||
const { from, to } = isRecord(entry) ? entry : ({} as Rec)
|
||||
if (typeof from !== 'string') return 'no phase recorded to return to'
|
||||
const rows = subRec(file, kind)
|
||||
if (typeof to !== 'string' || !hasKey(rows, name) || rows[name] !== to) {
|
||||
return 'the row differs from the recorded change, edit routing.json'
|
||||
}
|
||||
if (!hasKey(subRec(file, 'phases'), from) &&
|
||||
!hasKey(DEFAULT_CONFIG.phases, from)) {
|
||||
return `${from} is not a known phase`
|
||||
}
|
||||
return { kind, name, from, to }
|
||||
}
|
||||
|
||||
/** What the target removes from this file, and what it must keep. */
|
||||
function forgetPlan(file: Rec, t: Target): ForgetPlan {
|
||||
const plan: ForgetPlan = {
|
||||
confirmed: [], restores: [], kept: [], projects: [],
|
||||
}
|
||||
if (t.only !== 'projects') {
|
||||
for (const [kind, name] of decided(file.confirmed, t)) {
|
||||
plan.confirmed.push([kind, name])
|
||||
}
|
||||
for (const [kind, name, entry] of decided(file.changed, t)) {
|
||||
const done = restoreOf(file, kind, name, entry)
|
||||
if (typeof done === 'string') plan.kept.push(`${kind}.${name}: ${done}`)
|
||||
else plan.restores.push(done)
|
||||
}
|
||||
}
|
||||
const repos = isRecord(file.projects) ? file.projects : {}
|
||||
for (const [repo, tables] of Object.entries(repos)) {
|
||||
for (const [kind, name] of decided(tables, t)) {
|
||||
plan.projects.push([repo, kind, name])
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
const removals = (plan: ForgetPlan): number =>
|
||||
plan.confirmed.length + plan.restores.length + plan.projects.length
|
||||
const repoCount = (plan: ForgetPlan): number =>
|
||||
new Set(plan.projects.map(entry => entry[0])).size
|
||||
|
||||
/** Deletes one entry; an emptied kind table goes with it. */
|
||||
function dropEntry(parent: Rec, kind: string, name: string): void {
|
||||
const rows = subRec(parent, kind)
|
||||
delete rows[name]
|
||||
if (Object.keys(rows).length === 0) delete parent[kind]
|
||||
}
|
||||
|
||||
function applyPlan(file: Rec, plan: ForgetPlan): void {
|
||||
const changed = subRec(file, 'changed')
|
||||
const projects = subRec(file, 'projects')
|
||||
for (const [kind, name] of plan.confirmed) {
|
||||
dropEntry(subRec(file, 'confirmed'), kind, name)
|
||||
}
|
||||
for (const r of plan.restores) {
|
||||
setKey(subRec(file, r.kind), r.name, r.from)
|
||||
dropEntry(changed, r.kind, r.name)
|
||||
}
|
||||
for (const [repo, kind, name] of plan.projects) {
|
||||
const mine = subRec(projects, repo)
|
||||
dropEntry(mine, kind, name)
|
||||
if (Object.keys(mine).length === 0) delete projects[repo]
|
||||
}
|
||||
}
|
||||
|
||||
/** Keys of this session's asked set the target covers. */
|
||||
function askedKeys(st: State, t: Target): string[] {
|
||||
if (t.only === 'projects') return []
|
||||
if (t.name === undefined) return [...st.asked]
|
||||
const name = t.name
|
||||
return KINDS.map(kind => keyOf({ kind, name })).filter(k => st.asked.has(k))
|
||||
}
|
||||
|
||||
/** The machine override still sets the row: no forget reaches it. */
|
||||
function heldBy(st: State, t: Target): string | undefined {
|
||||
const name = t.name
|
||||
if (name === undefined) return undefined
|
||||
return ROWS.some(kind => hasKey(strTable(st.override?.[kind]), name))
|
||||
? `~/${OVERRIDE}`
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** The answer when nothing is written, or undefined when there is work. */
|
||||
function idleAnswer(
|
||||
st: State,
|
||||
t: Target,
|
||||
plan: ForgetPlan,
|
||||
): string | undefined {
|
||||
if (removals(plan) > 0) return undefined
|
||||
if (plan.kept.length > 0) {
|
||||
return `nothing restored for ${t.label}: ${plan.kept.join('; ')}`
|
||||
}
|
||||
const keys = askedKeys(st, t)
|
||||
if (keys.length === 0) {
|
||||
return t.name === undefined ? 'nothing to forget'
|
||||
: `nothing to forget for ${t.name}`
|
||||
}
|
||||
keys.forEach(key => st.asked.delete(key))
|
||||
const held = heldBy(st, t)
|
||||
return `${t.label}: ${held === undefined
|
||||
? 'asked again at the next use'
|
||||
: `still decided by ${held}`}, nothing was saved`
|
||||
}
|
||||
|
||||
async function confirmForget($: Api, plan: ForgetPlan): Promise<boolean> {
|
||||
const text = `Forget ${plural(removals(plan), 'decision')}: ` +
|
||||
`${plan.restores.length} row(s) restored, ${plan.projects.length} ` +
|
||||
`project exception(s) in ${plural(repoCount(plan), 'repo')}?`
|
||||
return (await askOr($, text, ['Cancel', 'Forget'])) === 'Forget'
|
||||
}
|
||||
|
||||
/** Where a restored row's frontmatter lives; none for a built-in agent. */
|
||||
function floorFile(r: Restore): string | undefined {
|
||||
if (r.kind === 'skills') return `skills/${r.name}/SKILL.md`
|
||||
return r.kind === 'agents' && !BUILTIN_AGENTS.has(r.name)
|
||||
? `agents/${r.name}.md`
|
||||
: undefined
|
||||
}
|
||||
|
||||
function restoreClause(st: State, r: Restore): string {
|
||||
const route = phaseRoute(st.cfg, r.from) ?? phaseRoute(DEFAULT_CONFIG, r.from)
|
||||
const alias = (route && headAlias(DEFAULT_CONFIG, route)) ?? 'its own model'
|
||||
const values = `${alias} at ${route?.effort ?? 'its own effort'}`
|
||||
const file = floorFile(r)
|
||||
const realign = file === undefined ? '' : `; if ${file} was aligned to ${
|
||||
r.to}, set model: ${alias}, effort: ${route?.effort ?? '-'} and its ` +
|
||||
'lock in lib/tests/model-routing.test.sh, then `make test`'
|
||||
return ` (${r.kind}.${r.name} → ${r.from}: ${values}${realign})`
|
||||
}
|
||||
|
||||
/** Restored skill rows whose old phase the current run still holds. */
|
||||
function runKeeps(st: State, plan: ForgetPlan): string {
|
||||
const live = [st.runMain, st.turnMain].filter(slot =>
|
||||
slot !== null && (slot.source === 'run' || slot.source === 'skill'))
|
||||
const kept = plan.restores.find(r => r.kind === 'skills' &&
|
||||
live.some(slot => slot?.phase === r.to))
|
||||
return kept === undefined ? '' :
|
||||
`; current run may keep ${kept.to}: /route clear to apply`
|
||||
}
|
||||
|
||||
/** The one answer of every form, from the counts really applied. */
|
||||
function forgetAnswer(st: State, t: Target, plan: ForgetPlan): string {
|
||||
const kept = plan.kept.length === 0 ? '' :
|
||||
`, ${plan.kept.length} restore(s) kept: ${plan.kept.join('; ')}`
|
||||
const name = t.name
|
||||
const wins = name !== undefined &&
|
||||
ROWS.some(kind => st.mem.local[kind].has(name))
|
||||
? `; ~/${OVERRIDE} still sets ${name} and wins here`
|
||||
: ''
|
||||
return `forgot ${t.label}: ${plan.confirmed.length} confirmed, ${
|
||||
plan.restores.length} row(s) restored, ${plan.projects.length} project ` +
|
||||
`exception(s) removed${kept}` +
|
||||
plan.restores.map(r => restoreClause(st, r)).join('') + wins +
|
||||
runKeeps(st, plan) + '; other live sessions see it after /route reload'
|
||||
}
|
||||
|
||||
/** One writer patch; the plan is recomputed on the file it really holds. */
|
||||
async function forgetWrite($: Api, st: State, t: Target, seen: ForgetPlan) {
|
||||
const out: { plan?: ForgetPlan } = {}
|
||||
const patch: Patch = file => {
|
||||
out.plan = forgetPlan(file, t)
|
||||
applyPlan(file, out.plan)
|
||||
}
|
||||
try {
|
||||
if (!(await writeRouting($, st, patch))) return REFUSED
|
||||
} catch (err) {
|
||||
return err instanceof RebuildFailed
|
||||
? 'saved, config not rebuilt: /route reload'
|
||||
: `forget not applied (${String(err)})`
|
||||
}
|
||||
askedKeys(st, t).forEach(key => st.asked.delete(key))
|
||||
return forgetAnswer(st, t, out.plan ?? seen)
|
||||
}
|
||||
|
||||
/** A row or phase called `all` or `projects` cannot be reached by name. */
|
||||
function reservedNote(file: Rec, t: Target): string {
|
||||
if (t.name !== undefined) return ''
|
||||
const clash = KINDS.some(kind => hasKey(subRec(file, kind), t.label))
|
||||
return clash ? `; a row or phase named ${t.label} is reserved here: ` +
|
||||
`edit ${ROUTING} by hand` : ''
|
||||
}
|
||||
|
||||
async function forgetFlow($: Api, st: State, t: Target): Promise<string> {
|
||||
await st.writes
|
||||
const file = await readRouting($, text => $.ui.log(text))
|
||||
if (typeof file === 'string') {
|
||||
return `${ROUTING} is missing or unreadable: nothing saved`
|
||||
}
|
||||
const plan = forgetPlan(file.data, t)
|
||||
const idle = idleAnswer(st, t, plan)
|
||||
const note = reservedNote(file.data, t)
|
||||
if (idle !== undefined) return idle + note
|
||||
if (t.name === undefined && !(await confirmForget($, plan))) {
|
||||
return `forget ${t.label} cancelled, nothing written` + note
|
||||
}
|
||||
return (await forgetWrite($, st, t, plan)) + note
|
||||
}
|
||||
|
||||
/** The forget holds the single-dialog slot: no first-use dialog under it. */
|
||||
async function forgetCommand($: Api, st: State, args: string[]) {
|
||||
if (args.length !== 1) return FORGET_USAGE
|
||||
if (st.asking !== null) return 'answer the open dialog first'
|
||||
const target = targetOf(args[0] ?? '')
|
||||
st.asking = `forget:${target.label}`
|
||||
try {
|
||||
return await forgetFlow($, st, target)
|
||||
} finally {
|
||||
st.asking = null
|
||||
}
|
||||
}
|
||||
|
||||
// ---- route tool ------------------------------------------------------
|
||||
|
||||
async function registerTool($: Api, st: State): Promise<void> {
|
||||
@@ -2086,7 +2347,8 @@ async function registerCommand($: Api): Promise<void> {
|
||||
name: 'route',
|
||||
description: 'model-router: show or set the model and effort route',
|
||||
argumentHint:
|
||||
'[show|clear|off|on|reload|pending|ask on|off|<phase>|' +
|
||||
'[show|clear|off|on|reload|pending|ask on|off|' +
|
||||
'forget <name|all|projects>|<phase>|' +
|
||||
'model=<alias|id> effort=<level>|switch on|off|verbose on|off]',
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user