canary-test-cli 5.11.0 → 5.13.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/bin/canary +0 -0
- package/bin/canary.js +12 -10
- package/dist/doctor-manifest.js +83 -52
- package/dist/doctor.js +90 -44
- package/dist/engine-checks.js +277 -60
- package/dist/overlay-commands.js +155 -42
- package/dist/overlay-conflicts.js +121 -0
- package/dist/overlay-lint.js +211 -0
- package/dist/overlays-registry.js +23 -12
- package/dist/router.js +15 -14
- package/dist/skill-requirements.js +129 -0
- package/dist/source-spec.js +19 -11
- package/package.json +1 -1
- package/scripts/install.js +49 -32
package/dist/overlay-commands.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict';
|
|
2
2
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
3
|
if (k2 === undefined) k2 = k;
|
|
4
4
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -39,6 +39,7 @@ exports.skillCount = skillCount;
|
|
|
39
39
|
exports.workingTreeStatus = workingTreeStatus;
|
|
40
40
|
exports.freshness = freshness;
|
|
41
41
|
exports.list = list;
|
|
42
|
+
exports.lint = lint;
|
|
42
43
|
exports.update = update;
|
|
43
44
|
exports.remove = remove;
|
|
44
45
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -48,13 +49,23 @@ const path = __importStar(require("node:path"));
|
|
|
48
49
|
const source_spec_js_1 = require("./source-spec.js");
|
|
49
50
|
const registry = __importStar(require("./overlays-registry.js"));
|
|
50
51
|
const doctor_manifest_js_1 = require("./doctor-manifest.js");
|
|
52
|
+
const overlay_conflicts_js_1 = require("./overlay-conflicts.js");
|
|
53
|
+
const overlay_lint_js_1 = require("./overlay-lint.js");
|
|
51
54
|
const defaultGit = (args, opts = {}) => {
|
|
52
|
-
const r = (0, node_child_process_1.spawnSync)(
|
|
55
|
+
const r = (0, node_child_process_1.spawnSync)('git', args, { cwd: opts.cwd, encoding: 'utf8' });
|
|
53
56
|
if (r.error) {
|
|
54
57
|
const code = r.error.code;
|
|
55
|
-
return {
|
|
58
|
+
return {
|
|
59
|
+
status: code === 'ENOENT' ? 127 : 1,
|
|
60
|
+
stdout: '',
|
|
61
|
+
stderr: String(r.error.message),
|
|
62
|
+
};
|
|
56
63
|
}
|
|
57
|
-
return {
|
|
64
|
+
return {
|
|
65
|
+
status: r.status ?? 1,
|
|
66
|
+
stdout: r.stdout ?? '',
|
|
67
|
+
stderr: r.stderr ?? '',
|
|
68
|
+
};
|
|
58
69
|
};
|
|
59
70
|
function today() {
|
|
60
71
|
return new Date().toISOString().slice(0, 10);
|
|
@@ -68,8 +79,8 @@ function defaultConfirm(question) {
|
|
|
68
79
|
const buf = Buffer.alloc(256);
|
|
69
80
|
try {
|
|
70
81
|
const n = fs.readSync(0, buf, 0, buf.length, null);
|
|
71
|
-
const answer = buf.toString(
|
|
72
|
-
return answer ===
|
|
82
|
+
const answer = buf.toString('utf8', 0, n).trim().toLowerCase();
|
|
83
|
+
return answer === 'y' || answer === 'yes';
|
|
73
84
|
}
|
|
74
85
|
catch {
|
|
75
86
|
return false;
|
|
@@ -90,10 +101,10 @@ function collectConsent(dest, name, confirm, out) {
|
|
|
90
101
|
if (hash === null) {
|
|
91
102
|
return { consent: null, consentCommandsHash: null };
|
|
92
103
|
}
|
|
93
|
-
const cmds = load.checks.filter((c) => c.type ===
|
|
104
|
+
const cmds = load.checks.filter((c) => c.type === 'command-succeeds');
|
|
94
105
|
out.write(`Overlay "${name}" ships ${cmds.length} command check(s) that 'canary doctor' can run:\n`);
|
|
95
106
|
for (const c of cmds) {
|
|
96
|
-
out.write(` - ${c.id}: ${(c.command ?? []).join(
|
|
107
|
+
out.write(` - ${c.id}: ${(c.command ?? []).join(' ')}\n`);
|
|
97
108
|
}
|
|
98
109
|
const granted = confirm(`Allow 'canary doctor' to run these commands for "${name}"? [y/N] `);
|
|
99
110
|
if (!granted) {
|
|
@@ -103,29 +114,43 @@ function collectConsent(dest, name, confirm, out) {
|
|
|
103
114
|
}
|
|
104
115
|
/** Ordered stderr-substring signatures for `git clone` failure classification. */
|
|
105
116
|
const CLONE_FAILURE_SIGNATURES = [
|
|
106
|
-
{ reason:
|
|
117
|
+
{ reason: 'git not found on PATH', needles: ['enoent', 'not found: git'] },
|
|
118
|
+
{
|
|
119
|
+
reason: 'network unreachable',
|
|
120
|
+
needles: [
|
|
121
|
+
'could not resolve host',
|
|
122
|
+
'network is unreachable',
|
|
123
|
+
'failed to connect',
|
|
124
|
+
'timed out',
|
|
125
|
+
],
|
|
126
|
+
},
|
|
107
127
|
{
|
|
108
|
-
reason:
|
|
109
|
-
needles: [
|
|
128
|
+
reason: 'authentication denied',
|
|
129
|
+
needles: [
|
|
130
|
+
'authentication failed',
|
|
131
|
+
'permission denied',
|
|
132
|
+
'could not read username',
|
|
133
|
+
'access denied',
|
|
134
|
+
'403 forbidden',
|
|
135
|
+
],
|
|
110
136
|
},
|
|
111
137
|
{
|
|
112
|
-
reason:
|
|
113
|
-
needles: [
|
|
138
|
+
reason: 'repository not found',
|
|
139
|
+
needles: ['repository not found', 'does not exist', 'not found'],
|
|
114
140
|
},
|
|
115
|
-
{ reason: "repository not found", needles: ["repository not found", "does not exist", "not found"] },
|
|
116
141
|
];
|
|
117
142
|
/** Best-effort classification of a failed `git clone`, for a useful remedy. */
|
|
118
143
|
function classifyCloneFailure(res) {
|
|
119
144
|
const s = res.stderr.toLowerCase();
|
|
120
145
|
if (res.status === 127) {
|
|
121
|
-
return
|
|
146
|
+
return 'git not found on PATH';
|
|
122
147
|
}
|
|
123
148
|
for (const { reason, needles } of CLONE_FAILURE_SIGNATURES) {
|
|
124
149
|
if (needles.some((needle) => s.includes(needle))) {
|
|
125
150
|
return reason;
|
|
126
151
|
}
|
|
127
152
|
}
|
|
128
|
-
return
|
|
153
|
+
return 'unknown error';
|
|
129
154
|
}
|
|
130
155
|
/**
|
|
131
156
|
* `canary overlay add <source> [--ref <tag>]` — clone a tracked overlay into
|
|
@@ -169,9 +194,9 @@ function add(source, options = {}, deps = {}) {
|
|
|
169
194
|
return 1;
|
|
170
195
|
}
|
|
171
196
|
fs.mkdirSync(registry.overlaysDir(homeDir), { recursive: true });
|
|
172
|
-
const args = [
|
|
197
|
+
const args = ['clone', '--quiet'];
|
|
173
198
|
if (ref) {
|
|
174
|
-
args.push(
|
|
199
|
+
args.push('--branch', ref);
|
|
175
200
|
}
|
|
176
201
|
args.push(parsed.cloneUrl, dest);
|
|
177
202
|
const res = git(args);
|
|
@@ -179,7 +204,7 @@ function add(source, options = {}, deps = {}) {
|
|
|
179
204
|
fs.rmSync(dest, { recursive: true, force: true }); // never leave a partial clone
|
|
180
205
|
const reason = classifyCloneFailure(res);
|
|
181
206
|
err.write(`canary overlay add: clone failed (${reason}).\n` +
|
|
182
|
-
(res.stderr.trim() ? `${res.stderr.trim()}\n` :
|
|
207
|
+
(res.stderr.trim() ? `${res.stderr.trim()}\n` : '') +
|
|
183
208
|
`Nothing was registered. Check the overlay's access docs and your git credentials.\n`);
|
|
184
209
|
return 1;
|
|
185
210
|
}
|
|
@@ -193,6 +218,9 @@ function add(source, options = {}, deps = {}) {
|
|
|
193
218
|
addedDate: stamp(),
|
|
194
219
|
consent,
|
|
195
220
|
consentCommandsHash,
|
|
221
|
+
// Undeclared precedence (#333). Set a number in overlays.json to declare
|
|
222
|
+
// which overlay wins a skill-name collision; higher wins.
|
|
223
|
+
precedence: null,
|
|
196
224
|
};
|
|
197
225
|
try {
|
|
198
226
|
registry.write(registry.add(reg, entry), homeDir);
|
|
@@ -202,12 +230,12 @@ function add(source, options = {}, deps = {}) {
|
|
|
202
230
|
err.write(`canary overlay add: ${e.message}\n`);
|
|
203
231
|
return 1;
|
|
204
232
|
}
|
|
205
|
-
out.write(`Added overlay "${parsed.name}"${ref ? ` @ ${ref}` :
|
|
233
|
+
out.write(`Added overlay "${parsed.name}"${ref ? ` @ ${ref}` : ''} → ${dest}\n`);
|
|
206
234
|
return 0;
|
|
207
235
|
}
|
|
208
236
|
/** Count `.canary/skills/<name>/SKILL.md` entries in a clone. */
|
|
209
237
|
function skillCount(dest) {
|
|
210
|
-
const skillsDir = path.join(dest,
|
|
238
|
+
const skillsDir = path.join(dest, '.canary', 'skills');
|
|
211
239
|
let entries;
|
|
212
240
|
try {
|
|
213
241
|
entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
@@ -215,7 +243,8 @@ function skillCount(dest) {
|
|
|
215
243
|
catch {
|
|
216
244
|
return 0;
|
|
217
245
|
}
|
|
218
|
-
return entries.filter((d) => d.isDirectory() &&
|
|
246
|
+
return entries.filter((d) => d.isDirectory() &&
|
|
247
|
+
fs.existsSync(path.join(skillsDir, d.name, 'SKILL.md'))).length;
|
|
219
248
|
}
|
|
220
249
|
/**
|
|
221
250
|
* Whether a clone's working tree is clean, dirty (local modifications), or its
|
|
@@ -223,11 +252,11 @@ function skillCount(dest) {
|
|
|
223
252
|
* the `doctor` engine check ("no local overlay modifications").
|
|
224
253
|
*/
|
|
225
254
|
function workingTreeStatus(dest, git) {
|
|
226
|
-
const status = git([
|
|
255
|
+
const status = git(['status', '--porcelain'], { cwd: dest });
|
|
227
256
|
if (status.status !== 0) {
|
|
228
|
-
return
|
|
257
|
+
return 'unreadable';
|
|
229
258
|
}
|
|
230
|
-
return status.stdout.trim() ===
|
|
259
|
+
return status.stdout.trim() === '' ? 'clean' : 'dirty';
|
|
231
260
|
}
|
|
232
261
|
/**
|
|
233
262
|
* Freshness of a clone against its LOCAL knowledge of the upstream — no fetch
|
|
@@ -238,22 +267,20 @@ function freshness(dest, entry, git) {
|
|
|
238
267
|
if (!fs.existsSync(dest)) {
|
|
239
268
|
return "missing — clone not found (run 'canary overlay update' or re-add)";
|
|
240
269
|
}
|
|
241
|
-
const behind = git([
|
|
270
|
+
const behind = git(['rev-list', '--count', 'HEAD..@{u}'], { cwd: dest });
|
|
242
271
|
if (behind.status !== 0) {
|
|
243
272
|
// No upstream tracking ref — typically a pinned tag/detached HEAD.
|
|
244
|
-
return entry.ref
|
|
273
|
+
return entry.ref
|
|
274
|
+
? `pinned @ ${entry.ref}`
|
|
275
|
+
: 'unknown (no upstream tracking ref)';
|
|
245
276
|
}
|
|
246
277
|
const n = Number.parseInt(behind.stdout.trim(), 10);
|
|
247
278
|
if (!Number.isFinite(n) || n === 0) {
|
|
248
|
-
return
|
|
279
|
+
return 'up to date';
|
|
249
280
|
}
|
|
250
|
-
return `${n} commit${n === 1 ?
|
|
281
|
+
return `${n} commit${n === 1 ? '' : 's'} behind`;
|
|
251
282
|
}
|
|
252
|
-
|
|
253
|
-
* `canary overlay list` — one block per registered overlay: name, source, ref,
|
|
254
|
-
* freshness, and skill count.
|
|
255
|
-
*/
|
|
256
|
-
function list(deps = {}) {
|
|
283
|
+
function list(deps = {}, opts = {}) {
|
|
257
284
|
const git = deps.git ?? defaultGit;
|
|
258
285
|
const homeDir = deps.homeDir ?? os.homedir();
|
|
259
286
|
const out = deps.out ?? process.stdout;
|
|
@@ -266,6 +293,9 @@ function list(deps = {}) {
|
|
|
266
293
|
err.write(`canary overlay list: ${e.message}\n`);
|
|
267
294
|
return 1;
|
|
268
295
|
}
|
|
296
|
+
if (opts.conflicts) {
|
|
297
|
+
return listConflicts(reg, out);
|
|
298
|
+
}
|
|
269
299
|
if (reg.overlays.length === 0) {
|
|
270
300
|
out.write("No overlays added. Add one with 'canary overlay add <source>'.\n");
|
|
271
301
|
return 0;
|
|
@@ -273,12 +303,93 @@ function list(deps = {}) {
|
|
|
273
303
|
for (const o of reg.overlays) {
|
|
274
304
|
out.write(`${o.name}\n`);
|
|
275
305
|
out.write(` source: ${o.source}\n`);
|
|
276
|
-
out.write(` ref: ${o.ref ??
|
|
306
|
+
out.write(` ref: ${o.ref ?? '(default branch)'}\n`);
|
|
277
307
|
out.write(` status: ${freshness(o.path, o, git)}\n`);
|
|
278
308
|
out.write(` skills: ${skillCount(o.path)}\n`);
|
|
279
309
|
}
|
|
280
310
|
return 0;
|
|
281
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Print the skill-name conflict report (`overlay list --conflicts`, #333).
|
|
314
|
+
* Returns 0 when there are no *unresolved* conflicts, 1 when at least one skill
|
|
315
|
+
* name collides with no declared precedence winner — so the flag doubles as a
|
|
316
|
+
* scriptable gate.
|
|
317
|
+
*/
|
|
318
|
+
function listConflicts(reg, out) {
|
|
319
|
+
const conflicts = (0, overlay_conflicts_js_1.detectSkillConflicts)(reg);
|
|
320
|
+
if (conflicts.length === 0) {
|
|
321
|
+
out.write('No skill-name conflicts across registered overlays.\n');
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
let unresolved = 0;
|
|
325
|
+
for (const c of conflicts) {
|
|
326
|
+
const sources = c.contenders
|
|
327
|
+
.map((ct) => `${ct.overlay} (precedence ${ct.precedence})`)
|
|
328
|
+
.join(', ');
|
|
329
|
+
if (c.resolved) {
|
|
330
|
+
out.write(`✓ ${c.skill}: ${sources}\n → resolved: ${c.winner} wins\n`);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
unresolved += 1;
|
|
334
|
+
out.write(`✗ ${c.skill}: ${sources}\n → UNRESOLVED (tie) — set a higher ` +
|
|
335
|
+
`precedence on the overlay you want to win in overlays.json\n`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
out.write(`\n${conflicts.length} conflict(s), ${unresolved} unresolved.\n`);
|
|
339
|
+
return unresolved === 0 ? 0 : 1;
|
|
340
|
+
}
|
|
341
|
+
/** Resolve an overlay `<name|path>` to a clone directory, or null if absent. */
|
|
342
|
+
function resolveOverlayDir(nameOrPath, homeDir) {
|
|
343
|
+
// A value with a path separator, or `.`/`..`/a dot-relative form, is a path;
|
|
344
|
+
// a bare token is a tracked overlay name (mirrors agent/core/overlays.py's
|
|
345
|
+
// disambiguation, extended so the natural CI form `overlay lint .` works).
|
|
346
|
+
const looksLikePath = nameOrPath.includes('/') ||
|
|
347
|
+
nameOrPath.includes(path.sep) ||
|
|
348
|
+
nameOrPath === '.' ||
|
|
349
|
+
nameOrPath === '..' ||
|
|
350
|
+
nameOrPath.startsWith('.');
|
|
351
|
+
const dir = looksLikePath
|
|
352
|
+
? path.resolve(nameOrPath)
|
|
353
|
+
: registry.clonePath(nameOrPath, homeDir);
|
|
354
|
+
return fs.existsSync(dir) ? dir : null;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* `canary overlay lint <name|path>` (#332) — validate an overlay against the
|
|
358
|
+
* authoring contract. Exits 0 when there are no errors (warnings are advisory),
|
|
359
|
+
* 1 on any error or an unresolvable target.
|
|
360
|
+
*/
|
|
361
|
+
function lint(nameOrPath, deps = {}, opts = {}) {
|
|
362
|
+
const homeDir = deps.homeDir ?? os.homedir();
|
|
363
|
+
const out = deps.out ?? process.stdout;
|
|
364
|
+
const err = deps.err ?? process.stderr;
|
|
365
|
+
if (!nameOrPath) {
|
|
366
|
+
err.write('usage: canary overlay lint <name|path> [--json]\n');
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
const dir = resolveOverlayDir(nameOrPath, homeDir);
|
|
370
|
+
if (dir === null) {
|
|
371
|
+
err.write(`canary overlay lint: no overlay found for "${nameOrPath}" (not a tracked name or an existing path).\n`);
|
|
372
|
+
return 1;
|
|
373
|
+
}
|
|
374
|
+
const result = (0, overlay_lint_js_1.lintOverlay)(dir);
|
|
375
|
+
const errors = result.findings.filter((f) => f.level === 'error');
|
|
376
|
+
if (opts.json) {
|
|
377
|
+
out.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
378
|
+
return errors.length === 0 ? 0 : 1;
|
|
379
|
+
}
|
|
380
|
+
const symbol = (f) => (f.level === 'error' ? '✗' : '⚠');
|
|
381
|
+
out.write(`canary overlay lint: ${nameOrPath}\n`);
|
|
382
|
+
if (result.findings.length === 0) {
|
|
383
|
+
out.write(`\n✓ ${result.skillsChecked} skill(s) — no issues.\n`);
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
for (const f of result.findings) {
|
|
387
|
+
out.write(` ${symbol(f)} ${f.skill}: ${f.message}\n`);
|
|
388
|
+
}
|
|
389
|
+
const warnings = result.findings.length - errors.length;
|
|
390
|
+
out.write(`\n${result.skillsChecked} skill(s) checked — ${errors.length} error(s), ${warnings} warning(s).\n`);
|
|
391
|
+
return errors.length === 0 ? 0 : 1;
|
|
392
|
+
}
|
|
282
393
|
/** Update one overlay clone. Returns 0 on success, 1 on refusal/failure. */
|
|
283
394
|
function updateOne(o, git, out, err) {
|
|
284
395
|
if (!fs.existsSync(o.path)) {
|
|
@@ -286,23 +397,25 @@ function updateOne(o, git, out, err) {
|
|
|
286
397
|
return 1;
|
|
287
398
|
}
|
|
288
399
|
const clean = workingTreeStatus(o.path, git);
|
|
289
|
-
if (clean ===
|
|
400
|
+
if (clean === 'unreadable') {
|
|
290
401
|
err.write(`overlay "${o.name}": cannot read git status at ${o.path}.\n`);
|
|
291
402
|
return 1;
|
|
292
403
|
}
|
|
293
|
-
if (clean ===
|
|
404
|
+
if (clean === 'dirty') {
|
|
294
405
|
err.write(`overlay "${o.name}": local modifications in ${o.path} — refusing to update. ` +
|
|
295
406
|
`Commit/stash them, or 'canary overlay remove ${o.name}' and re-add.\n`);
|
|
296
407
|
return 1;
|
|
297
408
|
}
|
|
298
409
|
if (o.ref) {
|
|
299
410
|
// Pinned to a tag/branch: fetch (incl. tags), then re-checkout the ref.
|
|
300
|
-
const fetch = git([
|
|
411
|
+
const fetch = git(['fetch', '--quiet', '--tags', 'origin'], {
|
|
412
|
+
cwd: o.path,
|
|
413
|
+
});
|
|
301
414
|
if (fetch.status !== 0) {
|
|
302
415
|
err.write(`overlay "${o.name}": fetch failed.\n${fetch.stderr.trim()}\n`);
|
|
303
416
|
return 1;
|
|
304
417
|
}
|
|
305
|
-
const co = git([
|
|
418
|
+
const co = git(['checkout', '--quiet', o.ref], { cwd: o.path });
|
|
306
419
|
if (co.status !== 0) {
|
|
307
420
|
err.write(`overlay "${o.name}": checkout ${o.ref} failed.\n${co.stderr.trim()}\n`);
|
|
308
421
|
return 1;
|
|
@@ -310,11 +423,11 @@ function updateOne(o, git, out, err) {
|
|
|
310
423
|
out.write(`overlay "${o.name}": fetched, pinned @ ${o.ref}.\n`);
|
|
311
424
|
return 0;
|
|
312
425
|
}
|
|
313
|
-
const pull = git([
|
|
426
|
+
const pull = git(['pull', '--ff-only', '--quiet'], { cwd: o.path });
|
|
314
427
|
if (pull.status !== 0) {
|
|
315
428
|
err.write(`overlay "${o.name}": cannot fast-forward ${o.path} ` +
|
|
316
429
|
`(diverged or rewritten history) — 'canary overlay remove ${o.name}' and re-add.\n` +
|
|
317
|
-
(pull.stderr.trim() ? `${pull.stderr.trim()}\n` :
|
|
430
|
+
(pull.stderr.trim() ? `${pull.stderr.trim()}\n` : ''));
|
|
318
431
|
return 1;
|
|
319
432
|
}
|
|
320
433
|
out.write(`overlay "${o.name}": updated.\n`);
|
|
@@ -348,7 +461,7 @@ function update(name, deps = {}) {
|
|
|
348
461
|
}
|
|
349
462
|
else {
|
|
350
463
|
if (reg.overlays.length === 0) {
|
|
351
|
-
out.write(
|
|
464
|
+
out.write('No overlays to update.\n');
|
|
352
465
|
return 0;
|
|
353
466
|
}
|
|
354
467
|
targets = reg.overlays;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.readOverlaySkillNames = readOverlaySkillNames;
|
|
37
|
+
exports.detectSkillConflicts = detectSkillConflicts;
|
|
38
|
+
/**
|
|
39
|
+
* Overlay skill-name conflict detection (#333).
|
|
40
|
+
*
|
|
41
|
+
* When two registered overlays ship a skill of the same name, which definition
|
|
42
|
+
* wins was previously undefined and undocumented. This module detects those
|
|
43
|
+
* collisions and decides — from each overlay's declared `precedence` — whether
|
|
44
|
+
* the winner is *declared* (resolved) or *accidental* (unresolved).
|
|
45
|
+
*
|
|
46
|
+
* Winner rule (must match the Python skill loader, `agent/core/skill_registry`):
|
|
47
|
+
* among the overlays contending for a skill name, the one with the highest
|
|
48
|
+
* `precedence` wins; a null/absent precedence counts as 0. The collision is
|
|
49
|
+
* only **resolved** when exactly one overlay holds that highest value —
|
|
50
|
+
* otherwise the winner is arbitrary (directory-name order) and `doctor` flags
|
|
51
|
+
* it so the operator declares a precedence.
|
|
52
|
+
*/
|
|
53
|
+
const fs = __importStar(require("node:fs"));
|
|
54
|
+
const path = __importStar(require("node:path"));
|
|
55
|
+
/** Effective precedence: null/absent is 0. */
|
|
56
|
+
function effectivePrecedence(entry) {
|
|
57
|
+
return typeof entry.precedence === 'number' ? entry.precedence : 0;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Skill (directory) names an overlay ships, read from
|
|
61
|
+
* `<overlay>/.canary/skills/<name>/SKILL.md`. A missing or unreadable skills
|
|
62
|
+
* dir yields no names (never throws) — mirrors the Python loader's tolerance.
|
|
63
|
+
*/
|
|
64
|
+
function readOverlaySkillNames(entry) {
|
|
65
|
+
// A hand-edited overlays.json (which #333 invites, to add `precedence`) may
|
|
66
|
+
// carry an entry with no `path`. Tolerate it rather than throwing — the
|
|
67
|
+
// "never throws" contract must hold before path.join, not only around readdir.
|
|
68
|
+
if (typeof entry.path !== 'string' || entry.path === '')
|
|
69
|
+
return [];
|
|
70
|
+
const skillsDir = path.join(entry.path, '.canary', 'skills');
|
|
71
|
+
let dirents;
|
|
72
|
+
try {
|
|
73
|
+
dirents = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const names = [];
|
|
79
|
+
for (const d of dirents) {
|
|
80
|
+
if (!d.isDirectory())
|
|
81
|
+
continue;
|
|
82
|
+
if (fs.existsSync(path.join(skillsDir, d.name, 'SKILL.md'))) {
|
|
83
|
+
names.push(d.name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return names;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Detect skill-name collisions across the registered overlays. Returns one
|
|
90
|
+
* {@link SkillConflict} per skill name shipped by two or more overlays, sorted
|
|
91
|
+
* by skill name.
|
|
92
|
+
*/
|
|
93
|
+
function detectSkillConflicts(reg, deps = {}) {
|
|
94
|
+
const readSkillNames = deps.readSkillNames ?? readOverlaySkillNames;
|
|
95
|
+
// skill name -> contending overlays
|
|
96
|
+
const bySkill = new Map();
|
|
97
|
+
for (const entry of reg.overlays) {
|
|
98
|
+
const precedence = effectivePrecedence(entry);
|
|
99
|
+
for (const skill of readSkillNames(entry)) {
|
|
100
|
+
const list = bySkill.get(skill) ?? [];
|
|
101
|
+
list.push({ overlay: entry.name, precedence });
|
|
102
|
+
bySkill.set(skill, list);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const conflicts = [];
|
|
106
|
+
for (const [skill, contenders] of bySkill) {
|
|
107
|
+
if (contenders.length < 2)
|
|
108
|
+
continue;
|
|
109
|
+
const ordered = [...contenders].sort((a, b) => b.precedence - a.precedence || a.overlay.localeCompare(b.overlay));
|
|
110
|
+
const top = ordered[0].precedence;
|
|
111
|
+
const topHolders = ordered.filter((c) => c.precedence === top);
|
|
112
|
+
const resolved = topHolders.length === 1;
|
|
113
|
+
conflicts.push({
|
|
114
|
+
skill,
|
|
115
|
+
contenders: ordered,
|
|
116
|
+
winner: resolved ? topHolders[0].overlay : null,
|
|
117
|
+
resolved,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return conflicts.sort((a, b) => a.skill.localeCompare(b.skill));
|
|
121
|
+
}
|