@mmerterden/multi-agent-pipeline 20.8.3 → 20.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/docs/facts.json +1 -1
  3. package/install/claude.mjs +1 -1
  4. package/manifest.json +37 -28
  5. package/package.json +1 -1
  6. package/pipeline/lib/claude-md-links.mjs +328 -0
  7. package/pipeline/lib/owned-path-gate.mjs +699 -0
  8. package/pipeline/lib/repo-profile-derive.mjs +1771 -0
  9. package/pipeline/lib/repo-profile.mjs +780 -0
  10. package/pipeline/lib/stack-detect.sh +59 -19
  11. package/pipeline/lib/unattended.mjs +17 -0
  12. package/pipeline/multi-agent-refs/features/repo-profile.md +96 -0
  13. package/pipeline/multi-agent-refs/features/review-decision.md +18 -13
  14. package/pipeline/multi-agent-refs/features/stack-skill-routing.md +179 -33
  15. package/pipeline/multi-agent-refs/outside-the-pipeline.md +33 -11
  16. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +26 -12
  17. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +24 -13
  18. package/pipeline/multi-agent-refs/phases/phase-3-review.md +16 -4
  19. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
  20. package/pipeline/multi-agent-refs/phases/phase-5-report.md +8 -0
  21. package/pipeline/rules/outside-the-pipeline.md +6 -1
  22. package/pipeline/schemas/agent-state.schema.json +66 -2
  23. package/pipeline/schemas/phases.json +4 -4
  24. package/pipeline/schemas/repo-profile.schema.json +1107 -0
  25. package/pipeline/schemas/token-budget.json +4 -4
  26. package/pipeline/scripts/agent-guard.py +30 -0
  27. package/pipeline/scripts/owned-path-gate.mjs +205 -0
  28. package/pipeline/scripts/pre-commit-check.sh +151 -1
  29. package/pipeline/scripts/repo-profile.mjs +244 -0
  30. package/pipeline/scripts/review-decision-gate.mjs +42 -18
  31. package/pipeline/scripts/skill-candidates.mjs +882 -0
  32. package/pipeline/scripts/unattended_policy.py +90 -0
  33. package/pipeline/scripts/usage-report.mjs +36 -6
  34. package/pipeline/skills/.skill-manifest.json +1 -1
@@ -30,7 +30,9 @@
30
30
  # unreadable directory as a language-free repo.
31
31
 
32
32
  # Markers whose mere presence decides a stack, as data rather than a `case`
33
- # cascade: `stack:glob:maxdepth`. Adding a language is one line here, which is
33
+ # cascade: `stack:glob:maxdepth:extensions`, where the extensions are the
34
+ # stack's source language (used to discount a marker below the root, see
35
+ # _ma_share_ok). Adding a language is one line here, which is
34
36
  # the point - a contributor who has to edit control flow to add Rust will
35
37
  # instead widen somebody else's regex.
36
38
  #
@@ -39,19 +41,28 @@
39
41
  # a JVM service), and one package.json can be web, backend or both. Those two
40
42
  # run after the table, with their reasoning at the point of decision.
41
43
  MA_STACK_MARKERS="\
42
- ios:Package.swift:3
43
- ios:*.xcodeproj:3
44
- ios:*.xcworkspace:3
45
- ios:Podfile:3
46
- web:vite.config.*:3
47
- web:nuxt.config.*:3
48
- web:angular.json:3
49
- web:svelte.config.*:3
50
- backend:requirements.txt:3
51
- backend:pyproject.toml:3
52
- backend:go.mod:3
53
- backend:Cargo.toml:3
54
- backend:pom.xml:3"
44
+ ios:Package.swift:3:swift|m|mm
45
+ ios:*.xcodeproj:3:swift|m|mm
46
+ ios:*.xcworkspace:3:swift|m|mm
47
+ ios:Podfile:3:swift|m|mm
48
+ web:vite.config.*:3:js|jsx|ts|tsx|mjs|cjs|vue|svelte
49
+ web:nuxt.config.*:3:js|jsx|ts|tsx|mjs|cjs|vue|svelte
50
+ web:angular.json:3:js|jsx|ts|tsx|mjs|cjs|vue|svelte
51
+ web:svelte.config.*:3:js|jsx|ts|tsx|mjs|cjs|vue|svelte
52
+ backend:requirements.txt:3:py
53
+ backend:pyproject.toml:3:py
54
+ backend:go.mod:3:go
55
+ backend:Cargo.toml:3:rs
56
+ backend:pom.xml:3:java|kt|scala"
57
+
58
+ # Every source extension a language share is measured against: the table's
59
+ # own plus common languages no marker names.
60
+ MA_STACK_SOURCE_EXTS='swift|m|mm|kt|java|scala|js|jsx|ts|tsx|mjs|cjs|vue|svelte|py|go|rs|dart|cs|rb|php|c|cc|cpp|h|hpp'
61
+ # Below this share (percent of tracked source files) a marker under the root
62
+ # is ignored, once the repo tracks at least MA_STACK_MIN_FILES source files; a
63
+ # smaller tree is too small to judge by share.
64
+ MA_STACK_MIN_SHARE=1
65
+ MA_STACK_MIN_FILES=50
55
66
 
56
67
  # Dependency names that decide which side of one package.json a repo is on. A
57
68
  # Next app with API routes is honestly both, so both may be recorded.
@@ -119,14 +130,42 @@ ma_stack_detect() {
119
130
  _why="${_why:+$_why }$1<-$2"
120
131
  }
121
132
 
122
- local _why="" hit row stack pat depth
133
+ # A marker below the root counts only when the stack's own language is at
134
+ # least MA_STACK_MIN_SHARE percent of the tracked source files. A subtree
135
+ # copied in without being a submodule is not pruned by name: a Kotlin app
136
+ # carrying a shared configuration repo's Package.swift has 1 .swift file
137
+ # against 9000+ .kt, and that marker is not this repo's stack. A root marker
138
+ # is the repo's own declaration and always counts. Outside a git work tree
139
+ # there is no tracked-file list, so the marker counts. Package manifests
140
+ # written in a source language (Package.swift) are not counted as source.
141
+ local _files="" _files_read=0 _ignored=""
142
+ _ma_share_ok() { # $1 = stack, $2 = extensions, $3 = hit path
143
+ case "$3" in "$root"/*/*) ;; *) return 0 ;; esac
144
+ if [ "$_files_read" -eq 0 ]; then
145
+ _files_read=1
146
+ _files=$(git -C "$root" ls-files 2>/dev/null | grep -iE "\.($MA_STACK_SOURCE_EXTS)\$" |
147
+ grep -vE '(^|/)Package(@[^/]*)?\.swift$')
148
+ fi
149
+ [ -n "$_files" ] || return 0
150
+ local total n
151
+ total=$(printf '%s\n' "$_files" | grep -c .)
152
+ [ "$total" -ge "$MA_STACK_MIN_FILES" ] || return 0
153
+ n=$(printf '%s\n' "$_files" | grep -ciE "\.($2)\$")
154
+ [ $((n * 100)) -ge $((total * MA_STACK_MIN_SHARE)) ] && return 0
155
+ case " $_ignored " in *" $1 "*) return 1 ;; esac
156
+ _ignored="${_ignored:+$_ignored }$1"
157
+ _why="${_why:+$_why }ignored $1<-${3#"$root"/}($n of $total source files)"
158
+ return 1
159
+ }
160
+
161
+ local _why="" hit row stack pat depth exts
123
162
 
124
163
  # --- table markers ----------------------------------------------------
125
- while IFS=: read -r stack pat depth; do
164
+ while IFS=: read -r stack pat depth exts; do
126
165
  [ -n "$stack" ] || continue
127
166
  case " $MA_STACKS " in *" $stack "*) continue ;; esac
128
167
  hit=$(_ma_has "$pat" "$depth")
129
- [ -n "$hit" ] && _ma_add "$stack" "${hit##*/}"
168
+ [ -n "$hit" ] && _ma_share_ok "$stack" "$exts" "$hit" && _ma_add "$stack" "${hit##*/}"
130
169
  done <<EOF
131
170
  $MA_STACK_MARKERS
132
171
  EOF
@@ -150,11 +189,12 @@ EOF
150
189
  fi
151
190
  done
152
191
  fi
153
- [ -n "$hit" ] && _ma_add "android" "${hit##*/}"
192
+ [ -n "$hit" ] && _ma_share_ok "android" "kt|java" "$hit" && _ma_add "android" "${hit##*/}"
154
193
 
155
194
  # --- web / backend, by dependency -------------------------------------
156
195
  local pkg
157
196
  pkg=$(_ma_has "package.json")
197
+ [ -n "$pkg" ] && ! _ma_share_ok "node" "js|jsx|ts|tsx|mjs|cjs|vue|svelte" "$pkg" && pkg=""
158
198
  if [ -n "$pkg" ]; then
159
199
  local is_web=0
160
200
  grep -qE "$MA_STACK_WEB_DEPS" "$pkg" 2>/dev/null && is_web=1
@@ -177,7 +217,7 @@ EOF
177
217
  done
178
218
  MA_STACKS="$ordered"
179
219
  MA_STACK_WHY="${_why:-no marker matched}"
180
- unset -f _ma_has _ma_add
220
+ unset -f _ma_has _ma_add _ma_share_ok
181
221
  return 0
182
222
  }
183
223
 
@@ -61,6 +61,23 @@ export function runMode(state, env = process.env) {
61
61
  return { unattended, stateClaimsAutopilot, claimMismatch: stateClaimsAutopilot && !unattended };
62
62
  }
63
63
 
64
+ /**
65
+ * The table above as one value for a consumer that needs both answers: `mode`
66
+ * is who can answer a question (only the environment decides it), `gatesActive`
67
+ * whether the quality gates run. Terminal autopilot is attended with the gates
68
+ * active: a person is present, but no confirmation is asked.
69
+ *
70
+ * @param {object|null|undefined} state
71
+ * @param {Record<string, string|undefined>} [env]
72
+ * @returns {{mode: "attended"|"unattended", gatesActive: boolean}}
73
+ */
74
+ export function runPosture(state, env = process.env) {
75
+ return {
76
+ mode: isUnattended(env) ? "unattended" : "attended",
77
+ gatesActive: gatesActive(state, env),
78
+ };
79
+ }
80
+
64
81
  /**
65
82
  * The verdict a quality gate returns when the gates are off.
66
83
  *
@@ -0,0 +1,96 @@
1
+ # Repo profile - how one repo works, derived once, read by every phase
2
+
3
+ > **TLDR** - A local-only JSON file per repo that tells generic skills what this repo does differently: paths a bot owns, generated trees and their inputs, the commit subject convention, the exact CI commands, hooks that rewrite a commit. Derived from the repo itself, confirmed once in attended runs, honoured without asking in unattended ones. Script: `repo-profile.mjs`. Schema: `schemas/repo-profile.schema.json`.
4
+
5
+ ## Where it lives
6
+
7
+ `~/.claude/projects/<slug>/repo-profile.json`, next to `figma-config.json`. The slug is the main checkout's absolute path with every non-alphanumeric character replaced by `-` (the host's own project-directory slug), so every worktree of a repo reads one profile. Written atomically at `0600`. Never inside the repo and never committed: the save refuses a path that resolves inside the repo root. It is not the learnings ledger's `<repo-profile>` prompt block; that one holds free-text learnings.
8
+
9
+ An unattended run's OS sandbox denies writes under `~/.claude`, so when that write is refused (`EACCES`, `EPERM`, `EROFS`) the profile goes to `<unattended run root>/repo-profiles/<slug>/repo-profile.json` (`MA_UNATTENDED_RUN_ROOT`, else `~/.multi-agent-unattended`, the directory the sandbox's `allowWrite` names). An unattended run reads whichever of the two copies was derived last. When neither is writable the profile is used from memory for the run, `persisted` is `false`, and the run continues.
10
+
11
+ ## What it holds
12
+
13
+ Every role is `{ value, source, evidence, confidence }`. `source` is `derived`, `confirmed` or `manual`; `evidence` is `file:line`, `commit:<sha>` or `git:<ref>`; `confidence` is `high`, `medium` or `low`. A `null` value means nothing was found and the consumer keeps its default.
14
+
15
+ | Role | Derived from |
16
+ |---|---|
17
+ | `ownedPaths[]` | candidates from a 2000-commit window: directories (and single files) only `[bot]` authors or `--bot-pattern` touched, at least 3 commits. Each candidate (at most 50) is then checked against its whole history in one `git log`; one with any human commit narrows to the subdirectories and files inside it that only the bot ever touched. `basis: "history"` rules are at most `medium`. Plus workflow rules: `case` arms (a case `*` crosses `/`: a star-only segment becomes `**`, a last segment such as `*.swift` becomes `**/*.swift`; any other star inside a segment widens an owned arm to its literal directory plus `/**` and drops an exception arm) and `git diff/log/ls-files -- <paths>` pathspecs (git semantics kept, stored repo-relative without `./`), in workflows that name a bot in code or read one from `vars.*BOT*` / `*BOT_LOGIN`; `#` comments are stripped first, so a login in a comment is an example, not an owner. The owner is the bots that actually committed under the job's paths, else the ones it names. A history directory that a same-owner workflow glob only partly covers folds into that glob (`basis: "history+workflow"`, `high`). An `on.*.paths` trigger filter only says when a workflow runs and is never read as ownership. A `labels.*.name` check becomes `bypass.label` |
18
+ | `generators[]` | files whose first 5 lines carry a comment stamp (`//`, `#`, `/*`, `<!--` followed by `generated by`, `auto-generated` or `do not edit`; a docstring does not count), grouped into the highest directory that is 90% stamped; any tracked `Generated/` directory; `input` is the common parent of the directories that change in the same commits, `sameCommit` is true at 80%; `command` is the CI step naming the stamp's tool with `2>&1` and `\| tee` removed; a step with `--check`, `--verify` or `--dry-run` is recorded as `check`, never as `command` |
19
+ | `commit.format` | last 200 non-merge, non-bot subjects: ticket `prefix` / `suffix` / `none` by majority, its style (`[KEY-123]`, `KEY-123:`), whether a conventional scope is required. A trailing `(#123)` is the host's PR number and is ignored |
20
+ | `repo.workBranch`, `repo.defaultBranch` | `pull_request: branches:` targets in workflows, else `push: branches:` targets, else the most active of the usual integration branches, remote or local; `origin/HEAD` |
21
+ | `build`, `test`, `lint` | CI `run:` steps, exact flags kept, continuations joined; `high` when a pull request triggers the step or it appears twice; `-testPlan` values and `*.xctestplan` files; `package.json` scripts as a low-confidence fallback |
22
+ | `accessors.*`, `di.*` | top-level types a generated localization / testing-id / token tree declares, ranked by `<Root>.<Seg>.<Seg>` usage outside it; the registrar suffix is the compound most files of the commonest `Registrar` / `Configurator` / `Assembly` / `Container` / `Module` family end in, read from the file names; its common method; the inject attribute. `--conventions <file>` or `--with-conventions <platform>` folds in `extract-conventions.sh` buckets |
23
+ | `module`, `mock` | repeated module manifests as a glob; a module validator script or step; mock framework imports or a fixture tree; a `Custom*` tree beside a generated one. `mock.system` and `di.injectAttribute` count only files of the repo's dominant language and carry `file:line` evidence; otherwise they are `null` |
24
+ | `hooks[]` | `.githooks/`, `scripts/git-hooks/`, `.husky/`, pre-commit and lefthook configs; side effects `amends-commit`, `stages-files`, `writes-files`, `network`; the script that sets `core.hooksPath` |
25
+ | `requiredChecks[]`, `docs.authoritative[]`, `inRepoSkills[]` | workflow jobs with a `required` comment (always low); the documents `claude-md-links.mjs` admits from the repo's `CLAUDE.md` files (same bounds, evidence is the CLAUDE.md that points at it); `.claude/skills/*/SKILL.md` with `trigger: explicit` when `disable-model-invocation: true` |
26
+
27
+ Derivation reads and never writes: one bounded `git log`, one whole-history `git log` limited to the owned-path candidates, one `git ls-files`, a 600-byte header per regular source file (symlinks, submodules and anything `lstat` does not call a regular file are skipped), one fixed-string `git grep` for accessor, inject and mock usage. Paths are read NUL-separated with `core.quotePath` off, so non-ASCII paths arrive as written. It stays within a few seconds on a 50k-file repo.
28
+
29
+ ## Run start: one call
30
+
31
+ ```bash
32
+ node $HOME/.claude/scripts/repo-profile.mjs ensure --repo "$PROJECT_ROOT" --state "$STATE_FILE"
33
+ ```
34
+
35
+ It prints `{action, mode, policy, gatesActive, path, persisted, writeError, confirmed, needsConfirmation, stale, report}` and persists nothing but the profile. Persist the result as `state.repoProfile = {path, action, mode, confirmed, staleReasons, ignored}` (`staleReasons` from `stale.reasons`, `ignored` from `report.ignored`). `path` is `null` and `persisted` is `false` when no location was writable.
36
+
37
+ The mode comes from `lib/unattended.mjs` (`runPosture`): only `MULTI_AGENT_UNATTENDED=1` is unattended. Terminal autopilot (`state.autopilot`) is attended with the gates active: its profile is stored under `~/.claude`, it is never asked to confirm, and because nobody confirms anything it applies the unattended confidence policy (`policy` in the output), so owned paths and generators fail safe.
38
+
39
+ | Situation | Unattended (`MULTI_AGENT_UNATTENDED=1`) | Terminal autopilot | Attended |
40
+ |---|---|---|---|
41
+ | no profile | derive, save as `derived`, continue | derive, save, continue | derive, save, ask once |
42
+ | stale | re-derive, keep confirmed and manual roles, continue | same, continue | re-derive, keep them, ask again |
43
+ | fresh, confirmed | use it | use it | use it |
44
+ | fresh, never confirmed | use it | use it | ask once |
45
+
46
+ Stale means: the stored `repoHead` is no longer an ancestor of HEAD, HEAD is more than 200 commits ahead of it (`--max-behind`), or any file under `.github/workflows/` changed between the two.
47
+
48
+ **The confirmation (attended only).** Render the derived roles as a table (role, value, confidence, first evidence) and ask one question through the picker contract (`question`, `label`, `description` in `outputLanguage`, `header` English, at most 12 characters, `"Profile"`): confirm (Recommended), edit, or skip for this run. Confirm runs `repo-profile.mjs confirm`. Edit starts from `show`, sets the corrected values with `source: "manual"`, and writes the whole profile back through `save --from -`, which replaces the stored one as given: a manual value wins over a confirmed one and an entry left out is removed. Then confirm. Skip leaves the profile derived and asks again next run.
49
+
50
+ ## Consuming a field
51
+
52
+ `repo-profile.mjs resolve <field>` applies the policy and prints `{use, value, reason, ignored}`:
53
+
54
+ | Role state | Used? |
55
+ |---|---|
56
+ | `confirmed` or `manual` | yes, whatever the confidence |
57
+ | `derived`, high | yes |
58
+ | `derived`, medium | only where a wrong value fails safe: `ownedPaths`, `generators`, `commit.format` |
59
+ | `derived`, low | no; the default is kept and the field is recorded as ignored |
60
+ | `ownedPaths` entry with `basis: "history"` (no CI job enforces it) | attended: no until confirmed, recorded as ignored; unattended and terminal autopilot: yes, and listed in `report.historyOnly` |
61
+ | `ownedPaths`, `generators` in an unattended run | always, even low and unconfirmed: blocking an edit a person can undo is safer than editing a path a bot overwrites or CI rejects |
62
+
63
+ ## Who reads it
64
+
65
+ - **Phase 1 (plan)** runs `ensure`, cites `ownedPaths` and `generators` in the plan so no task writes into them, and reads `docs.authoritative` and `inRepoSkills` as repo guidance.
66
+ - **Phase 2 (dev)** checks every target path against `resolve ownedPaths` and `resolve generators` before writing; an edit there moves to the generator's `input` or stops with the owner named. Its exit gate runs `owned-path-gate.mjs` (below) before the build, and a violation blocks the phase.
67
+ - **Phase 3 (review)** re-runs the gate at Step 1.761 and merges its `findings[]` (`tag: owned_path`, `blocking`) into triage; `review-decision-gate.mjs` keeps them blocking on the `owned-path` basis.
68
+ - **The agent commit hook** (`pre-commit-check.sh`) runs the gate on the commit's own paths: the index, plus what the same command stages (`git add <pathspec>`, `-A`, `-u`) and what `commit -a` or `commit <pathspec>` takes (`agent-guard.py --commit-scope`; without python3, every dirty and untracked file). A dirty file the commit leaves out is not judged. It passes `--state auto`, which finds the live run whose `worktreePath` is the checkout. A profile kept only in memory (`persisted: false`) gives the hook nothing to read; Phase 2 Gate 0 and Phase 3 Step 1.761 still check that run.
69
+ - **Phase 4 (commit)** takes the ticket position and style from `resolve commit.format`; `none` keeps the default suffix unless the role is confirmed or manual. It reads `hooks[]`: a hook that `amends-commit` or `writes-files` means the SHA after `git commit` is re-read, never assumed.
70
+ - **Phase 5 (report)** prints `repo-profile.mjs report`: which fields were derived, confirmed or manual, which were used, and which were ignored for confidence.
71
+
72
+ ## Owned-path gate
73
+
74
+ `owned-path-gate.mjs` turns `ownedPaths[]` into a deterministic check. For each changed path an honoured entry's `glob` covers and none of its `except[]` globs does, it reports a violation unless every author of the change matches the entry's `owner` (the account's own sync), every author matches `bypass.author`, or the PR carries `bypass.label`. `owner` and `bypass.author` are regular expressions when anchored (`^` or `$`) and otherwise one literal name compared exactly; an unanchored value with regex syntax (`\`, `*`, `+`, `?`, `()`, `{}`, `|`) fails validation. A merge commit counts as changing only the files its author edited while resolving it.
75
+
76
+ ```bash
77
+ node $HOME/.claude/scripts/owned-path-gate.mjs --repo "$WORKTREE" --base "origin/$BASE_BRANCH" --state "$STATE_FILE"
78
+ ```
79
+
80
+ | Input | Change set | Authors |
81
+ |---|---|---|
82
+ | default | merge-base with the profile's `repo.workBranch`, else `repo.defaultBranch`, `origin/HEAD`, `origin/main`, `origin/master`, `main`, `master`, plus uncommitted and untracked files | commits since the merge-base; the local identity for uncommitted files |
83
+ | `--base <ref>` | the default, from the merge-base with `<ref>` | same |
84
+ | `--range <base>..<head>` | `git diff <base>...<head>` | commits of the range that touch the path |
85
+ | `--staged` | the index | the local identity |
86
+ | `--paths <a,b>` or `--paths -` | the list given (stdin: NUL or newline separated) | the local identity, or `--author` |
87
+
88
+ The policy is the profile's own (`resolve ownedPaths`): attended runs honour confirmed, manual, high and medium entries, except a history-only one that is still derived, and list the rest in `ignored[]`; runs that ask nothing (unattended, or terminal autopilot through `--state`) honour all. The commit hook passes the worktree's `agent-state.json` as `--state` when one exists. Without a stored profile, attended exits 0 with `skipped: true` and `note: "no repo profile, gate skipped"`; a run that asks nothing derives one first through `ensure`, which stores it under `~/.claude` or the unattended run root (or keeps it in memory) and never writes into the repo. Labels come from `--pr-labels a,b`; when a violation's bypass label is not among them and `gh` is installed, the PR's labels are read once, read-only, and a missing `gh` or PR changes nothing (`--no-gh` turns it off).
89
+
90
+ Globs: `**` spans any number of segments, `*` and `?` stay inside one segment, `[...]` is a class, and a glob with no wildcard names a file or a whole directory. Output (stdout, or `--out <file>`): `{violations: [{path, glob, owner, evidence, confidence, fix}], exempt, findings, ignored, checked, mode, skipped, profileSource, profileAction}`. `fix` names where the change belongs: the matching `generators[].input` and `command`, the `resourceSource.authoring` commands, or the next sync. Exit 0 clean or skipped, 1 violations, 2 usage or environment error.
91
+
92
+ The commit hook computes the profile path itself and reads the globs' literal prefixes with `sed`, so a commit that touches no owned prefix never starts node. No profile, no node or a gate that cannot run lets the commit through with one stderr line; a violation blocks it with exit 2.
93
+
94
+ ## Commands
95
+
96
+ `derive` (print a draft), `save [--from <file|->] [--replace]`, `confirm`, `ensure [--max-behind <n>]`, `show`, `get <field>`, `resolve <field>`, `report`, `path`, `--help`. All take `--repo <path>`. `--history` and `--max-behind` must be positive integers. Exit `2` means no stored profile.
@@ -30,11 +30,12 @@ exits 0 and writes neither the triage file nor the state.
30
30
  An `accepted[]` finding of severity `blocking` keeps its severity only when
31
31
  one of these holds:
32
32
 
33
- | Basis | What has to be true |
34
- |---|---|
35
- | `corroborated` | at least two independent reviewer outputs of the iteration carry a `blocking` finding that is the same finding |
36
- | `failing-test` | its `verification` (Step 3.7, `verify-by-test.md`) is `confirmed`, and `evidencePath` names a non-empty log inside the checkout that shows a failing test and no pass |
37
- | `test-integrity` | it is the finding `test-integrity-gate.mjs` produced for that file: a command's output, not a model's |
33
+ | Basis | What has to be true |
34
+ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `corroborated` | at least two independent reviewer outputs of the iteration carry a `blocking` finding that is the same finding |
36
+ | `failing-test` | its `verification` (Step 3.7, `verify-by-test.md`) is `confirmed`, and `evidencePath` names a non-empty log inside the checkout that shows a failing test and no pass |
37
+ | `test-integrity` | it is the finding `test-integrity-gate.mjs` produced: the same file and the same `tag` (or the same fingerprint), a command's output, not a model's |
38
+ | `owned-path` | it is the finding `owned-path-gate.mjs` produced, matched the same way (an edit to a path the repo profile says an automated account owns), for the same reason. A reviewer's own blocker on a file the gate also flagged is not the gate's finding |
38
39
 
39
40
  Anything else is lowered to `important`. It stays in `accepted[]`: triage
40
41
  judged it real and in scope, so the rework still fixes it, it just no longer
@@ -100,6 +101,7 @@ result, before Step 3.8:
100
101
  ```bash
101
102
  node "$HOME/.claude/scripts/review-decision-gate.mjs" "$TRIAGE_FILE" --state "$STATE_FILE" \
102
103
  --integrity "$WORKTREE/.pipeline/test-integrity.json" \
104
+ --integrity "$WORKTREE/.pipeline/owned-path.json" \
103
105
  --source "$WORKTREE/.pipeline/security-audit-$ITERATION.json" \
104
106
  --json > "$WORKTREE/.pipeline/review-decision-$ITERATION.json"
105
107
  RD_RC=$?
@@ -111,8 +113,11 @@ nothing); `security-audit-$ITERATION.json` is the file Step 2.7 writes when the
111
113
  audit runs (`security-audit.md`). Both are files and not process substitutions
112
114
  of a shell variable with a `{}` brace default: bash 3.2, the stock macOS
113
115
  `/bin/bash`, expands that to `{\}`, and unparseable input is exit 3.
114
- A missing or empty `--integrity` file means no test-integrity findings, and a
115
- missing or empty `--source` means the audit did not run.
116
+ `owned-path.json` is the file Step 1.761 writes. `--integrity` repeats, one
117
+ file per deterministic gate. Both gates always write their file, so a declared
118
+ `--integrity` file that does not exist is unreadable (exit 3): the gate failed
119
+ before writing. An empty one means no findings from that gate, and a missing or
120
+ empty `--source` means the audit did not run.
116
121
 
117
122
  Merge the JSON into `state.reviewIterations[-1].reviewDecision` through
118
123
  `write-state.mjs`:
@@ -123,12 +128,12 @@ jq --slurpfile d "$WORKTREE/.pipeline/review-decision-$ITERATION.json" \
123
128
  | node "$HOME/.claude/scripts/write-state.mjs" "$STATE_FILE"
124
129
  ```
125
130
 
126
- | Exit | Ledger | The run |
127
- |---|---|---|
128
- | 0 | `pass` | continues with the rewritten triage |
129
- | 1 | `fail`: a rebuttal round ran | `gate-ledger.mjs park --outcome verification-failed --gate review-decision` |
130
- | 2 | nothing | usage error; fix the call |
131
- | 3 | `fail`: triage unreadable, or an accepted blocker with no reviewer record to check it against | parks the same way |
131
+ | Exit | Ledger | The run |
132
+ | ---- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
133
+ | 0 | `pass` | continues with the rewritten triage |
134
+ | 1 | `fail`: a rebuttal round ran | `gate-ledger.mjs park --outcome verification-failed --gate review-decision` |
135
+ | 2 | nothing | usage error; fix the call |
136
+ | 3 | `fail`: triage unreadable, or an accepted blocker with no reviewer record to check it against | parks the same way |
132
137
 
133
138
  The gate records the verdict; it does not park. Parking is the explicit call:
134
139
 
@@ -1,14 +1,24 @@
1
1
  # Stack skill routing - letting the toolkit plugin choose its own skills
2
2
 
3
- > **TLDR** - When a toolkit plugin is enabled, Phase 3 asks that 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.
3
+ <!-- toc -->
4
+ - [Why this exists](#why-this-exists)
5
+ - [When it runs](#when-it-runs)
6
+ - [Resolution](#resolution)
7
+ - [The call](#the-call)
8
+ - [Recording - what makes this checkable](#recording---what-makes-this-checkable)
9
+ - [Failure modes, and why none of them halt](#failure-modes-and-why-none-of-them-halt)
10
+ - [What this deliberately does NOT do](#what-this-deliberately-does-not-do)
11
+ <!-- /toc -->
12
+
13
+ > **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.
4
14
  >
5
15
  > 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.
6
16
 
7
17
  ## Why this exists
8
18
 
9
- Phase 3 dispatched to the toolkit plugin for exactly one case, `taskType === "component"` (see `component-dispatch.md`). Every other task - `bugfix`, `feature`, `refactor`, `chore` - had no skill dispatch at all: whichever skills the host happened to surface by description match were the ones that got used, and nothing recorded or required any of them.
19
+ 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.
10
20
 
11
- That is the dev-side half of the gap `features/skill-conformance.md` closes on the review side. Review now asks "was this built to the rules it was supposed to follow"; without this step, the answer for a non-component task was "there were no declared rules, because nobody chose any".
21
+ 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.
12
22
 
13
23
  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.
14
24
 
@@ -16,34 +26,154 @@ So the pipeline's job is to **ask**, not to know.
16
26
 
17
27
  ## When it runs
18
28
 
19
- Phase 3 pre-flight, before any code is written, for **every** `taskType`. 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.
29
+ 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.
20
30
 
21
31
  ## Resolution
22
32
 
23
- **Read the enabled set; do not keep a stack table.** Every `@multi-agent-plugins`
24
- toolkit enabled for this repo is a candidate, and each one ships its own `index`.
25
- The effective set is the repo's `.claude/settings.json` `enabledPlugins` over
26
- `~/.claude/settings.json`; `/multi-agent:stack` is what writes it.
33
+ **Read the enabled set, filter it by the repo's stack; do not keep a stack table.**
34
+ Candidates come from one resolver, run before the index call. The task text goes
35
+ through a file, never through a shell string: write the task title and one-line
36
+ intent to `$TASK_FILE` with the Write tool, then
27
37
 
28
- This replaced a `stack -> toolkit` table, and the reason is worth keeping. That table
29
- mapped `ios` and `android` and sent everything else to "no toolkit" - but six plugins
30
- ship an `index`, so a React or backend repo was told it had no toolkit while its
31
- plugin sat enabled with a routing table inside it. A table has to be widened every
32
- time a toolkit ships; the enabled set never goes stale, because it IS the answer to
33
- "what is on here".
34
-
35
- `ai-common-toolkit` and `ai-analyst-toolkit` are enabled everywhere and are always
36
- candidates - neither is stack-specific. A corporate variant (`ai-ios-engineering-toolkit`
37
- and friends) is a candidate too when a repo enables only it: it is in `enabledPlugins`
38
- like any other, which is the whole point of reading the set instead of naming names.
39
-
40
- Nothing enabled is not an error: a repo whose stack was never selected legitimately has
41
- no toolkit. Record the no-op with the enabled set that was read, so "none applied" is
42
- distinguishable from "never looked".
43
-
44
- **Not enabled is not an error here**, unlike component dispatch: a repo whose stack was never selected legitimately has no toolkit, and halting would make the pipeline unusable there. Record the no-op and continue. Every stack now has a toolkit, so "no toolkit" means the stack was never selected, not that none exists for it.
38
+ ```bash
39
+ node $HOME/.claude/scripts/skill-candidates.mjs resolve --dir "$PROJECT_ROOT" \
40
+ --state "$STATE_FILE" --task-file "$TASK_FILE"
41
+ ```
45
42
 
46
- Two marketplaces may ship the same toolkit name (a public one and a corporate one). Resolve whichever is enabled and record its **name and version** in the ledger entry, because the routing table and the skill set differ between versions - a finding that cites a skill has to be traceable to the version that defined it.
43
+ `--task -` reads the same text from stdin. `--task "<text>"` still takes a literal,
44
+ but ticket titles are untrusted input and a literal puts them inside a shell
45
+ command, so no pipeline document uses it.
46
+
47
+ Pass `--state "$STATE_FILE"` inside a run so autopilot is recognised. It prints JSON
48
+ with `mode`, `toolkits[]` (kept, each with `role`, `reason`, `source`, `version`),
49
+ `excluded[]` (each with the reason it was dropped), `unscoped[]`, `hints[]`,
50
+ `fallbacks[]`, `repoSkills[]`, `rejectedRepoSkills[]`, `repoMatches[]` (when a task
51
+ is given), `overlaps[]`, `decisions[]` and `detection`. Write `mode`, `toolkits`,
52
+ `excluded`, `unscoped`, `hints` and `fallbacks` to `state.telemetry.skillRouting` as
53
+ they are, so "none applied" is distinguishable from "never looked", an exclusion can
54
+ be traced to its cause, and Phase 5 can report it.
55
+
56
+ 1. **Enabled set.** The effective `enabledPlugins` is read from four layers, widest
57
+ first, the later layer winning per plugin, and `false` removes a plugin:
58
+ the user's `settings.json`, the user's `settings.local.json` (both under
59
+ `CLAUDE_CONFIG_DIR`, else `~/.claude`), the repo's `.claude/settings.json`, and the
60
+ repo's `.claude/settings.local.json`. The two user layers are *inherited*
61
+ (`source: "global"` / `"global-local"`); the repo layers are the repo's own
62
+ choice (`"repo"` / `"repo-local"`). Managed (enterprise) settings are not read.
63
+ `/multi-agent:stack` is what writes the repo layers. Only toolkits are candidates:
64
+ a plugin named `*-toolkit`, or one that ships a `skills/index/SKILL.md`.
65
+ 2. **Stack filter, inherited toolkits only.** A repo that never ran
66
+ `/multi-agent:stack` inherits the user-level set, which was chosen for some other
67
+ repo. So a toolkit from a user layer is checked against the stacks detected in
68
+ this repo (`lib/stack-detect.sh` plus `schemas/stack-adapters.json`, the same
69
+ detection the build and test adapters use). A toolkit's stacks are read from its
70
+ own name (`ai-<words>-toolkit`) and any `stack` / `stacks` / `keywords` in its
71
+ `plugin.json`, matched against the stack words the pipeline already knows: adapter
72
+ ids, the coarse stacks, and the aliases in `_stack-routing.mjs` (`web` and
73
+ `frontend`, `mobile` for iOS plus Android). No list of stacks lives in the resolver,
74
+ so a new `ai-<x>-toolkit` whose `<x>` is an adapter id is filtered correctly with no
75
+ code change.
76
+ - A toolkit whose stack words intersect the repo's is kept with `role: "stack"`.
77
+ Multi-stack repos (iOS plus a backend, Android plus web views, monorepos) keep
78
+ every matching toolkit.
79
+ - A toolkit whose name marks another stack only (`-ios-` in a Gradle-only repo) is
80
+ excluded, and `excluded[].reason` names both sides and the detector's evidence.
81
+ - An inherited toolkit whose name and `plugin.json` mark **no** stack (a vendor
82
+ utility plugin) says nothing about this repo. It is kept with `role: "common"`
83
+ only when it ships an `index`; otherwise it goes to `unscoped[]` and is not
84
+ routed to. An unattended or autopilot run never loads an `unscoped[]` toolkit;
85
+ an attended run loads one only when the user names it.
86
+ - A toolkit enabled in the repo's own settings is never filtered and has
87
+ `role: "stack"` (`"common"` for the always-on pair). That is an explicit per-repo
88
+ choice, and detection does not override it.
89
+ - Inconclusive or empty detection applies no filter to toolkits that mark a stack,
90
+ and `detection.filter` says why.
91
+ - Detection counts a marker below the repo root (a `Package.swift` two levels
92
+ down) only when that stack's language is at least 1% of the tracked source
93
+ files (`git ls-files`, from 50 tracked source files up). A subtree copied into a
94
+ Kotlin app, with one `.swift` file against thousands of `.kt`, is therefore not
95
+ iOS, and `detection.why` names the ignored marker. A root marker always counts.
96
+ 3. **Always-on.** `ai-common-toolkit` and `ai-analyst-toolkit` are never filtered and
97
+ have `role: "common"`; neither is stack-specific.
98
+ 4. **Hint, never a substitute.** When a detected stack has no kept toolkit, `hints[]`
99
+ carries `enable ai-<stack>-toolkit with /multi-agent:stack <stack>`. What happens
100
+ next depends on `mode`, which the resolver takes from `lib/unattended.mjs`, the
101
+ helper the rest of the pipeline uses (`MULTI_AGENT_UNATTENDED=1` is `unattended`;
102
+ otherwise `--state` with `autopilot: true` is `autopilot`; otherwise `attended`):
103
+ - **Attended**: show the hint once in the run log and continue with what is enabled.
104
+ - **Unattended or autopilot**: nobody can run `/multi-agent:stack`, and the
105
+ unattended policy forbids writing `.claude/` inside the repo. `fallbacks[]` then
106
+ names a local marketplace clone that carries the toolkit. Clones are found from
107
+ the host's `known_marketplaces.json` and, in addition, by scanning
108
+ `~/.claude/plugins/marketplaces/`; the plugin directory comes from each clone's
109
+ `marketplace.json`. Load its `index` (the `index` path) and the skills it routes
110
+ to **read-only** from `skillsDir`; never enable the plugin and
111
+ never write any settings file. Record each loaded skill with `source: "marketplace-fallback"`,
112
+ `toolkit` and `version` (from that clone's `plugin.json`; `marketplaceSkillCall()`
113
+ builds the entry), and put the entry's `reportLine` in the run report so the user
114
+ sees that the repo should enable the stack. When no clone carries it, the entry
115
+ has `gap` instead: record it and continue. The run never blocks on this.
116
+ - **Both modes**: never route the task to another stack's toolkit instead.
117
+ 5. **Repo-local skills.** `<repo>/.claude/skills/<name>/SKILL.md` are candidates
118
+ too. The resolver reads each one's `name` and `description`, and marks it
119
+ explicit-only when its frontmatter has `disable-model-invocation: true`, its
120
+ description asks for explicit invocation, or a line of the repo's CLAUDE.md that
121
+ names it says so ("only on explicit request"). Route them by the same intent
122
+ decision the index step makes, on the task title plus intent: `repoMatches[]` is
123
+ a word-overlap pre-ranking to start from, not the final word. An explicit-only
124
+ skill is loaded only when the task invokes it by name: `/name`, `` `name` `` or
125
+ "skill name". The bare word is not enough, so a skill called `release` does not
126
+ fire on "Fix crash in release notes".
127
+ 6. **Real paths.** A repo `SKILL.md` (or its directory) that is a symlink resolving
128
+ outside the repo is not read; it is listed in `rejectedRepoSkills[]`. A
129
+ marketplace plugin directory, its `skills/` or its `index` that resolves outside
130
+ its clone is treated as absent, so the fallback records a `gap`.
131
+
132
+ ### Precedence
133
+
134
+ When two candidates cover the same ground, the first wins:
135
+
136
+ 1. **Repo-local skill** (`.claude/skills/<name>`) - the repo's own rule. When it has
137
+ the same name as a toolkit skill, it replaces that skill for this repo, and
138
+ `decisions[]` records which toolkit skill it shadowed.
139
+ 2. **Detected-stack toolkit skill** - whatever a `role: "stack"` toolkit's `index`
140
+ routes to.
141
+ 3. **Common toolkit** (`ai-common-toolkit`, then `ai-analyst-toolkit`, then any
142
+ stackless toolkit kept in the common role).
143
+
144
+ Two kept toolkits can ship a skill of the same name, typically a public toolkit and a
145
+ vendor variant for the same stack. The resolver picks one owner per overlapping name
146
+ with one deterministic rule, and records each choice in `overlaps[]`
147
+ (`skill`, `winner`, `losers`, `rule`) and as a line in `decisions[]`:
148
+
149
+ 1. a repo-local skill of that name wins over every toolkit;
150
+ 2. otherwise the `stack` role wins over the `common` role;
151
+ 3. otherwise the toolkit enabled at the **narrowest scope** wins:
152
+ repo `settings.local.json`, then repo `settings.json`, then user
153
+ `settings.local.json`, then user `settings.json`;
154
+ 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.
155
+
156
+ Each toolkit's own `index` and `help` are its entry points, not competing skills, and
157
+ are never counted as an overlap. When the index of the losing toolkit routes to an
158
+ overlapping name, load the winner's skill of that name instead. The rule does not try
159
+ to judge which variant knows more about a topic; a repo that wants the other variant
160
+ enables it at a narrower scope.
161
+
162
+ No flow requires a particular toolkit. A corporate or otherwise differently-named
163
+ variant that is enabled is one more candidate, kept only if its name matches the
164
+ repo's stack like any other; it is never required, and its absence is never an
165
+ error. Two marketplaces may ship the same toolkit name: resolve whichever is enabled
166
+ and record its **name and version** (the resolver reads both from the host's
167
+ installed-plugin record), because the routing table and the skill set differ between
168
+ versions and a finding that cites a skill has to be traceable to the version that
169
+ defined it.
170
+
171
+ Nothing enabled is not an error: a repo whose stack was never selected legitimately
172
+ has no toolkit. Record the no-op with the enabled set that was read.
173
+
174
+ **Not enabled is not an error here**, unlike component dispatch: halting would make
175
+ the pipeline unusable in a repo whose stack was never selected. Record the no-op and
176
+ the hint, and continue.
47
177
 
48
178
  ## The call
49
179
 
@@ -57,28 +187,44 @@ Emit one progress line per loaded skill per `progress-contract.md`, so the user
57
187
 
58
188
  ## Recording - what makes this checkable
59
189
 
60
- Append one `state.telemetry.skillCalls[]` entry per skill actually loaded:
190
+ Append one `state.telemetry.skillCalls[]` entry per skill actually loaded, with `phase: 2`. A toolkit skill:
61
191
 
62
192
  ```json
63
- {"skill": "ai-ios-toolkit:reference/architecture", "phase": 3,
64
- "targetFiles": ["Domains/Checkin/Sources/CheckinScene.swift"],
193
+ {"skill": "ai-ios-toolkit:reference/architecture", "phase": 2,
194
+ "targetFiles": ["Sources/Feature/FeatureScene.swift"],
65
195
  "routedBy": "ai-ios-toolkit:index@0.13.0", "timestamp": "<ISO-8601>"}
66
196
  ```
67
197
 
198
+ A repo-local skill carries `source: "repo"` and `routedBy: "repo:.claude/skills"`
199
+ (`repoSkillCall()` in `skill-candidates.mjs` builds it):
200
+
201
+ ```json
202
+ {"skill": "fix-bug", "source": "repo", "phase": 2,
203
+ "targetFiles": ["Sources/App/LoginScene.swift"],
204
+ "routedBy": "repo:.claude/skills", "timestamp": "<ISO-8601>"}
205
+ ```
206
+
68
207
  `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".
69
208
 
70
- What Phase 4 actually 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.
209
+ 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.
210
+
211
+ 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.
71
212
 
72
213
  ## Failure modes, and why none of them halt
73
214
 
74
215
  | Situation | Behaviour |
75
216
  |---|---|
76
- | No toolkit for this stack | recorded no-op, continue |
217
+ | No toolkit for this stack | recorded no-op plus the `hints[]` entry, continue |
218
+ | No toolkit for this stack, unattended or autopilot | read it read-only from a marketplace clone (`fallbacks[]`), or record the `gap`, continue |
219
+ | An inherited toolkit marks another stack | excluded with its reason in `excluded[]`, continue |
220
+ | An inherited toolkit marks no stack and ships no index | listed in `unscoped[]`, not routed to, continue |
221
+ | Two toolkits ship the same skill name | one owner per name by the precedence rule, recorded in `overlaps[]`, continue |
222
+ | A repo skill or marketplace plugin dir is a symlink out of its root | not read (`rejectedRepoSkills[]`, or a fallback `gap`), continue |
77
223
  | Toolkit not enabled in this repo | recorded no-op, continue (component dispatch still halts for its own case) |
78
224
  | `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 |
79
- | `index` itself does not resolve | record and fall back to the host's own description matching, which is the pre-v14.1.0 behaviour - no worse than before |
225
+ | `index` itself does not resolve | record and fall back to the host's own description matching - no worse than having no routing step |
80
226
 
81
- Nothing here blocks Phase 3. 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.
227
+ 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.
82
228
 
83
229
  ## What this deliberately does NOT do
84
230