10 KiB
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.jsonunder~/.claude/skills/as<name>@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/skillsis already a symlink to the repo'sskills/(link.sh).- Repo scripts that walk
skills/glob*/SKILL.mdor 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
<mod>/tsconfig.json(extends the types) and<mod>/.claude-plugin/types/(own.gitignoreholding*) when a mod loads; todaymods/model-router/tsconfig.jsonshows as untracked. - settings.json carries the user's uncommitted
/modelchange: 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:Check first that no existing pattern ignores# mods/: files the engine lays beside a loaded mod (editor types) mods/*/tsconfig.json mods/*/.claude-plugin/types/skills/model-routeror 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:-<repo root from the script path>}".- Collect
$ROOT/mods/*/.claude-plugin/plugin.json; none → FAIL ("no mod found") so the suite can never pass vacuously. - Per mod dir
<name>: (1) the manifestname(python3 json, argv — never string-spliced) equals the folder name; (2)$ROOT/skills/<name>is a symlink whosereadlinkis exactly../mods/<name>; (3) whencommand -v claudesucceeds:claude plugin validate "$ROOT/mods/<name>"printsValidation passedand nowarning(case-insensitive); (4) same condition:claude plugin test "$ROOT/mods/<name>"exits 0. claudeabsent → oneSKIP: claude CLI not found — validate/test not runline; 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 -dif any scratch is needed (none expected).
doctor.sh— new section── Mods ──, placed right after the "Vendored skills" section (read lines 120-160 first; mirror itsecho ""/ heading / pass-warn-fail-info style). For each$REPO/mods/*/holding.claude-plugin/plugin.json(<name>= folder):- link
$HOME/.claude/skills/<name>:readlink -fequal to$REPO/mods/<name>→pass "mod <name>: loading link ~/.claude/skills/<name>"; missing →fail "mod <name>: ~/.claude/skills/<name> MISSING — git checkout skills/<name>, then make link"; elsewhere →warn. Do NOT callcheck_symlink(it feeds the core-link counter_LINK_PASS/_EXPECTED_LINKS). command -v claude→claude plugin list --jsonparsed with python3 (argv/stdin, no splicing): id<name>@skills-dirwithenabled: true→pass "mod <name>: loaded as <name>@skills-dir"; present but disabled →warn "... disabled (enabledPlugins \"<name>@skills-dir\": false)"; absent →warn "... not listed — new session or /reload-plugins".claudemissing →info "claude CLI not found — load state not checked".$HOME/.claude/<name>.jsonpresent →python3 -m json.tool(quiet) →pass "mod <name>: override ~/.claude/<name>.json parses"orfail "... invalid JSON"; absent → nothing.- No mod at all →
info "no mods".
- link
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 inmods/<name>/; it loads through the tracked relative symlinkskills/<name>→../mods/<name>as<name>@skills-dir(in place, live at the next session or/reload-plugins); why notCLAUDE_CODE_PLUGIN_DIRS(absolute path, settingsenvhas no$HOMEexpansion, settings.json is tracked) nor a local marketplace (itsaddwrites an absolute path into settings.json); engine-laidtsconfig.json+.claude-plugin/types/are gitignored; optional user config~/.claude/<name>.json; testsmake test suite=lib/tests/mods.test.sh(validate +claude plugin test); turn a mod off with"<name>@skills-dir": falseinenabledPlugins; a dev copy loaded with--plugin-diror 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.jsonalready exists on disk: after the.gitignorechange it must disappear fromgit status. - doctor runs without
claudeon PATH (Linux box): info line, no failure. - A second mod later: the suite and doctor loop over
mods/*/already. - A hot-reload or
--plugin-dircopy of the same mod shadows the skills-dir copy in that session; doctor readsclaude plugin listfrom 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. - honors the destructive-tools rule: no recursive delete, no transfer
tool; LRN-150/LRN-171 shell hygiene (
command grepwhere a shim can interfere is not needed here: plain bash).