chore(tasks): model-router B1/B2 plans r2 after the 6-lens challenge round

This commit is contained in:
bchanot
2026-10-09 09:24:32 +02:00
parent 77ad7cf494
commit 868a7f0515
4 changed files with 146 additions and 13 deletions
@@ -10,24 +10,27 @@ User, same turn: "continu avec Opus en /ultrathink".
## CLARIFICATIONS
Q: which loops does the floor cover? / A: the MAIN loop only ("le tour" = the user's turn); sub-agents keep their own routes and pins. [orchestrator — derived, stated to the user]
Q: model axis / A: the floor constrains effort only. A floor route's model (none by default: ultrathink → escalate carries none) applies on main only when neither the sticky nor the turn route names a model, and only with the switch on. [orchestrator — internal]
Q: model axis / A: one rule: `userMain?.route.model ?? turnMain?.route.model ?? turnFloor?.route.model`, applied only with the switch on (r2). [orchestrator — internal]
Q (r2): default vs minimum / A: the user's level is the turn's default when no sticky or turn route names an effort AND its minimum; a typed `/effort-low` therefore still lowers an unrouted turn. Derived from the chosen option ("Ton niveau est un minimum pour tout le tour") plus the challenge finding that a pure floor would make `/effort-low` a no-op. [orchestrator — r2]
Q (r2): mid-turn prompt / A: applied to the running turn AND kept for the next (`wait` ignored, the engine queues either way). [orchestrator — r2]
Q (r2): per-machine off switch / A: `"enabled": false` in `~/.claude/model-router.json` (untracked); an `enabledPlugins` entry would dirty the tracked settings.json on every machine. [orchestrator — r2, from the user's "configurable"]
Q: numeric or absent engine effort / A: the floor level replaces it (the user's explicit level wins over an unknown budget); the haiku effort omission still applies after flooring. [orchestrator — internal]
Q: who clears the floor / A: main turn end (a queued prompt's floor is then promoted), `/route clear`, `/route off` (pass-through). A model `route({clear})`, a `Skill(effort-*)` or any skill load never touches it. [orchestrator — derived from "jamais descendre en dessous"]
## ACCEPTANCE CRITERIA
1. Suite green with the new tests: `claude plugin test` passes with at least 19 `test(` calls; `claude plugin validate` passes with no warning; no line over 80 chars; no `any` type.
CHECK: cd mods/model-router && out=$(claude plugin test . 2>&1); rc=$?; echo "$out" | tail -n 3; [ $rc -eq 0 ] && [ "$(grep -cE '^\s*test\(' hooks/register.test.ts)" -ge 19 ] && v=$(claude plugin validate . 2>&1) && echo "$v" | grep -q 'Validation passed' && ! echo "$v" | grep -qi 'warning' && ! grep -nE '.{81,}' hooks/register.ts hooks/register.test.ts && ! grep -nE ':\s*any\b|<any>|as any\b' hooks/register.ts && echo FLOOR-SUITE-OK
1. Suite green with the new tests: `claude plugin test` passes with at least 22 `test(` calls; `claude plugin validate` passes with no warning; no line over 80 chars; no `any` type.
CHECK: cd mods/model-router && out=$(claude plugin test . 2>&1); rc=$?; echo "$out" | tail -n 3; [ $rc -eq 0 ] && [ "$(grep -cE '^\s*test\(' hooks/register.test.ts)" -ge 22 ] && v=$(claude plugin validate . 2>&1) && echo "$v" | grep -q 'Validation passed' && ! echo "$v" | grep -qi 'warning' && ! grep -nE '.{81,}' hooks/register.ts hooks/register.test.ts && ! grep -nE ':\s*any\b|<any>|as any\b' hooks/register.ts && echo FLOOR-SUITE-OK
EXPECT: FLOOR-SUITE-OK
EVIDENCE: pending
2. Type-check clean against this build's declarations.
CHECK: T=/Users/b.chanot/.claude/dev-mods/385f7190-70f5-4bdd-b0d8-e4566cd412fd/model-router/.claude-plugin/types; [ -d "$T" ] || T=/Users/b.chanot/Documents/claude/mods/model-router/.claude-plugin/types; W=$(mktemp -d) && printf '{"compilerOptions":{"target":"es2023","lib":["es2023"],"types":[],"module":"esnext","moduleResolution":"bundler","strict":true,"noUncheckedIndexedAccess":true,"noEmit":true,"skipLibCheck":true,"jsx":"react","jsxFactory":"h","jsxFragmentFactory":"Fragment"},"include":["%s/claude-code/index.d.ts","%s/claude-code-tools/index.d.ts","%s/hooks"]}' "$T" "$T" "$PWD/mods/model-router" > "$W/tsconfig.json" && (cd "$W" && npx --yes -p typescript@5 tsc -p tsconfig.json) && echo TSC-OK
EXPECT: TSC-OK
EVIDENCE: pending
3. The floor is its own slot: `turnFloor` is declared in `State`, initialised in `newState`, written by the prompt rule and by a typed `/effort-<l>`, cleared by `/route clear` and at main turn end; the suite carries at least 6 tests whose name contains `floor`.
CHECK: cd mods/model-router/hooks && [ "$(grep -c 'turnFloor' register.ts)" -ge 6 ] && [ "$(grep -cE "^\s*test\('[^']*floor" register.test.ts)" -ge 6 ] && echo FLOOR-SLOT-OK
3. The floor is its own slot: `turnFloor` is declared in `State`, initialised in `newState`, written by the prompt rule and by a typed `/effort-<l>`, cleared by `/route clear` and at main turn end; the suite carries at least 8 tests whose name contains `floor`; one helper `mainEffort` decides the main effort; the config key `enabled` exists.
CHECK: cd mods/model-router/hooks && [ "$(grep -c 'turnFloor' register.ts)" -ge 6 ] && [ "$(grep -cE "^\s*test\('[^']*floor" register.test.ts)" -ge 8 ] && grep -q 'mainEffort' register.ts && grep -q 'enabled' register.ts && echo FLOOR-SLOT-OK
EXPECT: FLOOR-SLOT-OK
EVIDENCE: pending
4. Judged by reading: effective main effort = the higher (LEVELS order) of the floor and `(userMain ?? turnMain)?.route.effort ?? e.effort`, the floor replacing a numeric or absent engine effort; the floor never applies to a sub-agent step; `turnMain` only ever holds 'model' or 'skill' sources; the prompt rule writes `turnFloor` (or `pendingPrompt` when typed mid-turn with `wait`), promoted into `turnFloor` at main turn end; a non-effort skill load resets `turnMain` only; a model `route({clear})` clears `turnMain` only; every answer that the floor overrides says so truthfully (Skill bridge context, route tool text, `/effort-<l>` text); `/route show` and the status line display the floor; every criterion of `.claude/tasks/contracts/2026-10-08-model-router-w1a-1533.md` still holds; no function over 25 logic lines.
4. Judged by reading: effective main effort comes from ONE helper `mainEffort` used by `mainPlan` and by every answer text: base `(userMain ?? turnMain)?.route.effort ?? turnFloor?.route.effort ?? e.effort`, then floored by `turnFloor` (LEVELS order; a numeric or absent value is replaced); the floor never applies to a sub-agent step; `turnMain` only ever holds 'model' or 'skill' sources; the prompt rule writes `turnFloor` (keeping the higher of two) and, when typed mid-turn (`turnId` set, `wait` ignored), also `pendingPrompt`, promoted into `turnFloor` at main turn end; `"enabled": false` in the override file makes every hook pass through after each config load (`/route on` re-enables for the session); a non-effort skill load resets `turnMain` only; a model `route({clear})` clears `turnMain` only; every answer that the floor overrides says so truthfully (Skill bridge context, route tool text, `/effort-<l>` text); `/route show` and the status line display the floor; every criterion of `.claude/tasks/contracts/2026-10-08-model-router-w1a-1533.md` still holds; no function over 25 logic lines.
## FILE SCOPE
mods/model-router/hooks/register.ts · mods/model-router/hooks/register.test.ts
@@ -19,18 +19,22 @@ Q: doctor scope / A: per mod: the loading link resolves into the repo mod dir; `
CHECK: [ -L skills/model-router ] && [ "$(readlink skills/model-router)" = "../mods/model-router" ] && ! git check-ignore -q skills/model-router && echo LINK-OK
EXPECT: LINK-OK
EVIDENCE: pending
2. Engine-laid files are ignored: a mod's root `tsconfig.json` and anything under its `.claude-plugin/types/`; the tracked mod files are not ignored.
CHECK: git check-ignore -q mods/model-router/tsconfig.json && git check-ignore -q mods/model-router/.claude-plugin/types/claude-code/index.d.ts && ! git check-ignore -q mods/model-router/hooks/register.ts && ! git check-ignore -q mods/model-router/.claude-plugin/plugin.json && echo IGNORE-OK
2. Engine-laid files are ignored for ANY mod: a mod's root `tsconfig.json` (root `.gitignore`; the `.claude-plugin/types/` folder ignores itself); the tracked mod files are not ignored.
CHECK: git check-ignore -q mods/model-router/tsconfig.json && git check-ignore -q mods/zz-future/tsconfig.json && ! git check-ignore -q mods/model-router/hooks/register.ts && ! git check-ignore -q mods/model-router/.claude-plugin/plugin.json && [ -z "$(git status --short mods/)" ] && echo IGNORE-OK
EXPECT: IGNORE-OK
EVIDENCE: pending
3. `lib/tests/mods.test.sh` passes on the repo and fails on a fixture that lacks the loading link (positive control through `MODS_ROOT`).
CHECK: make test suite=lib/tests/mods.test.sh >/dev/null 2>&1 && W=$(mktemp -d) && mkdir -p "$W/mods" "$W/skills" && cp -R mods/model-router "$W/mods/" && ! MODS_ROOT="$W" bash lib/tests/mods.test.sh >/dev/null 2>&1 && ln -s ../mods/model-router "$W/skills/model-router" && MODS_ROOT="$W" bash lib/tests/mods.test.sh >/dev/null 2>&1 && echo MODS-SUITE-OK
3. `lib/tests/mods.test.sh` passes on the repo, fails on a fixture that lacks the loading link, fails on an empty `mods/`, and SKIPs (exit 0) the CLI checks when `claude plugin test` is unavailable (probe by capability, PATH-shadowed `claude` in the control).
CHECK: make test suite=lib/tests/mods.test.sh >/dev/null 2>&1 && W=$(mktemp -d) && mkdir -p "$W/mods" "$W/skills" "$W/bin" && cp -R mods/model-router "$W/mods/" && ! MODS_ROOT="$W" bash lib/tests/mods.test.sh >/dev/null 2>&1 && ln -s ../mods/model-router "$W/skills/model-router" && MODS_ROOT="$W" bash lib/tests/mods.test.sh >/dev/null 2>&1 && printf '#!/bin/sh\nexit 1\n' > "$W/bin/claude" && chmod +x "$W/bin/claude" && PATH="$W/bin:$PATH" MODS_ROOT="$W" bash lib/tests/mods.test.sh 2>&1 | grep -q '^SKIP' && E=$(mktemp -d) && mkdir -p "$E/mods" "$E/skills" && ! MODS_ROOT="$E" bash lib/tests/mods.test.sh >/dev/null 2>&1 && echo MODS-SUITE-OK
EXPECT: MODS-SUITE-OK
EVIDENCE: pending
4. `doctor.sh` prints a `── Mods ──` section with a ✓ line for the model-router loading link.
CHECK: out=$(bash doctor.sh 2>&1); echo "$out" | sed -n '/── Mods ──/,/^$/p' | grep -q '✓.*model-router' && echo DOCTOR-MODS-OK
4. `doctor.sh` prints a `── Mods ──` section with a ✓ line for model-router; with the link absent (HOME pointed at a scratch `.claude` whose `skills/` lacks the link) the section prints an info line, doctor reaches its summary and exits 0 for that section's sake (no new error).
CHECK: out=$(bash doctor.sh 2>&1); echo "$out" | sed -n '/── Mods ──/,/^$/p' | grep -q '✓.*model-router' && H=$(mktemp -d) && mkdir -p "$H/.claude/skills" && o2=$(HOME="$H" bash doctor.sh 2>&1); echo "$o2" | sed -n '/── Mods ──/,/^$/p' | grep -qi 'not linked' && echo "$o2" | grep -q '═══' && echo DOCTOR-MODS-OK
EXPECT: DOCTOR-MODS-OK
EVIDENCE: pending
4b. The mod is enabled through the tracked link in a FRESH process: `claude plugin list --json` lists `model-router@skills-dir` with `enabled: true` (run after the dev-mods link is removed, see W6).
CHECK: claude plugin list --json 2>/dev/null | python3 -c 'import json,sys; rows=json.load(sys.stdin); ok=any(r.get("id")=="model-router@skills-dir" and r.get("enabled") is True for r in rows); sys.exit(0 if ok else 1)' && echo LOADED-OK
EXPECT: LOADED-OK
EVIDENCE: pending
5. `CLAUDE.md` has a `## mods/` section naming the `skills/<name>` relative symlink, the `@skills-dir` origin, why not `CLAUDE_CODE_PLUGIN_DIRS`, the gitignored engine-laid files, `~/.claude/<name>.json`, the suite command and how to turn a mod off.
CHECK: grep -q '^## mods/' CLAUDE.md && grep -q '@skills-dir' CLAUDE.md && grep -q 'CLAUDE_CODE_PLUGIN_DIRS' CLAUDE.md && grep -q 'mods.test.sh' CLAUDE.md && grep -q '<name>.json' CLAUDE.md && grep -q '@skills-dir": false' CLAUDE.md && echo CLAUDEMD-OK
EXPECT: CLAUDEMD-OK
@@ -39,7 +43,9 @@ Q: doctor scope / A: per mod: the loading link resolves into the repo mod dir; `
CHECK: shellcheck lib/tests/mods.test.sh doctor.sh && make test suite=lib/tests/doctrine-citers.test.sh >/dev/null 2>&1 && echo HEALTH-OK
EXPECT: HEALTH-OK
EVIDENCE: pending
7. Judged by reading: no change to settings.json, link.sh or any install script; the user's settings.json working-tree diff is untouched; the suite SKIPs (explicit SKIP line, exit 0 for that part) only the `claude`-dependent checks when `claude` is absent, and fails when no mod is found at all; doctor's new section never increments the core-link counter (`_LINK_PASS`) and never fails on a missing `claude`; the CLAUDE.md section is terse English matching the file's style.
7. Judged by reading: no change to settings.json, link.sh or any install script; the suite probes the CAPABILITY (`claude plugin test --help`), bounds every CLI call in time, captures `2>&1`, SKIPs with a reason, fails when no mod is found; doctor's section is fail-soft under `set -euo pipefail` (existence test before readlink, `-ef` comparison, one guarded `claude plugin list --json`, python exits 0 with `unknown` on any parse error), never increments `_LINK_PASS`, says "enabled" not "loaded", treats a missing link as info; the link step is idempotent; CLAUDE.md names the per-machine `"enabled": false` switch, the tracked-settings cost of `enabledPlugins`, and the dev-copy shadowing rule; the CLAUDE.md section is terse English matching the file's style.
Q (r2): ordering / A: this contract runs after the floor contract (`2026-10-08-model-router-floor-1835`) is committed and green. [orchestrator]
Q (r2): update-all `claude plugin update` over `@skills-dir` / A: accepted residual (one recurring warn), logged in TODO; out of FILE SCOPE. [orchestrator]
## FILE SCOPE
skills/model-router (new symlink) · .gitignore · lib/tests/mods.test.sh (new) · doctor.sh · CLAUDE.md
@@ -106,3 +106,67 @@ Optional seventh: the bridge context line names the floor when it wins.
derived phase); supersedes the 1-A contract's one-slot precedence for
prompt and slash sources.
- LRN-206 (kit facts) applies to every new test.
## r2 — challenge round (3 lenses, 0 BLOCKER, 4 MAJOR): BINDING, overrides the sections above where they conflict
R1. ONE decision helper, used by `mainPlan` AND by every answer text:
`mainEffort(st, engine: StepIn['effort'])` → `{ effort, by }` with
`by` ∈ `'floor' | 'sticky' | 'turn' | 'engine'`.
`base = (st.userMain ?? st.turnMain)?.route.effort
?? st.turnFloor?.route.effort ?? engine`
`effort = floored(base, st.turnFloor?.route.effort)`; `by = 'floor'`
when the floor raised or supplied the value, else the slot it came from.
The user's level is therefore BOTH the turn's default (when no sticky
or turn route names an effort) AND its minimum: a typed `/effort-low`
lowers a turn that has no route (engine `high` → `low`), and a route
can still go higher. No text function compares ranks on its own.
R2. Model axis, one rule written once (contract updated):
`st.userMain?.route.model ?? st.turnMain?.route.model ?? st.turnFloor?.route.model`,
switch and window guard unchanged.
R3. Prompt rule with `e.turnId !== undefined` (typed while a turn runs;
`wait` is IGNORED: the engine queues every mid-turn prompt either way):
write the floor NOW (higher of the existing floor and the new one)
AND set `pendingPrompt` to it, so the turn that reads the prompt has it
whichever it is. No `turnId` → write the floor (higher of two).
`endMainTurn` promotes `pendingPrompt` into `turnFloor`. Two floors in
one turn always keep the higher one (prompt rule and typed slash).
R4. Truthful texts, all phrased from `mainEffort` (main branch only):
- Skill bridge, route tool, typed `/effort-<l>`: when `by === 'floor'`
and the result differs from what was asked, name the floor and its
source (`ultrathink rule` or `typed /effort-<l>`) and add
`/route clear to drop it`; when `by === 'sticky'`, the sticky
sentence; the old fixed "sticky wins" sentences go.
- `/effort-<l>` text: `Effort <l> set by model-router for the main loop
this turn (minimum; a higher route still applies).` plus the floor
or sticky outcome when one changes it.
- main loop on a haiku model: print `effort - (haiku takes none)`.
- model `route({clear})` on main with a floor set: append `; user floor
<f> (<source>) still holds — /route clear drops it`.
R5. Display: `mainText` / `statusLine` show ` · floor <f>` only when the
floor carries an effort AND the router is on; the `skill.prompt` hook
calls `refresh($, st)` after the slash write.
R6. Persistent per-machine off switch (wiring challenge, user's "configurable"):
config key `enabled: boolean` (default `true`) in
`~/.claude/model-router.json` (untracked, per machine). `false` →
`st.off = true` after every config load (session start, `/route
reload`); `/route on` re-enables for the session only; `show` and the
status line say `off (config)` vs `off`. Merged with `pickBool` like
the other scalars; `DEFAULT_CONFIG.enabled = true`.
R7. Tests (replace the list above where it differs): `runStep` takes a
full `TurnStepInput` (from 'claude-code'); every floor test steps with
engine effort `high` (or `xhigh`); every `test('…', async (` line ≤ 80
chars with `floor` in the single-line name. At least 8 floor tests:
ultrathink survives a model route · typed /effort-medium clamps low,
lets max pass · typed /effort-low lowers an unrouted turn (engine high
→ low) · survives a skill load · lifts a lower sticky then ends with
the turn · main only (agent step unaffected) · /route clear removes it
· mid-turn prompt (turnId + wait) is applied now AND promoted after the
main turn.complete · mandatory text test: sticky `/route effort=low`,
ultrathink, route tool `plan` → the answer names the floor.
`enabled: false` cannot be reached in the kit (no fs, LRN-206): cover
the off path through `/route off` and say so in a comment.
R8. Residuals accepted (logged in TODO, not built): floor expiry depends on
a main `turn.complete` reaching this mod (another plugin answering it
without `next` would keep it); `skill.prompt` cannot tell a typed
`/effort-<l>` from a sub-agent preload (no agentId; no repo agent
preloads one); an incidental "ultrathink" in pasted text floors the
turn (mitigated by R4 naming the source and the `/route clear` hint).
@@ -98,6 +98,66 @@ repo root `bash ~/.claude/lib/gates.sh run .claude/tasks/contracts/2026-10-08-mo
skills-dir copy in that session; doctor reads `claude plugin list` from a
fresh process, which sees only the skills-dir copy.
## r2 — challenge round (3 lenses, 0 BLOCKER, 5 MAJOR): BINDING, overrides the sections above where they conflict
W1. ORDER: this plan runs AFTER the floor plan (B1) is committed and green
on the same branch: the suite and doctor test whatever register.ts is
on disk.
W2. `.gitignore`: add ONLY `mods/*/tsconfig.json` with the comment
`# mods/: the engine lays tsconfig.json beside a loaded mod; its
.claude-plugin/types/ ignores itself`. (The types folder carries its
own `.gitignore` holding `*`.)
W3. Link step idempotent: `[ -L skills/model-router ] || ln -s
../mods/model-router skills/model-router` (a bare `ln -s` re-run would
create a nested link inside the mod).
W4. `lib/tests/mods.test.sh` fail-soft and bounded:
- capability probe, not presence: `command -v claude` AND `claude plugin
test --help >/dev/null 2>&1`; otherwise ONE `SKIP: claude plugin test
unavailable (<reason>) — validate/test not run` line, checks (1)-(2)
still decide the exit code;
- `claude plugin validate` and `claude plugin test` captured with `2>&1`;
the validate verdict is the line matching `Validation passed`, with
`warning` searched only in that captured output;
- every CLI call bounded: `timeout 120` when available (coreutils /
`gtimeout`), else a background-and-wait guard; a timeout is a FAIL
naming it;
- no mod found → FAIL (never vacuous).
W5. doctor `── Mods ──` fail-soft under `set -euo pipefail`:
- `[ -L "$link" ] || [ -e "$link" ]` BEFORE any readlink; compare with
`[ "$link" -ef "$REPO/mods/<name>" ]` (handles logical vs physical
repo paths), never string equality on `readlink -f`;
- a missing link is `info "mod <name>: not linked (skills/<name> absent)
— git checkout skills/<name> if wanted"`, NOT `fail` (a user may
remove the link on purpose; doctor red forever would break
update-all's final doctor run);
- ONE `claude plugin list --json` call before the loop, inside
`if ! out=$(claude plugin list --json 2>/dev/null); then warn "mods:
claude plugin list failed — load state not checked"; out=""; fi`; the
python3 parse reads stdin, exits 0 always, prints `enabled|disabled|
absent|unknown` per name (any parse error → `unknown`);
- wording: `pass "mod <name>: enabled as <name>@skills-dir"` (not
"loaded": the list proves enablement, not a successful load);
`disabled` → warn naming `"<name>@skills-dir": false`; `absent` →
`warn "mod <name>: not listed as @skills-dir — run: claude plugin
validate mods/<name> (policy, manifest or name conflict)"` (a fresh
process rescans skills/, so a restart changes nothing); `unknown` →
warn "list output not understood";
- `claude` missing → nothing (doctor's Prerequisites section already
fails on it); no override-file JSON check (the mod validates its own
config and logs at session start).
W6. CLAUDE.md `## mods/` also says: the only per-machine off switch is
`"enabled": false` in `~/.claude/<name>.json` (untracked); an
`enabledPlugins` `"<name>@skills-dir": false` entry works too but lands
in the TRACKED settings.json, so it dirties every machine's tree; and
that a hot-reload / `--plugin-dir` copy of the same name shadows the
skills-dir copy for that session (docs plugins/loading "Name
conflicts"), so the dev link in `~/.claude/dev-mods/<session>/` must
be removed before `/reload-plugins` is read as a test of the skills-dir
path.
W7. `update-all.sh` runs `claude plugin update` over every listed plugin
(lines ~606-618): a `@skills-dir` entry will produce one recurring
warn there. Accepted residual, logged in TODO (an update-all edit is
out of this contract's FILE SCOPE).
## Disposition
- honors BDR-115 (mod in `mods/`, single source); amends its "Load:" line
(PLUGIN_DIRS → skills-dir link), to be recorded at capitalize.