# 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. ## 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. - 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).