vigiles 5.0.1 → 5.2.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 +15 -9
- package/dist/adapters/claude-code/adapter.js +1 -0
- package/dist/adapters/claude-code/agent-runtime.d.ts +30 -6
- package/dist/adapters/claude-code/agent-runtime.js +66 -37
- package/dist/adapters/claude-code/dialect.js +37 -2
- package/dist/adapters/claude-code/effect-region.d.ts +9 -0
- package/dist/adapters/claude-code/effect-region.js +45 -0
- package/dist/adapters/claude-code/layout.js +3 -0
- package/dist/adapters/claude-code/skill-runtime.d.ts +25 -0
- package/dist/adapters/claude-code/skill-runtime.js +48 -0
- package/dist/adapters/codex/adapter.js +3 -0
- package/dist/adapters/codex/eval.d.ts +94 -0
- package/dist/adapters/codex/eval.js +227 -0
- package/dist/adapters/codex/layout.js +3 -0
- package/dist/adapters/opencode/adapter.js +1 -0
- package/dist/adapters/opencode/layout.js +3 -0
- package/dist/check.d.ts +8 -0
- package/dist/check.js +27 -3
- package/dist/cli.js +712 -21
- package/dist/codex.d.ts +1 -0
- package/dist/codex.js +3 -0
- package/dist/core/adapter.d.ts +10 -0
- package/dist/core/bash-effects.d.ts +41 -0
- package/dist/core/bash-effects.js +405 -0
- package/dist/core/compile.d.ts +3 -1
- package/dist/core/compile.js +169 -74
- package/dist/core/description-overlap.d.ts +27 -0
- package/dist/core/description-overlap.js +53 -0
- package/dist/core/dialect.d.ts +18 -0
- package/dist/core/effects.d.ts +172 -0
- package/dist/core/effects.js +245 -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/layout.d.ts +6 -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/orphans.js +21 -0
- package/dist/core/spec.d.ts +142 -3
- package/dist/core/spec.js +48 -0
- package/dist/core/tool-contract.d.ts +68 -0
- package/dist/core/tool-contract.js +113 -0
- package/dist/core/types.d.ts +91 -2
- package/dist/core/validate.js +23 -1
- package/dist/eval.d.ts +69 -13
- package/dist/eval.js +106 -51
- package/dist/harness-test.d.ts +7 -0
- package/dist/harness-test.js +19 -7
- package/dist/leaderboard.d.ts +2 -0
- package/dist/leaderboard.js +63 -3
- package/dist/optimize.d.ts +74 -0
- package/dist/optimize.js +94 -0
- package/dist/plugin-loader.d.ts +1 -0
- package/dist/plugin-loader.js +71 -18
- package/dist/scaffold-test.d.ts +30 -0
- package/dist/scaffold-test.js +158 -0
- package/dist/scan-behavioral.d.ts +73 -0
- package/dist/scan-behavioral.js +150 -0
- package/dist/scan.d.ts +166 -1
- package/dist/scan.js +622 -55
- package/dist/score-explainer.d.ts +69 -0
- package/dist/score-explainer.js +169 -0
- package/dist/test-coverage.d.ts +7 -0
- package/dist/test-coverage.js +39 -24
- package/package.json +2 -1
- package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -6
- package/skills/edit-spec/SKILL.md +1 -1
package/dist/leaderboard.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* model and stack on top later; this part runs anywhere in CI for free.
|
|
13
13
|
*/
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.gradeFor = gradeFor;
|
|
15
16
|
exports.scoreReport = scoreReport;
|
|
16
17
|
exports.rankPlugins = rankPlugins;
|
|
17
18
|
exports.formatLeaderboard = formatLeaderboard;
|
|
@@ -20,8 +21,10 @@ const scan_js_1 = require("./scan.js");
|
|
|
20
21
|
// Penalty weights — broken-at-runtime costs most, footguns less, nudges least.
|
|
21
22
|
const W_MISSING_HOOK = 15; // a hook script that doesn't exist → never runs
|
|
22
23
|
const W_NO_DESCRIPTION = 10; // a skill with no usable description → can't trigger
|
|
24
|
+
const W_DANGLING_REF = 8; // a referenced intra-plugin file that's missing → broken path
|
|
23
25
|
const W_NO_CONTRACT = 5; // an agent with no `tools:` line → inherits everything
|
|
24
26
|
const W_UNTESTED = 3; // a surface with no test/eval → warning-tier
|
|
27
|
+
/** Map a 0–100 structural-health score to its letter grade (A ≥90 … F <60). */
|
|
25
28
|
function gradeFor(score) {
|
|
26
29
|
if (score >= 90)
|
|
27
30
|
return "A";
|
|
@@ -35,29 +38,86 @@ function gradeFor(score) {
|
|
|
35
38
|
}
|
|
36
39
|
/** Deterministic structural-health score for one scanned plugin. */
|
|
37
40
|
function scoreReport(r) {
|
|
38
|
-
// An empty/unloadable machine isn't healthy — it's a non-plugin or a broken
|
|
39
|
-
|
|
41
|
+
// An empty/unloadable machine isn't healthy — it's a non-plugin or a broken
|
|
42
|
+
// load. A command-only or MCP-only plugin (commands/*.md or .mcp.json with no
|
|
43
|
+
// skills/agents/hooks) IS a legitimate plugin, though — Anthropic ships
|
|
44
|
+
// command-only plugins in its own marketplace — so it must NOT score 0.
|
|
45
|
+
const surfaces = r.skills.length + r.agents.length + r.hooks.length + r.commands;
|
|
46
|
+
if (surfaces === 0 && !r.mcp) {
|
|
40
47
|
return { score: 0, issues: ["no loadable plugin surface"] };
|
|
41
48
|
}
|
|
42
49
|
const missingHooks = r.hooks.filter((h) => h.status === "missing").length;
|
|
43
50
|
const noDesc = r.skills.filter((s) => !s.hasDescription).length;
|
|
44
51
|
const noContract = r.agents.filter((a) => a.tools === null).length;
|
|
52
|
+
const deadTools = r.agents.reduce((n, a) => n + a.toolIssues.length, 0);
|
|
53
|
+
const deadMcpTools = r.agents.reduce((n, a) => n + a.mcpToolIssues.length, 0);
|
|
54
|
+
const deadDisallowed = r.agents.reduce((n, a) => n + a.disallowedToolIssues.length, 0);
|
|
55
|
+
const deadHookEvents = r.hookEventIssues.length;
|
|
56
|
+
const badFrontmatter = r.frontmatterIssues.length;
|
|
57
|
+
const badFrontmatterValues = r.frontmatterValueIssues.length;
|
|
58
|
+
const badMcp = r.mcpIssues.length;
|
|
59
|
+
const badMcpHooks = r.mcpHookIssues.length;
|
|
45
60
|
const deductions = [
|
|
46
61
|
{
|
|
47
62
|
n: missingHooks,
|
|
48
63
|
weight: W_MISSING_HOOK,
|
|
49
64
|
label: "hook script(s) MISSING",
|
|
50
65
|
},
|
|
66
|
+
{
|
|
67
|
+
n: deadHookEvents,
|
|
68
|
+
weight: W_MISSING_HOOK,
|
|
69
|
+
label: "hook(s) on an unknown event (never fire)",
|
|
70
|
+
},
|
|
51
71
|
{
|
|
52
72
|
n: noDesc,
|
|
53
73
|
weight: W_NO_DESCRIPTION,
|
|
54
74
|
label: "skill(s) with no usable description",
|
|
55
75
|
},
|
|
76
|
+
{
|
|
77
|
+
n: r.danglingRefs.length,
|
|
78
|
+
weight: W_DANGLING_REF,
|
|
79
|
+
label: "broken intra-plugin reference(s)",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
n: deadTools,
|
|
83
|
+
weight: W_DANGLING_REF,
|
|
84
|
+
label: "agent tool(s) that don't exist (typo / never-available)",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
n: deadMcpTools,
|
|
88
|
+
weight: W_DANGLING_REF,
|
|
89
|
+
label: "agent MCP tool(s) whose server isn't declared (can't resolve)",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
n: deadDisallowed,
|
|
93
|
+
weight: W_NO_CONTRACT,
|
|
94
|
+
label: "agent disallowedTools typo(s) that block nothing",
|
|
95
|
+
},
|
|
56
96
|
{
|
|
57
97
|
n: noContract,
|
|
58
98
|
weight: W_NO_CONTRACT,
|
|
59
99
|
label: "agent(s) inherit all tools (no contract)",
|
|
60
100
|
},
|
|
101
|
+
{
|
|
102
|
+
n: badFrontmatter,
|
|
103
|
+
weight: W_NO_DESCRIPTION,
|
|
104
|
+
label: "surface(s) missing required frontmatter (name/description)",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
n: badFrontmatterValues,
|
|
108
|
+
weight: W_NO_CONTRACT,
|
|
109
|
+
label: "agent(s) with an invalid model/color (typo → silent fallback)",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
n: badMcp,
|
|
113
|
+
weight: W_DANGLING_REF,
|
|
114
|
+
label: "MCP server(s) that can't start (no command/url)",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
n: badMcpHooks,
|
|
118
|
+
weight: W_DANGLING_REF,
|
|
119
|
+
label: "mcp_tool hook(s) incomplete / targeting an undeclared server",
|
|
120
|
+
},
|
|
61
121
|
{ n: r.untested, weight: W_UNTESTED, label: "untested surface(s)" },
|
|
62
122
|
];
|
|
63
123
|
let penalty = 0;
|
|
@@ -101,7 +161,7 @@ function formatLeaderboard(scores) {
|
|
|
101
161
|
const issue = s.issues.length > 0 ? ` — ${s.issues.join("; ")}` : "";
|
|
102
162
|
out.push(` ${rank} ${score} ${s.grade} ${s.name}${issue}`);
|
|
103
163
|
});
|
|
104
|
-
out.push("", "Structural health only (no model). Weights: missing hook -15, no-description", "skill -10, agent-without-tool-contract -5, untested surface -3.");
|
|
164
|
+
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
165
|
return out.join("\n");
|
|
106
166
|
}
|
|
107
167
|
//# sourceMappingURL=leaderboard.js.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-repo harness optimizer's DETERMINISTIC spine — shipped as the
|
|
3
|
+
* `vigiles scan --fix-plan` lens (NOT its own `optimize` verb: until the measured
|
|
4
|
+
* A/B half lands, an "optimizer" that only re-prints scan's findings doesn't earn
|
|
5
|
+
* a separate command, so it's folded into scan as one more view on the same
|
|
6
|
+
* report; see research/roadmap.md §P2 "reconsider an `optimize` verb").
|
|
7
|
+
*
|
|
8
|
+
* A2 in the measurement-authority pivot is the ADOPTION product: measure a user's
|
|
9
|
+
* own skills/model/rules on their tasks and recommend add/drop/swap with a MEASURED
|
|
10
|
+
* delta. The measured delta is the real-model layer (gated on the Pro/Max
|
|
11
|
+
* subscription, costs tokens); this v0 ships the deterministic HALF — the free
|
|
12
|
+
* pre-filter that runs on every commit with no model.
|
|
13
|
+
*
|
|
14
|
+
* It answers "what should I fix in my harness, and why" using only the cross-ref
|
|
15
|
+
* findings the linter already computes: it reuses `scoreReport` for the headline
|
|
16
|
+
* structural-health score and `explainScore` for the per-surface cause + one-line
|
|
17
|
+
* fix (one-detector-no-drift — it never re-detects). The result is a prioritized,
|
|
18
|
+
* typed action list — the spine the measured A/B (the behavioral delta) stacks on.
|
|
19
|
+
*
|
|
20
|
+
* This is the "linting as a free pre-filter to measurement" thesis made a command:
|
|
21
|
+
* clear the structural dead-ends a model can't help with FIRST (free, certain),
|
|
22
|
+
* THEN spend tokens measuring whether the structurally-clean skills earn their keep.
|
|
23
|
+
*
|
|
24
|
+
* Distinct from `vigiles explain` (which diagnoses ONE underperforming surface a
|
|
25
|
+
* measurement flagged): `optimize` is the whole-repo adoption view — health score +
|
|
26
|
+
* the ranked fix list + the hand-off to the measured layer. Same findings, the
|
|
27
|
+
* optimization framing. See research/measurement-authority.md (A2) + roadmap §P1.
|
|
28
|
+
*/
|
|
29
|
+
import { type PluginScore } from "./leaderboard.js";
|
|
30
|
+
import { type ExplanationConfidence } from "./score-explainer.js";
|
|
31
|
+
import type { ScanReport } from "./scan.js";
|
|
32
|
+
/**
|
|
33
|
+
* The action a recommendation asks for. The deterministic detectors yield two:
|
|
34
|
+
* `"differentiate"` (a description-overlap PAIR — make the two distinct so the
|
|
35
|
+
* selector can disambiguate) and `"fix"` (every other structural dead-end —
|
|
36
|
+
* correct/add/remove the offending bit). The richer add/drop/swap vocabulary
|
|
37
|
+
* belongs to the MEASURED layer, once a behavioral delta ranks the alternatives.
|
|
38
|
+
*/
|
|
39
|
+
export type OptimizeAction = "fix" | "differentiate";
|
|
40
|
+
export interface Recommendation {
|
|
41
|
+
/** The affected surface (a skill/agent/hook name or path, or an `a ↔ b` pair). */
|
|
42
|
+
readonly surface: string;
|
|
43
|
+
readonly action: OptimizeAction;
|
|
44
|
+
/** The deterministic cause (the detector's own message — no drift). */
|
|
45
|
+
readonly rationale: string;
|
|
46
|
+
/** A single, actionable fix. */
|
|
47
|
+
readonly fix: string;
|
|
48
|
+
/** The lint rule that found it (open `docs/rules/<detector>.md`). */
|
|
49
|
+
readonly detector: string;
|
|
50
|
+
readonly confidence: ExplanationConfidence;
|
|
51
|
+
}
|
|
52
|
+
export interface OptimizeReport {
|
|
53
|
+
readonly dir: string;
|
|
54
|
+
/** Structural-health score 0–100 (the same `scoreReport` the leaderboard uses). */
|
|
55
|
+
readonly score: number;
|
|
56
|
+
readonly grade: PluginScore["grade"];
|
|
57
|
+
/** The free deterministic fixes, `likely` dead-ends first (explainScore's order). */
|
|
58
|
+
readonly recommendations: readonly Recommendation[];
|
|
59
|
+
/**
|
|
60
|
+
* No loadable surface at all (not a plugin, or a broken load) — distinct from a
|
|
61
|
+
* clean-and-loaded harness with zero findings, so the formatter doesn't call an
|
|
62
|
+
* EMPTY machine "clean". Mirrors `scoreReport`'s empty-machine case.
|
|
63
|
+
*/
|
|
64
|
+
readonly empty: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Turn a scan report into a prioritized optimization plan: the structural-health
|
|
68
|
+
* score + a typed recommendation per deterministic finding (`likely` dead-ends
|
|
69
|
+
* before `possible` proxies, via explainScore's own ordering). Pure over the report.
|
|
70
|
+
*/
|
|
71
|
+
export declare function optimize(report: ScanReport): OptimizeReport;
|
|
72
|
+
/** Render an optimization plan for the CLI. */
|
|
73
|
+
export declare function formatOptimize(rep: OptimizeReport): string;
|
|
74
|
+
//# sourceMappingURL=optimize.d.ts.map
|
package/dist/optimize.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.optimize = optimize;
|
|
4
|
+
exports.formatOptimize = formatOptimize;
|
|
5
|
+
/**
|
|
6
|
+
* The per-repo harness optimizer's DETERMINISTIC spine — shipped as the
|
|
7
|
+
* `vigiles scan --fix-plan` lens (NOT its own `optimize` verb: until the measured
|
|
8
|
+
* A/B half lands, an "optimizer" that only re-prints scan's findings doesn't earn
|
|
9
|
+
* a separate command, so it's folded into scan as one more view on the same
|
|
10
|
+
* report; see research/roadmap.md §P2 "reconsider an `optimize` verb").
|
|
11
|
+
*
|
|
12
|
+
* A2 in the measurement-authority pivot is the ADOPTION product: measure a user's
|
|
13
|
+
* own skills/model/rules on their tasks and recommend add/drop/swap with a MEASURED
|
|
14
|
+
* delta. The measured delta is the real-model layer (gated on the Pro/Max
|
|
15
|
+
* subscription, costs tokens); this v0 ships the deterministic HALF — the free
|
|
16
|
+
* pre-filter that runs on every commit with no model.
|
|
17
|
+
*
|
|
18
|
+
* It answers "what should I fix in my harness, and why" using only the cross-ref
|
|
19
|
+
* findings the linter already computes: it reuses `scoreReport` for the headline
|
|
20
|
+
* structural-health score and `explainScore` for the per-surface cause + one-line
|
|
21
|
+
* fix (one-detector-no-drift — it never re-detects). The result is a prioritized,
|
|
22
|
+
* typed action list — the spine the measured A/B (the behavioral delta) stacks on.
|
|
23
|
+
*
|
|
24
|
+
* This is the "linting as a free pre-filter to measurement" thesis made a command:
|
|
25
|
+
* clear the structural dead-ends a model can't help with FIRST (free, certain),
|
|
26
|
+
* THEN spend tokens measuring whether the structurally-clean skills earn their keep.
|
|
27
|
+
*
|
|
28
|
+
* Distinct from `vigiles explain` (which diagnoses ONE underperforming surface a
|
|
29
|
+
* measurement flagged): `optimize` is the whole-repo adoption view — health score +
|
|
30
|
+
* the ranked fix list + the hand-off to the measured layer. Same findings, the
|
|
31
|
+
* optimization framing. See research/measurement-authority.md (A2) + roadmap §P1.
|
|
32
|
+
*/
|
|
33
|
+
const leaderboard_js_1 = require("./leaderboard.js");
|
|
34
|
+
const score_explainer_js_1 = require("./score-explainer.js");
|
|
35
|
+
function actionFor(e) {
|
|
36
|
+
return e.symptom === "wrong-skill-fires" ? "differentiate" : "fix";
|
|
37
|
+
}
|
|
38
|
+
function isEmptyMachine(r) {
|
|
39
|
+
const surfaces = r.skills.length + r.agents.length + r.hooks.length + r.commands;
|
|
40
|
+
return surfaces === 0 && !r.mcp;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Turn a scan report into a prioritized optimization plan: the structural-health
|
|
44
|
+
* score + a typed recommendation per deterministic finding (`likely` dead-ends
|
|
45
|
+
* before `possible` proxies, via explainScore's own ordering). Pure over the report.
|
|
46
|
+
*/
|
|
47
|
+
function optimize(report) {
|
|
48
|
+
const { score } = (0, leaderboard_js_1.scoreReport)(report);
|
|
49
|
+
const recommendations = (0, score_explainer_js_1.explainScore)(report).map((e) => ({
|
|
50
|
+
surface: e.surface,
|
|
51
|
+
action: actionFor(e),
|
|
52
|
+
rationale: e.cause,
|
|
53
|
+
fix: e.fix,
|
|
54
|
+
detector: e.detector,
|
|
55
|
+
confidence: e.confidence,
|
|
56
|
+
}));
|
|
57
|
+
return {
|
|
58
|
+
dir: report.dir,
|
|
59
|
+
score,
|
|
60
|
+
grade: (0, leaderboard_js_1.gradeFor)(score),
|
|
61
|
+
recommendations,
|
|
62
|
+
empty: isEmptyMachine(report),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const ACTION_LABEL = {
|
|
66
|
+
fix: "FIX",
|
|
67
|
+
differentiate: "DIFFERENTIATE",
|
|
68
|
+
};
|
|
69
|
+
const measureHint = (dir) => `\`vigiles scan ${dir} --trigger\` — real-model, runs on your subscription`;
|
|
70
|
+
/** Render an optimization plan for the CLI. */
|
|
71
|
+
function formatOptimize(rep) {
|
|
72
|
+
const head = `Harness health: ${String(rep.score)}/100 (${rep.grade}) — ${rep.dir}`;
|
|
73
|
+
if (rep.empty) {
|
|
74
|
+
return `${head}\n\nNothing loaded — this isn't a plugin/harness, or the load failed. Point optimize at a dir with a CLAUDE.md/AGENTS.md, skills, agents, or hooks.`;
|
|
75
|
+
}
|
|
76
|
+
if (rep.recommendations.length === 0) {
|
|
77
|
+
return `${head}\n\nNo deterministic fixes found — the structure is clean. Whether your skills actually help is a BEHAVIORAL question; measure it with ${measureHint(rep.dir)}.`;
|
|
78
|
+
}
|
|
79
|
+
const lines = [
|
|
80
|
+
head,
|
|
81
|
+
"",
|
|
82
|
+
`${String(rep.recommendations.length)} deterministic fix(es) — free, no model. Apply these before measuring:`,
|
|
83
|
+
"",
|
|
84
|
+
];
|
|
85
|
+
for (const r of rep.recommendations) {
|
|
86
|
+
const mark = r.confidence === "likely" ? "✗" : "⚠";
|
|
87
|
+
lines.push(`${mark} [${ACTION_LABEL[r.action]}] ${r.surface}`);
|
|
88
|
+
lines.push(` why: ${r.rationale} [${r.detector}]`);
|
|
89
|
+
lines.push(` → ${r.fix}`);
|
|
90
|
+
}
|
|
91
|
+
lines.push("", `Then measure the behavioral delta of what's left (does each skill earn its keep?) with ${measureHint(rep.dir)}.`);
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=optimize.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,30 @@
|
|
|
1
|
+
export type SurfaceKind = "skill" | "agent" | "hook";
|
|
2
|
+
/** The cheapest meaningful tier for a surface kind (mirrors the test-harness skill). */
|
|
3
|
+
export type TestTier = "unit" | "harness" | "eval";
|
|
4
|
+
/** What the generator needs to know about a surface to scaffold its test. */
|
|
5
|
+
export interface ScaffoldInput {
|
|
6
|
+
readonly kind: SurfaceKind;
|
|
7
|
+
/** Skill dir name / agent name / hook-script basename. */
|
|
8
|
+
readonly name: string;
|
|
9
|
+
/** Repo-relative path to the surface (SKILL.md / agent .md / hook script). */
|
|
10
|
+
readonly path: string;
|
|
11
|
+
/** Plugin name for the namespaced skill id; a placeholder TODO when unknown. */
|
|
12
|
+
readonly pluginName?: string;
|
|
13
|
+
/** A user-invoked skill — trigger-rate is for model-invocable skills, so note it. */
|
|
14
|
+
readonly userInvoked?: boolean;
|
|
15
|
+
/** A subagent's declared tool contract (drives the assertion hint); null = inherits all. */
|
|
16
|
+
readonly tools?: readonly string[] | null;
|
|
17
|
+
/** How the CLI invokes the hook (e.g. `bash hooks/pre-edit.sh`); a TODO when unknown. */
|
|
18
|
+
readonly hookCommand?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface Scaffold {
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly content: string;
|
|
23
|
+
readonly kind: SurfaceKind;
|
|
24
|
+
readonly tier: TestTier;
|
|
25
|
+
}
|
|
26
|
+
/** Scaffold a starter test for one surface. Pure: path + content, no I/O. */
|
|
27
|
+
export declare function scaffoldTest(input: ScaffoldInput): Scaffold;
|
|
28
|
+
/** Render a set of scaffolds for the CLI (what was generated, where, which tier). */
|
|
29
|
+
export declare function formatScaffolds(scaffolds: readonly Scaffold[]): string;
|
|
30
|
+
//# sourceMappingURL=scaffold-test.d.ts.map
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scaffoldTest = scaffoldTest;
|
|
4
|
+
exports.formatScaffolds = formatScaffolds;
|
|
5
|
+
/**
|
|
6
|
+
* `vigiles scaffold-test` — the deterministic test-gen engine (B1 v0).
|
|
7
|
+
*
|
|
8
|
+
* Free-form in, a RUNNABLE starter test out. Given an existing hand-written
|
|
9
|
+
* skill / subagent / hook, emit a scaffolded `*.harness.mjs` / `*.eval.mjs` at the
|
|
10
|
+
* surface's suggested test path — the deterministic counterpart to the
|
|
11
|
+
* `test-harness` SKILL (which picks the tier with a model). The scaffold picks the
|
|
12
|
+
* cheapest meaningful tier for the kind, wires the real public API + the surface's
|
|
13
|
+
* own metadata (name, namespaced id, declared tools), and leaves TODOs only where a
|
|
14
|
+
* human/model must supply judgement (the prompts, the event input, the assertion).
|
|
15
|
+
*
|
|
16
|
+
* Pure + model-free: hand it a `ScaffoldInput`, get back a `{ path, content }`. The
|
|
17
|
+
* CLI resolves a surface path / plugin dir into inputs (reusing the scan + untested
|
|
18
|
+
* detectors) and writes the files; this module owns only the templating.
|
|
19
|
+
*/
|
|
20
|
+
const node_path_1 = require("node:path");
|
|
21
|
+
const PLUGIN_TODO = "<plugin>";
|
|
22
|
+
/**
|
|
23
|
+
* The suggested test path for a surface — mirrors `suggestedTestPath` in
|
|
24
|
+
* `test-coverage.ts` (a skill gets an `.eval.mjs`, agent/hook a `.harness.mjs`),
|
|
25
|
+
* so a generated file is colocated where the untested-surface detector looks for it
|
|
26
|
+
* and the surface stops being reported untested.
|
|
27
|
+
*/
|
|
28
|
+
function suggestedPath(input) {
|
|
29
|
+
const dir = (0, node_path_1.dirname)(input.path);
|
|
30
|
+
const ext = input.kind === "skill" ? "eval.mjs" : "harness.mjs";
|
|
31
|
+
return `${dir}/${input.name}.${ext}`;
|
|
32
|
+
}
|
|
33
|
+
function header(title, run) {
|
|
34
|
+
return [
|
|
35
|
+
"/**",
|
|
36
|
+
` * ${title}`,
|
|
37
|
+
" *",
|
|
38
|
+
" * Generated by `vigiles scaffold-test` — a STARTER, not a finished test. Fill in",
|
|
39
|
+
" * the TODOs (they're where a human/model must supply judgement), then run:",
|
|
40
|
+
` * ${run}`,
|
|
41
|
+
" */",
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
/** A hook → the unit tier (`runHook`): free, no model, reaches every event. */
|
|
45
|
+
function hookScaffold(input) {
|
|
46
|
+
const cmd = input.hookCommand ?? `bash ${input.path}`;
|
|
47
|
+
return `${header(`Starter unit test for the \`${input.name}\` hook.`, `npx vigiles test ${suggestedPath(input)}`)}
|
|
48
|
+
import { runHook, assertHookAllowed } from "vigiles/unit";
|
|
49
|
+
|
|
50
|
+
// TODO: set the event + input your hook actually inspects (PreToolUse/Bash shown).
|
|
51
|
+
const event = {
|
|
52
|
+
hook_event_name: "PreToolUse",
|
|
53
|
+
tool_name: "Bash",
|
|
54
|
+
tool_input: { command: "echo hello" },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const r = runHook(${JSON.stringify(cmd)}, event);
|
|
58
|
+
|
|
59
|
+
// TODO: assert the decision you expect. Use assertHookBlocked(r) for the deny case
|
|
60
|
+
// (and a second runHook with the input that SHOULD be blocked).
|
|
61
|
+
assertHookAllowed(r);
|
|
62
|
+
console.log("✓ ${input.name}: hook allowed the benign event");
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
/** A skill → the eval tier (`measureTriggerRate`): does its description FIRE? */
|
|
66
|
+
function skillScaffold(input) {
|
|
67
|
+
const id = `${input.pluginName ?? PLUGIN_TODO}:${input.name}`;
|
|
68
|
+
const note = input.userInvoked
|
|
69
|
+
? "\n// NOTE: this skill is user-invoked (disableModelInvocation). Trigger-rate\n// measures MODEL-invocable skills; either make it model-invocable or test its\n// slash-command invocation with runHarnessTest instead.\n"
|
|
70
|
+
: "";
|
|
71
|
+
return `${header(`Starter trigger-rate eval for the \`${id}\` skill (recall + precision).`, `npx vigiles eval ${suggestedPath(input)} # real model, on your subscription`)}
|
|
72
|
+
import {
|
|
73
|
+
measureTriggerRate,
|
|
74
|
+
formatTriggerRateReport,
|
|
75
|
+
assertTriggerRate,
|
|
76
|
+
skillResolved,
|
|
77
|
+
} from "vigiles/testing";
|
|
78
|
+
import { fileURLToPath } from "node:url";
|
|
79
|
+
${note}
|
|
80
|
+
// TODO: point at the plugin root (the dir holding .claude-plugin/ or skills/).
|
|
81
|
+
const pluginDir = fileURLToPath(new URL("../../", import.meta.url));
|
|
82
|
+
const skill = ${JSON.stringify(id)};
|
|
83
|
+
|
|
84
|
+
const report = await measureTriggerRate({
|
|
85
|
+
pluginDir,
|
|
86
|
+
stubSkillBodies: true, // firing is a frontmatter property — stub bodies, pay less
|
|
87
|
+
prompts: [
|
|
88
|
+
// TODO: >=5 varied prompts that SHOULD fire ${input.name} (recall).
|
|
89
|
+
"TODO: a realistic task that should trigger ${input.name}",
|
|
90
|
+
],
|
|
91
|
+
irrelevantPrompts: [
|
|
92
|
+
// TODO: >=5 unrelated prompts that should NOT fire it (precision).
|
|
93
|
+
"TODO: an unrelated coding task",
|
|
94
|
+
],
|
|
95
|
+
fired: (t) => skillResolved(t, skill),
|
|
96
|
+
trials: Number(process.env.VIGILES_TRIALS || 1),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
console.log(formatTriggerRateReport(report));
|
|
100
|
+
assertTriggerRate(report, { min: 0.8, maxFalsePositive: 0.3 });
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
/** A subagent → the deterministic harness tier; point at the railway/Result path. */
|
|
104
|
+
function agentScaffold(input) {
|
|
105
|
+
const toolHint = input.tools && input.tools.length > 0
|
|
106
|
+
? `assertToolUsed(r, ${JSON.stringify(input.tools[0])}); // its declared contract: ${input.tools.join(", ")}`
|
|
107
|
+
: `assertToolUsed(r, "Task"); // TODO: assert what the subagent should do`;
|
|
108
|
+
return `${header(`Starter harness test for the \`${input.name}\` subagent.`, `npx vigiles test ${suggestedPath(input)}`)}
|
|
109
|
+
import { runHarnessTest, assertToolUsed } from "vigiles/testing";
|
|
110
|
+
|
|
111
|
+
// A subagent's OUTCOME is best asserted via a result() contract — deterministic,
|
|
112
|
+
// no LLM judge. If ${input.name} has one, use assertAgentOk(r.output, contract)
|
|
113
|
+
// instead; see the railway-result example in the vigiles docs.
|
|
114
|
+
|
|
115
|
+
const r = await runHarnessTest({
|
|
116
|
+
plugin: ".", // TODO: the plugin dir holding this subagent
|
|
117
|
+
// TODO: a prompt that dispatches ${input.name} (via the Task tool).
|
|
118
|
+
prompt: "TODO: a task that should dispatch ${input.name}",
|
|
119
|
+
transcript: true,
|
|
120
|
+
model: [{ text: "on it" }],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
${toolHint}
|
|
124
|
+
console.log("✓ ${input.name}: subagent test ran");
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
127
|
+
const TIER = {
|
|
128
|
+
hook: "unit",
|
|
129
|
+
skill: "eval",
|
|
130
|
+
agent: "harness",
|
|
131
|
+
};
|
|
132
|
+
const BUILDER = {
|
|
133
|
+
hook: hookScaffold,
|
|
134
|
+
skill: skillScaffold,
|
|
135
|
+
agent: agentScaffold,
|
|
136
|
+
};
|
|
137
|
+
/** Scaffold a starter test for one surface. Pure: path + content, no I/O. */
|
|
138
|
+
function scaffoldTest(input) {
|
|
139
|
+
return {
|
|
140
|
+
path: suggestedPath(input),
|
|
141
|
+
content: BUILDER[input.kind](input),
|
|
142
|
+
kind: input.kind,
|
|
143
|
+
tier: TIER[input.kind],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Render a set of scaffolds for the CLI (what was generated, where, which tier). */
|
|
147
|
+
function formatScaffolds(scaffolds) {
|
|
148
|
+
if (scaffolds.length === 0) {
|
|
149
|
+
return "Nothing to scaffold — every surface already has a test, or none was found.";
|
|
150
|
+
}
|
|
151
|
+
const lines = [`Scaffolded ${String(scaffolds.length)} starter test(s):`, ""];
|
|
152
|
+
for (const s of scaffolds) {
|
|
153
|
+
lines.push(` ${s.path} [${s.kind} → ${s.tier} tier]`);
|
|
154
|
+
}
|
|
155
|
+
lines.push("", "These are STARTERS — fill in the TODOs (prompts / event / assertions), then run", "them with `npx vigiles test` (deterministic) or `npx vigiles eval` (real model).");
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=scaffold-test.js.map
|