chore(tasks): model-router W1-B split — floor + wiring contracts/plans, skills-dir loading decision, journal

This commit is contained in:
bchanot
2026-10-08 18:37:32 +02:00
parent ae0179f491
commit 77ad7cf494
7 changed files with 316 additions and 1 deletions
@@ -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).