# Stack skill routing  -  letting the toolkit plugin choose its own skills

<!-- toc -->
- [Why this exists](#why-this-exists)
- [When it runs](#when-it-runs)
- [Resolution](#resolution)
- [The call](#the-call)
- [Recording  -  what makes this checkable](#recording---what-makes-this-checkable)
- [Failure modes, and why none of them halt](#failure-modes-and-why-none-of-them-halt)
- [What this deliberately does NOT do](#what-this-deliberately-does-not-do)
<!-- /toc -->

> **TLDR**  -  Phase 2 (Dev) asks each kept toolkit plugin's own `index` skill which of its skills apply to this task, loads them before writing code, and records each into `state.telemetry.skillCalls[]`. The routing table lives in the plugin; the pipeline copies none of it, and does not keep a stack-to-plugin table either - it reads the enabled set, drops inherited toolkits that contradict the repo's detected stack, and puts the repo's own `.claude/skills` ahead of both. The step runs for every task; with no toolkit it is a recorded no-op.
>
> The same routing applies outside a run: `rules/outside-the-pipeline.md` carries it for ordinary sessions. One discipline, two callers; only the recording is run-specific.

## Why this exists

Component dispatch (`component-dispatch.md`) covers exactly one case, `taskType === "component"`. Without this step every other task  -  `bugfix`, `feature`, `refactor`, `chore`  -  would have no skill dispatch at all: whichever skills the host surfaced by description match would be the ones used, and nothing would record or require any of them.

That is the dev-side half of the gap `features/skill-conformance.md` closes on the review side. Review asks "was this built to the rules it was supposed to follow"; that question only has an answer when some rules were chosen.

The fix is not a routing table in the pipeline. Each `ai-<platform>-toolkit` already ships one: an `index` skill whose description says *"Load this first when unsure which skill applies"*, holding a 30-plus row intent-to-skill map maintained alongside the skills it points at. A second copy in this repo would drift the moment the plugin shipped a new skill, and the pipeline's copy would be the stale one.

So the pipeline's job is to **ask**, not to know.

## When it runs

Phase 2 (Dev) pre-flight, before any code is written, for **every** `taskType` and every run, whether or not a toolkit is enabled: the unattended marketplace fallback (step 4 below) exists precisely for the run that has none. Component tasks keep their dedicated dispatch in `component-dispatch.md`; this step runs in addition, because the reference skills (architecture, naming, file placement, tokens) apply to a component build too.

## Resolution

**Read the enabled set, filter it by the repo's stack; do not keep a stack table.**
Candidates come from one resolver, run before the index call. The task text goes
through a file, never through a shell string: write the task title and one-line
intent to `$TASK_FILE` with the Write tool, then

```bash
node $HOME/.claude/scripts/skill-candidates.mjs resolve --dir "$PROJECT_ROOT" \
  --state "$STATE_FILE" --task-file "$TASK_FILE"
```

`--task -` reads the same text from stdin. `--task "<text>"` still takes a literal,
but ticket titles are untrusted input and a literal puts them inside a shell
command, so no pipeline document uses it.

Pass `--state "$STATE_FILE"` inside a run so autopilot is recognised. It prints JSON
with `mode`, `toolkits[]` (kept, each with `role`, `reason`, `source`, `version`),
`excluded[]` (each with the reason it was dropped), `unscoped[]`, `hints[]`,
`fallbacks[]`, `repoSkills[]`, `rejectedRepoSkills[]`, `repoMatches[]` (when a task
is given), `overlaps[]`, `decisions[]` and `detection`. Write `mode`, `toolkits`,
`excluded`, `unscoped`, `hints` and `fallbacks` to `state.telemetry.skillRouting` as
they are, so "none applied" is distinguishable from "never looked", an exclusion can
be traced to its cause, and Phase 5 can report it.

1. **Enabled set.** The effective `enabledPlugins` is read from four layers, widest
   first, the later layer winning per plugin, and `false` removes a plugin:
   the user's `settings.json`, the user's `settings.local.json` (both under
   `CLAUDE_CONFIG_DIR`, else `~/.claude`), the repo's `.claude/settings.json`, and the
   repo's `.claude/settings.local.json`. The two user layers are *inherited*
   (`source: "global"` / `"global-local"`); the repo layers are the repo's own
   choice (`"repo"` / `"repo-local"`). Managed (enterprise) settings are not read.
   `/multi-agent:stack` is what writes the repo layers. Only toolkits are candidates:
   a plugin named `*-toolkit`, or one that ships a `skills/index/SKILL.md`.
2. **Stack filter, inherited toolkits only.** A repo that never ran
   `/multi-agent:stack` inherits the user-level set, which was chosen for some other
   repo. So a toolkit from a user layer is checked against the stacks detected in
   this repo (`lib/stack-detect.sh` plus `schemas/stack-adapters.json`, the same
   detection the build and test adapters use). A toolkit's stacks are read from its
   own name (`ai-<words>-toolkit`) and any `stack` / `stacks` / `keywords` in its
   `plugin.json`, matched against the stack words the pipeline already knows: adapter
   ids, the coarse stacks, and the aliases in `_stack-routing.mjs` (`web` and
   `frontend`, `mobile` for iOS plus Android). No list of stacks lives in the resolver,
   so a new `ai-<x>-toolkit` whose `<x>` is an adapter id is filtered correctly with no
   code change.
   - A toolkit whose stack words intersect the repo's is kept with `role: "stack"`.
     Multi-stack repos (iOS plus a backend, Android plus web views, monorepos) keep
     every matching toolkit.
   - A toolkit whose name marks another stack only (`-ios-` in a Gradle-only repo) is
     excluded, and `excluded[].reason` names both sides and the detector's evidence.
   - An inherited toolkit whose name and `plugin.json` mark **no** stack (a vendor
     utility plugin) says nothing about this repo. It is kept with `role: "common"`
     only when it ships an `index`; otherwise it goes to `unscoped[]` and is not
     routed to. An unattended or autopilot run never loads an `unscoped[]` toolkit;
     an attended run loads one only when the user names it.
   - A toolkit enabled in the repo's own settings is never filtered and has
     `role: "stack"` (`"common"` for the always-on pair). That is an explicit per-repo
     choice, and detection does not override it.
   - Inconclusive or empty detection applies no filter to toolkits that mark a stack,
     and `detection.filter` says why.
   - Detection counts a marker below the repo root (a `Package.swift` two levels
     down) only when that stack's language is at least 1% of the tracked source
     files (`git ls-files`, from 50 tracked source files up). A subtree copied into a
     Kotlin app, with one `.swift` file against thousands of `.kt`, is therefore not
     iOS, and `detection.why` names the ignored marker. A root marker always counts.
3. **Always-on.** `ai-common-toolkit` and `ai-analyst-toolkit` are never filtered and
   have `role: "common"`; neither is stack-specific.
4. **Hint, never a substitute.** When a detected stack has no kept toolkit, `hints[]`
   carries `enable ai-<stack>-toolkit with /multi-agent:stack <stack>`. What happens
   next depends on `mode`, which the resolver takes from `lib/unattended.mjs`, the
   helper the rest of the pipeline uses (`MULTI_AGENT_UNATTENDED=1` is `unattended`;
   otherwise `--state` with `autopilot: true` is `autopilot`; otherwise `attended`):
   - **Attended**: show the hint once in the run log and continue with what is enabled.
   - **Unattended or autopilot**: nobody can run `/multi-agent:stack`, and the
     unattended policy forbids writing `.claude/` inside the repo. `fallbacks[]` then
     names a local marketplace clone that carries the toolkit. Clones are found from
     the host's `known_marketplaces.json` and, in addition, by scanning
     `~/.claude/plugins/marketplaces/`; the plugin directory comes from each clone's
     `marketplace.json`. Load its `index` (the `index` path) and the skills it routes
     to **read-only** from `skillsDir`; never enable the plugin and
     never write any settings file. Record each loaded skill with `source: "marketplace-fallback"`,
     `toolkit` and `version` (from that clone's `plugin.json`; `marketplaceSkillCall()`
     builds the entry), and put the entry's `reportLine` in the run report so the user
     sees that the repo should enable the stack. When no clone carries it, the entry
     has `gap` instead: record it and continue. The run never blocks on this.
   - **Both modes**: never route the task to another stack's toolkit instead.
5. **Repo-local skills.** `<repo>/.claude/skills/<name>/SKILL.md` are candidates
   too. The resolver reads each one's `name` and `description`, and marks it
   explicit-only when its frontmatter has `disable-model-invocation: true`, its
   description asks for explicit invocation, or a line of the repo's CLAUDE.md that
   names it says so ("only on explicit request"). Route them by the same intent
   decision the index step makes, on the task title plus intent: `repoMatches[]` is
   a word-overlap pre-ranking to start from, not the final word. An explicit-only
   skill is loaded only when the task invokes it by name: `/name`, `` `name` `` or
   "skill name". The bare word is not enough, so a skill called `release` does not
   fire on "Fix crash in release notes".
6. **Real paths.** A repo `SKILL.md` (or its directory) that is a symlink resolving
   outside the repo is not read; it is listed in `rejectedRepoSkills[]`. A
   marketplace plugin directory, its `skills/` or its `index` that resolves outside
   its clone is treated as absent, so the fallback records a `gap`.

### Precedence

When two candidates cover the same ground, the first wins:

1. **Repo-local skill** (`.claude/skills/<name>`) - the repo's own rule. When it has
   the same name as a toolkit skill, it replaces that skill for this repo, and
   `decisions[]` records which toolkit skill it shadowed.
2. **Detected-stack toolkit skill** - whatever a `role: "stack"` toolkit's `index`
   routes to.
3. **Common toolkit** (`ai-common-toolkit`, then `ai-analyst-toolkit`, then any
   stackless toolkit kept in the common role).

Two kept toolkits can ship a skill of the same name, typically a public toolkit and a
vendor variant for the same stack. The resolver picks one owner per overlapping name
with one deterministic rule, and records each choice in `overlaps[]`
(`skill`, `winner`, `losers`, `rule`) and as a line in `decisions[]`:

1. a repo-local skill of that name wins over every toolkit;
2. otherwise the `stack` role wins over the `common` role;
3. otherwise the toolkit enabled at the **narrowest scope** wins:
   repo `settings.local.json`, then repo `settings.json`, then user
   `settings.local.json`, then user `settings.json`;
4. a remaining tie goes to a toolkit the pipeline itself publishes, so an outside toolkit never wins a tie by how its name sorts; between two outside toolkits, the name that sorts first.

Each toolkit's own `index` and `help` are its entry points, not competing skills, and
are never counted as an overlap. When the index of the losing toolkit routes to an
overlapping name, load the winner's skill of that name instead. The rule does not try
to judge which variant knows more about a topic; a repo that wants the other variant
enables it at a narrower scope.

No flow requires a particular toolkit. A corporate or otherwise differently-named
variant that is enabled is one more candidate, kept only if its name matches the
repo's stack like any other; it is never required, and its absence is never an
error. Two marketplaces may ship the same toolkit name: resolve whichever is enabled
and record its **name and version** (the resolver reads both from the host's
installed-plugin record), because the routing table and the skill set differ between
versions and a finding that cites a skill has to be traceable to the version that
defined it.

Nothing enabled is not an error: a repo whose stack was never selected legitimately
has no toolkit. Record the no-op with the enabled set that was read.

**Not enabled is not an error here**, unlike component dispatch: halting would make
the pipeline unusable in a repo whose stack was never selected. Record the no-op and
the hint, and continue.

## The call

```text
Skill(<toolkit>:index, args: "<task title + one-line intent>")
```

The index returns which `reference/` and `workflow/` skills apply. Load each via the Skill tool before writing code. A typical task pulls one workflow skill plus one or more reference skills.

Emit one progress line per loaded skill per `progress-contract.md`, so the user can see which standards the run bound itself to rather than inferring it afterwards.

## Recording  -  what makes this checkable

Append one `state.telemetry.skillCalls[]` entry per skill actually loaded, with `phase: 2`. A toolkit skill:

```json
{"skill": "ai-ios-toolkit:reference/architecture", "phase": 2,
 "targetFiles": ["Sources/Feature/FeatureScene.swift"],
 "routedBy": "ai-ios-toolkit:index@0.13.0", "timestamp": "<ISO-8601>"}
```

A repo-local skill carries `source: "repo"` and `routedBy: "repo:.claude/skills"`
(`repoSkillCall()` in `skill-candidates.mjs` builds it):

```json
{"skill": "fix-bug", "source": "repo", "phase": 2,
 "targetFiles": ["Sources/App/LoginScene.swift"],
 "routedBy": "repo:.claude/skills", "timestamp": "<ISO-8601>"}
```

`routedBy` names the index and version that chose it. That is the difference between "the model happened to read a skill" and "the toolkit said this skill governs this task".

Usage reporting (`scripts/usage-report.mjs`) never sends a repo-local skill's name, which the repo's owner chose: it sends the constant `repo-local`. A `marketplace-fallback` entry keeps its name only when the toolkit is one the public marketplace carries, and is sent as `marketplace-fallback` otherwise.

What Phase 3 (Review) does with it, precisely: Step 1.78 lists these entries in the manifest under `ledger.routedByToolkit`, so a reviewer and the Phase 5 report can see which skills the project's own toolkit selected. It does **not** give them extra weight in the coverage maths. The deterministic resolver stays primary because an unrecorded load and no load are indistinguishable in state, and no `routedBy` tag changes that  -  the tag says who chose the skill, not that the code honoured it.

## Failure modes, and why none of them halt

| Situation | Behaviour |
|---|---|
| No toolkit for this stack | recorded no-op plus the `hints[]` entry, continue |
| No toolkit for this stack, unattended or autopilot | read it read-only from a marketplace clone (`fallbacks[]`), or record the `gap`, continue |
| An inherited toolkit marks another stack | excluded with its reason in `excluded[]`, continue |
| An inherited toolkit marks no stack and ships no index | listed in `unscoped[]`, not routed to, continue |
| Two toolkits ship the same skill name | one owner per name by the precedence rule, recorded in `overlaps[]`, continue |
| A repo skill or marketplace plugin dir is a symlink out of its root | not read (`rejectedRepoSkills[]`, or a fallback `gap`), continue |
| Toolkit not enabled in this repo | recorded no-op, continue (component dispatch still halts for its own case) |
| `index` resolves but routes to a skill that does not exist in this version | record the miss with the version, load the rest, continue. A stale row in a plugin's table must not stop a run |
| `index` itself does not resolve | record and fall back to the host's own description matching  -  no worse than having no routing step |

Nothing here blocks Phase 2. What is downstream is visibility, not enforcement: routed skills appear in the manifest's `ledger.routedByToolkit`, and a task that recorded nothing shows up as `ledgerSource: derived` with its coverage gap stated. Enforcement over rule IDs is the registry's job (`features/skill-conformance.md`), not this step's.

## What this deliberately does NOT do

- It does not decide which skills apply. Copying the plugin's routing into this repo would put the authoritative table in the wrong place and guarantee drift.
- It does not fail a run for a missing skill. The pipeline's contract is to ask and record, not to require that a third-party plugin be complete.
- It does not replace `component-dispatch.md`. That path owns the component build itself, including the `figma-validate` pre-check and the halt-on-incomplete-state rule.
