diff --git a/.claude/memory/journal.md b/.claude/memory/journal.md index adc0290..0af5f38 100644 --- a/.claude/memory/journal.md +++ b/.claude/memory/journal.md @@ -581,3 +581,4 @@ rules: - model-router mod, wave 0 spike (user ask: one mod routes model + effort per request, replaces effort-* shifters + pins). Plan `.claude/tasks/plans/2026-10-08-model-router-mod.md`, 4 decisions by AskUserQuestion (spike-first main-loop switch, `CLAUDE_CODE_PLUGIN_DIRS` load, migration wave 2, names model-router / route / /route), rule "pin = entry default, sub-tasks route finer". Spike in dev-mods, hot reload on: `turn.step` effort rewrite proven (transcript `effort` field is the oracle, not `CLAUDE_EFFORT`); sub-agent model at `agent.spawn` + effort per step by agentId proven; main-loop fable → sonnet-5-5/low for 3 steps then back: works, one cold-cache step per switch INTO a model, return free. Found: hook-side alias resolver stale (`sonnet` → `claude-sonnet-5`, 404; Agent tool enum resolves the same alias to 5-5) → mod writes full ids only. feature/model-router-mod open, nothing committed yet (plan + TODO + journal pending). - model-router wave 1-A (/feat, user go): mod built in `mods/model-router/` (4 files, 834 + 202 lines, 11 plugin tests). Plan r1 → r3: 3 challengers (simplicity CONCERNS, robustness CONCERNS(6), correctness FATAL(8)) + 1 confirmation CONCERNS(4); converged on: agents table = built-ins only in wave 1 (frontmatter stays single writer), agent model written once at spawn, explicit Agent params frozen per loop, every sub-agent write on its own loop, Skill bridge answers in the Skill tool's OUTPUT schema (string result refused → skill would load), config validated before merge, state in closure, `/route off`. feater DONE first pass; GATE 0 MET; verifier CONFORME 6/6; security PASS (4 MEDIUM + 5 LOW parked in TODO for user go). Commits b721c94 (mod) + e8ca713 (contract/plan). Override `~/.claude/model-router.json` {verbose:true} written (pass B). Doc-sync deferred to 1-B. - model-router W1-A hardening (user go): fresh feater on contract criteria 7-11 → gap round (dead `Loop.frozen`, spawn returns `started` verbatim, tool description) → verifier CONFORME 11/11 → security PASS (1 MEDIUM residual: ReDoS size-bounded only, self-inflicted config; 5 LOW parked). Registries BDR-115, LRN-205, LRN-206, EVAL-040 written on user go (64702d5). Next: live swap of the real mod into the hot-reload folder, then W1-B install + docs. +- model-router live checks on Opus 5.5 (user /model, uncommitted settings.json change left to the user): Skill(effort-low) bridge answered in place → next request low; ultrathink turn ran max (engine base medium); Explore without params → claude-sonnet-5-5, 3 steps medium. Loading switched to tracked symlink skills/model-router → @skills-dir (PLUGIN_DIRS non-portable: absolute path, no $HOME expansion, tracked settings); isolated-HOME probe listed/enabled/loaded. User chose floor semantics for ultrathink + typed /effort-. W1-B split: B1 floor (register.ts) + B2 wiring (symlink, gitignore, mods suite, doctor, CLAUDE.md); 6 challengers in flight. Effort shifters skipped this turn: the bridge would overwrite the user's ultrathink until B1 lands. diff --git a/.claude/tasks/TODO.md b/.claude/tasks/TODO.md index b391e2e..d1b2620 100644 --- a/.claude/tasks/TODO.md +++ b/.claude/tasks/TODO.md @@ -8,7 +8,8 @@ migration of shifters/pins/model-gate in wave 2 after proof; names model-router - [x] W1-A the mod in `mods/model-router/` (b721c94, contract `2026-10-08-model-router-w1a-1533`, plan r3): challenge 3 lenses + 1 confirmation (2 BLOCKER + 10 MAJOR closed by named changes), feater DONE first pass, GATE 0 MET 5/5, verifier CONFORME 6/6, security PASS (4 MEDIUM + 5 LOW reported, below) - [x] W1-A hardening (user go "oui durcis", contract criteria 7-11): `/route` composer-only; no agent model axis (effort only after spawn); pattern ≤ 200 / scan ≤ 4096 / phase keys `^[a-z][a-z0-9_-]{0,31}$` / config ≤ 64 KB / `additionalProperties: false` / `typeof e.skill`; `warnOnce` in all 14 catches + config-drop logs; `safely` around post-`next` bookkeeping. feater DONE, gap round (dead `Loop.frozen`, spawn returns `started` verbatim, tool description), GATE 0 MET 9/9, verifier CONFORME 11/11, security PASS. - [ ] W1-A residuals (security, accepted, none exploitable from outside the user's own files): ReDoS is size-bounded only (`(a+)+$` in `~/.claude/model-router.json` + a 4 KB paste hangs the hook; fix = nested-quantifier rejection or a far lower scan cap); `stat().size` trusted (FIFO/device path in ~/.claude); unrestricted `models`/`agents`/`skills` KEYS echoed raw in logs (log flood); `agentId` read from the flat tool event (engine strip unverified); 5 closures without a kit test (no fs in the kit, LRN-206); "nothing routed" catch text after a state write. -- [ ] W1-B install + docs: `mods/` symlink + `CLAUDE_CODE_PLUGIN_DIRS` in settings.json env via link.sh, root `.gitignore` for the engine-laid `mods/*/tsconfig.json` + `.claude-plugin/types/`, doctor line, README/USAGE/CHANGELOG (doc-sync deferred here from the 1-A /feat run: nothing to document before the install exists), live test in session (swap the spike for the real mod) +- [ ] W1-B1 floor precedence (contract `2026-10-08-model-router-floor-1835`): `ultrathink` + typed `/effort-` = floor for the main turn (user choice 2026-10-08) +- [ ] W1-B2 wiring (contract `2026-10-08-model-router-wiring-1835`): tracked symlink `skills/model-router` → `../mods/model-router` (loads as `@skills-dir`; PLUGIN_DIRS dropped: absolute paths in tracked settings), `.gitignore` engine-laid files, `lib/tests/mods.test.sh`, doctor `── Mods ──`, CLAUDE.md `## mods/`; then doc-sync (README/USAGE/CHANGELOG), live swap (remove the hot-reload link, `/reload-plugins`), BDR-115 amendment - [ ] W2 migration: 15 skills off `Skill(effort-*)`, remove shifters + effort-pins + model-gate, census repointed, docs - [ ] W3 optional: step heuristics, haiku classifier, quota-aware downgrade, A/B 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 new file mode 100644 index 0000000..53daccc --- /dev/null +++ b/.claude/tasks/contracts/2026-10-08-model-router-floor-1835.md @@ -0,0 +1,33 @@ +# CONTRACT — model-router-floor (wave 1-B1: user effort floor for the turn) +- date: 2026-10-08 | flow: feat | branch: feature/model-router-mod +- status: active + +## REQUEST (verbatim — IMMUTABLE) +AskUserQuestion 2026-10-08, question: "Aujourd'hui, `ultrathink` met le tour en max, mais si je déclare une phase (ex. orchestrate) puis charge un skill, ton max est perdu pour la suite du tour. Ça contredit notre règle « un choix explicite bat la phase déduite ». Quel sens donner à `ultrathink` et à un `/effort-x` tapé par toi ?" +User's answer: "Plancher pour le tour (Recommended)" — option text: "Ton niveau est un minimum pour tout le tour. Les routes du modèle et des skills peuvent monter au-dessus (escalade à max), jamais descendre en dessous. Il passe aussi par-dessus un /route sticky plus bas." +Session rule this fixes (wave plan, user-approved 2026-10-08): "an explicit per-call choice (Agent `model`/`effort` param, `/route`, `ultrathink`) beats the derived phase for that span". +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: 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 + 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 + 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. + +## 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 new file mode 100644 index 0000000..fac021b --- /dev/null +++ b/.claude/tasks/contracts/2026-10-08-model-router-wiring-1835.md @@ -0,0 +1,45 @@ +# CONTRACT — model-router-wiring (wave 1-B2: active in every session, tests, doctor) +- date: 2026-10-08 | flow: feat | branch: feature/model-router-mod +- status: active + +## REQUEST (verbatim — IMMUTABLE) +User (fr, first message of the session): "Et le mieux que ca soit configurable et qu'on puisse l'installer et qu'il soit actif sur toutes les session en userscope". +AskUserQuestion 2026-10-08, question on the loading mechanism (CLAUDE_CODE_PLUGIN_DIRS needs an absolute path, no $HOME expansion in settings `env`, settings.json is tracked and shared): answer "1 . Mais ce n'est pas un skill on est d'accord ? C'est un mod. don cplus u plugin. ET du coup pourquoi pqs link directement ule dossier mod vers le .claude/mods directement via le link.sh ? Pourquoi passer par skills ?" (option 1 = "Lien sous skills/ (Recommended)"). +Explanation given to the user the same turn: a mod is a plugin, not a skill; Claude Code loads a plugin only from a marketplace, an absolute `CLAUDE_CODE_PLUGIN_DIRS` path, a claude.ai sync, or a plugin folder (`.claude-plugin/plugin.json`) under `~/.claude/skills/` (origin `@skills-dir`, loaded in place); a `~/.claude/mods` link alone loads nothing. +Same question batch, settings answer: "Je gère moi-même" (the user's uncommitted `/model` change to settings.json). + +## CLARIFICATIONS +Q: loading mechanism / A: `skills/` = relative symlink `../mods/`, tracked in git; loads as `@skills-dir`, in place, on every machine where link.sh links `~/.claude/skills`. No settings.json change, no link.sh change. Probe 2026-10-08 in an isolated HOME: listed, enabled, "Status: ✔ loaded". [gated 2026-10-08] +Q: settings.json / A: the working-tree change (model → opus, env block moved) stays untouched and out of every commit. [gated 2026-10-08] +Q: override convention / A: a mod's optional user config lives at `~/.claude/.json` (model-router already reads `~/.claude/model-router.json`). [orchestrator] +Q: doctor scope / A: per mod: the loading link resolves into the repo mod dir; `claude plugin list --json` lists `@skills-dir` enabled (warn, not fail, when absent, disabled, or `claude` missing); the override file parses as JSON when present. [orchestrator] + +## ACCEPTANCE CRITERIA +1. `skills/model-router` is a symlink whose target is exactly `../mods/model-router`, and git does not ignore it. + 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 + 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 + 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 + EXPECT: DOCTOR-MODS-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 + EVIDENCE: pending +6. Health stack on the touched shell files, doctrine census green. + 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. + +## 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 new file mode 100644 index 0000000..6058c60 --- /dev/null +++ b/.claude/tasks/plans/2026-10-08-model-router-floor-1835.md @@ -0,0 +1,108 @@ +# PLAN — model-router wave 1-B1: user effort floor for the turn (dispatch-ready) +Contract: .claude/tasks/contracts/2026-10-08-model-router-floor-1835.md +Code: mods/model-router/hooks/register.ts (read it in full first) and +register.test.ts. API truth: the engine-laid declarations under +mods/model-router/.claude-plugin/types/ (claude-code/index.d.ts, +claude-code-tools/index.d.ts). + +## Why +Today the prompt rule (`ultrathink` → escalate) and a typed `/effort-` +share ONE slot (`turnMain`) with the model's `route` calls and the +`Skill(effort-*)` bridge: last writer wins, and a later skill load resets +the slot to the session default. A user's explicit level is therefore lost +mid-turn. The user chose FLOOR semantics: their level is a minimum for the +whole main turn; derived routes may go above it, never below; it also +lifts a lower sticky `/route`. + +## Precedence after the change (main loop only) +- model axis (unchanged order, floor last): `userMain ?? turnMain ?? turnFloor` + route's `model`, applied only with `mainModelSwitch` and the window guard. +- effort axis: `base = (userMain ?? turnMain)?.route.effort ?? e.effort`; + `effort = floored(base, turnFloor?.route.effort)`. +- `floored(effort, floor)`: no floor → `effort`; `effort` is a Level whose + LEVELS index ≥ the floor's → `effort`; otherwise (lower Level, a number, + or undefined) → `floor`. +- The haiku omission (`effort: undefined` when the model sent starts with + `claude-haiku`) still runs AFTER flooring. +- Sub-agent steps (`e.agentId` set) never read `turnFloor`. + +## Changes in register.ts (names as in the current file) +1. `State`: add `turnFloor: Routed | null` with the comment `user-explicit + level for this turn (prompt rule, typed /effort-): a floor, main loop + only`; reword the `turnMain` comment to `model route tool, skill table + row, Skill(effort-*) bridge; dropped at turn end`. `newState`: + `turnFloor: null`. +2. Helpers (new, small): `const rank = (l: Level): number => LEVELS.indexOf(l)`; + `function floored(effort: StepIn['effort'], floor: Level | undefined)` + per the rule above. A helper `floorLevel(st)` returning + `st.turnFloor?.route.effort` is allowed if it keeps call sites short. +3. `mainPlan`: compute `base` and `effort = floored(base, floorLevel(st))`; + `wanted = set?.route.model ?? st.turnFloor?.route.model`; the rest + (switch, window guard) unchanged. +4. `registerPrompt` / `prompt.submit`: the non-queued branch writes + `st.turnFloor = routed` (instead of `st.turnMain`); the queued branch + (`e.turnId !== undefined && e.wait`) keeps writing `st.pendingPrompt`. +5. `slashEffort`: write `st.turnFloor = { phase: skill, route: { effort: + level }, source: 'slash' }`; returned text line becomes `Effort floor + set by model-router for this turn: nothing below it runs.` + followed by `\n` + the original text (prepend, never replace). +6. `onSkillLoad` (main branch): `st.turnMain = null` unconditionally (the + slot no longer holds prompt or slash routes), then the table row as now. + `turnFloor` is never touched there. +7. `clearRoutes` (`/route clear`): also `st.turnFloor = null`. + `clearLoop` (model `route({clear})`, main branch): `turnMain` only, as now. +8. `endMainTurn`: `st.turnFloor = st.pendingPrompt; st.pendingPrompt = + null; st.turnMain = null;` then the existing resets. +9. Truthful answers (main branch only; agent branches unchanged): + - `effortBridge`: keep the sticky sentence when `st.userMain` is set; + else when the floor ranks above `level`: `model-router: + recorded, but the user's floor for this turn keeps main at ; + the skill text was not loaded.`; else the current sentence. + - `routedText`: keep the sticky branch; else when `p.route.effort` is + set and the floor ranks above it, print the effort as ` (user + floor; asked )`. +10. Display: `mainText` appends ` · floor ()` when `turnFloor` is + set (also after `main: session defaults`); `statusLine` appends + ` · floor `. + +## Tests in register.test.ts (keep every existing test; adapt only what +the new slot changes, e.g. the `ultrathink` test now expects the floor on +the `main:` line). Add at least six tests whose names contain `floor`, +using the existing boot helper, full typed inputs and bottom hooks, and +asserting on the `main:` line or on what the bottom `turn.step` hook +receives (drain the stream with `for await`, then `.result`): +- `floor: ultrathink survives a model route` — prompt `ultrathink` + (composer, `wait: false`, no `turnId`) then route tool `orchestrate` → + a main step with engine effort `high` reaches the bottom at `max`. +- `floor: a typed /effort-medium floors a lower route and allows a higher + one` — `$.skill.prompt({ skill: 'effort-medium', text: 'x' })` (no Skill + call in flight) → route tool `mechanical` → main step at `medium`; + then route tool `escalate` → main step at `max`. +- `floor: survives a skill load` — ultrathink, route tool `orchestrate`, + then a non-effort `Skill` call (bottom `tool.call` hook registered) → + main step at `max`, and `main:` line no longer names `orchestrate`. +- `floor: lifts a lower sticky route, then ends with the turn` — `/route + effort=low` then ultrathink → main step at `max`; fire a main + `turn.complete` → next main step at `low`. +- `floor: main only` — ultrathink, spawn `Explore` (bottom `agent.spawn` + hook returning an `agentId`), then a step for that `agentId` → reaches + the bottom at `medium`, not `max`. +- `floor: /route clear removes it` — ultrathink, `/route clear` → main + step keeps the engine effort. +Optional seventh: the bridge context line names the floor when it wins. + +## Constraints +- Style: ≤ 25 logic lines per function, 80 chars per line, no `any`, no + module-level mutable state, doc comments state intent. +- Do not touch: the agent axis, the spawn table, config loading, the + hardening (caps, warnOnce, safely), the route tool schema. +- Verify (paste outputs): `claude plugin validate .`, the contract's tsc + command, `claude plugin test .`, then from the repo root + `bash ~/.claude/lib/gates.sh run .claude/tasks/contracts/2026-10-08-model-router-floor-1835.md`. + +## Disposition +- honors BDR-115 (one writer per axis, calling-loop writes, truthful + answers) and the wave plan's routing rule (explicit user choice beats the + 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. diff --git a/.claude/tasks/plans/2026-10-08-model-router-mod.md b/.claude/tasks/plans/2026-10-08-model-router-mod.md index 20f3832..b5ff7a6 100644 --- a/.claude/tasks/plans/2026-10-08-model-router-mod.md +++ b/.claude/tasks/plans/2026-10-08-model-router-mod.md @@ -95,6 +95,27 @@ does many different things inside one run. So: | verify | sonnet | xhigh | verifier, security-auditor | | mechanical | haiku | low | cp/mv, git bookkeeping, status collection, listing | +## Decisions 2026-10-08 (evening, user via AskUserQuestion) +- LOADING (supersedes "CLAUDE_CODE_PLUGIN_DIRS via settings.json + link.sh"): + PLUGIN_DIRS needs absolute paths, settings `env` has no `$HOME` + expansion, settings.json is tracked and shared across machines; a local + marketplace `add` writes an absolute path into settings.json too. Chosen: + tracked relative symlink `skills/` → `../mods/`; Claude Code + loads it as `@skills-dir`, in place (docs plugins/loading; probe in + an isolated HOME: listed, enabled, loaded). Repo scripts walking skills/ + glob `*/SKILL.md` or fixed names: unaffected. User asked why not + `~/.claude/mods`: Claude Code scans no such folder, a link there loads + nothing. +- PRECEDENCE: `ultrathink` (prompt rule) and a typed `/effort-` become a + FLOOR for the main turn: derived routes may go above, never below; it + lifts a lower sticky `/route`. Main loop only. +- settings.json: the user's uncommitted `/model` change (model → opus) is + theirs to manage; never staged. +- Live 2026-10-08: Skill(effort-low) bridge answered in place, next request + `low`; `ultrathink` turn on Opus ran at `max` (engine base `medium`); + Explore without params spawned on `claude-sonnet-5-5`, all 3 steps + `medium` (no spawn/step race observed). + ## Wave 0 — spike (dev-mods folder, hot reload, this session) - [x] W0.1 minimal mod: `/route` command, `route` tool, `turn.step` logging + rewrite, `agent.spawn` rewrite, `ultrathink` → max, spinner suffix 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 new file mode 100644 index 0000000..b136601 --- /dev/null +++ b/.claude/tasks/plans/2026-10-08-model-router-wiring-1835.md @@ -0,0 +1,106 @@ +# PLAN — model-router wave 1-B2: active in every session, tests, doctor (dispatch-ready) +Contract: .claude/tasks/contracts/2026-10-08-model-router-wiring-1835.md +Repo root: /Users/b.chanot/Documents/claude (branch feature/model-router-mod). + +## Facts this plan rests on (verified 2026-10-08) +- Claude Code loads a folder holding `.claude-plugin/plugin.json` under + `~/.claude/skills/` as `@skills-dir`, in place, live at the next + session start or `/reload-plugins` (docs: plugins/loading "In-place and + copied plugins"; probe in an isolated HOME: listed, enabled, loaded). +- `~/.claude/skills` is already a symlink to the repo's `skills/` (link.sh). +- Repo scripts that walk `skills/` glob `*/SKILL.md` or fixed paths + (doctor.sh, lib/skill-routing-census.py, the census suites); + lib/profile.sh only moves entries named in a profile. An entry without + SKILL.md is never counted, moved or flagged. +- The engine lays `/tsconfig.json` (extends the types) and + `/.claude-plugin/types/` (own `.gitignore` holding `*`) when a mod + loads; today `mods/model-router/tsconfig.json` shows as untracked. +- settings.json carries the user's uncommitted `/model` change: never + stage, edit or restore it. + +## Files +- [ ] `skills/model-router` — new RELATIVE symlink: from the repo root, + `ln -s ../mods/model-router skills/model-router`. Nothing else in skills/. +- [ ] `.gitignore` — append a block: + ``` + # mods/: files the engine lays beside a loaded mod (editor types) + mods/*/tsconfig.json + mods/*/.claude-plugin/types/ + ``` + Check first that no existing pattern ignores `skills/model-router` or + the tracked mod files (contract AC1/AC2 oracles). +- [ ] `lib/tests/mods.test.sh` — new suite, style of the existing suites + (read lib/tests/effort-pins.test.sh first and mirror its header, helpers + and summary). Behaviour: + - `ROOT="${MODS_ROOT:-}"`. + - Collect `$ROOT/mods/*/.claude-plugin/plugin.json`; none → FAIL + ("no mod found") so the suite can never pass vacuously. + - Per mod dir ``: (1) the manifest `name` (python3 json, argv — + never string-spliced) equals the folder name; (2) `$ROOT/skills/` + is a symlink whose `readlink` is exactly `../mods/`; (3) when + `command -v claude` succeeds: `claude plugin validate "$ROOT/mods/"` + prints `Validation passed` and no `warning` (case-insensitive); + (4) same condition: `claude plugin test "$ROOT/mods/"` exits 0. + - `claude` absent → one `SKIP: claude CLI not found — validate/test not + run` line; checks (1)-(2) still run and decide the exit code. + - Exit 1 on any failure, 0 otherwise; one PASS/FAIL line per check and a + final count line. + - shellcheck clean. No network, no writes outside a `mktemp -d` if any + scratch is needed (none expected). +- [ ] `doctor.sh` — new section `── Mods ──`, placed right after the + "Vendored skills" section (read lines 120-160 first; mirror its + `echo ""` / heading / pass-warn-fail-info style). For each + `$REPO/mods/*/` holding `.claude-plugin/plugin.json` (`` = folder): + - link `$HOME/.claude/skills/`: `readlink -f` equal to + `$REPO/mods/` → `pass "mod : loading link ~/.claude/skills/"`; + missing → `fail "mod : ~/.claude/skills/ MISSING — git checkout skills/, then make link"`; + elsewhere → `warn`. Do NOT call `check_symlink` (it feeds the core-link + counter `_LINK_PASS` / `_EXPECTED_LINKS`). + - `command -v claude` → `claude plugin list --json` parsed with python3 + (argv/stdin, no splicing): id `@skills-dir` with `enabled: true` + → `pass "mod : loaded as @skills-dir"`; present but + disabled → `warn "... disabled (enabledPlugins \"@skills-dir\": false)"`; + absent → `warn "... not listed — new session or /reload-plugins"`. + `claude` missing → `info "claude CLI not found — load state not checked"`. + - `$HOME/.claude/.json` present → `python3 -m json.tool` (quiet) + → `pass "mod : override ~/.claude/.json parses"` or + `fail "... invalid JSON"`; absent → nothing. + - No mod at all → `info "no mods"`. +- [ ] `CLAUDE.md` (project, repo root) — new section `## mods/ — function-hooks + plugins (Claude Code mods)` placed after the graphify section, terse + English in the file's own style, at most ~14 lines, covering: what lives + in `mods//`; it loads through the tracked relative symlink + `skills/` → `../mods/` as `@skills-dir` (in place, live + at the next session or `/reload-plugins`); why not + `CLAUDE_CODE_PLUGIN_DIRS` (absolute path, settings `env` has no `$HOME` + expansion, settings.json is tracked) nor a local marketplace (its `add` + writes an absolute path into settings.json); engine-laid + `tsconfig.json` + `.claude-plugin/types/` are gitignored; optional user + config `~/.claude/.json`; tests `make test suite=lib/tests/mods.test.sh` + (validate + `claude plugin test`); turn a mod off with + `"@skills-dir": false` in `enabledPlugins`; a dev copy loaded with + `--plugin-dir` or the hot-reload folder shadows the skills-dir copy + (same name, session-only wins). + +## Verify (executor pastes outputs) +`ls -l skills/model-router`; `git check-ignore -v mods/model-router/tsconfig.json`; +`make test suite=lib/tests/mods.test.sh`; the contract AC3 positive control; +`bash doctor.sh | sed -n '/── Mods ──/,/^$/p'`; `shellcheck lib/tests/mods.test.sh doctor.sh`; +`git status --short` (settings.json still ` M`, untouched); then from the +repo root `bash ~/.claude/lib/gates.sh run .claude/tasks/contracts/2026-10-08-model-router-wiring-1835.md`. + +## Edge cases +- The engine-laid `mods/model-router/tsconfig.json` already exists on disk: + after the `.gitignore` change it must disappear from `git status`. +- doctor runs without `claude` on PATH (Linux box): info line, no failure. +- A second mod later: the suite and doctor loop over `mods/*/` already. +- A hot-reload or `--plugin-dir` copy of the same mod shadows the + skills-dir copy in that session; doctor reads `claude plugin list` from a + fresh process, which sees only the skills-dir copy. + +## Disposition +- honors BDR-115 (mod in `mods/`, single source); amends its "Load:" line + (PLUGIN_DIRS → skills-dir link), to be recorded at capitalize. +- honors the destructive-tools rule: no recursive delete, no transfer + tool; LRN-150/LRN-171 shell hygiene (`command grep` where a shim can + interfere is not needed here: plain bash).