@mmerterden/multi-agent-pipeline 20.8.2 → 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.
- package/CHANGELOG.md +43 -0
- package/docs/facts.json +1 -1
- package/install/_common.mjs +49 -0
- package/install/_dev-only-files.mjs +1 -0
- package/install/catalog-history.json +1 -0
- package/install/claude.mjs +13 -7
- package/manifest.json +40 -30
- package/package.json +3 -2
- package/pipeline/lib/claude-md-links.mjs +328 -0
- package/pipeline/lib/owned-path-gate.mjs +699 -0
- package/pipeline/lib/repo-profile-derive.mjs +1771 -0
- package/pipeline/lib/repo-profile.mjs +780 -0
- package/pipeline/lib/stack-detect.sh +59 -19
- package/pipeline/lib/unattended.mjs +17 -0
- package/pipeline/multi-agent-refs/features/repo-profile.md +96 -0
- package/pipeline/multi-agent-refs/features/review-decision.md +18 -13
- package/pipeline/multi-agent-refs/features/stack-skill-routing.md +179 -33
- package/pipeline/multi-agent-refs/outside-the-pipeline.md +33 -11
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +26 -12
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +24 -13
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +16 -4
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +8 -0
- package/pipeline/rules/outside-the-pipeline.md +6 -1
- package/pipeline/schemas/agent-state.schema.json +66 -2
- package/pipeline/schemas/phases.json +4 -4
- package/pipeline/schemas/repo-profile.schema.json +1107 -0
- package/pipeline/schemas/token-budget.json +4 -4
- package/pipeline/scripts/agent-guard.py +30 -0
- package/pipeline/scripts/owned-path-gate.mjs +205 -0
- package/pipeline/scripts/pre-commit-check.sh +151 -1
- package/pipeline/scripts/repo-profile.mjs +244 -0
- package/pipeline/scripts/review-decision-gate.mjs +42 -18
- package/pipeline/scripts/skill-candidates.mjs +882 -0
- package/pipeline/scripts/unattended_policy.py +90 -0
- package/pipeline/scripts/usage-report.mjs +36 -6
- 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
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
| `corroborated`
|
|
36
|
-
| `failing-test`
|
|
37
|
-
| `test-integrity` | it is the finding `test-integrity-gate.mjs` produced
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
| 0
|
|
129
|
-
| 1
|
|
130
|
-
| 2
|
|
131
|
-
| 3
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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":
|
|
64
|
-
"targetFiles": ["
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|