diff --git a/CHANGELOG.md b/CHANGELOG.md index e1151b9..f9b619d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and this project ## [Unreleased] ### Added -- **model-router mod**: `mods/model-router/`, a Claude Code mod (function-hooks plugin), routes every repo skill and agent from phase rows (`plan`, `reflect`, `orchestrate`, `escalate`, `judge`, `implement`, `write`, `verify`, `explore`, `apply`, `mechanical`; built-ins Explore on sonnet/medium, Plan on opus/xhigh). A typed skill routes the main loop to its row, and a best-tier row holds across turns in a run slot until `/route clear`, `/route off`, a user `/model` or a typed skill on a non-best row. Agents get their row's model at spawn (within the tier, upward only) and its effort on every step; explicit Agent params win. Orchestrators declare their phases through the `mcp__model-router__route` tool. `ultrathink` in a prompt sets the turn's minimum effort and `/route effort=max` holds until `/route clear`; the built-in `/effort` is not a lever inside a run. `/route show` names the run slot (`main: run `), and a `null` row in the override drops a default row. The model gets a `route` tool and the user a `/route` command (`show|clear|off|on|reload|pending|ask on|off||model= effort=|switch on|off|verbose on|off`). Optional per-machine config `~/.claude/model-router.json`, where `"enabled": false` turns it off on that machine. The spinner suffix and the status line show the route in force. It loads in every session through the tracked symlink `skills/model-router` (`model-router@skills-dir`). `make doctor` gains a Mods section; suite `make test suite=lib/tests/mods.test.sh`. Known limits: the main loop switches model only with `mainModelSwitch` on (default off, one cold-cache step per switch into another model), and the hooks send full model ids, so the `models` table has to follow new versions. -- **model-router first-use confirmation**: `mods/model-router/routing.json` (tracked) is the single source of the phase table and of every skill and agent row, and keeps the decisions: `confirmed`, `changed` (`from`/`to`), `projects` exceptions keyed by the origin remote reduced to `host/path` (credentials and local paths never stored), and `ask`. The first use of a rowed typed skill, a rowed agent spawn or a main-loop phase declared through the `route` tool opens a dialog with context: the skill's description (first sentence of its `SKILL.md` frontmatter) or the agent's, the phase with its `about` line (a new field on each of the 11 phases) and the model id and effort the next step really runs on. A row offers Later, Keep or Change; a main-loop phase Later or Keep. Change asks the model (fable, opus, sonnet, haiku with their tier), then the effort among those the phases of that model use (skipped when there is only one), then Everywhere or This project only; the pair maps to an existing phase (rows stay phase names, the same phase counts as Keep, a pair no phase offers is added by hand as a new phase). A skill-row change toasts the model and effort it now runs on, with the `/route switch on` hint when the main loop holds back a downgrade; a free-text answer counts as Later. One dialog at a time, never in headless (`-p`) runs or inside a sub-agent. Only a dialog answer or `/route ask on|off` writes the file (serialized, 64 KiB cap, never created when absent); each write asks you to commit it from the config repo. `/route pending` lists unconfirmed rows and phases. Layers: routing.json < `~/.claude/model-router.json` (its `ask` wins); a project's `.claude/model-router.json` is never read. Tests: `mods/model-router/hooks/register.test.ts`. +- **model-router mod**: `mods/model-router/`, a Claude Code mod (function-hooks plugin), routes every repo skill and agent from phase rows (`plan`, `reflect`, `orchestrate`, `escalate`, `judge`, `implement`, `write`, `verify`, `explore`, `apply`, `mechanical`; built-ins Explore on sonnet/medium, Plan on opus/xhigh). A typed skill routes the main loop to its row, and a best-tier row holds across turns in a run slot until `/route clear`, `/route off`, a user `/model` or a typed skill on a non-best row. Agents get their row's model at spawn (within the tier, upward only) and its effort on every step; explicit Agent params win. Orchestrators declare their phases through the `mcp__model-router__route` tool. `ultrathink` in a prompt sets the turn's minimum effort and `/route effort=max` holds until `/route clear`; the built-in `/effort` is not a lever inside a run. `/route show` names the run slot (`main: run `), and a `null` row in the override drops a default row. The model gets a `route` tool and the user a `/route` command (`show|clear|off|on|reload|pending|ask on|off|forget ||model= effort=|switch on|off|verbose on|off`). Optional per-machine config `~/.claude/model-router.json`, where `"enabled": false` turns it off on that machine. The spinner suffix and the status line show the route in force. It loads in every session through the tracked symlink `skills/model-router` (`model-router@skills-dir`). `make doctor` gains a Mods section; suite `make test suite=lib/tests/mods.test.sh`. Known limits: the main loop switches model only with `mainModelSwitch` on (default off, one cold-cache step per switch into another model), and the hooks send full model ids, so the `models` table has to follow new versions. +- **model-router first-use confirmation**: `mods/model-router/routing.json` (tracked) is the single source of the phase table and of every skill and agent row, and keeps the decisions: `confirmed`, `changed` (`from`/`to`), `projects` exceptions keyed by the origin remote reduced to `host/path` (credentials and local paths never stored), and `ask`. The first use of a rowed typed skill, a rowed agent spawn or a main-loop phase declared through the `route` tool opens a dialog with context: the skill's description (first sentence of its `SKILL.md` frontmatter) or the agent's, the phase with its `about` line (a new field on each of the 11 phases) and the model id and effort the next step really runs on. A row offers Later, Keep or Change; a main-loop phase Later or Keep. Change asks the model (fable, opus, sonnet, haiku with their tier), then the effort among those the phases of that model use (skipped when there is only one), then Everywhere or This project only; the pair maps to an existing phase (rows stay phase names, the same phase counts as Keep, a pair no phase offers is added by hand as a new phase). A skill-row change toasts the model and effort it now runs on, with the `/route switch on` hint when the main loop holds back a downgrade; a free-text answer counts as Later. One dialog at a time, never in headless (`-p`) runs or inside a sub-agent. Only a dialog answer, `/route ask on|off` or `/route forget` (which takes decisions back; a changed row returns to its shipped phase, the frontmatter floors stay yours to realign) writes the file (serialized, 64 KiB cap, never created when absent); each write asks you to commit it from the config repo. `/route pending` lists unconfirmed rows and phases. Layers: routing.json < `~/.claude/model-router.json` (its `ask` wins); a project's `.claude/model-router.json` is never read. Tests: `mods/model-router/hooks/register.test.ts`. - **Manual-push mode**: `git config gitflow.autopush false` (human-set) now stops every push the gitflow lib makes, not only the post-commit / post-merge hooks. `gitflow start` and `finish` branch, commit and merge locally and push nothing; `gitflow delete` leaves the `origin/` copy in place and prints `git push origin --delete
` for the user to run. `hooks/unpushed-guard.sh` stays silent at turn end in this mode and opens each session with one `ℹ manual push mode:` line counting the commits no remote holds across every local branch; an unparseable or unreadable `gitflow.autopush` value is treated as manual push mode too, and that line names it. `hooks/push-guard.sh` (PreToolUse, `Bash|Monitor`) refuses any `git push` Claude types while `gitflow.autopush` reads false in the session cwd or in a literal `-C`/`cd` directory the command names (global config counts outside a repo); the refusal tells the user to run it with `! git push`. It reads the mode through the same lib verb as every other reader and fails closed: an unparseable or unreadable value reads as manual, and an internal error, a missing `lib/gitflow.sh`, more than 20 distinct directory tokens in one command (capped before any token is classified), a `cd`/`-C` directory token mixing quoted and unquoted parts, or a payload jq cannot parse whose raw text looks like a push refuse the push (these pathological cases fire in auto mode too). Directory tokens are read as whole shell words, adjacent quoted segments and backslash escapes included. In manual mode it over-blocks any command where a `push` word follows a `git` token; the misses listed in its header fall to a new `autoMode.soft_deny` rule that no request in the turn clears. The session banner adds `🔒 push : manual (autopush=false) — ! git push` when the key reads false, and `🔒 push : manual (autopush bad) — ! git push` when the value is invalid. Skills read the mode through a new lib verb, `bash ~/.claude/lib/gitflow.sh push-mode`: it prints `auto`, `manual` or `invalid` (rc 0) and names an invalid value on stderr (printable characters only, 64 at most). It is the one reader a skill may call, since the `git config` read of the key is denied to Claude. Skills push nothing on their own, except the `/release-candidate` tag in auto-push mode on an explicit go. Every "on origin" or "not pushed" line they print comes from `git rev-list --count origin/
..
` read after the fact, with the complete `! git …` command when something is left for the user to push. An invalid value (anything but unset, true or false, or a read that fails) is manual push mode for every reader and is named where it is read (see Fixed). Tests: `lib/gitflow-test.sh` T11b (push-mode verb), T18m and T18q blocks, `lib/tests/unpushed-guard.test.sh` T10-T16, `lib/tests/push-guard.test.sh` (98 checks). ### Changed diff --git a/README.md b/README.md index 3a1a880..421ea41 100644 --- a/README.md +++ b/README.md @@ -135,9 +135,9 @@ effort, never version. Transcript audit `python3 lib/effort-audit.py`. - Main loop: every request gets the route in force. A typed skill with a row routes the main loop to it; a best-tier row (`plan`, `reflect`, `orchestrate`, `escalate`) holds across turns in a run slot until `/route clear`, `/route off`, a user `/model` or a typed skill on a non-best row. A skill without a row leaves the route as it is. - Sub-agents: a routed agent gets its row's model at spawn, within its tier and only upward from its frontmatter model, and the row's effort on every step. Explicit `model` / `effort` params on the Agent call win; a project-defined agent of the same name keeps its own definition. Built-ins: Explore runs on sonnet/medium, Plan on opus/xhigh. - Levers inside a run: `ultrathink` in a prompt sets the turn's minimum effort; `/route effort=max` holds until `/route clear`. The built-in `/effort` is not a lever inside a run, rows and routes outrank it. -- `/route` (user command) shows or sets the route: `show`, `clear`, `off`, `on`, `reload`, `pending` (rows and phases not confirmed yet), `ask on|off` (first-use dialog on or off), a phase name, `model= effort=`, `switch on|off`, `verbose on|off`. `/route show` names the run slot when one holds (`main: run `). The model sets routes through a `route` tool. +- `/route` (user command) shows or sets the route: `show`, `clear`, `off`, `on`, `reload`, `pending` (rows and phases not confirmed yet), `ask on|off` (first-use dialog on or off), `forget ` (takes decisions back: a changed row returns to its shipped phase; `all` and `projects` ask first), a phase name, `model= effort=`, `switch on|off`, `verbose on|off`. `/route show` names the run slot when one holds (`main: run `). The model sets routes through a `route` tool. - First use: the first time a rowed skill is typed, a rowed agent is spawned or a phase is declared on the main loop through the `route` tool, the mod asks once whether the route is right. The question gives context: the skill's description (first sentence of its `SKILL.md` frontmatter) or the agent's, the phase with its `about` line, and the model id and effort the next step really runs on; a declared phase also says what uses it (rows, prompt rules). A row offers Later, Keep or Change; a declared phase offers Later or Keep. Change asks the model (fable, opus, sonnet, haiku, each shown with the tier it heads), then the effort among those the phases on that model use (shipped table: fable medium, high, xhigh or max; sonnet low, medium, high or xhigh; opus and haiku have one level each, so no question), then Everywhere or This project only. Rows stay phase names, so the pair maps to an existing phase: the row's current phase wins a tie and the same phase counts as Keep; a pair no phase offers needs a new phase added by hand in routing.json. Everywhere moves the row and records the shipped phase under `changed`; This project only stores an exception under `projects`, keyed by the origin remote reduced to `host/path` (no credentials, no local paths; a remote that cannot be read that way offers no project choice). Keep lands under `confirmed`; Later, a dismissed dialog or a free-text answer asks again next session. After a skill row changes, a toast names the model and effort that skill now runs on, plus the `/route switch on` hint when the main loop holds back a downgrade. One dialog at a time, never in a headless (`-p`) run, never inside a sub-agent. -- Only a dialog answer or `/route ask on|off` writes `routing.json`, never the model. Writes are serialized, capped at 64 KiB, and refused when the file is missing (it is never created). Each write leaves the config repo dirty; a toast reminds you to commit it from there. +- Only a dialog answer, `/route ask on|off` or `/route forget` writes `routing.json`, never the model. Writes are serialized, capped at 64 KiB, and refused when the file is missing (it is never created). Each write leaves the config repo dirty; a toast reminds you to commit it from there. - The spinner suffix and the status line under the prompt show the route in force. Config layers: `mods/model-router/routing.json` (tracked: phases with their tier, effort and `about` line, rows, decisions, `ask`), then the optional per-machine `~/.claude/model-router.json`, which wins. A `.claude/model-router.json` inside a project is never read. Machine keys: `models` (alias → full id), `windows` (context window per full id), `tiers` (ordered alias lists per tier: `best` fable>opus>sonnet, `big` opus>fable>sonnet, `work` sonnet>opus, `cheap` haiku>sonnet; the first alias not down is used), `fallback` (the rank order of the aliases, best first, used by the breaker), `cooldownMinutes` (how long a model stays marked down after an availability error, 15 by default, doubling per episode up to 300), `mainUpgrade` (default `true`: the main loop may move up to a phase's tier), `upgradeMaxTokens` (default 200000: no main-loop upgrade above this context size, since an upgrade re-reads the whole context cold), `phases`, `agents`, `skills`, `prompt` (rules), `mainModelSwitch` (default `false`), `verbose` (default `false`), `spinner` (default `true`), `enabled` (default `true`; `false` turns the mod off on that machine), `ask` (overrides routing.json's `ask` on that machine). A `null` value in `agents` or `skills` drops a row. Edit phases and rows by hand in routing.json (a phase's `about`, 120 characters at most, is read from there only and shown in the dialog; a model and effort pair no phase offers needs a new phase there); without it the mod runs the code's default phases, with no rows and no dialog. `/route reload` re-reads both files. diff --git a/USAGE.md b/USAGE.md index ac9fe55..b4d37f2 100644 --- a/USAGE.md +++ b/USAGE.md @@ -187,7 +187,7 @@ l'outil `mcp__model-router__route` (`lib/effort-shift.md`). Les skills externes vendorés (pile design, superpowers, agent-skills, skills scroll MengTo, 21st) ont aussi leur ligne. -Taper un skill qui a une ligne route la boucle principale dessus. Une ligne du tier best (plan, reflect, orchestrate, escalate) tient d'un tour à l'autre pendant tout le run, jusqu'à `/route clear`, `/route off`, un `/model` tapé ou un skill d'un autre tier tapé. Pour relancer un tour bloqué : `ultrathink` dans le prompt (plancher du tour) ou `/route effort=max` (tient jusqu'à `/route clear`). Le `/effort` intégré n'a pas d'effet dans un run : les lignes et les routes passent devant. Les sous-agents reçoivent le modèle de leur ligne au lancement (dans leur tier, jamais en dessous de leur frontmatter) et son niveau à chaque étape ; un `model` ou `effort` explicite sur l'appel gagne. Explore tourne en sonnet/medium, Plan en opus/xhigh. `/route show` affiche la route en cours, slot de run compris (`main: run `). Première utilisation : la première fois qu'un skill avec ligne est tapé, qu'un agent avec ligne est lancé ou qu'une phase est déclarée sur la boucle principale via l'outil `route`, le mod demande une fois si la route convient. La question donne le contexte : la description du skill (première phrase du frontmatter de son `SKILL.md`) ou celle de l'agent, la phase avec sa ligne `about` (champ de la phase dans `routing.json`), puis le modèle et le niveau réels de l'étape suivante ; une phase déclarée dit aussi qui l'utilise. Pour une ligne : Later, Keep ou Change ; pour une phase déclarée : Later ou Keep. Change demande le modèle (fable, opus, sonnet, haiku, chacun avec son tier), puis le niveau parmi ceux des phases de ce modèle (fable : medium, high, xhigh ou max ; sonnet : low, medium, high ou xhigh ; opus et haiku n'en ont qu'un, la question est sautée), puis Everywhere ou This project only (exception rangée sous le remote origin du dépôt réduit à `host/path`, jamais d'identifiants). Une ligne reste un nom de phase : le couple modèle et niveau désigne une phase existante, et la même phase qu'avant vaut Keep. Un couple qu'aucune phase n'offre s'ajoute à la main comme nouvelle phase dans `routing.json`. Après un changement sur un skill, un toast donne le modèle et le niveau réels, et rappelle `/route switch on` quand la boucle principale refuse de descendre. La réponse est écrite dans `routing.json` ; Later, un texte libre (Other) ou un dialogue fermé redemande à une session suivante. Un seul dialogue à la fois, jamais en headless (`-p`) ni dans un sous-agent, et le modèle n'écrit jamais ce fichier. Chaque réponse laisse le repo de config modifié : commite-le depuis ce repo. `/route pending` liste ce qui reste à confirmer, `/route ask off|on` coupe ou rallume le dialogue. +Taper un skill qui a une ligne route la boucle principale dessus. Une ligne du tier best (plan, reflect, orchestrate, escalate) tient d'un tour à l'autre pendant tout le run, jusqu'à `/route clear`, `/route off`, un `/model` tapé ou un skill d'un autre tier tapé. Pour relancer un tour bloqué : `ultrathink` dans le prompt (plancher du tour) ou `/route effort=max` (tient jusqu'à `/route clear`). Le `/effort` intégré n'a pas d'effet dans un run : les lignes et les routes passent devant. Les sous-agents reçoivent le modèle de leur ligne au lancement (dans leur tier, jamais en dessous de leur frontmatter) et son niveau à chaque étape ; un `model` ou `effort` explicite sur l'appel gagne. Explore tourne en sonnet/medium, Plan en opus/xhigh. `/route show` affiche la route en cours, slot de run compris (`main: run `). Première utilisation : la première fois qu'un skill avec ligne est tapé, qu'un agent avec ligne est lancé ou qu'une phase est déclarée sur la boucle principale via l'outil `route`, le mod demande une fois si la route convient. La question donne le contexte : la description du skill (première phrase du frontmatter de son `SKILL.md`) ou celle de l'agent, la phase avec sa ligne `about` (champ de la phase dans `routing.json`), puis le modèle et le niveau réels de l'étape suivante ; une phase déclarée dit aussi qui l'utilise. Pour une ligne : Later, Keep ou Change ; pour une phase déclarée : Later ou Keep. Change demande le modèle (fable, opus, sonnet, haiku, chacun avec son tier), puis le niveau parmi ceux des phases de ce modèle (fable : medium, high, xhigh ou max ; sonnet : low, medium, high ou xhigh ; opus et haiku n'en ont qu'un, la question est sautée), puis Everywhere ou This project only (exception rangée sous le remote origin du dépôt réduit à `host/path`, jamais d'identifiants). Une ligne reste un nom de phase : le couple modèle et niveau désigne une phase existante, et la même phase qu'avant vaut Keep. Un couple qu'aucune phase n'offre s'ajoute à la main comme nouvelle phase dans `routing.json`. Après un changement sur un skill, un toast donne le modèle et le niveau réels, et rappelle `/route switch on` quand la boucle principale refuse de descendre. La réponse est écrite dans `routing.json` ; Later, un texte libre (Other) ou un dialogue fermé redemande à une session suivante. Un seul dialogue à la fois, jamais en headless (`-p`) ni dans un sous-agent, et le modèle n'écrit jamais ce fichier. Chaque réponse laisse le repo de config modifié : commite-le depuis ce repo. `/route pending` liste ce qui reste à confirmer, `/route ask off|on` coupe ou rallume le dialogue, `/route forget ` efface une décision (une ligne changée revient à la phase livrée ; `all` et `projects` demandent d'abord). La config par machine, optionnelle, vit dans `~/.claude/model-router.json` et passe devant `routing.json` ; `"enabled": false` y coupe le mod sur cette machine, `"ask": false` y coupe le dialogue, une ligne à `null` y retire une ligne. Un `.claude/model-router.json` dans un projet n'est jamais lu. Les phases et les lignes se modifient à la main dans `routing.json` ; `/route reload` relit les deux fichiers. diff --git a/lib/effort-shift.md b/lib/effort-shift.md index db781a0..84c099c 100644 --- a/lib/effort-shift.md +++ b/lib/effort-shift.md @@ -51,7 +51,8 @@ Builtin `/effort` is NOT a lever inside a run: rows and routes outrank it. - First use of a row asks once, with context (Later, Keep or Change: model, then effort, then Everywhere or this project only); the answer is kept in `mods/model-router/routing.json`. `/route pending` lists what is still - unconfirmed, `/route ask off|on` toggles the dialog. + unconfirmed, `/route ask off|on` toggles the dialog, `/route forget + ` takes a decision back. Measure the split any time: `python3 ~/.claude/lib/effort-audit.py` (thinking/output/cache tokens per scope, model and effort). diff --git a/mods/model-router/hooks/register.test.ts b/mods/model-router/hooks/register.test.ts index 8c4b626..55824c0 100644 --- a/mods/model-router/hooks/register.test.ts +++ b/mods/model-router/hooks/register.test.ts @@ -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 ') + 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 ` 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([]) +}) diff --git a/mods/model-router/hooks/register.ts b/mods/model-router/hooks/register.ts index 2092106..a73739f 100644 --- a/mods/model-router/hooks/register.ts +++ b/mods/model-router/hooks/register.ts @@ -1506,6 +1506,8 @@ async function handleCommand($: Api, st: State, args: string): Promise { 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 { // ---- 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 { 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 ' +const BUILTIN_AGENTS: ReadonlySet = 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 { + 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 { + 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 { @@ -2086,7 +2347,8 @@ async function registerCommand($: Api): Promise { name: 'route', description: 'model-router: show or set the model and effort route', argumentHint: - '[show|clear|off|on|reload|pending|ask on|off||' + + '[show|clear|off|on|reload|pending|ask on|off|' + + 'forget ||' + 'model= effort=|switch on|off|verbose on|off]', immediate: true, })