chore(tasks): model-router W1-B split — floor + wiring contracts/plans, skills-dir loading decision, journal
This commit is contained in:
@@ -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-<l>. 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.
|
||||
|
||||
@@ -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-<l>` = 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
|
||||
|
||||
|
||||
@@ -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|<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
|
||||
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.
|
||||
|
||||
## FILE SCOPE
|
||||
mods/model-router/hooks/register.ts · mods/model-router/hooks/register.test.ts
|
||||
@@ -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/<name>` = relative symlink `../mods/<name>`, tracked in git; loads as `<name>@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/<name>.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 `<name>@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/<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
|
||||
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
|
||||
@@ -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-<l>`
|
||||
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-<l>): 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
|
||||
<level> 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: <skill>
|
||||
recorded, but the user's floor <f> for this turn keeps main at <f>;
|
||||
the <skill> 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 `<f> (user
|
||||
floor; asked <asked>)`.
|
||||
10. Display: `mainText` appends ` · floor <f> (<phase>)` when `turnFloor` is
|
||||
set (also after `main: session defaults`); `statusLine` appends
|
||||
` · floor <f>`.
|
||||
|
||||
## 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.
|
||||
@@ -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/<name>` → `../mods/<name>`; Claude Code
|
||||
loads it as `<name>@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-<l>` 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
|
||||
|
||||
@@ -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 `<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/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 `<mod>/tsconfig.json` (extends the types) and
|
||||
`<mod>/.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:-<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 manifest `name` (python3 json, argv —
|
||||
never string-spliced) equals the folder name; (2) `$ROOT/skills/<name>`
|
||||
is a symlink whose `readlink` is exactly `../mods/<name>`; (3) when
|
||||
`command -v claude` succeeds: `claude plugin validate "$ROOT/mods/<name>"`
|
||||
prints `Validation passed` and no `warning` (case-insensitive);
|
||||
(4) same condition: `claude plugin test "$ROOT/mods/<name>"` 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` (`<name>` = folder):
|
||||
- link `$HOME/.claude/skills/<name>`: `readlink -f` equal 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 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 `<name>@skills-dir` with `enabled: 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"`.
|
||||
`claude` missing → `info "claude CLI not found — load state not checked"`.
|
||||
- `$HOME/.claude/<name>.json` present → `python3 -m json.tool` (quiet)
|
||||
→ `pass "mod <name>: override ~/.claude/<name>.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/<name>/`; it loads through the tracked relative symlink
|
||||
`skills/<name>` → `../mods/<name>` as `<name>@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/<name>.json`; tests `make test suite=lib/tests/mods.test.sh`
|
||||
(validate + `claude plugin test`); turn a mod off with
|
||||
`"<name>@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).
|
||||
Reference in New Issue
Block a user