@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
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* skill-candidates.mjs - which skills Phase 2 stack skill routing may use for
|
|
4
|
+
* a repo, and why each candidate is in or out.
|
|
5
|
+
*
|
|
6
|
+
* Two sources compete, and the order is the contract
|
|
7
|
+
* (multi-agent-refs/features/stack-skill-routing.md):
|
|
8
|
+
*
|
|
9
|
+
* 1. repo-local skills, `<repo>/.claude/skills/<name>/SKILL.md`
|
|
10
|
+
* 2. toolkits for the stacks this repo is built with
|
|
11
|
+
* 3. the always-on toolkits (ai-common-toolkit, ai-analyst-toolkit), and any
|
|
12
|
+
* inherited toolkit that marks no stack but ships an index
|
|
13
|
+
*
|
|
14
|
+
* The toolkit set starts from the effective enabledPlugins: the user's
|
|
15
|
+
* settings.json and settings.local.json (under CLAUDE_CONFIG_DIR, else
|
|
16
|
+
* ~/.claude), then the repo's .claude/settings.json and .claude/settings.local.json;
|
|
17
|
+
* the later layer wins per plugin. A toolkit inherited from a user layer is then
|
|
18
|
+
* checked against the repo's detected stacks, because a repo that never ran
|
|
19
|
+
* /multi-agent:stack inherits whatever the user enabled for some other repo. A
|
|
20
|
+
* toolkit the repo enabled itself is never filtered: that is an explicit choice.
|
|
21
|
+
* Managed (enterprise) settings are not read.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here names a stack. The stack vocabulary comes from the stack-adapter
|
|
24
|
+
* table and the alias tables in _stack-routing.mjs, and a toolkit's stacks come
|
|
25
|
+
* from its own name (`ai-<words>-toolkit`) plus any `stack` / `stacks` /
|
|
26
|
+
* `keywords` in its plugin.json. A new `ai-<x>-toolkit` whose `<x>` is an
|
|
27
|
+
* adapter id is filtered correctly with no edit to this file. An inherited
|
|
28
|
+
* toolkit that marks no stack is kept only in the common role and only when it
|
|
29
|
+
* ships an index; otherwise it is listed in `unscoped[]` and not routed to,
|
|
30
|
+
* because nothing ties it to this repo.
|
|
31
|
+
*
|
|
32
|
+
* When kept toolkits ship skills of the same name, one owner is chosen per name
|
|
33
|
+
* and recorded in `overlaps[]` and `decisions[]`: a repo-local skill first, then
|
|
34
|
+
* the stack role over the common role, then the narrowest enabling scope
|
|
35
|
+
* (repo-local, repo, user-local, user), then the toolkit name.
|
|
36
|
+
*
|
|
37
|
+
* An unattended or autopilot run cannot ask anybody to run /multi-agent:stack,
|
|
38
|
+
* and must not write `.claude/` inside the repo. So when a detected stack has no
|
|
39
|
+
* enabled toolkit, such a run reads that toolkit's skills READ-ONLY from a local
|
|
40
|
+
* marketplace clone (`fallbacks[]`), and an attended run only gets the hint. A
|
|
41
|
+
* clone that does not carry the toolkit is a recorded gap; neither mode ever
|
|
42
|
+
* substitutes another stack's toolkit.
|
|
43
|
+
*
|
|
44
|
+
* Every path read from a repo or a marketplace clone is checked on its real
|
|
45
|
+
* path: a SKILL.md or plugin directory that is a symlink out of its root is
|
|
46
|
+
* rejected.
|
|
47
|
+
*
|
|
48
|
+
* Usage:
|
|
49
|
+
* node skill-candidates.mjs resolve --dir <repo> [--task-file <path> | --task -]
|
|
50
|
+
* [--home <dir>] [--state <agent-state.json>]
|
|
51
|
+
*
|
|
52
|
+
* The task text is read from a file or from stdin (`--task -`), so ticket text
|
|
53
|
+
* never has to be pasted into a shell string. `--task "<text>"` still takes a
|
|
54
|
+
* literal. Prints JSON. Exit 0 on success, 2 on usage.
|
|
55
|
+
*
|
|
56
|
+
* @module pipeline/scripts/skill-candidates
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
60
|
+
import { basename, isAbsolute, join, relative, sep } from "node:path";
|
|
61
|
+
import { homedir } from "node:os";
|
|
62
|
+
|
|
63
|
+
import {
|
|
64
|
+
ALWAYS_ON,
|
|
65
|
+
KNOWN_STACKS,
|
|
66
|
+
STACK_ARG_ALIAS,
|
|
67
|
+
STACK_PLUGIN_ALIAS,
|
|
68
|
+
pluginsForStacks,
|
|
69
|
+
} from "./_stack-routing.mjs";
|
|
70
|
+
import { detectCoarse, loadAdapters, resolveStack, UNKNOWN } from "./_stack-adapter.mjs";
|
|
71
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
72
|
+
import { gatesActive, runMode } from "../lib/unattended.mjs";
|
|
73
|
+
|
|
74
|
+
export const PRECEDENCE = ["repo", "stack", "common"];
|
|
75
|
+
export const REPO_SKILLS_DIR = ".claude/skills";
|
|
76
|
+
export const DEV_PHASE = 2;
|
|
77
|
+
const CLAUDE_MD = ["CLAUDE.md", ".claude/CLAUDE.md"];
|
|
78
|
+
const MAX_TASK_BYTES = 64 * 1024;
|
|
79
|
+
|
|
80
|
+
/** Enabling scopes, narrowest first. The two user layers are inherited. */
|
|
81
|
+
export const SCOPES = ["repo-local", "repo", "global-local", "global"];
|
|
82
|
+
const INHERITED = new Set(["global", "global-local"]);
|
|
83
|
+
|
|
84
|
+
/** Every toolkit has its own entry-point skills; a shared name there is no overlap. */
|
|
85
|
+
const META_SKILLS = new Set(["index", "help"]);
|
|
86
|
+
|
|
87
|
+
const EXPLICIT_ONLY =
|
|
88
|
+
/only (?:on|upon|when|if) (?:an? |the user )?explicit(?:ly)?(?: (?:request(?:ed)?|ask(?:ed|s)?|invoked|named))?|explicit request only|only when (?:asked|requested) explicitly|never (?:auto(?:matically)?[- ]?(?:load|invoke|run|route)|load (?:it )?automatically)/i;
|
|
89
|
+
|
|
90
|
+
function readJson(path) {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isFile(p) {
|
|
99
|
+
try {
|
|
100
|
+
return statSync(p).isFile();
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isDir(p) {
|
|
107
|
+
try {
|
|
108
|
+
return statSync(p).isDirectory();
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function realOrNull(p) {
|
|
115
|
+
try {
|
|
116
|
+
return realpathSync(p);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function inside(root, p) {
|
|
123
|
+
const rel = relative(root, p);
|
|
124
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The host's user config dir: `<home>/.claude` when a home is given, else CLAUDE_CONFIG_DIR or ~/.claude. */
|
|
128
|
+
export function userConfigDir({ home, env = process.env } = {}) {
|
|
129
|
+
if (home) return join(home, ".claude");
|
|
130
|
+
return env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Stack vocabulary
|
|
135
|
+
|
|
136
|
+
function words(id) {
|
|
137
|
+
return [id, ...id.split("-")].filter(Boolean);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function expand(token) {
|
|
141
|
+
const out = new Set([token]);
|
|
142
|
+
for (const s of STACK_ARG_ALIAS[token] || []) out.add(s);
|
|
143
|
+
for (const [stack, alias] of Object.entries(STACK_PLUGIN_ALIAS)) {
|
|
144
|
+
if (alias === token) out.add(stack);
|
|
145
|
+
if (stack === token) out.add(alias);
|
|
146
|
+
}
|
|
147
|
+
return [...out];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Every word that names a stack somewhere in the pipeline's own data. */
|
|
151
|
+
export function stackVocabulary(doc = loadAdapters()) {
|
|
152
|
+
const v = new Set(KNOWN_STACKS);
|
|
153
|
+
for (const [id, a] of Object.entries(doc.adapters)) {
|
|
154
|
+
if (id === UNKNOWN || a.applicable === false) continue;
|
|
155
|
+
for (const w of words(id)) v.add(w);
|
|
156
|
+
}
|
|
157
|
+
for (const k of Object.keys(STACK_ARG_ALIAS)) v.add(k);
|
|
158
|
+
for (const a of Object.values(STACK_PLUGIN_ALIAS)) v.add(a);
|
|
159
|
+
return v;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Stack words a toolkit marks, from its name and optional plugin.json metadata. */
|
|
163
|
+
export function toolkitStacks(name, meta = null, vocab = stackVocabulary()) {
|
|
164
|
+
const core = name
|
|
165
|
+
.replace(/@.*$/, "")
|
|
166
|
+
.replace(/^ai-/, "")
|
|
167
|
+
.replace(/-toolkit$/, "");
|
|
168
|
+
const raw = core.split("-");
|
|
169
|
+
for (const k of ["stack", "stacks", "keywords"]) {
|
|
170
|
+
const val = meta?.[k];
|
|
171
|
+
for (const x of Array.isArray(val) ? val : val ? [val] : []) {
|
|
172
|
+
if (typeof x === "string") raw.push(x.toLowerCase());
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const out = new Set();
|
|
176
|
+
for (const t of raw) for (const e of expand(t)) if (vocab.has(e)) out.add(e);
|
|
177
|
+
return [...out].sort();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Enabled set
|
|
182
|
+
|
|
183
|
+
function enabledLayer(path) {
|
|
184
|
+
const s = readJson(path);
|
|
185
|
+
const map = s?.enabledPlugins;
|
|
186
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) return {};
|
|
187
|
+
return map;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Effective enabledPlugins with the layer each value came from.
|
|
192
|
+
* @param {string} repo
|
|
193
|
+
* @param {string} claudeDir the user config dir (see userConfigDir)
|
|
194
|
+
* @returns {Map<string, {on: boolean, source: string}>}
|
|
195
|
+
*/
|
|
196
|
+
export function effectiveEnabled(repo, claudeDir) {
|
|
197
|
+
const layers = [
|
|
198
|
+
["global", join(claudeDir, "settings.json")],
|
|
199
|
+
["global-local", join(claudeDir, "settings.local.json")],
|
|
200
|
+
["repo", join(repo, ".claude", "settings.json")],
|
|
201
|
+
["repo-local", join(repo, ".claude", "settings.local.json")],
|
|
202
|
+
];
|
|
203
|
+
const out = new Map();
|
|
204
|
+
for (const [source, path] of layers) {
|
|
205
|
+
for (const [key, on] of Object.entries(enabledLayer(path))) {
|
|
206
|
+
out.set(key, { on: on === true, source });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Installed plugin metadata
|
|
214
|
+
|
|
215
|
+
function newestDir(root) {
|
|
216
|
+
try {
|
|
217
|
+
const dirs = readdirSync(root, { withFileTypes: true })
|
|
218
|
+
.filter((e) => e.isDirectory())
|
|
219
|
+
.map((e) => e.name);
|
|
220
|
+
const num = (v) => v.split(".").map((n) => parseInt(n, 10) || 0);
|
|
221
|
+
dirs.sort((a, b) => {
|
|
222
|
+
const pa = num(a);
|
|
223
|
+
const pb = num(b);
|
|
224
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
225
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
226
|
+
if (d) return d;
|
|
227
|
+
}
|
|
228
|
+
return a.localeCompare(b);
|
|
229
|
+
});
|
|
230
|
+
return dirs.at(-1) ?? null;
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function skillsUnder(dir, depth = 3) {
|
|
237
|
+
const out = [];
|
|
238
|
+
const walk = (d, left) => {
|
|
239
|
+
let entries;
|
|
240
|
+
try {
|
|
241
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
242
|
+
} catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
for (const e of entries) {
|
|
246
|
+
if (!e.isDirectory()) continue;
|
|
247
|
+
const p = join(d, e.name);
|
|
248
|
+
if (isFile(join(p, "SKILL.md"))) out.push(e.name);
|
|
249
|
+
else if (left > 1) walk(p, left - 1);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
walk(dir, depth);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Version, install path, plugin.json and skill names of an installed plugin. */
|
|
257
|
+
export function installedPlugin(key, claudeDir) {
|
|
258
|
+
const [name, marketplace] = key.split("@");
|
|
259
|
+
const pluginsDir = join(claudeDir, "plugins");
|
|
260
|
+
let installPath = null;
|
|
261
|
+
let version = null;
|
|
262
|
+
const rec = readJson(join(pluginsDir, "installed_plugins.json"))?.plugins?.[key];
|
|
263
|
+
if (Array.isArray(rec) && rec[0]?.installPath) {
|
|
264
|
+
installPath = rec[0].installPath;
|
|
265
|
+
version = rec[0].version ?? null;
|
|
266
|
+
} else if (marketplace) {
|
|
267
|
+
const root = join(pluginsDir, "cache", marketplace, name);
|
|
268
|
+
const v = newestDir(root);
|
|
269
|
+
if (v) {
|
|
270
|
+
installPath = join(root, v);
|
|
271
|
+
version = v;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!installPath || !existsSync(installPath)) {
|
|
275
|
+
return { installPath: null, version, meta: null, skills: null };
|
|
276
|
+
}
|
|
277
|
+
const meta = readJson(join(installPath, ".claude-plugin", "plugin.json"));
|
|
278
|
+
if (meta?.version) version = meta.version;
|
|
279
|
+
const listed = Array.isArray(meta?.skills) ? meta.skills.map((s) => basename(s)) : null;
|
|
280
|
+
const skills = listed?.length ? listed : skillsUnder(join(installPath, "skills"));
|
|
281
|
+
return { installPath, version, meta, skills };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function hasIndex(info) {
|
|
285
|
+
return Boolean(info.installPath && isFile(join(info.installPath, "skills", "index", "SKILL.md")));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isToolkit(name, info) {
|
|
289
|
+
return /-toolkit$/.test(name) || hasIndex(info);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Marketplace clones
|
|
294
|
+
|
|
295
|
+
function marketplaceLocations(claudeDir) {
|
|
296
|
+
const pluginsDir = join(claudeDir, "plugins");
|
|
297
|
+
const out = [];
|
|
298
|
+
const known = readJson(join(pluginsDir, "known_marketplaces.json"));
|
|
299
|
+
if (known && typeof known === "object") {
|
|
300
|
+
for (const [name, v] of Object.entries(known)) {
|
|
301
|
+
if (typeof v?.installLocation === "string") out.push({ name, root: v.installLocation });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const dir = join(pluginsDir, "marketplaces");
|
|
305
|
+
try {
|
|
306
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
307
|
+
const root = join(dir, e.name);
|
|
308
|
+
if (e.isDirectory() && !out.some((o) => o.root === root)) out.push({ name: e.name, root });
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
/* no marketplaces dir */
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The plugin directory a marketplace entry points at, or null when it is a
|
|
318
|
+
* remote source or resolves outside the clone, on the written path or on the
|
|
319
|
+
* real path.
|
|
320
|
+
*/
|
|
321
|
+
function sourceDir(root, entry, name) {
|
|
322
|
+
const src = entry?.source;
|
|
323
|
+
const rel =
|
|
324
|
+
typeof src === "string"
|
|
325
|
+
? src
|
|
326
|
+
: typeof src?.path === "string"
|
|
327
|
+
? src.path
|
|
328
|
+
: typeof src?.source === "string" && !/^[a-z]+:/i.test(src.source)
|
|
329
|
+
? src.source
|
|
330
|
+
: `plugins/${name}`;
|
|
331
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rel)) return null;
|
|
332
|
+
const dir = join(root, rel);
|
|
333
|
+
if (!inside(root, dir)) return null;
|
|
334
|
+
const realRoot = realOrNull(root);
|
|
335
|
+
const realDir = realOrNull(dir);
|
|
336
|
+
if (!realRoot || !realDir || !inside(realRoot, realDir)) return null;
|
|
337
|
+
return { dir, realRoot };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A local marketplace clone that carries `name`, found from the host's
|
|
342
|
+
* known_marketplaces.json, then by scanning <claudeDir>/plugins/marketplaces.
|
|
343
|
+
* @returns {{marketplace: string, root: string, skillsDir: string, version: string|null,
|
|
344
|
+
* index: string|null, skills: string[]}|null}
|
|
345
|
+
*/
|
|
346
|
+
export function findMarketplacePlugin(name, claudeDir) {
|
|
347
|
+
for (const loc of marketplaceLocations(claudeDir)) {
|
|
348
|
+
const manifest = readJson(join(loc.root, ".claude-plugin", "marketplace.json"));
|
|
349
|
+
const entry = (manifest?.plugins || []).find((p) => p?.name === name);
|
|
350
|
+
const found = sourceDir(loc.root, entry, name);
|
|
351
|
+
if (!found) continue;
|
|
352
|
+
const { dir, realRoot } = found;
|
|
353
|
+
const skillsDir = join(dir, "skills");
|
|
354
|
+
const realSkills = realOrNull(skillsDir);
|
|
355
|
+
if (!realSkills || !isDir(realSkills) || !inside(realRoot, realSkills)) continue;
|
|
356
|
+
const meta = readJson(join(dir, ".claude-plugin", "plugin.json"));
|
|
357
|
+
const index = join(skillsDir, "index", "SKILL.md");
|
|
358
|
+
const realIndex = realOrNull(index);
|
|
359
|
+
return {
|
|
360
|
+
marketplace: manifest?.name || loc.name,
|
|
361
|
+
root: dir,
|
|
362
|
+
skillsDir,
|
|
363
|
+
version: meta?.version ?? entry?.version ?? null,
|
|
364
|
+
index: realIndex && inside(realRoot, realIndex) && isFile(realIndex) ? index : null,
|
|
365
|
+
skills: skillsUnder(skillsDir),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Repo-local skills
|
|
373
|
+
|
|
374
|
+
/** Minimal frontmatter reader: `key: value` lines and `|` / `>` block scalars. */
|
|
375
|
+
export function frontmatter(text) {
|
|
376
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
377
|
+
if (!m) return {};
|
|
378
|
+
const out = {};
|
|
379
|
+
const lines = m[1].split(/\r?\n/);
|
|
380
|
+
for (let i = 0; i < lines.length; i++) {
|
|
381
|
+
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]);
|
|
382
|
+
if (!kv) continue;
|
|
383
|
+
let val = kv[2].trim();
|
|
384
|
+
if (/^[|>][-+]?$/.test(val)) {
|
|
385
|
+
const block = [];
|
|
386
|
+
while (i + 1 < lines.length && /^\s+\S|^\s*$/.test(lines[i + 1]))
|
|
387
|
+
block.push(lines[++i].trim());
|
|
388
|
+
val = block.join(" ").trim();
|
|
389
|
+
}
|
|
390
|
+
val = val.replace(/^(["'])(.*)\1$/, "$2");
|
|
391
|
+
out[kv[1]] = val;
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function claudeMdLines(repo) {
|
|
397
|
+
const out = [];
|
|
398
|
+
for (const rel of CLAUDE_MD) {
|
|
399
|
+
try {
|
|
400
|
+
for (const line of readFileSync(join(repo, rel), "utf8").split(/\r?\n/)) {
|
|
401
|
+
out.push({ rel, line });
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
/* absent CLAUDE.md is normal */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function escapeRe(name) {
|
|
411
|
+
return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function nameRe(name) {
|
|
415
|
+
return new RegExp(`(^|[^A-Za-z0-9-])${escapeRe(name)}([^A-Za-z0-9-]|$)`, "i");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* How a task names an explicit-only skill: `/name`, `` `name` `` or
|
|
420
|
+
* "skill <name>". A bare word is not an invocation: `release` must not fire on
|
|
421
|
+
* "Fix crash in release notes".
|
|
422
|
+
*/
|
|
423
|
+
export function explicitNameRe(name) {
|
|
424
|
+
const n = escapeRe(name);
|
|
425
|
+
const end = "(?![A-Za-z0-9-])";
|
|
426
|
+
return new RegExp(`(?:^|[\\s(])/${n}${end}|\`/?${n}\`|\\bskill[:\\s]+\`?/?${n}${end}`, "i");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* `<repo>/.claude/skills/<name>/SKILL.md`, with explicit-only detection. A
|
|
431
|
+
* skill whose directory or SKILL.md resolves outside the repo is not read; it is
|
|
432
|
+
* pushed to `rejected` with its reason.
|
|
433
|
+
* @param {string} repo
|
|
434
|
+
* @param {{path: string, reason: string}[]} [rejected]
|
|
435
|
+
*/
|
|
436
|
+
export function discoverRepoSkills(repo, rejected = []) {
|
|
437
|
+
const dir = join(repo, REPO_SKILLS_DIR);
|
|
438
|
+
let entries;
|
|
439
|
+
try {
|
|
440
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
441
|
+
} catch {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
const root = realOrNull(repo);
|
|
445
|
+
if (!root) return [];
|
|
446
|
+
const md = claudeMdLines(repo);
|
|
447
|
+
const out = [];
|
|
448
|
+
for (const e of entries) {
|
|
449
|
+
if (!e.isDirectory() && !(e.isSymbolicLink() && isDir(join(dir, e.name)))) continue;
|
|
450
|
+
const rel = `${REPO_SKILLS_DIR}/${e.name}/SKILL.md`;
|
|
451
|
+
const real = realOrNull(join(dir, e.name, "SKILL.md"));
|
|
452
|
+
if (!real) continue;
|
|
453
|
+
if (!inside(root, real)) {
|
|
454
|
+
rejected.push({ path: rel, reason: "SKILL.md resolves outside the repo" });
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
let text;
|
|
458
|
+
try {
|
|
459
|
+
text = readFileSync(real, "utf8");
|
|
460
|
+
} catch {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const fm = frontmatter(text);
|
|
464
|
+
const name = fm.name || e.name;
|
|
465
|
+
const description = fm.description || "";
|
|
466
|
+
let explicitOnlyReason = null;
|
|
467
|
+
if (String(fm["disable-model-invocation"]).toLowerCase() === "true") {
|
|
468
|
+
explicitOnlyReason = "frontmatter disable-model-invocation: true";
|
|
469
|
+
} else if (EXPLICIT_ONLY.test(description)) {
|
|
470
|
+
explicitOnlyReason = "description asks for explicit invocation";
|
|
471
|
+
} else {
|
|
472
|
+
const re = nameRe(name);
|
|
473
|
+
const hit = md.find((l) => re.test(l.line) && EXPLICIT_ONLY.test(l.line));
|
|
474
|
+
if (hit) explicitOnlyReason = `${hit.rel}: "${hit.line.trim().slice(0, 160)}"`;
|
|
475
|
+
}
|
|
476
|
+
out.push({
|
|
477
|
+
name,
|
|
478
|
+
description,
|
|
479
|
+
path: rel,
|
|
480
|
+
explicitOnly: explicitOnlyReason !== null,
|
|
481
|
+
explicitOnlyReason,
|
|
482
|
+
shadows: [],
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const STOP = new Set(
|
|
489
|
+
"the and for with this that from into when then than are was were has have had not but you your our its any all can use using run via per new app"
|
|
490
|
+
.split(" ")
|
|
491
|
+
.filter(Boolean),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
function stem(w) {
|
|
495
|
+
return w.replace(/(ing|ed|es|s)$/, "");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function tokens(text) {
|
|
499
|
+
return new Set(
|
|
500
|
+
String(text || "")
|
|
501
|
+
.toLowerCase()
|
|
502
|
+
.split(/[^a-z0-9]+/)
|
|
503
|
+
.filter((w) => w.length >= 3 && !STOP.has(w))
|
|
504
|
+
.map(stem),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Rank repo-local skills against a task by word overlap with name and
|
|
510
|
+
* description. A skill the task names is always a match; an explicit-only skill
|
|
511
|
+
* matches ONLY when invoked by name (`/name`, `` `name` ``, "skill <name>").
|
|
512
|
+
* @returns {{name: string, score: number, reason: string}[]}
|
|
513
|
+
*/
|
|
514
|
+
export function matchRepoSkills(skills, task, { minScore = 2 } = {}) {
|
|
515
|
+
const want = tokens(task);
|
|
516
|
+
const out = [];
|
|
517
|
+
for (const s of skills) {
|
|
518
|
+
if (s.explicitOnly) {
|
|
519
|
+
if (explicitNameRe(s.name).test(task)) {
|
|
520
|
+
out.push({ name: s.name, score: 100, reason: "invoked by name in the task" });
|
|
521
|
+
}
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (nameRe(s.name).test(task)) {
|
|
525
|
+
out.push({ name: s.name, score: 100, reason: "named in the task" });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const have = tokens(`${s.name.replace(/-/g, " ")} ${s.description}`);
|
|
529
|
+
const shared = [...want].filter((w) => have.has(w));
|
|
530
|
+
if (shared.length >= minScore) {
|
|
531
|
+
out.push({ name: s.name, score: shared.length, reason: `shares ${shared.join(", ")}` });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return out.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** A telemetry.skillCalls[] entry for a skill read from a marketplace fallback. */
|
|
538
|
+
export function marketplaceSkillCall(
|
|
539
|
+
fallback,
|
|
540
|
+
skill,
|
|
541
|
+
{ phase = DEV_PHASE, targetFiles = [], timestamp } = {},
|
|
542
|
+
) {
|
|
543
|
+
const version = fallback.version ?? "unknown";
|
|
544
|
+
return {
|
|
545
|
+
skill: `${fallback.toolkit}:${skill}`,
|
|
546
|
+
source: "marketplace-fallback",
|
|
547
|
+
toolkit: fallback.toolkit,
|
|
548
|
+
version,
|
|
549
|
+
phase,
|
|
550
|
+
targetFiles,
|
|
551
|
+
routedBy: `${fallback.toolkit}:index@${version}`,
|
|
552
|
+
timestamp: timestamp ?? new Date().toISOString(),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** A telemetry.skillCalls[] entry for a repo-local skill that was loaded. */
|
|
557
|
+
export function repoSkillCall(name, { phase = DEV_PHASE, targetFiles = [], timestamp } = {}) {
|
|
558
|
+
return {
|
|
559
|
+
skill: name,
|
|
560
|
+
source: "repo",
|
|
561
|
+
phase,
|
|
562
|
+
targetFiles,
|
|
563
|
+
routedBy: `repo:${REPO_SKILLS_DIR}`,
|
|
564
|
+
timestamp: timestamp ?? new Date().toISOString(),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// Resolution
|
|
570
|
+
|
|
571
|
+
function detection(repo, doc) {
|
|
572
|
+
const coarse = detectCoarse(repo);
|
|
573
|
+
const resolved = resolveStack(repo, { doc, coarse });
|
|
574
|
+
const adapters = resolved.adapters.map((a) => a.id);
|
|
575
|
+
const conclusive = coarse.ok && (coarse.stacks.length > 0 || adapters.length > 0);
|
|
576
|
+
const repoWords = new Set();
|
|
577
|
+
for (const s of coarse.stacks) for (const e of expand(s)) repoWords.add(e);
|
|
578
|
+
for (const id of adapters) for (const w of words(id)) repoWords.add(w);
|
|
579
|
+
return {
|
|
580
|
+
stacks: coarse.stacks,
|
|
581
|
+
adapters,
|
|
582
|
+
why: coarse.why,
|
|
583
|
+
conclusive,
|
|
584
|
+
words: [...repoWords].sort(),
|
|
585
|
+
filter: conclusive
|
|
586
|
+
? "inherited toolkits are filtered by the detected stacks"
|
|
587
|
+
: `no filter: detection inconclusive (${coarse.ok ? coarse.why || "no marker matched" : coarse.why})`,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function hintsFor(det, kept, doc, manifestPath) {
|
|
592
|
+
const hints = [];
|
|
593
|
+
for (const s of det.stacks) {
|
|
594
|
+
const own = new Set(expand(s));
|
|
595
|
+
for (const id of det.adapters) {
|
|
596
|
+
const claimed = doc.adapters[id]?.detection?.stackDetect;
|
|
597
|
+
if (!claimed || claimed === s) for (const w of words(id)) own.add(w);
|
|
598
|
+
}
|
|
599
|
+
const covered = kept.some((t) => !t.alwaysOn && t.stacks.some((w) => own.has(w)));
|
|
600
|
+
if (covered) continue;
|
|
601
|
+
const r = pluginsForStacks([s], { manifestPath });
|
|
602
|
+
const plugin = r.plugins.find((p) => !ALWAYS_ON.includes(p)) ?? null;
|
|
603
|
+
const dropped = r.dropped.find((p) => !ALWAYS_ON.includes(p)) ?? null;
|
|
604
|
+
hints.push({
|
|
605
|
+
stack: s,
|
|
606
|
+
plugin: plugin ?? dropped,
|
|
607
|
+
message: plugin
|
|
608
|
+
? `stack "${s}" detected but no toolkit for it is enabled: enable ${plugin} with /multi-agent:stack ${s}`
|
|
609
|
+
: `stack "${s}" detected and the marketplace has no toolkit for it (${dropped})`,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
return hints;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function modeOf(state, env) {
|
|
616
|
+
if (runMode(state, env).unattended) return "unattended";
|
|
617
|
+
return gatesActive(state, env) ? "autopilot" : "attended";
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Classify one enabled toolkit: kept with a role, excluded, or unscoped.
|
|
622
|
+
* @returns {{kind: "kept"|"excluded"|"unscoped", role?: string, reason: string}}
|
|
623
|
+
*/
|
|
624
|
+
function classify(entry, det) {
|
|
625
|
+
const { alwaysOn, source, stacks } = entry;
|
|
626
|
+
if (alwaysOn) return { kind: "kept", role: "common", reason: "always-on toolkit" };
|
|
627
|
+
if (!INHERITED.has(source)) {
|
|
628
|
+
return { kind: "kept", role: "stack", reason: `enabled by the repo (${source} settings)` };
|
|
629
|
+
}
|
|
630
|
+
if (!stacks.length) {
|
|
631
|
+
if (entry.hasIndex) {
|
|
632
|
+
return {
|
|
633
|
+
kind: "kept",
|
|
634
|
+
role: "common",
|
|
635
|
+
reason:
|
|
636
|
+
"inherited; its name and plugin.json mark no stack, and it ships an index: common role only",
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
kind: "unscoped",
|
|
641
|
+
reason:
|
|
642
|
+
"inherited; its name and plugin.json mark no stack and it ships no index: not routed to (attended runs may load it only when the user names it)",
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
if (!det.conclusive) return { kind: "kept", role: "stack", reason: det.filter };
|
|
646
|
+
const hit = stacks.filter((w) => det.words.includes(w));
|
|
647
|
+
if (hit.length) {
|
|
648
|
+
return { kind: "kept", role: "stack", reason: `matches detected stack (${hit.join(", ")})` };
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
kind: "excluded",
|
|
652
|
+
reason: `inherited from user settings; marks ${stacks.join(", ")} but the repo is ${
|
|
653
|
+
det.stacks.join(", ") || det.adapters.join(", ")
|
|
654
|
+
} (${det.why})`,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// The toolkits the pipeline itself publishes. On an otherwise even tie they
|
|
659
|
+
// win, so which skill a run binds to never depends on how an outside
|
|
660
|
+
// toolkit's name happens to sort.
|
|
661
|
+
let OWN_TOOLKITS = null;
|
|
662
|
+
function ownToolkits() {
|
|
663
|
+
if (!OWN_TOOLKITS) {
|
|
664
|
+
try {
|
|
665
|
+
OWN_TOOLKITS = new Set(pluginsForStacks(KNOWN_STACKS).plugins);
|
|
666
|
+
} catch {
|
|
667
|
+
OWN_TOOLKITS = new Set();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return OWN_TOOLKITS;
|
|
671
|
+
}
|
|
672
|
+
const isOwn = (t) => ownToolkits().has(t.name);
|
|
673
|
+
|
|
674
|
+
function ownerOrder(a, b) {
|
|
675
|
+
const role = PRECEDENCE.indexOf(a.role) - PRECEDENCE.indexOf(b.role);
|
|
676
|
+
if (role) return role;
|
|
677
|
+
const scope = SCOPES.indexOf(a.source) - SCOPES.indexOf(b.source);
|
|
678
|
+
if (scope) return scope;
|
|
679
|
+
const own = Number(isOwn(b)) - Number(isOwn(a));
|
|
680
|
+
if (own) return own;
|
|
681
|
+
return a.name.localeCompare(b.name);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function ruleBetween(w, l) {
|
|
685
|
+
if (w.role !== l.role) return `${w.role} role over ${l.role} role`;
|
|
686
|
+
if (w.source !== l.source) {
|
|
687
|
+
return `narrowest enabling scope (${w.source} settings over ${l.source} settings)`;
|
|
688
|
+
}
|
|
689
|
+
if (isOwn(w) !== isOwn(l)) {
|
|
690
|
+
return `same role and scope (${w.source} settings): the pipeline's own toolkit`;
|
|
691
|
+
}
|
|
692
|
+
return `same role and scope (${w.source} settings): first by toolkit name`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** One owner per skill name that more than one candidate ships. */
|
|
696
|
+
function overlapsFor(repoSkills, toolkits) {
|
|
697
|
+
const owners = new Map();
|
|
698
|
+
for (const t of toolkits) {
|
|
699
|
+
for (const s of t.skills || []) {
|
|
700
|
+
if (META_SKILLS.has(s)) continue;
|
|
701
|
+
if (!owners.has(s)) owners.set(s, []);
|
|
702
|
+
owners.get(s).push(t);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const overlaps = [];
|
|
706
|
+
const decisions = [];
|
|
707
|
+
for (const s of repoSkills) {
|
|
708
|
+
const shadowed = owners.get(s.name) || [];
|
|
709
|
+
for (const t of toolkits) {
|
|
710
|
+
if (t.skills?.includes(s.name)) s.shadows.push(`${t.name}:${s.name}`);
|
|
711
|
+
}
|
|
712
|
+
if (!s.shadows.length) continue;
|
|
713
|
+
decisions.push(
|
|
714
|
+
`${s.name}: repo skill ${s.path} wins over ${s.shadows.join(", ")} for this repo`,
|
|
715
|
+
);
|
|
716
|
+
if (shadowed.length) {
|
|
717
|
+
overlaps.push({
|
|
718
|
+
skill: s.name,
|
|
719
|
+
winner: "repo",
|
|
720
|
+
losers: shadowed.map((t) => t.name).sort(),
|
|
721
|
+
rule: "repo-local skill over any toolkit",
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
owners.delete(s.name);
|
|
725
|
+
}
|
|
726
|
+
for (const [skill, list] of [...owners].sort(([a], [b]) => a.localeCompare(b))) {
|
|
727
|
+
if (list.length < 2) continue;
|
|
728
|
+
const sorted = [...list].sort(ownerOrder);
|
|
729
|
+
const [w, ...rest] = sorted;
|
|
730
|
+
const rule = ruleBetween(w, rest[0]);
|
|
731
|
+
overlaps.push({ skill, winner: w.name, losers: rest.map((t) => t.name), rule });
|
|
732
|
+
decisions.push(
|
|
733
|
+
`${skill}: ${w.name}:${skill} wins over ${rest.map((t) => `${t.name}:${skill}`).join(", ")} (${rule})`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
return { overlaps, decisions };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* @param {string} repo
|
|
741
|
+
* @param {{home?: string, manifestPath?: string, doc?: object,
|
|
742
|
+
* env?: Record<string, string|undefined>, state?: object|null}} [opts]
|
|
743
|
+
*/
|
|
744
|
+
export function resolveSkillCandidates(repo, opts = {}) {
|
|
745
|
+
const env = opts.env ?? process.env;
|
|
746
|
+
const claudeDir = userConfigDir({ home: opts.home, env });
|
|
747
|
+
const mode = modeOf(opts.state ?? null, env);
|
|
748
|
+
const doc = opts.doc ?? loadAdapters();
|
|
749
|
+
const vocab = stackVocabulary(doc);
|
|
750
|
+
const det = detection(repo, doc);
|
|
751
|
+
|
|
752
|
+
const toolkits = [];
|
|
753
|
+
const excluded = [];
|
|
754
|
+
const unscoped = [];
|
|
755
|
+
const notes = [];
|
|
756
|
+
for (const [key, { on, source }] of effectiveEnabled(repo, claudeDir)) {
|
|
757
|
+
if (!on) continue;
|
|
758
|
+
const [name, marketplace = null] = key.split("@");
|
|
759
|
+
const info = installedPlugin(key, claudeDir);
|
|
760
|
+
if (!isToolkit(name, info)) continue;
|
|
761
|
+
const entry = {
|
|
762
|
+
name,
|
|
763
|
+
marketplace,
|
|
764
|
+
source,
|
|
765
|
+
alwaysOn: ALWAYS_ON.includes(name),
|
|
766
|
+
stacks: toolkitStacks(name, info.meta, vocab),
|
|
767
|
+
version: info.version,
|
|
768
|
+
installPath: info.installPath,
|
|
769
|
+
skills: info.skills,
|
|
770
|
+
hasIndex: hasIndex(info),
|
|
771
|
+
};
|
|
772
|
+
const c = classify(entry, det);
|
|
773
|
+
if (c.kind === "kept") toolkits.push({ ...entry, role: c.role, reason: c.reason });
|
|
774
|
+
else if (c.kind === "excluded") excluded.push({ ...entry, reason: c.reason });
|
|
775
|
+
else unscoped.push({ ...entry, reason: c.reason });
|
|
776
|
+
}
|
|
777
|
+
for (const n of ALWAYS_ON) {
|
|
778
|
+
if (!toolkits.some((t) => t.name === n)) {
|
|
779
|
+
notes.push(`${n} is always-on but not enabled here; /multi-agent:stack enables it`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const rejectedRepoSkills = [];
|
|
784
|
+
const repoSkills = discoverRepoSkills(repo, rejectedRepoSkills);
|
|
785
|
+
const { overlaps, decisions } = overlapsFor(repoSkills, toolkits);
|
|
786
|
+
|
|
787
|
+
const hints = det.conclusive ? hintsFor(det, toolkits, doc, opts.manifestPath) : [];
|
|
788
|
+
const fallbacks = [];
|
|
789
|
+
if (mode !== "attended") {
|
|
790
|
+
for (const h of hints) {
|
|
791
|
+
if (!h.plugin) continue;
|
|
792
|
+
const found = findMarketplacePlugin(h.plugin, claudeDir);
|
|
793
|
+
if (!found) {
|
|
794
|
+
fallbacks.push({
|
|
795
|
+
stack: h.stack,
|
|
796
|
+
toolkit: h.plugin,
|
|
797
|
+
gap: `no marketplace clone carries ${h.plugin}; continuing with the enabled toolkits`,
|
|
798
|
+
});
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
fallbacks.push({
|
|
802
|
+
stack: h.stack,
|
|
803
|
+
toolkit: h.plugin,
|
|
804
|
+
...found,
|
|
805
|
+
readOnly: true,
|
|
806
|
+
reportLine: `${h.plugin} is not enabled for this repo; this ${mode} run read its skills read-only from the ${found.marketplace} marketplace clone (version ${found.version ?? "unknown"}). Enable it with /multi-agent:stack ${h.stack}.`,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return {
|
|
812
|
+
repo,
|
|
813
|
+
mode,
|
|
814
|
+
precedence: PRECEDENCE,
|
|
815
|
+
detection: det,
|
|
816
|
+
toolkits,
|
|
817
|
+
excluded,
|
|
818
|
+
unscoped,
|
|
819
|
+
hints,
|
|
820
|
+
fallbacks,
|
|
821
|
+
repoSkills,
|
|
822
|
+
rejectedRepoSkills,
|
|
823
|
+
overlaps,
|
|
824
|
+
decisions,
|
|
825
|
+
notes,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* The task text from `--task-file <path>`, `--task -` (stdin) or `--task <text>`.
|
|
831
|
+
* @returns {{task?: string, error?: string}}
|
|
832
|
+
*/
|
|
833
|
+
export function readTaskArg(args, { stdin = () => readFileSync(0, "utf8") } = {}) {
|
|
834
|
+
const at = (flag) => {
|
|
835
|
+
const i = args.indexOf(flag);
|
|
836
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
837
|
+
};
|
|
838
|
+
const file = at("--task-file");
|
|
839
|
+
const literal = at("--task");
|
|
840
|
+
let text;
|
|
841
|
+
try {
|
|
842
|
+
if (file !== undefined) text = file === "-" ? stdin() : readFileSync(file, "utf8");
|
|
843
|
+
else if (literal === "-") text = stdin();
|
|
844
|
+
else text = literal;
|
|
845
|
+
} catch (e) {
|
|
846
|
+
return { error: `cannot read --task-file ${file}: ${e.code || e.message}` };
|
|
847
|
+
}
|
|
848
|
+
if (text === undefined) return {};
|
|
849
|
+
return { task: String(text).slice(0, MAX_TASK_BYTES).trim() };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (invokedDirectly(import.meta.url)) {
|
|
853
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
854
|
+
const arg = (flag) => {
|
|
855
|
+
const i = rest.indexOf(flag);
|
|
856
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
857
|
+
};
|
|
858
|
+
const dir = arg("--dir");
|
|
859
|
+
if (cmd !== "resolve" || !dir) {
|
|
860
|
+
console.error(
|
|
861
|
+
"usage: skill-candidates.mjs resolve --dir <repo> [--task-file <path> | --task -] [--home <dir>] [--state <file>]",
|
|
862
|
+
);
|
|
863
|
+
process.exit(2);
|
|
864
|
+
}
|
|
865
|
+
const statePath = arg("--state");
|
|
866
|
+
let state = null;
|
|
867
|
+
if (statePath) {
|
|
868
|
+
state = readJson(statePath);
|
|
869
|
+
if (!state) {
|
|
870
|
+
console.error(`skill-candidates: cannot read state ${statePath}`);
|
|
871
|
+
process.exit(2);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const { task, error } = readTaskArg(rest);
|
|
875
|
+
if (error) {
|
|
876
|
+
console.error(`skill-candidates: ${error}`);
|
|
877
|
+
process.exit(2);
|
|
878
|
+
}
|
|
879
|
+
const out = resolveSkillCandidates(dir, { home: arg("--home"), state });
|
|
880
|
+
if (task) out.repoMatches = matchRepoSkills(out.repoSkills, task);
|
|
881
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
882
|
+
}
|