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.
Files changed (72) hide show
  1. package/README.md +15 -9
  2. package/dist/adapters/claude-code/adapter.js +1 -0
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +30 -6
  4. package/dist/adapters/claude-code/agent-runtime.js +66 -37
  5. package/dist/adapters/claude-code/dialect.js +37 -2
  6. package/dist/adapters/claude-code/effect-region.d.ts +9 -0
  7. package/dist/adapters/claude-code/effect-region.js +45 -0
  8. package/dist/adapters/claude-code/layout.js +3 -0
  9. package/dist/adapters/claude-code/skill-runtime.d.ts +25 -0
  10. package/dist/adapters/claude-code/skill-runtime.js +48 -0
  11. package/dist/adapters/codex/adapter.js +3 -0
  12. package/dist/adapters/codex/eval.d.ts +94 -0
  13. package/dist/adapters/codex/eval.js +227 -0
  14. package/dist/adapters/codex/layout.js +3 -0
  15. package/dist/adapters/opencode/adapter.js +1 -0
  16. package/dist/adapters/opencode/layout.js +3 -0
  17. package/dist/check.d.ts +8 -0
  18. package/dist/check.js +27 -3
  19. package/dist/cli.js +712 -21
  20. package/dist/codex.d.ts +1 -0
  21. package/dist/codex.js +3 -0
  22. package/dist/core/adapter.d.ts +10 -0
  23. package/dist/core/bash-effects.d.ts +41 -0
  24. package/dist/core/bash-effects.js +405 -0
  25. package/dist/core/compile.d.ts +3 -1
  26. package/dist/core/compile.js +169 -74
  27. package/dist/core/description-overlap.d.ts +27 -0
  28. package/dist/core/description-overlap.js +53 -0
  29. package/dist/core/dialect.d.ts +18 -0
  30. package/dist/core/effects.d.ts +172 -0
  31. package/dist/core/effects.js +245 -0
  32. package/dist/core/frontmatter-read.d.ts +25 -0
  33. package/dist/core/frontmatter-read.js +138 -0
  34. package/dist/core/hook-events.d.ts +34 -0
  35. package/dist/core/hook-events.js +48 -0
  36. package/dist/core/layout.d.ts +6 -0
  37. package/dist/core/mcp-config.d.ts +20 -0
  38. package/dist/core/mcp-config.js +40 -0
  39. package/dist/core/mcp-hook.d.ts +35 -0
  40. package/dist/core/mcp-hook.js +70 -0
  41. package/dist/core/mcp-tool.d.ts +50 -0
  42. package/dist/core/mcp-tool.js +61 -0
  43. package/dist/core/orphans.js +21 -0
  44. package/dist/core/spec.d.ts +142 -3
  45. package/dist/core/spec.js +48 -0
  46. package/dist/core/tool-contract.d.ts +68 -0
  47. package/dist/core/tool-contract.js +113 -0
  48. package/dist/core/types.d.ts +91 -2
  49. package/dist/core/validate.js +23 -1
  50. package/dist/eval.d.ts +69 -13
  51. package/dist/eval.js +106 -51
  52. package/dist/harness-test.d.ts +7 -0
  53. package/dist/harness-test.js +19 -7
  54. package/dist/leaderboard.d.ts +2 -0
  55. package/dist/leaderboard.js +63 -3
  56. package/dist/optimize.d.ts +74 -0
  57. package/dist/optimize.js +94 -0
  58. package/dist/plugin-loader.d.ts +1 -0
  59. package/dist/plugin-loader.js +71 -18
  60. package/dist/scaffold-test.d.ts +30 -0
  61. package/dist/scaffold-test.js +158 -0
  62. package/dist/scan-behavioral.d.ts +73 -0
  63. package/dist/scan-behavioral.js +150 -0
  64. package/dist/scan.d.ts +166 -1
  65. package/dist/scan.js +622 -55
  66. package/dist/score-explainer.d.ts +69 -0
  67. package/dist/score-explainer.js +169 -0
  68. package/dist/test-coverage.d.ts +7 -0
  69. package/dist/test-coverage.js +39 -24
  70. package/package.json +2 -1
  71. package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -6
  72. package/skills/edit-spec/SKILL.md +1 -1
@@ -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 load.
39
- if (r.skills.length + r.agents.length + r.hooks.length === 0) {
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
@@ -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
@@ -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
@@ -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' `SessionStart` reads
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 text files under the surface dirs (hooks scripts
216
- * included — those aren't materialized into `files`) for root-relative path refs
217
- * and report the ones missing on disk. A static check that would have caught a
218
- * bug the dogfood hit twice. Best-effort: a warning, not an error.
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
- function danglingRefs(root, layout) {
221
- const missing = new Set();
222
- const seen = new Set();
223
- const re = intraRefRe(layout);
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.values(readTree(dir, root))) {
229
- for (const m of content.matchAll(re)) {
230
- const ref = m[0];
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