vigiles 5.0.0 → 5.1.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/README.md +82 -116
- package/dist/adapters/claude-code/agent-runtime.d.ts +10 -0
- package/dist/adapters/claude-code/agent-runtime.js +15 -29
- package/dist/adapters/claude-code/dialect.js +18 -2
- package/dist/adapters/codex/eval.d.ts +94 -0
- package/dist/adapters/codex/eval.js +227 -0
- package/dist/cli.js +464 -8
- package/dist/codex.d.ts +1 -0
- package/dist/codex.js +3 -0
- package/dist/core/compile.js +8 -36
- package/dist/core/description-overlap.d.ts +27 -0
- package/dist/core/description-overlap.js +53 -0
- package/dist/core/dialect.d.ts +8 -0
- package/dist/core/frontmatter-read.d.ts +25 -0
- package/dist/core/frontmatter-read.js +138 -0
- package/dist/core/hook-events.d.ts +34 -0
- package/dist/core/hook-events.js +48 -0
- package/dist/core/mcp-config.d.ts +20 -0
- package/dist/core/mcp-config.js +40 -0
- package/dist/core/mcp-hook.d.ts +35 -0
- package/dist/core/mcp-hook.js +70 -0
- package/dist/core/mcp-tool.d.ts +50 -0
- package/dist/core/mcp-tool.js +61 -0
- package/dist/core/tool-contract.d.ts +68 -0
- package/dist/core/tool-contract.js +113 -0
- package/dist/core/types.d.ts +89 -0
- package/dist/core/validate.js +22 -0
- package/dist/eval.d.ts +69 -13
- package/dist/eval.js +106 -51
- package/dist/leaderboard.js +61 -3
- package/dist/plugin-loader.d.ts +1 -0
- package/dist/plugin-loader.js +71 -18
- package/dist/scan-behavioral.d.ts +73 -0
- package/dist/scan-behavioral.js +150 -0
- package/dist/scan.d.ts +126 -1
- package/dist/scan.js +559 -40
- package/package.json +27 -4
- package/skills/migrate-to-spec/SKILL.md +0 -2
package/dist/eval.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.EPHEMERAL_HOME_KEEP = void 0;
|
|
3
|
+
exports.claudeEvalDriver = exports.EPHEMERAL_HOME_KEEP = void 0;
|
|
4
4
|
exports.resolveSpawnEnv = resolveSpawnEnv;
|
|
5
|
+
exports.spawnAgent = spawnAgent;
|
|
5
6
|
exports.runEval = runEval;
|
|
6
7
|
exports.measureWith = measureWith;
|
|
7
8
|
exports.measure = measure;
|
|
@@ -12,6 +13,7 @@ exports.formatCheckReport = formatCheckReport;
|
|
|
12
13
|
exports.assertRates = assertRates;
|
|
13
14
|
exports.checkReportToJUnit = checkReportToJUnit;
|
|
14
15
|
exports.parseUsage = parseUsage;
|
|
16
|
+
exports.parseClaudeRun = parseClaudeRun;
|
|
15
17
|
exports.aggregate = aggregate;
|
|
16
18
|
exports.aggregateStats = aggregateStats;
|
|
17
19
|
exports.aggregateUsage = aggregateUsage;
|
|
@@ -91,6 +93,8 @@ function resolveSpawnEnv(a, base = process.env) {
|
|
|
91
93
|
return a.replaceEnv ? (a.env ?? {}) : { ...base, ...a.env };
|
|
92
94
|
}
|
|
93
95
|
/* v8 ignore start -- real claude subprocess; exercised by bench/, not the unit gate */
|
|
96
|
+
/** The real `claude`-spawning runner (composition root). Exported so other
|
|
97
|
+
* real-model entries (e.g. `scan --trigger`) bind the same runner. */
|
|
94
98
|
function spawnAgent(a) {
|
|
95
99
|
return new Promise((resolvePromise) => {
|
|
96
100
|
const args = [
|
|
@@ -392,21 +396,31 @@ function usageFrom(result) {
|
|
|
392
396
|
function parseUsage(stdout) {
|
|
393
397
|
return usageFrom((0, harness_test_js_1.parseResultEvent)(stdout));
|
|
394
398
|
}
|
|
395
|
-
|
|
399
|
+
/** Parse Claude Code's stream-json stdout into the common trace fields. */
|
|
400
|
+
function parseClaudeRun(out) {
|
|
396
401
|
const result = (0, harness_test_js_1.parseResultEvent)(out.stdout);
|
|
397
|
-
const turns = typeof result?.num_turns === "number" ? result.num_turns : 0;
|
|
398
|
-
const output = typeof result?.result === "string" ? result.result : "";
|
|
399
402
|
return {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
stdout: out.stdout,
|
|
403
|
-
turns,
|
|
403
|
+
turns: typeof result?.num_turns === "number" ? result.num_turns : 0,
|
|
404
|
+
output: typeof result?.result === "string" ? result.result : "",
|
|
404
405
|
toolCalls: (0, harness_test_js_1.parseToolCalls)(out.stdout),
|
|
405
406
|
hooks: (0, harness_test_js_1.parseHooks)(out.stdout),
|
|
406
|
-
output,
|
|
407
407
|
subagents: (0, harness_test_js_1.parseSubagents)(out.stdout),
|
|
408
408
|
usage: usageFrom(result),
|
|
409
|
-
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function makeContext(cwd, out, parse = parseClaudeRun) {
|
|
412
|
+
const p = parse(out);
|
|
413
|
+
return {
|
|
414
|
+
cwd,
|
|
415
|
+
exitCode: out.code,
|
|
416
|
+
stdout: out.stdout,
|
|
417
|
+
turns: p.turns,
|
|
418
|
+
toolCalls: p.toolCalls,
|
|
419
|
+
hooks: p.hooks,
|
|
420
|
+
output: p.output,
|
|
421
|
+
subagents: p.subagents,
|
|
422
|
+
usage: p.usage,
|
|
423
|
+
// The eval tier drives the real API (no mock between the agent and the model),
|
|
410
424
|
// so the requests can't be captured here — modelRequests is harness-tier only.
|
|
411
425
|
modelRequests: [],
|
|
412
426
|
file: (p) => {
|
|
@@ -993,6 +1007,11 @@ function formatEvalReport(report) {
|
|
|
993
1007
|
}
|
|
994
1008
|
return lines.join("\n");
|
|
995
1009
|
}
|
|
1010
|
+
/** The default (Claude Code) eval driver: real `claude` + stream-json parsing. */
|
|
1011
|
+
exports.claudeEvalDriver = {
|
|
1012
|
+
runner: spawnAgent,
|
|
1013
|
+
parse: parseClaudeRun,
|
|
1014
|
+
};
|
|
996
1015
|
/**
|
|
997
1016
|
* Package loose `<skillsDir>/<name>/SKILL.md` skills into a throwaway plugin dir
|
|
998
1017
|
* that `claude --plugin-dir` accepts — so repo-local skills (e.g. `.claude/skills`)
|
|
@@ -1284,42 +1303,65 @@ function resolveTriggerPluginDir(spec) {
|
|
|
1284
1303
|
};
|
|
1285
1304
|
}
|
|
1286
1305
|
/** Run one prompt set × trials through `runner`, aggregating fired counts. */
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
hasSettings: false,
|
|
1302
|
-
pluginDir: cfg.pluginDir,
|
|
1303
|
-
timeoutMs: cfg.timeoutMs,
|
|
1304
|
-
});
|
|
1305
|
-
if (cfg.fired(makeContext(cwd, out)))
|
|
1306
|
-
fired++;
|
|
1307
|
-
}
|
|
1308
|
-
finally {
|
|
1309
|
-
(0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
|
|
1310
|
-
await sleep(cfg.spacing);
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
perPrompt.push({
|
|
1314
|
-
prompt,
|
|
1315
|
-
fired,
|
|
1316
|
-
trials: cfg.trials,
|
|
1317
|
-
rate: cfg.trials > 0 ? fired / cfg.trials : 0,
|
|
1306
|
+
/** Run one trigger trial in a throwaway cwd (fixture seeded) → fired 0/1. */
|
|
1307
|
+
async function runTriggerTrial(prompt, cfg, runner) {
|
|
1308
|
+
const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-trigger-"));
|
|
1309
|
+
try {
|
|
1310
|
+
if (cfg.fixture)
|
|
1311
|
+
writeFiles(cwd, cfg.fixture);
|
|
1312
|
+
const out = await runner({
|
|
1313
|
+
task: prompt,
|
|
1314
|
+
cwd,
|
|
1315
|
+
model: cfg.model,
|
|
1316
|
+
tools: cfg.tools,
|
|
1317
|
+
hasSettings: false,
|
|
1318
|
+
pluginDir: cfg.pluginDir,
|
|
1319
|
+
timeoutMs: cfg.timeoutMs,
|
|
1318
1320
|
});
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
+
// An errored/rate-limited turn is NOT a "skill didn't fire" miss — it's
|
|
1322
|
+
// excluded from the rate, so e.g. a Codex usage limit can't read as recall 0.
|
|
1323
|
+
if (cfg.runError?.(out))
|
|
1324
|
+
return { fired: 0, errored: true };
|
|
1325
|
+
return {
|
|
1326
|
+
fired: cfg.fired(makeContext(cwd, out, cfg.parse)) ? 1 : 0,
|
|
1327
|
+
errored: false,
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
finally {
|
|
1331
|
+
(0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
|
|
1332
|
+
await sleep(cfg.spacing);
|
|
1321
1333
|
}
|
|
1322
|
-
|
|
1334
|
+
}
|
|
1335
|
+
/** A count for reporting: the number if positive, else undefined (omit zero). */
|
|
1336
|
+
const positiveOrUndefined = (n) => n > 0 ? n : undefined;
|
|
1337
|
+
async function runTriggerSet(prompts, cfg, runner) {
|
|
1338
|
+
// Flatten prompts × trials into one work list so concurrency spans both.
|
|
1339
|
+
const jobs = prompts.flatMap((prompt, promptIndex) => Array.from({ length: cfg.trials }, () => ({ prompt, promptIndex })));
|
|
1340
|
+
const outcomes = await runPool(jobs, cfg.concurrency, (job) => runTriggerTrial(job.prompt, cfg, runner));
|
|
1341
|
+
// Re-aggregate per prompt, preserving input order; errored runs don't count.
|
|
1342
|
+
const firedBy = new Array(prompts.length).fill(0);
|
|
1343
|
+
const trialsBy = new Array(prompts.length).fill(0);
|
|
1344
|
+
let errored = 0;
|
|
1345
|
+
jobs.forEach((job, i) => {
|
|
1346
|
+
if (outcomes[i].errored) {
|
|
1347
|
+
errored += 1;
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
firedBy[job.promptIndex] += outcomes[i].fired;
|
|
1351
|
+
trialsBy[job.promptIndex] += 1;
|
|
1352
|
+
});
|
|
1353
|
+
const perPrompt = prompts.map((prompt, i) => ({
|
|
1354
|
+
prompt,
|
|
1355
|
+
fired: firedBy[i],
|
|
1356
|
+
trials: trialsBy[i],
|
|
1357
|
+
rate: trialsBy[i] > 0 ? firedBy[i] / trialsBy[i] : 0,
|
|
1358
|
+
}));
|
|
1359
|
+
return {
|
|
1360
|
+
perPrompt,
|
|
1361
|
+
fired: firedBy.reduce((a, b) => a + b, 0),
|
|
1362
|
+
n: trialsBy.reduce((a, b) => a + b, 0),
|
|
1363
|
+
errored,
|
|
1364
|
+
};
|
|
1323
1365
|
}
|
|
1324
1366
|
/**
|
|
1325
1367
|
* Trigger-rate orchestration — every prompt × trial via `runner`, the `fired`
|
|
@@ -1329,9 +1371,8 @@ async function runTriggerSet(prompts, cfg, runner) {
|
|
|
1329
1371
|
* injectable `runner` so the loop is unit-testable without a model;
|
|
1330
1372
|
* `measureTriggerRate` is this with the real agent runner.
|
|
1331
1373
|
*/
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
// before spending a token (and before packaging a skillsDir).
|
|
1374
|
+
/** Diversity pre-flight: reject a too-small / near-duplicate prompt set (both sides). */
|
|
1375
|
+
function assertTriggerDiversity(spec) {
|
|
1335
1376
|
const diversity = {
|
|
1336
1377
|
minPrompts: spec.minPrompts,
|
|
1337
1378
|
minDistance: spec.minDistance,
|
|
@@ -1343,6 +1384,10 @@ async function measureTriggerRateWith(spec, runner) {
|
|
|
1343
1384
|
label: "irrelevantPrompts",
|
|
1344
1385
|
});
|
|
1345
1386
|
}
|
|
1387
|
+
}
|
|
1388
|
+
async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runError) {
|
|
1389
|
+
// Deterministic gate FIRST — before spending a token (or packaging a skillsDir).
|
|
1390
|
+
assertTriggerDiversity(spec);
|
|
1346
1391
|
// Model floor (default Sonnet): trigger-rate under-measures selection on a
|
|
1347
1392
|
// weaker model, so FAIL before spending a token rather than report a
|
|
1348
1393
|
// false-negative recall. The floor lives in the spec (`minModel`), not an env
|
|
@@ -1365,6 +1410,10 @@ async function measureTriggerRateWith(spec, runner) {
|
|
|
1365
1410
|
spacing: (spec.spacingSec ?? 4) * 1000,
|
|
1366
1411
|
pluginDir,
|
|
1367
1412
|
fired: spec.fired,
|
|
1413
|
+
fixture: spec.fixture,
|
|
1414
|
+
concurrency: Math.max(1, spec.concurrency ?? 1),
|
|
1415
|
+
parse,
|
|
1416
|
+
runError,
|
|
1368
1417
|
};
|
|
1369
1418
|
try {
|
|
1370
1419
|
const relevant = await runTriggerSet(spec.prompts, cfg, runner);
|
|
@@ -1373,6 +1422,7 @@ async function measureTriggerRateWith(spec, runner) {
|
|
|
1373
1422
|
n: relevant.n,
|
|
1374
1423
|
perPrompt: relevant.perPrompt,
|
|
1375
1424
|
competitors,
|
|
1425
|
+
errored: positiveOrUndefined(relevant.errored),
|
|
1376
1426
|
};
|
|
1377
1427
|
if ((spec.irrelevantPrompts?.length ?? 0) === 0)
|
|
1378
1428
|
return base;
|
|
@@ -1380,6 +1430,7 @@ async function measureTriggerRateWith(spec, runner) {
|
|
|
1380
1430
|
const fires = relevant.fired + irrelevant.fired;
|
|
1381
1431
|
return {
|
|
1382
1432
|
...base,
|
|
1433
|
+
errored: positiveOrUndefined(relevant.errored + irrelevant.errored),
|
|
1383
1434
|
falsePositiveRate: irrelevant.n > 0 ? irrelevant.fired / irrelevant.n : 0,
|
|
1384
1435
|
precision: fires > 0 ? relevant.fired / fires : undefined,
|
|
1385
1436
|
perIrrelevant: irrelevant.perPrompt,
|
|
@@ -1393,11 +1444,15 @@ async function measureTriggerRateWith(spec, runner) {
|
|
|
1393
1444
|
}
|
|
1394
1445
|
/* v8 ignore start -- real claude subprocess; thin wrapper over measureTriggerRateWith */
|
|
1395
1446
|
/**
|
|
1396
|
-
* Measure a skill/behaviour's real trigger rate across prompts × trials
|
|
1397
|
-
* the real `claude` CLI
|
|
1447
|
+
* Measure a skill/behaviour's real trigger rate across prompts × trials. Defaults
|
|
1448
|
+
* to the real `claude` CLI (`claudeEvalDriver`); pass `{ evalDriver }` to drive a
|
|
1449
|
+
* second harness — e.g. `measureTriggerRate(spec, { evalDriver: codexEvalDriver })`
|
|
1450
|
+
* from `vigiles/codex` (the eval-tier analog of `runHarnessTest`'s `{ adapter }`).
|
|
1451
|
+
* Requires that harness's binary + auth.
|
|
1398
1452
|
*/
|
|
1399
|
-
async function measureTriggerRate(spec) {
|
|
1400
|
-
|
|
1453
|
+
async function measureTriggerRate(spec, opts = {}) {
|
|
1454
|
+
const d = opts.evalDriver ?? exports.claudeEvalDriver;
|
|
1455
|
+
return measureTriggerRateWith(spec, d.runner, d.parse, d.runError);
|
|
1401
1456
|
}
|
|
1402
1457
|
/* v8 ignore stop */
|
|
1403
1458
|
/** Format a trigger-rate report: overall %, then each prompt's rate. */
|
package/dist/leaderboard.js
CHANGED
|
@@ -20,6 +20,7 @@ const scan_js_1 = require("./scan.js");
|
|
|
20
20
|
// Penalty weights — broken-at-runtime costs most, footguns less, nudges least.
|
|
21
21
|
const W_MISSING_HOOK = 15; // a hook script that doesn't exist → never runs
|
|
22
22
|
const W_NO_DESCRIPTION = 10; // a skill with no usable description → can't trigger
|
|
23
|
+
const W_DANGLING_REF = 8; // a referenced intra-plugin file that's missing → broken path
|
|
23
24
|
const W_NO_CONTRACT = 5; // an agent with no `tools:` line → inherits everything
|
|
24
25
|
const W_UNTESTED = 3; // a surface with no test/eval → warning-tier
|
|
25
26
|
function gradeFor(score) {
|
|
@@ -35,29 +36,86 @@ function gradeFor(score) {
|
|
|
35
36
|
}
|
|
36
37
|
/** Deterministic structural-health score for one scanned plugin. */
|
|
37
38
|
function scoreReport(r) {
|
|
38
|
-
// An empty/unloadable machine isn't healthy — it's a non-plugin or a broken
|
|
39
|
-
|
|
39
|
+
// An empty/unloadable machine isn't healthy — it's a non-plugin or a broken
|
|
40
|
+
// load. A command-only or MCP-only plugin (commands/*.md or .mcp.json with no
|
|
41
|
+
// skills/agents/hooks) IS a legitimate plugin, though — Anthropic ships
|
|
42
|
+
// command-only plugins in its own marketplace — so it must NOT score 0.
|
|
43
|
+
const surfaces = r.skills.length + r.agents.length + r.hooks.length + r.commands;
|
|
44
|
+
if (surfaces === 0 && !r.mcp) {
|
|
40
45
|
return { score: 0, issues: ["no loadable plugin surface"] };
|
|
41
46
|
}
|
|
42
47
|
const missingHooks = r.hooks.filter((h) => h.status === "missing").length;
|
|
43
48
|
const noDesc = r.skills.filter((s) => !s.hasDescription).length;
|
|
44
49
|
const noContract = r.agents.filter((a) => a.tools === null).length;
|
|
50
|
+
const deadTools = r.agents.reduce((n, a) => n + a.toolIssues.length, 0);
|
|
51
|
+
const deadMcpTools = r.agents.reduce((n, a) => n + a.mcpToolIssues.length, 0);
|
|
52
|
+
const deadDisallowed = r.agents.reduce((n, a) => n + a.disallowedToolIssues.length, 0);
|
|
53
|
+
const deadHookEvents = r.hookEventIssues.length;
|
|
54
|
+
const badFrontmatter = r.frontmatterIssues.length;
|
|
55
|
+
const badFrontmatterValues = r.frontmatterValueIssues.length;
|
|
56
|
+
const badMcp = r.mcpIssues.length;
|
|
57
|
+
const badMcpHooks = r.mcpHookIssues.length;
|
|
45
58
|
const deductions = [
|
|
46
59
|
{
|
|
47
60
|
n: missingHooks,
|
|
48
61
|
weight: W_MISSING_HOOK,
|
|
49
62
|
label: "hook script(s) MISSING",
|
|
50
63
|
},
|
|
64
|
+
{
|
|
65
|
+
n: deadHookEvents,
|
|
66
|
+
weight: W_MISSING_HOOK,
|
|
67
|
+
label: "hook(s) on an unknown event (never fire)",
|
|
68
|
+
},
|
|
51
69
|
{
|
|
52
70
|
n: noDesc,
|
|
53
71
|
weight: W_NO_DESCRIPTION,
|
|
54
72
|
label: "skill(s) with no usable description",
|
|
55
73
|
},
|
|
74
|
+
{
|
|
75
|
+
n: r.danglingRefs.length,
|
|
76
|
+
weight: W_DANGLING_REF,
|
|
77
|
+
label: "broken intra-plugin reference(s)",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
n: deadTools,
|
|
81
|
+
weight: W_DANGLING_REF,
|
|
82
|
+
label: "agent tool(s) that don't exist (typo / never-available)",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
n: deadMcpTools,
|
|
86
|
+
weight: W_DANGLING_REF,
|
|
87
|
+
label: "agent MCP tool(s) whose server isn't declared (can't resolve)",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
n: deadDisallowed,
|
|
91
|
+
weight: W_NO_CONTRACT,
|
|
92
|
+
label: "agent disallowedTools typo(s) that block nothing",
|
|
93
|
+
},
|
|
56
94
|
{
|
|
57
95
|
n: noContract,
|
|
58
96
|
weight: W_NO_CONTRACT,
|
|
59
97
|
label: "agent(s) inherit all tools (no contract)",
|
|
60
98
|
},
|
|
99
|
+
{
|
|
100
|
+
n: badFrontmatter,
|
|
101
|
+
weight: W_NO_DESCRIPTION,
|
|
102
|
+
label: "surface(s) missing required frontmatter (name/description)",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
n: badFrontmatterValues,
|
|
106
|
+
weight: W_NO_CONTRACT,
|
|
107
|
+
label: "agent(s) with an invalid model/color (typo → silent fallback)",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
n: badMcp,
|
|
111
|
+
weight: W_DANGLING_REF,
|
|
112
|
+
label: "MCP server(s) that can't start (no command/url)",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
n: badMcpHooks,
|
|
116
|
+
weight: W_DANGLING_REF,
|
|
117
|
+
label: "mcp_tool hook(s) incomplete / targeting an undeclared server",
|
|
118
|
+
},
|
|
61
119
|
{ n: r.untested, weight: W_UNTESTED, label: "untested surface(s)" },
|
|
62
120
|
];
|
|
63
121
|
let penalty = 0;
|
|
@@ -101,7 +159,7 @@ function formatLeaderboard(scores) {
|
|
|
101
159
|
const issue = s.issues.length > 0 ? ` — ${s.issues.join("; ")}` : "";
|
|
102
160
|
out.push(` ${rank} ${score} ${s.grade} ${s.name}${issue}`);
|
|
103
161
|
});
|
|
104
|
-
out.push("", "Structural health only (no model). Weights: missing hook -15, no-description", "skill -10, agent-without-tool-contract -5, untested surface -3.");
|
|
162
|
+
out.push("", "Structural health only (no model). Weights: missing hook -15, no-description", "skill -10, broken intra-plugin ref -8, agent-without-tool-contract -5,", "untested surface -3.");
|
|
105
163
|
return out.join("\n");
|
|
106
164
|
}
|
|
107
165
|
//# sourceMappingURL=leaderboard.js.map
|
package/dist/plugin-loader.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface LoadedPlugin {
|
|
|
22
22
|
* `settings` with any inline settings and spread `files` into the fixture.
|
|
23
23
|
*/
|
|
24
24
|
export declare function loadPlugin(pluginPath: string, layout: PluginLayout): LoadedPlugin;
|
|
25
|
+
export declare function danglingRefs(root: string, layout: PluginLayout): string[];
|
|
25
26
|
/**
|
|
26
27
|
* Resolve the effective harness for a test/eval (arm): load the plugin if given,
|
|
27
28
|
* then layer inline settings + files on top. Shared by `runHarnessTest` and
|
package/dist/plugin-loader.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.loadPlugin = loadPlugin;
|
|
4
|
+
exports.danglingRefs = danglingRefs;
|
|
4
5
|
exports.resolveHarness = resolveHarness;
|
|
5
6
|
/**
|
|
6
7
|
* vigiles — harness-agnostic plugin/repo harness loader (composition root).
|
|
@@ -208,34 +209,86 @@ const INTRA_REF_EXTS = "md|sh|cmd|mjs|cjs|js|ts|py|rb|txt|json";
|
|
|
208
209
|
function intraRefRe(layout) {
|
|
209
210
|
return new RegExp(`(?:${layout.intraRefDirs.join("|")})/[A-Za-z0-9._/-]+\\.(?:${INTRA_REF_EXTS})`, "g");
|
|
210
211
|
}
|
|
212
|
+
// Shell vars that root a path OUTSIDE the plugin (the user's project / home), so
|
|
213
|
+
// a `surface/…` after one is NOT a plugin-root ref. Anything else ($ROOT,
|
|
214
|
+
// $PLUGIN_ROOT, ${CLAUDE_PLUGIN_ROOT}, …) is taken as the plugin root.
|
|
215
|
+
const NON_PLUGIN_VARS = new Set([
|
|
216
|
+
"CLAUDE_PROJECT_DIR",
|
|
217
|
+
"CLAUDE_PROJECT",
|
|
218
|
+
"HOME",
|
|
219
|
+
"PWD",
|
|
220
|
+
"OLDPWD",
|
|
221
|
+
]);
|
|
222
|
+
/**
|
|
223
|
+
* Is a surface-dir match at `idx` actually rooted at the PLUGIN (so checkable
|
|
224
|
+
* under `root`), vs nested under a literal dir or a project/home var? A match
|
|
225
|
+
* preceded by a literal segment (`.claude/hooks/…` — a PROJECT path, the
|
|
226
|
+
* gmickel/flow-next false positive) or a project var (`$CLAUDE_PROJECT_DIR/…`)
|
|
227
|
+
* is NOT a plugin ref. A bare ref (`cat skills/…`) or one after a plugin-root
|
|
228
|
+
* var (`${PLUGIN_ROOT}/skills/…`, obra/superpowers) IS.
|
|
229
|
+
*/
|
|
230
|
+
function isPluginRooted(content, idx) {
|
|
231
|
+
if (idx === 0 || content[idx - 1] !== "/")
|
|
232
|
+
return true; // bare / after quote-space
|
|
233
|
+
// The path component immediately before the separating slash.
|
|
234
|
+
const seg = /([^\s"'`(=:/]*)$/.exec(content.slice(0, idx - 1))?.[1] ?? "";
|
|
235
|
+
const varName = /^\$\{?(\w+)\}?$/.exec(seg)?.[1];
|
|
236
|
+
if (varName !== undefined)
|
|
237
|
+
return !NON_PLUGIN_VARS.has(varName); // a var root
|
|
238
|
+
return false; // a literal dir segment → nested, not a plugin-root ref
|
|
239
|
+
}
|
|
240
|
+
// Documentation files (skill bodies, command docs, reference notes) are PROSE —
|
|
241
|
+
// a `skills/foo/SKILL.md` path inside them is almost always an example, a
|
|
242
|
+
// template placeholder (`wc -w skills/path/SKILL.md`), or a "❌ Bad" sample, not
|
|
243
|
+
// a real file operation. Scanning them produced near-100% false positives across
|
|
244
|
+
// real plugins (wshobson/agents, obra/superpowers), so we skip them as SOURCES.
|
|
245
|
+
// A path in an executable hook/helper script (incl. extensionless ones like
|
|
246
|
+
// obra/superpowers' `hooks/session-start`) IS a real file op — those we scan.
|
|
247
|
+
const DOC_SOURCE_RE = /\.(?:md|markdown|mdx|txt|rst)$/i;
|
|
211
248
|
/**
|
|
212
249
|
* Intra-plugin file references that don't resolve — the partial-vendor / broken-
|
|
213
|
-
* path class (e.g. obra/superpowers' `
|
|
250
|
+
* path class (e.g. obra/superpowers' `hooks/session-start` reads
|
|
214
251
|
* `skills/using-superpowers/SKILL.md`, which a sliced vendor snapshot omits). We
|
|
215
|
-
* scan the plugin's own
|
|
216
|
-
*
|
|
217
|
-
* and report the ones missing on disk.
|
|
218
|
-
*
|
|
252
|
+
* scan the plugin's own EXECUTABLE files under the surface dirs (hook/helper
|
|
253
|
+
* scripts — those aren't materialized into `files`) for root-relative path refs
|
|
254
|
+
* and report the ones missing on disk. Documentation sources are deliberately
|
|
255
|
+
* excluded (see `DOC_SOURCE_RE`) — a path in prose is undecidably ref-or-example,
|
|
256
|
+
* the same heuristic-scanning anti-pattern reference verification rejects. A
|
|
257
|
+
* static check that would have caught a bug the dogfood hit twice. Best-effort:
|
|
258
|
+
* a warning, not an error.
|
|
219
259
|
*/
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
const
|
|
260
|
+
/** Path refs in `content` (matched by `re`) that don't resolve under `root`. */
|
|
261
|
+
function missingRefsIn(content, re, root) {
|
|
262
|
+
const out = [];
|
|
263
|
+
for (const m of content.matchAll(re)) {
|
|
264
|
+
if (m.index !== undefined && !isPluginRooted(content, m.index))
|
|
265
|
+
continue;
|
|
266
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, m[0])))
|
|
267
|
+
out.push(m[0]);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
/** The plugin's executable (non-prose) source files under the surface dirs. */
|
|
272
|
+
function executableSources(root, layout) {
|
|
273
|
+
const sources = {};
|
|
224
274
|
for (const surface of layout.intraRefDirs) {
|
|
225
275
|
const dir = (0, node_path_1.join)(root, surface);
|
|
226
276
|
if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory())
|
|
227
277
|
continue;
|
|
228
|
-
for (const content of Object.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (seen.has(ref))
|
|
232
|
-
continue;
|
|
233
|
-
seen.add(ref);
|
|
234
|
-
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, ref)))
|
|
235
|
-
missing.add(ref);
|
|
236
|
-
}
|
|
278
|
+
for (const [path, content] of Object.entries(readTree(dir, root))) {
|
|
279
|
+
if (!DOC_SOURCE_RE.test(path))
|
|
280
|
+
sources[path] = content; // skip prose
|
|
237
281
|
}
|
|
238
282
|
}
|
|
283
|
+
return sources;
|
|
284
|
+
}
|
|
285
|
+
function danglingRefs(root, layout) {
|
|
286
|
+
const re = intraRefRe(layout);
|
|
287
|
+
const missing = new Set();
|
|
288
|
+
for (const content of Object.values(executableSources(root, layout))) {
|
|
289
|
+
for (const ref of missingRefsIn(content, re, root))
|
|
290
|
+
missing.add(ref);
|
|
291
|
+
}
|
|
239
292
|
return [...missing];
|
|
240
293
|
}
|
|
241
294
|
/**
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vigiles scan --trigger` — the BEHAVIORAL column of the scan report.
|
|
3
|
+
*
|
|
4
|
+
* Structural `scan`/`scanPlugin` is deterministic, no-model, CI-free — and stays
|
|
5
|
+
* that way. This is the opt-in, model-gated column that stacks on top: for each
|
|
6
|
+
* model-invocable skill in a plugin, it measures how reliably the description
|
|
7
|
+
* actually FIRES (recall, + precision when irrelevant prompts are supplied),
|
|
8
|
+
* reusing `measureTriggerRate`. It degrades honestly when the `claude` CLI / auth
|
|
9
|
+
* is absent rather than faking a pass — exactly like the egress column.
|
|
10
|
+
*
|
|
11
|
+
* Prompts are AUTHOR-SUPPLIED (a per-skill JSON map), not model-generated — a
|
|
12
|
+
* path in prose is undecidable, and the deterministic-input discipline is what
|
|
13
|
+
* makes the column trustworthy. See `research/plugin-behavioral-findings.md`.
|
|
14
|
+
*/
|
|
15
|
+
import { type EvalDriver } from "./eval.js";
|
|
16
|
+
import { type Trace } from "./harness-test.js";
|
|
17
|
+
/** Which harness drives the behavioral column (default Claude Code). */
|
|
18
|
+
export type ProbeHarness = "claude-code" | "codex";
|
|
19
|
+
/** Author-supplied prompt sets for one skill (bare skill name → these). */
|
|
20
|
+
export interface SkillPrompts {
|
|
21
|
+
readonly prompts: readonly string[];
|
|
22
|
+
readonly irrelevant?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
/** The `--prompts <file>` shape: bare skill name → its prompt sets. */
|
|
25
|
+
export type TriggerPromptSet = Record<string, SkillPrompts>;
|
|
26
|
+
export interface SkillTriggerResult {
|
|
27
|
+
readonly skill: string;
|
|
28
|
+
/** Whether a model probe actually ran (false = skipped, see `note`). */
|
|
29
|
+
readonly measured: boolean;
|
|
30
|
+
readonly recall?: number;
|
|
31
|
+
readonly precision?: number;
|
|
32
|
+
readonly falsePositiveRate?: number;
|
|
33
|
+
readonly n?: number;
|
|
34
|
+
/** Why it was skipped, or a measurement error. */
|
|
35
|
+
readonly note?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface BehavioralReport {
|
|
38
|
+
/** False when the `claude` CLI / auth is absent — the column couldn't run. */
|
|
39
|
+
readonly available: boolean;
|
|
40
|
+
readonly results: readonly SkillTriggerResult[];
|
|
41
|
+
}
|
|
42
|
+
export interface ProbeOptions {
|
|
43
|
+
readonly concurrency?: number;
|
|
44
|
+
readonly model?: string;
|
|
45
|
+
readonly minPrompts?: number;
|
|
46
|
+
readonly minDistance?: number;
|
|
47
|
+
/** Which harness to drive (default `"claude-code"`). */
|
|
48
|
+
readonly harness?: ProbeHarness;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Per-harness probe wiring: the eval driver (runner+parse), how to build the
|
|
52
|
+
* `fired` predicate for a skill, whether to stub bodies, and an availability
|
|
53
|
+
* gate. Claude detects firing via the `Skill` tool_use (namespaced by the
|
|
54
|
+
* plugin name); Codex has no skill event, so firing is the SKILL.md read
|
|
55
|
+
* (`codexSkillFired`, bare name) — see `research/codex-prototype-findings.md`.
|
|
56
|
+
*/
|
|
57
|
+
export interface HarnessProbe {
|
|
58
|
+
readonly evalDriver: EvalDriver;
|
|
59
|
+
readonly firedFor: (name: string) => (t: Trace) => boolean;
|
|
60
|
+
readonly stub: boolean;
|
|
61
|
+
readonly available: () => boolean;
|
|
62
|
+
}
|
|
63
|
+
/** The injectable core (for tests): probe every model-invocable skill that has prompts. */
|
|
64
|
+
export declare function probePluginTriggersWith(dir: string, promptSet: TriggerPromptSet, probe: HarnessProbe, opts?: ProbeOptions): Promise<BehavioralReport>;
|
|
65
|
+
/**
|
|
66
|
+
* Probe a plugin's skills against the real harness (default Claude Code; Codex via
|
|
67
|
+
* `opts.harness`). Needs that harness's binary + auth; degrades to
|
|
68
|
+
* `available: false` otherwise.
|
|
69
|
+
*/
|
|
70
|
+
export declare function probePluginTriggers(dir: string, promptSet: TriggerPromptSet, opts?: ProbeOptions): Promise<BehavioralReport>;
|
|
71
|
+
/** Format the behavioral column as a scan-report section. */
|
|
72
|
+
export declare function formatBehavioralReport(b: BehavioralReport): string;
|
|
73
|
+
//# sourceMappingURL=scan-behavioral.d.ts.map
|