routing.json (tracked, reached through the plugin directory) is now the
single source of the phase table and of every skill/agent row, plus the
decisions: confirmed rows/phases, changed rows (from/to) and projects
exceptions keyed by a normalized git remote (credentials never stored, no
machine paths). First use of a rowed typed skill, a rowed agent spawn or a
main-loop phase opens the engine's dialog (Later / Keep / two alternative
phases; Other = a phase name); a change asks Everywhere or This project
only. One dialog at a time, never in headless, never inside an agent,
never written by the model: only a dialog answer or /route ask writes,
serialized, size-capped, never creating the file. Layers: routing.json <
~/.claude/model-router.json; the project tree is never read. /route
pending, /route ask on|off. Census reads rows and phases from the file and
tolerates a user-changed row (WARN). Kit suite 86 → 190 tests.
Contract .claude/tasks/contracts/2026-10-10-model-router-w3a-confirm-1201.md,
plan r4: 3 lenses + 1 confirmation, feater + 4 rounds, GATE 0 MET,
verifier CONFORME then re-verify after security, security BLOCK(1) fixed
then PASS. Live: T2 dialogs answered by the user from the hot-loaded mod.
The 15 Skill(effort-*) citers now call mcp__model-router__route per phase
(orchestrate at a dispatch span, reflect/plan for the skill's own level,
apply at the bookkeeping tail, escalate at the verify-secure caps); built-in
judgment dispatches carry an explicit effort= param. lib/effort-shift.md is
the route doctrine, lib/model-gate.md the mod rule (route answer = witness,
/route on as remedy). Deleted: skills/effort-*, lib/effort-pins.txt/.sh,
lib/model-check.sh, their tests, the installers' re-apply blocks. The mod
drops its Skill(effort-*) bridge. The tracked model:/effort: frontmatter
stays as the off-state floor, census-locked equal to the rows
(lib/tests/effort-routing.test.sh rewritten, 140 checks; analyzer → xhigh).
Contract .claude/tasks/contracts/2026-10-10-model-router-w2b-1045.md, plan
r4 § W2-B: GATE 0 MET, verifier ECARTS(7) then CONFORME 10/10, security
PASS, full make test green (design-tool-gate env red only).
Rows by role replace the pins as the live source (frontmatter stays as the
off-state floor): phases write=work/high and apply=work/low, 56 skill rows,
21 agent rows + Explore/Plan. Agents get the row's model at spawn (within
the tier, upward only, explicit params win, project-defined agents skipped
via agent.offer) and its effort per step. A typed slash of a rowed skill
routes main through a name-bound marker (composer|sdk|bridge, pending slot
mid-turn) or the idle fallback; a best-tier row lives in a runMain slot
that survives turn end and route calls. An unrowed skill leaves the route.
Typed /effort-* floor code removed (bridge kept until W2-B). The route
answer always names the id. Override rows accept null. Kit suite 58 → 88.
Contract .claude/tasks/contracts/2026-10-09-model-router-w2a-1546.md, plan
r4 .claude/tasks/plans/2026-10-09-model-router-w2-1546.md: 3 lenses + 2
confirmations, feater + 4 rounds, GATE 0 MET, verifier 3x ECARTS on test
coverage only (user-accepted at the cap), security PASS.
A full make test printed only the == headers and an aggregate exit code,
so finding the red suite meant re-running every suite one by one (the
pre-merge check of 2026-10-09 took 7.5 min for that reason). The loop now
prints FAIL <suite> as it happens and ends with 'all suites green' or
'<n> suite(s) red: <names>'; the exit code is unchanged.
Phases name absolute tiers (best fable>opus>sonnet, big opus>fable>sonnet,
work sonnet>opus, cheap haiku>sonnet) resolved to the first available full
id; per-model circuit breaker fed by StopFailure kinds (rate_limit,
overloaded, billing_error, model_not_found) and PostModelSwitch auto, with
episode backoff 15→300 min, cleared by a user /model or /route reload and
kept across /clear; fallback chain fable→opus→sonnet→haiku with the effort
unchanged; main loop upgrades to a phase's tier by itself under a context
cap (fails closed on unknown usage), downgrades only with the switch on,
sticky within a turn; derived orchestrate on background dispatches;
prompt default rules (plan/reflect, Unicode guards, skipped on slash
commands, floor matches and mid-turn). 58 plugin tests.
Tracked relative symlink skills/model-router -> ../mods/model-router: Claude
Code loads the mod in place as model-router@skills-dir wherever link.sh
links ~/.claude/skills (no CLAUDE_CODE_PLUGIN_DIRS: absolute paths in the
tracked settings.json). Engine-laid mods/*/tsconfig.json gitignored.
lib/tests/mods.test.sh: manifest name, link target, claude plugin validate
and test per mod, capability-probed, time-bounded, SKIP with reason.
doctor.sh: fail-soft Mods section (link by -ef, one guarded plugin list).
CLAUDE.md: mods/ section (loading, per-machine enabled:false switch,
dev-copy shadowing, tests).
One decision helper (mainEffort) feeds the plan and every answer text;
per-axis precedence (sticky > turn route > floor > engine); a mid-turn
prompt floors the running turn and the next; per-machine kill switch
"enabled": false in ~/.claude/model-router.json, kept across /clear and
across a failed reload; typed /effort-<l> attested at prompt.submit so a
sub-agent preload cannot floor the main loop. 30 plugin tests.
Security-gate round on the wave 1-A mod: /route answers only a composer
origin; an in-agent route call can no longer change the agent's model
(effort only, model fixed at spawn); config patterns capped (200 chars,
4096-char scan), phase keys restricted, override file refused above 64 KB,
additionalProperties false on the tool schema; every .catch logs once per
session; post-next bookkeeping isolated. 14 plugin tests, verifier 11/11.
Function-hooks plugin under mods/model-router: routes effort (and, behind a
flag, the model) of every main-loop request, sets built-in sub-agents' model
at spawn with full ids, answers Skill(effort-*) itself (single writer, no
pairing rule), exposes the route tool and /route, validates the optional
~/.claude/model-router.json. 11 plugin tests, validate + tsc clean.
Contract .claude/tasks/contracts/2026-10-08-model-router-w1a-1533.md.
Run D3 of manual-push mode (BDR-114). With every reader now failing
closed, the skill prose stops saying the lib and hooks still push on an
invalid value:
- capitalize STEP 5C / STEP 6: the invalid outcome is split on the ahead
count (nothing pushed vs pushed anyway by a stale fail-open hook or a
manual push); the verb's stderr line is quoted verbatim; neighbouring
closing lines carry push-mode qualifiers so none shadows the invalid
case; the --no-push lines follow the same rule.
- client-handover: "COMMIT + PUSH" labels become "COMMIT + PUSH STATE
READ"; the STEP 5 residual sentences no longer imply the pipeline
pushes; the invalid value is named as a case where the user pushes.
- release-executor: prep span checks the version format by reading the
string (never in a Bash command); manual mode and an invalid value
both leave main/develop local.
Run D2 of manual-push mode (BDR-114).
- push-guard sources lib/gitflow.sh once (absolute path) and reads each
candidate dir through gitflow_push_mode; a missing lib denies.
- Dir tokens are extracted as whole shell words: a fully quoted token
(inner apostrophe allowed) is resolved, a backslash-escaped space is
unescaped deterministically, a token mixing quoted and unquoted parts
is refused (fail closed) instead of resolving to its parent.
- A payload jq cannot parse is scanned as raw text with its JSON escapes
folded; a push-looking one gets the static deny through the trap.
- The 20-token cap runs before any per-token classification (a flood of
20 000 tokens is refused in 0.13 s; T58 locks it under 5 s).
- `case "$mode"` has a deny default; missing core tools warn and allow.
- T42 compares the deny list against main (the last release) instead of
HEAD; literal-true, mixed-token, broken-payload, lib-missing and
banner-on-bad-value cases added (98 checks).
- session-start banner reads the mode through the verb and shows
`push : manual (autopush bad)` on an unparseable value.
- tour hints quote "<abs project>".
Run D1 of manual-push mode (BDR-114). `git config --bool --default true
gitflow.autopush` only covered a MISSING key: an unparseable value made
git die with empty output, the `= false` test failed, and every push ran
again. A typo on a work machine silently re-enabled the pushes it was
meant to stop.
- lib/gitflow.sh: `_gitflow_push_off` reads the mode through the lib
verb (`push-mode`); anything but `auto` is push-off, and the verb's
stderr line names an invalid value during start/finish.
- Emitted post-commit/post-merge hooks (POSIX sh, standalone): push only
when the key reads `true` or is unset; `false` exits quietly; any
other result prints one stderr line ("NOT pushed, treated as manual
push mode") and exits 0. Mirrors gitflow_push_mode.
- .githooks/ and githooks/ regenerated files-only through `emit-hook`
(no config read or write; .git/config hash unchanged).
- hooks/unpushed-guard.sh: mode from the lib verb (absolute lib path
resolved before any cd, no temp file); anything but auto is manual;
the SessionStart line names an invalid or unreadable value.
- Tests: gitflow-test T18q block (invalid → start, hook and finish push
nothing and say so; `true` → the hook pushes; emitted hook is
POSIX-clean), unpushed-guard T14 rewritten.