Skill Store 33

Agent Router Setup

Installs, configures or removes the agent-router hook (routes a generic Agent spawn to the real p…

by kochetkov-ma · MIT

The skill

Installs, configures or removes the agent-router hook (routes a generic Agent spawn to the real project/plugin expert). Triggers: agent-router, wrong agent, route to expert, роутер агентов, не тот агент.

Agent Router

EXPERIMENTAL. Installs one self-contained PreToolUse hook on the Agent tool. When the main loop spawns a generic agent although a real expert owns the task — a project agent from .claude/agents/, or an installed brewcode specialist — the hook denies the spawn and names the expert; an uncertain fit is only a nudge. Runtime behavior lives in the hook file and a JSON config; this skill resolves mode and level, then delegates the file work to the brewcode:hook-creator agent, which follows the runbook.

Prompt contract

$ARGUMENTS is one free-form RU/EN prompt; mode tokens and flags (--unattended) may sit anywhere in it.

  1. Mode: strip --unattended first: a flag, never a mode, level or name (Modes, Unattended). Then an explicit mode token wins outright; else the most distinct whole-word keyword hits (table in Step 2). Tie with a destructive mode (purge) or of install with a removal mode (uninstall) -> AskUserQuestion; with status -> status; of two other mutating modes -> the keyword that comes first. No hit or empty -> status.
  2. Scope: prose that is not a mode/id/path is still input: take the id, path or target from it.
  3. Ask: at most ONE AskUserQuestion (max 4 questions) to resolve mode and scope, before any work, only when the answer changes what gets written. A read-only run asks nothing. --unattended asks nothing: an open question there ends in RESULT skipped (Modes, Unattended).

Print once, before the first action (labels literal, values in the conversation language):

PLAN — brewtools:agent-router-setup
INPUT:  <arguments verbatim, or "(empty)">
MODE:   <resolved> — <explicit | matched keyword: X | default>
SCOPE:  <resolved paths / target / level / flags>
DO:     <2-5 imperative bullets>
RESULT: <what the user ends up holding>

What the hook does (informational — skill does NOT implement)

Tier hooks.PreToolUse entry (timeouts in seconds) Behavior
1 — always on matcher Agent: {"type":"command","command":"node","args":["<PD>/.claude/hooks/agent-router.mjs"],"timeout":5}, where <PD> is the dollar-brace CLAUDE_PROJECT_DIR token, stored unexpanded (written out here it would be replaced by a path at load) deterministic, <100 ms incl. node startup, zero tokens
2 — opt-in (level strict) matcher Agent: {"type":"agent","prompt":"<inlined judge-prompt.md>","timeout":30,"statusMessage":"agent-router: checking agent fit"}. No model key: Claude Code then runs its default small model (claude-haiku-5-5 on 2.1.296), and any explicit model is reported as drift an LLM judges a generic spawn against the project roster
heartbeat — with tier 2 matcher StructuredOutput: the tier-1 command handler again counts the judge's verdicts for --judge-health

Tier 1 decision order — it allows as early as it can:

# Check Result
1 tool is not Agent allow
2 agent_id present: a subagent's spawn, or a hook verifier (hook-agent-*) allow; only the main loop is policed
3 enabled:false, or a config file that exists but does not parse allow
4 the picked type is a project agent (.claude/agents/**/*.md, by frontmatter name) allow
5 the picked type is not on genericTypes allow. An omitted subagent_type counts as general-purpose (the Agent-tool default) and is policed
6 agent-router: override (also allow / skip) in the description or prompt allow, silently: the escape hatch, matched on the untruncated text before any rule and named in every deny message
7 STRONG intent: an authoring verb aimed at the artifact (skill -> brewcode:skill-creator, agent -> brewcode:agent-creator, hooks -> brewcode:hook-creator, bash/sh -> brewcode:bash-expert), not behind do not / never / how to / instead of deny naming the expert: the first ranked project agent that scores and covers the intent (its frontmatter matches the rule's domain regex), else the plugin specialist, and that one only when found on disk (project agents, user agents, plugin cache). Not found -> a notice, never a deny
8 roster scoring: every .claude/agents/**/*.md frontmatter (name + Triggers:), each agent's own name struck from the text unless it lists that name in Triggers: one clear winner (minScore + margin over the runner-up) -> deny naming it; several plausible -> additionalContext nudge with the top 3; nothing -> silent allow
9 WEAK intent: a bare artifact mention (SKILL.md, .claude/agents/, hooks.json, an event name, a shebang) never denies; nudges, merged with a step-8 nudge into one message
10 anti-loop guard a (session, project root, task) is denied at most once; the retry passes with a nudge. task = the description, or the prompt's first 300 normalized chars when there is none
11 any error fail open

neverFlag (types never flagged) defaults to eight entries: Explore, Plan, statusline-setup, output-style-setup and the four step-7 experts; normalizeConfig() adds every configured intents[].expert at load. There is no nudge-threshold key: the nudge floor is max(1, ceil(minScore/2)). Every condition and config key is in the runbook (## What it does, ## Config): read them before explaining a specific deny.

Honest limits (state these to the user, do not oversell)

Fact Consequence
Claude Code runs all hooks matching an event in parallel; no hook can skip another Tier 2 makes a model call on every Agent spawn; its own step-1 fast exit is the only cost control, tier 1 cannot gate it. Hence level fast is the default and recommendation.
Tier 2 cannot be made fail-closed A judge that times out or hits a model error silently allows the spawn; tier 1 still decides every spawn independently. --judge-health (printed by status at strict) shows that the judge stopped answering.
Tier 2 is narrower than tier 1 It judges general-purpose and worker only, redirects to project agents only, keeps no state, reads neither genericTypes nor neverFlag. It honors the escape hatch and enabled:false, and does not read the transcript.
No supported signal for "this tool call came from inside a Skill" Neither tier attempts it.
Tier 1 matches English trigger words, not meaning It errs toward allowing: an ambiguous case becomes a nudge, never a block.
Tier 1 denies for an expert only when it is found on disk A --plugin-dir plugin is not in the plugin cache, so its specialists get a notice; a cached but disabled plugin still gets the deny.
Every failure mode — bad config, unreadable roster, timeout, malformed output Fails open. A config that exists but does not parse turns the feature fully off, not back to defaults.
State (anti-loop markers; at strict the heartbeat) lives in <os.tmpdir()>/brewtools-agent-router/p-<sha1(real root)[0..16]>/ Tmp unusable (read-only, foreign-owned brewtools-agent-router/, sandbox) -> every deny degrades to a notice (an unrecorded deny could repeat forever). Say so when a user reports "it never blocks anything".
A deny is not a wall It is a tool error the model can act on: the human is never prompted, the retry passes, and every deny text ends with the escape hatch agent-router: override.
EXPERIMENTAL Opt-in (not registered in brewtools/hooks/hooks.json, so installing the plugin does nothing until this skill runs) and project scope only: the roster is per-project, so there is no global install and no scope question.

<instructions>

BT_ROOT Resolver (use in EVERY bash block)

CLAUDE_SKILL_DIR and CLAUDE_PLUGIN_ROOT are text substitutions Claude Code applies to this file before the model reads it, in the dollar-brace form only; they are not Bash-tool env vars, and an unbraced or :-fallback spelling reaches the shell unsubstituted.

BT_ROOT="${CLAUDE_PLUGIN_ROOT}"
test -d "$BT_ROOT/skills/agent-router-setup/assets" || { echo "❌ FAILED — BT_ROOT invalid: $BT_ROOT"; exit 1; }

Assets, all under $BT_ROOT/skills/agent-router-setup/assets/:

  • INSTALL.md — the runbook: install, level, config shape, disable/enable, uninstall, purge, verify. Single source of truth: follow its commands, never re-derive them here.
  • agent-router.mjs — the hook (tier 1 and the heartbeat), the only file copied into the project. The plugin's copy is also the installer CLI (--probe <settings.json>, --judge-health <root>, --purge-state <root>); --probe works only there, beside judge-prompt.md.
  • judge-prompt.md — the tier-2 judge prompt, inlined into settings.json, never copied

Never write to ~/.claude/*: a protected path, blocked in all modes, and a global roster does not exist.


Step 1 — STATUS FIRST, always

After the initial PLAN, probe status in every mode, before any installation, reinstallation or removal.

EXECUTE using Bash tool:

BT_ROOT="${CLAUDE_PLUGIN_ROOT}"
A="$BT_ROOT/skills/agent-router-setup/assets"
test -f "$A/INSTALL.md" && test -f "$A/agent-router.mjs" && test -f "$A/judge-prompt.md" || { echo "❌ FAILED — assets incomplete under BT_ROOT=$BT_ROOT"; exit 1; }
echo "ASSETS_DIR=$A"
echo "RUNBOOK=$A/INSTALL.md"
# rc 0: the dir is HOME or the active config dir's parent - its .claude is a profile, never a project root.
is_profile_parent() {
  _pp=$(cd "${1}" 2>/dev/null && pwd -P) || return 1
  [ -n "${HOME:-}" ] && [ "$_pp" = "$(cd "$HOME" 2>/dev/null && pwd -P)" ] && return 0
  [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "$_pp/.claude" = "$(cd "$CLAUDE_CONFIG_DIR" 2>/dev/null && pwd -P)" ]
}
claude_project_root() {
  PD="${CLAUDE_PROJECT_DIR}"   # substituted at load: the session start dir, as hooks get it
  if [ -n "$PD" ] && [ -d "$PD" ]; then
    printf '%s\n' "$PD"; return 0
  fi
  d=$PWD
  while [ "$d" != "/" ]; do
    if [ -f "$d/.claude/brewtools/agent-router.json" ] && ! is_profile_parent "$d"; then printf '%s\n' "$d"; return 0; fi
    d=$(dirname "$d")
  done
  if r=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$r" ]; then
    printf '%s\n' "$r"; return 0
  fi
  d=$PWD
  while [ "$d" != "/" ]; do
    if [ -d "$d/.claude" ] && ! is_profile_parent "$d"; then printf '%s\n' "$d"; return 0; fi
    d=$(dirname "$d")
  done
  printf '%s\n' "$PWD"; return 1
}
if ROOT=$(claude_project_root); then ROOT_OK=yes; else ROOT_OK=no; fi
echo "project_root=$ROOT root_resolved=$ROOT_OK"
D="$ROOT/.claude"
H=no; [ -f "$D/hooks/agent-router.mjs" ] && H=yes
REFS=$(node "$A/agent-router.mjs" --probe "$D/settings.json") || { echo "❌ FAILED — settings probe did not run"; exit 1; }
T1=${REFS%%|*}; REST=${REFS#*|}; LEGACY_T1=${REST%%|*}; REST=${REST#*|}; T2=${REST%%|*}; REST=${REST#*|}; HB=${REST%%|*}; REST=${REST#*|}; VALID=${REST%%|*}; WR=${REFS##*|}
CFG=none; [ -s "$D/brewtools/agent-router.json" ] && CFG=$(tr -d '\n ' < "$D/brewtools/agent-router.json"); CFG=${CFG:-none}
EN=n/a; case "$CFG" in *'"enabled":true'*) EN=true;; *'"enabled":false'*) EN=false;; esac
LV=n/a; case "$CFG" in *'"level":"strict"'*) LV=strict;; *'"level":"fast"'*) LV=fast;; esac
CV=$({ jq -r '.version // empty' "$D/brewtools/agent-router.json" 2>/dev/null || true; }); CV=${CV:-n/a}
PV=$({ jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true; }); PV=${PV:-n/a}
cv_of(){ { sed -n '1,3p' "${1}" 2>/dev/null || true; } | sed -n 's/.*content_version=\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1; }
HCV=$(cv_of "$D/hooks/agent-router.mjs"); HCV=${HCV:-n/a}          # INSTALLED hook body
TCV=$(cv_of "$A/agent-router.mjs"); TCV=${TCV:-n/a}                # template it was copied from
GCV=$({ jq -r '.content_version // empty' "$D/brewtools/agent-router.json" 2>/dev/null || true; }); GCV=${GCV:-n/a}
KCV=$TCV                                                           # config contract = the hook asset that parses it
RCV=$(cv_of "$A/INSTALL.md"); RCV=${RCV:-n/a}                      # informational: runbook prose never stales a config
STALE=n/a
if [ "$HCV" != n/a ] && [ "$TCV" != n/a ]; then [ "$HCV" = "$TCV" ] && STALE=no || STALE=yes; fi
if [ "$STALE" != yes ] && [ "$GCV" != n/a ] && [ "$KCV" != n/a ] && [ "$GCV" != "$KCV" ]; then STALE=yes; fi
R=$(node "$A/agent-router.mjs" --roster "$ROOT" 2>/dev/null) || R=n/a
echo "project: hook_file=$H tier1_refs=$T1 legacy_refs=$LEGACY_T1 tier2_refs=$T2 settings_valid=$VALID heartbeat_refs=$HB wrapped_refs=$WR enabled=$EN level_recorded=$LV roster=$R"
echo "content_version: hook=$HCV template=$TCV config=$GCV contract=$KCV runbook=$RCV stale=$STALE"
echo "version: config=$CV plugin=$PV"
echo "config=$CFG"
[ "$T2" = 0 ] || node "$A/agent-router.mjs" --judge-health "$ROOT" || true
echo "✅ status"

STOP if ❌ — plugin cache incomplete; reinstall/update brewtools first.

*_refs and settings_valid come from the plugin copy's agent-router.mjs --probe <settings.json> (tier1|legacy|tier2|heartbeat|valid|wrapped), the installer's own identity check. Do not paraphrase a field into something stronger:

Field Value
project_root / root_resolved the hook's own ladder (16 levels per step there): CLAUDE_PROJECT_DIR (substituted at load: the session start dir, the same value the hook gets), else the nearest ancestor holding .claude/brewtools/agent-router.json, else git toplevel, else the nearest holding a .claude dir, so a nested bare .claude cannot mask the owning root. root_resolved=no = read-only fallback to $PWD
hook_file yes/no — agent-router.mjs present in <repo>/.claude/hooks/
tier1_refs handlers exactly matching the tier-1 entry above; 1 = wired
tier2_refs handlers exactly matching the tier-2 entry above, prompt byte-equal to the current judge-prompt.md with its stamp line stripped on both sides (a meta-only bump stays current), no model key; 0 = tier 2 off, 1 = wired
heartbeat_refs handlers exactly matching the heartbeat entry; must equal tier2_refs
legacy_refs owned handlers off their exact shape: absolute path, wrong event/matcher/type/command/timeout, extra args/keys, stale or empty judge prompt, wrong status message, any explicit tier-2 model (installs up to 6.4.0 pinned one). Nonzero -> install/upgrade migrates
settings_valid yes only when settings are absent/empty or parse as a JSON object
wrapped_refs handlers naming agent-router.mjs (in command or args) launched by something other than node: the project's own launcher, e.g. "$CLAUDE_PROJECT_DIR/.claude/scripts/preflight.py" exec node -- ".../agent-router.mjs". Custom, user-owned, report-only: never legacy, never rewritten, and install/upgrade then add no command handler of their own (no unwrapped duplicate)
judge_expected / judge_answered / judge_overdue printed when tier2_refs is not 0: Agent calls seen at level strict / verdicts returned / verdicts expected over 90 s ago that never came (silent allows). judge_overdue>0, or judge_answered=0 with judge_expected>0 = registered but not answering. Counters cover ~24 h and include every type:"agent" hook verdict in the project
enabled from the config; n/a = no config or no such key
level_recorded the config's level: an install-time record, not proof of wiring; tier2_refs is the authority
content_version (hook / template / config / contract / runbook) the staleness signal: hook = the brewcode-meta: header of the installed .claude/hooks/agent-router.mjs, template = that header in the plugin's asset copy, config = the config's content_version key, contract = the config contract, the template CV (the hook is the one file that parses the config), runbook = the header of assets/INSTALL.md, informational only: a runbook-only change never stales a config. A difference on hook/template or config/contract -> stale=yes -> offer upgrade. n/a on a side (pre-5.6 artifact, or not installed) = unknown, not "current"
version / plugin the config's version key vs the installed brewtools version. Informational: every config write (enable/disable included) re-stamps it while the hook file stays old, so never decide staleness from it
roster number of project agents the hook loads (its own walk via --roster: recursive, up to the git root, files Claude Code skips excluded); 0 = roster scoring has no candidates, the four intent routes still work

Print the status in this table; when tier2_refs is not 0, add one sentence giving the three judge counters and what they mean:

Hook file portable tier1 legacy refs tier2 wired heartbeat wrapped settings valid enabled level (recorded) hook cv template cv stale roster

Custom = hook_file=yes tier1_refs=0 legacy_refs=0 wrapped_refs>=1 settings_valid=yes: the project wraps the hook in its own launcher. Say "registered through a custom launcher (report-only)", never offer install/upgrade for the registration: the launcher cannot be verified from here, and a reinstall adds no unwrapped copy beside it anyway.

Effective = hook_file=yes tier1_refs=1 legacy_refs=0 settings_valid=yes, tier 2 either off (tier2_refs=0 heartbeat_refs=0) or complete (tier2_refs=1 heartbeat_refs=1), and enabled anything but false: a missing config leaves tier 1 on with the built-in defaults, so enabled=n/a is effective. Not effective: a count above 1, any legacy_refs, invalid settings, or a judge without its heartbeat (its silent failures cannot be seen). stale=yes is effective but runs old logic: say "installed, stale — run upgrade".

level_recorded=strict with tier2_refs=0 = judge not wired: report the mismatch, offer level strict or level fast. Editing the config value wires nothing.

Config metadata (the four standard JSON keys)

Every mode that writes agent-router.json (install, upgrade, enable, disable, both level operations) writes these four keys beside the behavior keys; doc_type is a .md-frontmatter field and never appears in JSON:

{ "version": "{PLUGIN_VERSION}", "content_version": "<agent-router.mjs header>", "generated_by": "brewtools:agent-router-setup", "last_updated": "{LAST_UPDATED}" }

content_version is not passed in: the runbook's node blocks read it from the brewcode-meta: header of agent-router.mjs beside $RUNBOOK (the config contract, so a runbook-only change never stales a config) and abort if unreadable. Resolve only version and last_updated here, never hardcode. EXECUTE using Bash tool:

BT_ROOT="${CLAUDE_PLUGIN_ROOT}"
[ -f "$BT_ROOT/.claude-plugin/plugin.json" ] || { echo "ERROR: brewtools plugin root not resolved: '$BT_ROOT'"; exit 1; }
PV=$(jq -r '.version // empty' "$BT_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true)
PV=${PV:-$(basename "$BT_ROOT")}
echo "PLUGIN_VERSION=$PV LAST_UPDATED=$(date +%F)"

The hook reads only the config keys it names, so these four cannot change routing.

Early exit

Already installed and the intent is not explicit (no argument, or vague like "роутер агентов"): print the status, list the operations (upgrade, enable, disable, uninstall, purge, level fast|strict) and stop: no re-install, no questions.

Step 2 — Decide MODE

Mode EN keywords RU keywords Mutates?
status (empty), status статус, проверь, что стоит no
install install, set up поставь, установи, включи роутер yes
upgrade upgrade, update, refresh обнови, перевыстави, после обновления плагина yes
enable enable включи обратно, верни yes
disable disable выключи, отключи, паузу yes
uninstall uninstall убери, сними, удали хук yes
purge purge, wipe, remove everything вычисти всё, удали полностью, снеси yes, destructive
level fast | level strict (extra) level, fast, strict дешёвый, строгий, с LLM, без LLM yes

Ties resolve per Prompt contract rule 1. Never ask scope (project only). Step 4 asks a missing level.

Step 3 — Explain pending changes before questions

Add concrete state/action details to the initial PLAN without repeating its block. Example:

Current state: agent-router not installed; .claude/agents/ holds 4 project agents. Plan: copy agent-router.mjs into <repo>/.claude/hooks/, write <repo>/.claude/brewtools/agent-router.json with level: "fast", merge one PreToolUse (Agent) entry into <repo>/.claude/settings.json. Project scope only. One question first: level.

If roster=0, say so first: only the 4 intent rules can fire, step 8 has nothing to score. Offer to stop.

Step 4 — Ask ONLY what is missing (AskUserQuestion)

Skip any question already answered by the prompt or settled by the status table.

# Question Options Default
1 Level? fast — tier 1 only, deterministic, zero tokens (Recommended) / strict — adds an LLM judge on every Agent spawn fast

When strict is requested or asked about, state its cost and limit before writing anything (option description or plan, not only the final report): Claude Code runs all matching hooks in parallel, so tier 1 cannot gate tier 2. Strict makes a model call on every Agent spawn, and the judge's own fast exit is the only cost control. A judge that times out or hits a model error allows the spawn silently.

Nothing else is asked: disable/enable ask nothing, uninstall/purge only the deletion confirmation of Step 5.

Step 5 — Act on the resolved PLAN

Use the PLAN printed before Step 1; do not print it twice. Resolve MODE/SCOPE, exact paths, level and settings.json entries before mutating. uninstall/purge: list the exact deletions and confirm once. Status and early exit report the table and stop; DO: is "read state, report".

Delegation

One mode is one bounded unit (1 asset file + one settings.json + one config): one hook-creator spawn per mode.

Every spawn brief carries six labeled fields, since a subagent starts with an empty context: GOAL (task + why), ROLE (owns, must not touch), SCOPE (paths/commands in and out), CONTEXT (done, by whom, in parallel), CONSUMER (who uses the result + its shape), DONE (acceptance + return: verdict first, evidence, bounded; long output to a file, path returned).

Spawn: substitute MODE, LEVEL, RUNBOOK, ASSETS_DIR, PROJECT_ROOT, PLUGIN_VERSION, LAST_UPDATED from Steps 1-4 and the Config-metadata block into both the CONTEXT block and the literal export line. The runbook reads LEVEL/RUNBOOK from process.env, which prose cannot set; an empty LEVEL aborts config and merge (no silent fast fallback). PROJECT_ROOT = Step 1 project_root: the runbook's root block takes CLAUDE_PROJECT_DIR first and the Bash tool does not have it; with root_resolved=no, drop it from the export line. Afterwards check the reported config for the chosen level.

Agent(subagent_type="brewcode:hook-creator", prompt="
GOAL: the user wants the agent-router hook MODE-ed for THIS project. One PreToolUse hook
(matcher Agent) checks whether the main loop picked the right agent for a spawn and denies
with the name of the real expert when it reached for a generic one. Runtime behavior lives
entirely in agent-router.mjs and agent-router.json, so this task is pure file + settings +
config wiring.
ROLE: you own the file copy/removal, the settings.json merge/strip and the config write.
Do NOT edit hook logic, do NOT touch judge-prompt.md, do NOT touch unrelated hooks or
settings keys, do NOT touch ~/.claude (this skill is project-scope only), do NOT register
anything in the plugin's own hooks.json.
SCOPE: in — the assets under ASSETS_DIR, <repo>/.claude/hooks/, <repo>/.claude/settings.json,
<repo>/.claude/brewtools/agent-router.json. Out — everything else. Project paths: Write/Edit
are fine, but use the runbook's node blocks for settings.json and the config, never a hand Edit.
CONTEXT:
  Status was already collected and every path below resolved; nothing has been written yet.
  MODE = MODE (install|upgrade|enable|disable|uninstall|purge|level)
  LEVEL = LEVEL (fast|strict — required for install, upgrade and level; ignored by the rest.
    For upgrade it is the level ALREADY in the config, never a new choice)
  RUNBOOK = RUNBOOK (absolute path to assets/INSTALL.md)
  ASSETS_DIR = ASSETS_DIR (absolute path to the assets source dir — copy agent-router.mjs FROM here)
  MANDATORY FIRST BASH COMMAND — the runbook's node blocks read these from the ENVIRONMENT,
  not from this prompt. Run this VERBATIM as the first line of EVERY Bash call that executes
  a runbook block (a new Bash call does NOT inherit exports from the previous one).
  MODE=upgrade runs the 'UPGRADE' section, which is the INSTALL blocks replayed with the
  level read back from the existing config — never a level the user did not pick:
    export RUNBOOK='RUNBOOK' LEVEL='LEVEL' PLUGIN_VERSION='PLUGIN_VERSION' LAST_UPDATED='LAST_UPDATED' CLAUDE_PROJECT_DIR='PROJECT_ROOT'
  Then verify before writing anything:
    echo \"LEVEL=\$LEVEL RUNBOOK=\$RUNBOOK PV=\$PLUGIN_VERSION LU=\$LAST_UPDATED PD=\$CLAUDE_PROJECT_DIR\"
  If LEVEL prints empty, STOP and report — the config and merge blocks ABORT on an empty
  LEVEL by design; re-export it rather than hardcoding a value.
  Follow the runbook at RUNBOOK exactly and use ITS commands — it self-locates its source via
  SRC=\$(dirname \"\$RUNBOOK\"). Sections map 1:1 to MODE: 'INSTALL', 'UPGRADE', 'LEVEL', 'DISABLE /
  ENABLE', 'UNINSTALL', 'PURGE'. Merge = strip owned handlers individually while preserving foreign
  co-handlers, then append exactly one portable tier-1 handler whose arg is the unexpanded
  dollar-brace CLAUDE_PROJECT_DIR token + /.claude/hooks/agent-router.mjs, exactly as the runbook
  block writes it (idempotent); legacy absolute checkout args are removed, and
  the tier-2 entry (ASSETS_DIR/judge-prompt.md inlined, no model key) plus its StructuredOutput
  heartbeat entry are re-derived from LEVEL: both at strict, neither at fast.
  Uninstall = strip owned handlers (tier-1 and heartbeat by basename, tier-2 by statusMessage), drop
  only empty entries/event arrays, delete agent-router.mjs, KEEP the config. Purge = uninstall +
  delete the config + the runbook's --purge-state call, which removes this project's tmp state only.
  METADATA: every mode that WRITES the config (install, upgrade, enable, disable, level) must
  leave these four keys in agent-router.json:
  version=\$PLUGIN_VERSION, content_version=<read by the runbook block from the brewcode-meta
  header of agent-router.mjs beside \$RUNBOOK — never passed in, never hardcoded>, generated_by=\"brewtools:agent-router-setup\",
  last_updated=\$LAST_UPDATED. No doc_type — it is a .md-frontmatter field and never belongs
  in a JSON carrier. Set them INSIDE the runbook's node block that re-serializes the
  object with JSON.stringify — never by appending text to the file. An unparseable config
  silently disables the whole feature, so a hand-edited append is a defect, not a shortcut.
  Do NOT touch enabled or level while doing it: enabled is off only when exactly false, and
  level is a record of what is wired.
CONSUMER: Step 6 reports your result to the user; Claude Code watches settings.json and
  applies your write to the running session, so a malformed merge breaks hooks there instead
  of failing here — report the exact paths you touched so they can be checked.
DONE: report the settings.json path, the hooks dir, the config path with its final contents,
  and the runbook 'Verify' output if you ran it. The reported config MUST show
  level = LEVEL — a 'fast' where the user asked for 'strict' is a FAILURE, not a detail —
  and version = \$PLUGIN_VERSION plus a non-empty content_version. For install/upgrade also
  report the content_version of the hook file you COPIED (head -2 of the installed
  .claude/hooks/agent-router.mjs) — status keys staleness on it. Prove the config still
  parses: jq . <config path>.
")

Step 6 — Final status

Re-run the Step 1 status block and print the refreshed table, plus:

  • what changed (file, settings.json, config values);
  • hook wiring changes (install / upgrade / level / uninstall / purge; the tier-2 and heartbeat entries are wiring) apply to the running session, no restart: Claude Code watches settings files; /hooks confirms. /reload-plugins does not apply: this is a plain settings.json hook;
  • config value changes (enabled, genericTypes, neverFlag, minScore, margin, intents) are read live, no restart. level is the exception: it is only the record Step 1 describes;
  • whether stale flipped to no: only a re-copied hook file clears it, a version bump proves nothing;
  • the honest limits, at minimum: tier 2 costs a model call per Agent spawn and allows silently on judge timeout/error, tier 1 matches words not meaning, everything fails open.

Modes

Mode Effect Hook file settings.json Config tmp state
status report only — — — —
install wire tier 1 (+ tier 2 and its heartbeat if strict) copied entries merged written —
upgrade re-emit from the current plugin version at the already-configured level re-copied entries re-merged behavior values preserved, metadata re-stamped kept
enable enabled:true kept kept edited kept
disable enabled:false — hook stays wired, becomes a no-op kept kept edited kept
uninstall unwire deleted entries stripped kept kept
purge full wipe of this project deleted entries stripped deleted this project's dir deleted (--purge-state <root>); other projects' state stays
level fast (extra) drop the tier-2 and heartbeat entries kept both stripped level:"fast" kept
level strict (extra) add the tier-2 entry (judge prompt inlined) and the heartbeat entry kept both appended level:"strict" kept

upgrade asks nothing: it reads level from the existing config and replays the install (fresh agent-router.mjs, freshly inlined judge prompt). Not installed -> it is an install, so ask the level question.

Unattended (--unattended anywhere in the prompt; set by /brewcode:setup-status upgrade).

  1. A flag, never a mode or name: strip it first. Any resolved mode other than upgrade -> no write, RESULT skipped <id> cv=?->? pending=0 note="not-upgrade".
  2. No AskUserQuestion at all. Every gate in this section takes its decline branch; a declined gate is pending, never applied.
  3. A precondition this section would ask about (not installed, parked, custom/wrapped registration, orphan config, user-modified managed file, missing tokenizer) -> no write, RESULT skipped <id> cv=?->? pending=0 note="<reason>".
  4. Write only what this section already writes. No git commit, push, stash or reset; scratch only in a private mktemp -d.
  5. Last line, exactly once: RESULT <ok|partial|skipped|failed> <id> cv=<before>-><after> pending=<n> note="<one line>", <id> = <plugin>:<skill> [ scope=<project|global>| team=<name>], ? = unknown version.

Here <id> = brewtools:agent-router-setup (project only: no scope part, a passed scope token is ignored):

  • Step 1 decides, first hit wins, before the Smoke Test: config=none, or hook_file=no with every *_refs count 0 (uninstall keeps the config; the replay would undo it) -> skipped, note not installed; wrapped_refs not 0 (Custom above included) -> skipped, note custom launcher; settings_valid=no -> skipped, note settings.json invalid; level_recorded=strict with tier2_refs=0 -> skipped, note level mismatch: strict recorded, judge not wired (the replay would wire a paid judge that status asks about).
  • Otherwise Steps 3-4 are skipped: Smoke Test, then the one hook-creator spawn with MODE=upgrade. The read-back ABORT: (UPGRADE step 1, nothing written yet) -> skipped, its text as note; an ABORT: from a later block (the hook copy already ran), a ❌ in Step 1 or the Smoke Test, or a failed spawn -> failed.
  • Step 6 status: stale=no -> ok, else failed. cv = content_version config= before -> after (n/a -> ?); pending=0 (this upgrade has no gate); a kept enabled=false is named in note.

Upgrading an install from brewtools 6.4.0 or older changes settings.json at level strict: the tier-2 handler loses its model key (a pinned ID ages out, an omitted one cannot) and the heartbeat entry appears. At level fast only the hook file changes. Old anti-loop markers are not migrated: one already-denied task per live session can be denied once more.

Re-install is idempotent, not inert: the merge converges to the same entries, but the copy always overwrites agent-router.mjs with the current asset, which repairs a stale=yes install. Never talk a user out of it.

Guards

Condition Response
$BT_ROOT/skills/agent-router-setup/assets missing ERROR: agent-router: assets not found under $BT_ROOT — plugin cache incomplete. STOP.
BT_ROOT empty or without .claude-plugin/plugin.json ERROR: agent-router: cannot locate plugin root — install/update brewtools first. STOP.
stale=yes (hook content_version != template, or config != contract) Report it together with "installed": the project runs an old hook body. Recommend upgrade (re-copies the file, re-stamps the config).
User asks for a global install Refuse and explain: the roster is per-project, ~/.claude/* is protected, and a global hook would route every repo against one repo's agents. Offer the project install.
User wants to add or change an intent route Warn first: a config intents array replaces the built-in four wholesale (no merge, no warning when three routes vanish), so copy DEFAULT_INTENTS out of agent-router.mjs and append. An entry needs label + expert + match (STRONG, may deny) and may carry weakMatch (nudge only) and domain (noun-only regex deciding which project agent covers the intent; falls back to match|weakMatch). An entry whose match does not compile is skipped entirely, weak side included. Install never writes the key.
Existing config is malformed JSON Report it: the runbook aborts rather than overwriting it blind, and the hook fails open (every spawn allowed) until it is fixed. Offer to rewrite.
User reports a spawn being blocked repeatedly Tier 1 denies a (session, project, task description) at most once, even when the retry rewrites the prompt — so a repeat means a different description each time, or tier 2 (no state, can deny the same task again). Collect the deny text, check tier2_refs. Immediate unblock: agent-router: override (or allow / skip); both tiers honor it.
User says a wrong expert was named Both deny rules fire on coverage, not score (steps 7-8): that agent really describes the domain. Fix its description/Triggers:, or add domain to a custom intents entry; agent-router: override unblocks meanwhile.

Smoke Test

Verify the 3 assets exist and the hook parses before delegating.

EXECUTE using Bash tool:

BT_ROOT="${CLAUDE_PLUGIN_ROOT}"
A="$BT_ROOT/skills/agent-router-setup/assets"
test -d "$A" || { echo "❌ smoke FAILED — assets dir missing: $A"; exit 1; }
for f in agent-router.mjs judge-prompt.md INSTALL.md; do
  test -f "$A/$f" || { echo "❌ smoke FAILED — missing $f"; exit 1; }
done
test -s "$A/judge-prompt.md" || { echo "❌ smoke FAILED — judge-prompt.md is empty (it is inlined into settings.json)"; exit 1; }
node --check "$A/agent-router.mjs" && echo "✅ smoke" || echo "❌ smoke FAILED — syntax error in the hook file"

STOP if ❌ — do NOT delegate; reinstall/update brewtools first.

node --check proves only that the file parses. Behavioral verification is the runbook's Verify section; tests/run.sh is not run here.

</instructions>

Use this skill

Connect your agent to the Skill Store MCP server and ask for it by ref: kochetkov-ma/agent-router-setup-99898027470.

Download SKILL.md · Markdown version

Details

Publisher
kochetkov-ma
Category
AI & Agents › Orchestration & multi-agent
License
MIT
Updated
2026-10-17

References

More from kochetkov-ma