diff --git a/.claude/tasks/contracts/2026-10-08-model-router-floor-1835.md b/.claude/tasks/contracts/2026-10-08-model-router-floor-1835.md index 53daccc..30943eb 100644 --- a/.claude/tasks/contracts/2026-10-08-model-router-floor-1835.md +++ b/.claude/tasks/contracts/2026-10-08-model-router-floor-1835.md @@ -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||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||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-`, 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-`, 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-` 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-` 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 diff --git a/.claude/tasks/contracts/2026-10-08-model-router-wiring-1835.md b/.claude/tasks/contracts/2026-10-08-model-router-wiring-1835.md index fac021b..6f887de 100644 --- a/.claude/tasks/contracts/2026-10-08-model-router-wiring-1835.md +++ b/.claude/tasks/contracts/2026-10-08-model-router-wiring-1835.md @@ -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/` relative symlink, the `@skills-dir` origin, why not `CLAUDE_CODE_PLUGIN_DIRS`, the gitignored engine-laid files, `~/.claude/.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 '.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 diff --git a/.claude/tasks/plans/2026-10-08-model-router-floor-1835.md b/.claude/tasks/plans/2026-10-08-model-router-floor-1835.md index 6058c60..8c3019b 100644 --- a/.claude/tasks/plans/2026-10-08-model-router-floor-1835.md +++ b/.claude/tasks/plans/2026-10-08-model-router-floor-1835.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-`: when `by === 'floor'` + and the result differs from what was asked, name the floor and its + source (`ultrathink rule` or `typed /effort-`) and add + `/route clear to drop it`; when `by === 'sticky'`, the sticky + sentence; the old fixed "sticky wins" sentences go. + - `/effort-` text: `Effort 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 + () still holds — /route clear drops it`. +R5. Display: `mainText` / `statusLine` show ` · floor ` 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-` 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). diff --git a/.claude/tasks/plans/2026-10-08-model-router-wiring-1835.md b/.claude/tasks/plans/2026-10-08-model-router-wiring-1835.md index b136601..9b05f82 100644 --- a/.claude/tasks/plans/2026-10-08-model-router-wiring-1835.md +++ b/.claude/tasks/plans/2026-10-08-model-router-wiring-1835.md @@ -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 () — 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/" ]` (handles logical vs physical + repo paths), never string equality on `readlink -f`; + - a missing link is `info "mod : not linked (skills/ absent) + — git checkout skills/ 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 : enabled as @skills-dir"` (not + "loaded": the list proves enablement, not a successful load); + `disabled` → warn naming `"@skills-dir": false`; `absent` → + `warn "mod : not listed as @skills-dir — run: claude plugin + validate mods/ (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/.json` (untracked); an + `enabledPlugins` `"@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//` 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.