vigiles 33.1.0 โ†’ 33.3.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 (66) hide show
  1. package/README.md +3 -2
  2. package/dist/adapter-conformance.js +14 -10
  3. package/dist/adapter.d.ts +3 -1
  4. package/dist/adapters/claude-code/layout.js +3 -0
  5. package/dist/adapters/claude-code/output-style.d.ts +3 -0
  6. package/dist/adapters/claude-code/output-style.js +81 -0
  7. package/dist/adapters/claude-code/replies.d.ts +14 -0
  8. package/dist/adapters/claude-code/replies.js +78 -0
  9. package/dist/adapters/claude-code/runtime.js +70 -0
  10. package/dist/adapters/claude-code/skill-reachability.d.ts +42 -21
  11. package/dist/adapters/claude-code/skill-reachability.js +91 -28
  12. package/dist/audit-report.d.ts +5 -0
  13. package/dist/audit-report.js +29 -20
  14. package/dist/audit-report.template.html +25 -25
  15. package/dist/audit-score.js +2 -2
  16. package/dist/check.d.ts +20 -0
  17. package/dist/check.js +87 -2
  18. package/dist/cli-main.js +249 -30
  19. package/dist/core/adapter.d.ts +7 -0
  20. package/dist/core/config-schema.d.ts +9 -0
  21. package/dist/core/config-schema.js +1 -0
  22. package/dist/core/eval-driver.d.ts +10 -0
  23. package/dist/core/frontmatter-read.d.ts +31 -0
  24. package/dist/core/frontmatter-read.js +4 -2
  25. package/dist/core/harness-driver.d.ts +2 -0
  26. package/dist/core/install-reader.js +15 -0
  27. package/dist/core/layout.d.ts +17 -0
  28. package/dist/core/layout.js +17 -0
  29. package/dist/core/output-style.d.ts +84 -0
  30. package/dist/core/output-style.js +90 -0
  31. package/dist/core/rule-meta.d.ts +1 -1
  32. package/dist/core/rule-meta.js +8 -0
  33. package/dist/core/run-env.d.ts +50 -0
  34. package/dist/core/run-env.js +51 -0
  35. package/dist/core/runtime.d.ts +44 -0
  36. package/dist/core/skill-link-target.d.ts +20 -0
  37. package/dist/core/skill-link-target.js +33 -0
  38. package/dist/core/types.d.ts +2 -0
  39. package/dist/coverage-evidence.d.ts +42 -0
  40. package/dist/coverage-evidence.js +39 -0
  41. package/dist/eval.d.ts +65 -42
  42. package/dist/eval.js +116 -120
  43. package/dist/harness-test.d.ts +65 -1
  44. package/dist/harness-test.js +114 -22
  45. package/dist/optimize.js +1 -5
  46. package/dist/output-style-arms.d.ts +27 -0
  47. package/dist/output-style-arms.js +51 -0
  48. package/dist/plugin-declaration.d.ts +6 -6
  49. package/dist/plugin-declaration.js +6 -6
  50. package/dist/scan-files.js +1 -1
  51. package/dist/scan.d.ts +6 -0
  52. package/dist/scan.js +14 -4
  53. package/dist/score-core.d.ts +3 -0
  54. package/dist/score-core.js +5 -1
  55. package/dist/setup-plan.d.ts +50 -10
  56. package/dist/setup-plan.js +103 -68
  57. package/dist/skill-links.d.ts +183 -0
  58. package/dist/skill-links.js +255 -0
  59. package/dist/test-coverage-files.d.ts +3 -1
  60. package/dist/test-coverage-files.js +20 -6
  61. package/dist/test-coverage.d.ts +18 -1
  62. package/dist/test-coverage.js +101 -15
  63. package/dist/test.d.ts +3 -1
  64. package/dist/test.js +8 -2
  65. package/dist/vigilesrc.schema.json +134 -0
  66. package/package.json +1 -1
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VIGILES_PACKAGE = void 0;
4
+ exports.decideSkillLink = decideSkillLink;
5
+ exports.locatePackage = locatePackage;
6
+ exports.isWithinProject = isWithinProject;
7
+ exports.linkPrecondition = linkPrecondition;
8
+ exports.portableTarget = portableTarget;
9
+ exports.planSkillLinks = planSkillLinks;
10
+ exports.relinkTempPath = relinkTempPath;
11
+ exports.writeSkillLink = writeSkillLink;
12
+ exports.linkFailureReason = linkFailureReason;
13
+ exports.skillLinksUsable = skillLinksUsable;
14
+ exports.formatSkillLinks = formatSkillLinks;
15
+ /**
16
+ * The pure half of how `vigiles init` makes vigiles's skills visible to the
17
+ * agent in EVERY clone: one relative symlink per shipped skill,
18
+ * `<skills home>/<name> -> ../../node_modules/vigiles/skills/<name>`, committed
19
+ * with the repo. The IO half (lstat, readlink, symlink) is `linkVigilesSkills`
20
+ * in `cli-main.ts`; everything that decides something is here, so it is tested
21
+ * without a filesystem.
22
+ *
23
+ * ๐Ÿ”ด WHY LINKS AND NOT ONLY THE PLUGIN. `npm install vigiles` puts the skills at
24
+ * `node_modules/vigiles/skills/`, which no harness scans. The Claude Code plugin
25
+ * install carries them, but it lands in `~/.claude/plugins/` โ€” a fact about ONE
26
+ * machine. A fresh clone or a fresh container has an empty home directory, and
27
+ * the project's `enabledPlugins` declaration only makes Claude Code PROMPT for
28
+ * an install (`./plugin-declaration.ts`); headless, nobody sees the prompt. The
29
+ * skills were on disk and silently absent. A link is part of the repository, so
30
+ * it arrives with `git clone` and resolves as soon as `npm install` has run โ€”
31
+ * the same step every other dependency already needs.
32
+ *
33
+ * Both harnesses document following a symlinked skill folder:
34
+ *
35
+ * > Claude Code: "a `<skill-name>` entry in the enterprise, personal, or project
36
+ * > location can be a symlink to a directory elsewhere on disk. Claude Code
37
+ * > reads `SKILL.md` from the target" (code.claude.com/docs/en/skills, fetched
38
+ * > 2026-10-07).
39
+ *
40
+ * > Codex: "Codex supports symlinked skill folders and follows the symlink
41
+ * > target when scanning these locations" (learn.chatgpt.com/docs/build-skills,
42
+ * > fetched 2026-10-07).
43
+ *
44
+ * The layout is the one paperlint's `init` already ships (its `link-skills.ts`),
45
+ * kept on purpose: the links that work in a real consumer are the proven shape.
46
+ *
47
+ * โ”€โ”€ THE RULES โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
48
+ * what to link every skill the package ships: the installed package's
49
+ * `skills/` when it is installed, the running CLI's own
50
+ * otherwise (the version `init` just declared).
51
+ * where it points `node_modules/vigiles` as the PROJECT spells it, found by
52
+ * walking up no further than the repository root. A link that
53
+ * leaves the repository resolves on this machine only โ€” the
54
+ * defect this module exists to remove. Under pnpm the real
55
+ * path is a version-stamped store directory; a link spelled
56
+ * through `node_modules/vigiles` survives the next upgrade.
57
+ * what it touches only what is missing. An entry that already leads to the
58
+ * shipped skill is kept; ANY other entry of the same name โ€”
59
+ * the user's own skill directory, a file, a link elsewhere โ€”
60
+ * is theirs, and is reported, never replaced.
61
+ *
62
+ * Names are not namespaced: a linked skill is `test-harness`, where the plugin's
63
+ * copy is `vigiles:test-harness`. A user's own skill of the same name therefore
64
+ * wins by construction (we never replace it) and the report names the skill
65
+ * they did not get.
66
+ */
67
+ const node_path_1 = require("node:path");
68
+ const skill_link_target_js_1 = require("./core/skill-link-target.js");
69
+ /** The package whose skills are linked. */
70
+ exports.VIGILES_PACKAGE = "vigiles";
71
+ /**
72
+ * Decide one entry. A link with OUR target is ours even while it dangles โ€” that
73
+ * is exactly how a committed link looks in a clone that has not run
74
+ * `npm install` yet, and calling it foreign would make re-running `init` noisy
75
+ * in precisely the place it is meant to help.
76
+ */
77
+ function decideSkillLink(name, entry, want) {
78
+ switch (entry.kind) {
79
+ case "missing":
80
+ return { name, action: "create", target: want.target };
81
+ case "link":
82
+ if (entry.target === want.target ||
83
+ (entry.resolvesTo !== null && entry.resolvesTo === want.real))
84
+ return { name, action: "keep" };
85
+ // Provably ours (the shape `init` writes, same skill) but aimed elsewhere โ€”
86
+ // e.g. linked before a workspace install hoisted the package. Rewrite it.
87
+ if ((0, skill_link_target_js_1.isVigilesSkillTarget)(entry.target, name))
88
+ return { name, action: "replace", target: want.target };
89
+ return {
90
+ name,
91
+ action: "refuse",
92
+ reason: entry.resolvesTo === null
93
+ ? `a dangling link to ${entry.target}`
94
+ : `a link to ${entry.target}`,
95
+ };
96
+ case "directory":
97
+ return { name, action: "refuse", reason: "a directory (your own skill)" };
98
+ case "file":
99
+ return { name, action: "refuse", reason: "a file" };
100
+ }
101
+ }
102
+ /** Is there a workspace root strictly above `project`, up to the repo root? */
103
+ function insideWorkspace(project, probe) {
104
+ const walk = (dir) => {
105
+ if (dir !== project && probe.isWorkspaceRoot(dir))
106
+ return true;
107
+ if (probe.isRepoRoot(dir) || (0, node_path_1.dirname)(dir) === dir)
108
+ return false;
109
+ return walk((0, node_path_1.dirname)(dir));
110
+ };
111
+ return walk(project);
112
+ }
113
+ /**
114
+ * Node's own lookup โ€” the first `node_modules/vigiles` up the tree โ€” bounded by
115
+ * the repository root, because a committed link that leaves the repository is a
116
+ * link only this machine can follow.
117
+ */
118
+ function locatePackage(project, probe) {
119
+ // The walk stops at the repository root; with no repository yet (before
120
+ // `git init`) the project itself is the boundary, or an unrelated ancestor's
121
+ // node_modules would be linked into it.
122
+ const repoRoot = (dir) => {
123
+ if (probe.isRepoRoot(dir))
124
+ return dir;
125
+ return (0, node_path_1.dirname)(dir) === dir ? null : repoRoot((0, node_path_1.dirname)(dir));
126
+ };
127
+ const boundary = repoRoot(project) ?? project;
128
+ const walk = (dir) => {
129
+ const candidate = (0, node_path_1.join)(dir, "node_modules", exports.VIGILES_PACKAGE);
130
+ if (probe.isPackage(candidate))
131
+ return { kind: "installed", dir: candidate };
132
+ if (dir === boundary || (0, node_path_1.dirname)(dir) === dir)
133
+ return null;
134
+ return walk((0, node_path_1.dirname)(dir));
135
+ };
136
+ const installed = walk(project);
137
+ if (installed !== null)
138
+ return installed;
139
+ return insideWorkspace(project, probe)
140
+ ? {
141
+ kind: "unsure",
142
+ reason: "vigiles isn't installed yet and this package is in a workspace, where the install decides whether it lands in this package's node_modules or the workspace root's โ€” run npm install, then npx vigiles init",
143
+ }
144
+ : { kind: "expected", dir: (0, node_path_1.join)(project, "node_modules", exports.VIGILES_PACKAGE) };
145
+ }
146
+ /**
147
+ * Is the real path `path` the project's own real path or somewhere under it?
148
+ * Both must be physical (symlink-free), or a link could pass for a child.
149
+ */
150
+ function isWithinProject(project, path) {
151
+ const rel = (0, node_path_1.relative)(project, path);
152
+ // A child can be NAMED `..cache`; only a whole `..` segment leaves the project.
153
+ return rel.split(node_path_1.sep)[0] !== ".." && !(0, node_path_1.isAbsolute)(rel);
154
+ }
155
+ /**
156
+ * Why NOT to link, or null to go ahead. A link into a `node_modules/vigiles`
157
+ * that no install will ever create is a dangling link committed to the repo โ€” a
158
+ * worse state than no link, because it looks like the job was done.
159
+ */
160
+ function linkPrecondition(facts) {
161
+ if (facts.isVigilesItself)
162
+ return "this is the vigiles repository itself; its skills are the plugin's own skills/";
163
+ if (!facts.installed && !facts.dependsOnVigiles)
164
+ return "this repo has no package.json dependency on vigiles, so no install will create node_modules/vigiles for a link to point at; the global install carries the skills here instead";
165
+ return null;
166
+ }
167
+ /**
168
+ * A link target as it is committed: `/` separators on every OS. `relative()` on
169
+ * Windows yields `..\\..\\node_modules\\โ€ฆ`, and a clone on Linux or macOS
170
+ * reads those backslashes as ordinary filename characters, so the link dangles.
171
+ */
172
+ function portableTarget(target, separator = node_path_1.sep) {
173
+ return target.split(separator).join("/");
174
+ }
175
+ function planSkillLinks(input) {
176
+ const skillsDir = (0, node_path_1.join)(input.site.dir, "skills");
177
+ return {
178
+ pending: input.site.kind === "expected",
179
+ decisions: input.names.map((name) => decideSkillLink(name, input.entries(name), {
180
+ target: portableTarget((0, node_path_1.relative)(input.physicalHome, (0, node_path_1.join)(skillsDir, name))),
181
+ real: input.realOf(name),
182
+ })),
183
+ };
184
+ }
185
+ /** Where a replacement link is built before it takes the old one's place. */
186
+ function relinkTempPath(entry) {
187
+ return (0, node_path_1.join)((0, node_path_1.dirname)(entry), `.${(0, node_path_1.basename)(entry)}.vigiles-new`);
188
+ }
189
+ /**
190
+ * Write the link `decision` asks for. A `replace` never leaves a gap: the new
191
+ * link is built under a temporary name beside the old one and renamed over it,
192
+ * which is atomic. Removing the old link first would lose it for good when the
193
+ * new one cannot be created (a Windows account without the symlink privilege,
194
+ * a full disk) โ€” a skill that worked yesterday gone, and only a warning to show
195
+ * for it. Any failure leaves the old link in place and throws.
196
+ */
197
+ function writeSkillLink(io, entry, target, action) {
198
+ if (action === "create") {
199
+ io.symlink(target, entry);
200
+ return;
201
+ }
202
+ const temp = relinkTempPath(entry);
203
+ // Whatever already sits at the temporary name is not ours to delete: the
204
+ // `symlink` then fails (EEXIST) and is reported, and both entries stay.
205
+ io.symlink(target, temp);
206
+ try {
207
+ io.rename(temp, entry);
208
+ }
209
+ catch (e) {
210
+ try {
211
+ // Only the link this call just created is removed.
212
+ io.unlink(temp);
213
+ }
214
+ catch {
215
+ // Already gone: the rename error is the news.
216
+ }
217
+ throw e;
218
+ }
219
+ }
220
+ /** Why a `symlink` call failed, with the fix when there is a known one. */
221
+ function linkFailureReason(code, message) {
222
+ const base = `could not create the link: ${message}`;
223
+ return code === "EPERM"
224
+ ? `${base} โ€” on Windows, creating a symlink needs Developer Mode (or an elevated shell), and git needs core.symlinks=true to check one out`
225
+ : base;
226
+ }
227
+ /** At least one shipped skill now reaches the agent through a link. */
228
+ function skillLinksUsable(outcome) {
229
+ return (outcome.kind === "linked" &&
230
+ outcome.results.some((r) => r.status !== "skipped"));
231
+ }
232
+ /**
233
+ * The lines `init` prints. Skipped skills are named one by one: the user loses
234
+ * that skill, and the reason is theirs to act on.
235
+ */
236
+ function formatSkillLinks(outcome) {
237
+ if (outcome.kind === "not-linked")
238
+ return [`โš  vigiles's skills were NOT linked โ€” ${outcome.reason}`];
239
+ const count = (s) => outcome.results.filter((r) => r.status === s).length;
240
+ const skipped = outcome.results.filter((r) => r.status === "skipped");
241
+ const mark = skipped.length > 0 ? "โš " : "โœ“";
242
+ return [
243
+ `${mark} vigiles's ${String(outcome.results.length)} skills โ†’ ${outcome.home}/ (relative links into node_modules; commit them): ` +
244
+ `${String(count("created"))} linked now, ${String(count("present"))} already linked, ` +
245
+ (count("relinked") > 0 ? `${String(count("relinked"))} relinked, ` : "") +
246
+ `${String(skipped.length)} skipped`,
247
+ ...skipped.map((r) => ` ${outcome.home}/${r.name} left alone โ€” it is ${r.reason}. Rename it to get vigiles's ${r.name}.`),
248
+ ...(outcome.pending
249
+ ? [
250
+ " They resolve once vigiles is installed: run npm install (every clone and container needs it anyway).",
251
+ ]
252
+ : []),
253
+ ];
254
+ }
255
+ //# sourceMappingURL=skill-links.js.map
@@ -1,7 +1,7 @@
1
1
  import { type PluginLayout } from "./core/layout.js";
2
2
  import type { CoverageDecision, CoverageTier, Surface } from "./test-coverage.js";
3
3
  /**
4
- * The untested harness surfaces (skills / agents / hooks) in a file map โ€” the
4
+ * The untested harness surfaces (skills / agents / hooks / output styles) in a file map โ€” the
5
5
  * browser-safe twin of `findUntestedSurfaces`, returning the union `untested` list
6
6
  * plus the same per-tier split (`scanFiles` needs the counts). Same coverage rules
7
7
  * as the disk detector; the tier split is by suffix, exactly as on disk.
@@ -17,5 +17,7 @@ repoName: string): {
17
17
  decisions: readonly CoverageDecision[];
18
18
  harness: CoverageTier;
19
19
  evals: CoverageTier;
20
+ /** Surfaces opted out via `vigiles:ignore-test`; still part of the inventory. */
21
+ exemptSurfaces: readonly Surface[];
20
22
  };
21
23
  //# sourceMappingURL=test-coverage-files.d.ts.map
@@ -34,6 +34,7 @@ exports.findUntestedSurfacesInFiles = findUntestedSurfacesInFiles;
34
34
  const posix_path_js_1 = require("./posix-path.js");
35
35
  const layout_js_1 = require("./core/layout.js");
36
36
  const coverage_evidence_js_1 = require("./coverage-evidence.js");
37
+ const output_style_js_1 = require("./core/output-style.js");
37
38
  const source_kinds_js_1 = require("./source-kinds.js");
38
39
  const IGNORE_MARKER = "vigiles:ignore-test";
39
40
  /** Mirror of the DEFAULT_IGNORE globs (root-anchored), as a key predicate. */
@@ -136,6 +137,13 @@ function discoverAgents(files, layout) {
136
137
  }
137
138
  return out;
138
139
  }
140
+ /** Twin of test-coverage.ts `discoverOutputStyles`: the harness decides, over the map. */
141
+ function discoverOutputStyles(files, layout) {
142
+ const scan = (0, output_style_js_1.findOutputStyles)(layout, new Map(Object.entries(files).filter(([path]) => !isIgnored(path))));
143
+ return scan.kind === "not-supported"
144
+ ? []
145
+ : scan.styles.map((style) => (0, coverage_evidence_js_1.outputStyleSurface)(style.path, files[style.path] ?? ""));
146
+ }
139
147
  /**
140
148
  * Hook-script paths referenced from a manifest's `hooks` block (file hooks only).
141
149
  *
@@ -226,8 +234,17 @@ function tierOf(considered, tests, globs) {
226
234
  }
227
235
  return { covered, untested, decisions };
228
236
  }
237
+ /** Every surface kind in the map โ€” twin of test-coverage.ts `discoverSurfaces`. */
238
+ function discoverSurfaces(files, layout, repoName) {
239
+ return [
240
+ ...discoverSkills(files, layout, repoName),
241
+ ...discoverAgents(files, layout),
242
+ ...discoverHooks(files, layout),
243
+ ...discoverOutputStyles(files, layout),
244
+ ];
245
+ }
229
246
  /**
230
- * The untested harness surfaces (skills / agents / hooks) in a file map โ€” the
247
+ * The untested harness surfaces (skills / agents / hooks / output styles) in a file map โ€” the
231
248
  * browser-safe twin of `findUntestedSurfaces`, returning the union `untested` list
232
249
  * plus the same per-tier split (`scanFiles` needs the counts). Same coverage rules
233
250
  * as the disk detector; the tier split is by suffix, exactly as on disk.
@@ -239,11 +256,7 @@ function findUntestedSurfacesInFiles(files, layout,
239
256
  * `name:`. Same value `scanFiles` already threads for the skill inventory.
240
257
  */
241
258
  repoName) {
242
- const surfaces = [
243
- ...discoverSkills(files, layout, repoName),
244
- ...discoverAgents(files, layout),
245
- ...discoverHooks(files, layout),
246
- ];
259
+ const surfaces = discoverSurfaces(files, layout, repoName);
247
260
  const considered = surfaces.filter((s) => !s.ignored);
248
261
  const tests = discoverTests(files);
249
262
  // NO configured `{surface}` globs here, and that is a property of this twin
@@ -257,6 +270,7 @@ repoName) {
257
270
  return {
258
271
  untested: [...union.untested],
259
272
  decisions: union.decisions,
273
+ exemptSurfaces: surfaces.filter((s) => s.ignored),
260
274
  harness: tierOf(considered, tests.filter((t) => !(0, coverage_evidence_js_1.isEvalScript)((0, posix_path_js_1.basename)(t.path))), noConfiguredGlobs),
261
275
  evals: tierOf(considered, tests.filter((t) => (0, coverage_evidence_js_1.isEvalScript)((0, posix_path_js_1.basename)(t.path))), noConfiguredGlobs),
262
276
  };
@@ -64,7 +64,7 @@ import { type ExcludeSet } from "./exclude.js";
64
64
  import type { RepoPath } from "./core/frame.js";
65
65
  import { type PluginLayout } from "./core/layout.js";
66
66
  import { type CoverageEvidence, type EvidenceCounts } from "./coverage-evidence.js";
67
- export type SurfaceKind = "skill" | "agent" | "hook";
67
+ export type SurfaceKind = "skill" | "agent" | "hook" | "output-style";
68
68
  export interface Surface {
69
69
  readonly kind: SurfaceKind;
70
70
  /** Repo-relative path to the surface file (SKILL.md / agent .md / hook script). */
@@ -119,6 +119,11 @@ export interface UntestedReport {
119
119
  readonly untested: readonly Surface[];
120
120
  /** Surfaces explicitly opted out via `vigiles:ignore-test`. */
121
121
  readonly exempt: number;
122
+ /**
123
+ * The exempt surfaces themselves. The marker waives a surface's test, not its
124
+ * existence, so an inventory counts them (an exempt style still ships).
125
+ */
126
+ readonly exemptSurfaces: readonly Surface[];
122
127
  /**
123
128
  * Test files still carrying the RETIRED `vigiles:covers` marker.
124
129
  *
@@ -201,6 +206,18 @@ export interface TestCoverageOptions {
201
206
  readonly agents?: boolean;
202
207
  /** Scan hook scripts referenced from plugin.json / settings.json. Default true. */
203
208
  readonly hooks?: boolean;
209
+ /**
210
+ * Scan output styles, in every folder the layout's `outputStyles` names.
211
+ * Default true; a layout with no output styles finds none either way.
212
+ */
213
+ readonly outputStyles?: boolean;
214
+ /**
215
+ * Every harness the repository declares, when it declares more than one.
216
+ * Surfaces are found in each of them, one per file: a repository can list a
217
+ * harness first and keep its skills, agents or styles in another's folder.
218
+ * Defaults to `[layout]`.
219
+ */
220
+ readonly harnessLayouts?: readonly PluginLayout[];
204
221
  /** Globs of test files that count as coverage. */
205
222
  readonly include?: readonly string[];
206
223
  /**
@@ -78,6 +78,7 @@ const ts_runner_caps_js_1 = require("./ts-runner-caps.js");
78
78
  const glob_1 = require("glob");
79
79
  const glob_ignore_js_1 = require("./core/glob-ignore.js");
80
80
  const exclude_js_1 = require("./exclude.js");
81
+ const output_style_js_1 = require("./core/output-style.js");
81
82
  const source_kinds_js_1 = require("./source-kinds.js");
82
83
  const layout_js_1 = require("./core/layout.js");
83
84
  const coverage_evidence_js_1 = require("./coverage-evidence.js");
@@ -146,6 +147,42 @@ function read(path) {
146
147
  return "";
147
148
  }
148
149
  }
150
+ /**
151
+ * Is this skill directory a DEPENDENCY's โ€” does it really live under a
152
+ * `node_modules` that is not the scanned base's own ancestor?
153
+ *
154
+ * The `node_modules/**` floor (`EXCLUDE_FLOOR`) is applied to the path as
155
+ * SPELLED, and `.claude/skills/<name>` spells no `node_modules` even when it is
156
+ * a link into one โ€” which is exactly what `vigiles init` (and paperlint's)
157
+ * commits so the agent sees a package's skills in every clone. Those skills are
158
+ * the package's work, tested by the package; counting them here would report
159
+ * the repo's own `init` as six untested skills, and fail `--strict`.
160
+ *
161
+ * Both the link as written and its real target are asked (see the body).
162
+ * Measured on the path RELATIVE to the base's real path, so auditing a package
163
+ * that itself sits in `node_modules` still sees its own skills, and a pnpm
164
+ * store target (`node_modules/.pnpm/โ€ฆ`) or a hoisted workspace root
165
+ * (`../../node_modules/โ€ฆ`) both count as dependencies. A link elsewhere (one
166
+ * shared skills folder linked per skill) is the repo's own and stays graded.
167
+ * Unresolvable โ†’ not a dependency (the skill stays visible: silence is the
168
+ * worse error here).
169
+ */
170
+ function isDependencySkill(basePath, skillDir) {
171
+ try {
172
+ const base = (0, node_fs_1.realpathSync)(basePath);
173
+ const inNodeModules = (p) => (0, node_path_1.relative)(base, p).split(node_path_1.sep).includes("node_modules");
174
+ // Both the link as WRITTEN and where it finally lands: under `npm link`, a
175
+ // `file:` dependency or a workspace, `node_modules/<pkg>` is itself a link
176
+ // to a source checkout, so the real path alone names no `node_modules`.
177
+ const spelled = (0, node_fs_1.lstatSync)(skillDir).isSymbolicLink()
178
+ ? (0, node_path_1.resolve)((0, node_fs_1.realpathSync)((0, node_path_1.dirname)(skillDir)), (0, node_fs_1.readlinkSync)(skillDir))
179
+ : skillDir;
180
+ return inNodeModules(spelled) || inNodeModules((0, node_fs_1.realpathSync)(skillDir));
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ }
149
186
  function discoverSkills(basePath, ignore, layout) {
150
187
  const out = [];
151
188
  // ๐Ÿ”ด ONE GATE FOR THE WHOLE FUNCTION, and it is structural rather than
@@ -158,7 +195,7 @@ function discoverSkills(basePath, ignore, layout) {
158
195
  const skillDir = layout.surfaces.skill;
159
196
  if (skillDir === undefined)
160
197
  return out;
161
- const found = (0, glob_1.globSync)(surfaceGlobs(skillDir, "*/SKILL.md", (0, layout_js_1.materializePrefix)(layout)), { cwd: basePath, ignore });
198
+ const found = (0, glob_1.globSync)(surfaceGlobs(skillDir, "*/SKILL.md", (0, layout_js_1.materializePrefix)(layout)), { cwd: basePath, ignore }).filter((path) => !isDependencySkill(basePath, (0, node_path_1.join)(basePath, (0, node_path_1.dirname)(path))));
162
199
  for (const path of found.sort()) {
163
200
  const name = (0, node_path_1.basename)((0, node_path_1.dirname)(path));
164
201
  const content = read((0, node_path_1.join)(basePath, path));
@@ -249,6 +286,46 @@ function discoverAgents(basePath, ignore, layout) {
249
286
  }
250
287
  return out;
251
288
  }
289
+ /**
290
+ * Output styles, by the layout's own rules: every file in a style home that
291
+ * the harness would load. {@link findOutputStyles} decides; this only reads the
292
+ * candidates off the disk, so it and the browser twin cannot disagree on which
293
+ * files are styles.
294
+ */
295
+ function discoverOutputStyles(basePath, ignore, layout) {
296
+ const candidates = new Map((0, output_style_js_1.outputStyleHomes)(layout).flatMap((home) => (0, glob_1.globSync)(`${home}/**/*`, {
297
+ cwd: basePath,
298
+ ignore: [...ignore],
299
+ dot: true,
300
+ nodir: true,
301
+ }).map((path) => [path.split(node_path_1.sep).join("/"), ""])));
302
+ const scan = (0, output_style_js_1.findOutputStyles)(layout, candidates);
303
+ return scan.kind === "not-supported"
304
+ ? []
305
+ : scan.styles.map((style) => (0, coverage_evidence_js_1.outputStyleSurface)(style.path, read((0, node_path_1.join)(basePath, style.path))));
306
+ }
307
+ /**
308
+ * Every kind the options switch on, found under the bundle. Discovery gets only
309
+ * the frame-free ignore floor; the rule's and the repo's excludes are applied
310
+ * later, from `root`, by the caller.
311
+ */
312
+ function discoverSurfaces(basePath, layout, options) {
313
+ const on = (flag) => flag !== false;
314
+ const found = (options.harnessLayouts ?? [layout]).flatMap((each) => [
315
+ ...(on(options.skills)
316
+ ? discoverSkills(basePath, [...DEFAULT_IGNORE], each)
317
+ : []),
318
+ ...(on(options.agents)
319
+ ? discoverAgents(basePath, [...DEFAULT_IGNORE], each)
320
+ : []),
321
+ ...(on(options.hooks) ? discoverHooks(basePath, each) : []),
322
+ ...(on(options.outputStyles)
323
+ ? discoverOutputStyles(basePath, DEFAULT_IGNORE, each)
324
+ : []),
325
+ ]);
326
+ // Two declared harnesses can read the same file: one surface per file.
327
+ return [...new Map(found.map((s) => [`${s.kind}\0${s.path}`, s])).values()];
328
+ }
252
329
  /**
253
330
  * Hook-script paths referenced from a manifest's `hooks` block (file hooks only).
254
331
  *
@@ -419,6 +496,11 @@ function tierOf(considered, tests, index, tier, globs) {
419
496
  // ---------------------------------------------------------------------------
420
497
  // Public API
421
498
  // ---------------------------------------------------------------------------
499
+ /** The exempt surfaces and their count: waived from testing, still in the inventory. */
500
+ function exemptOf(surfaces) {
501
+ const exemptSurfaces = surfaces.filter((s) => s.ignored);
502
+ return { exempt: exemptSurfaces.length, exemptSurfaces };
503
+ }
422
504
  /**
423
505
  * Find harness surfaces (skills / agents / hooks) that no test or eval covers.
424
506
  * A surface is covered by a colocated `*.{harness,eval}.mjs` OR any discovered
@@ -449,24 +531,15 @@ function findUntestedSurfaces(options) {
449
531
  // Discovery globs from the BUNDLE, so it gets only the frame-free floor; the
450
532
  // rule's patterns are root-relative and the repo's carry their own root, so
451
533
  // both are applied below, to the root-relative path.
452
- const floor = [...DEFAULT_IGNORE];
453
- const found = [];
454
- if (options.skills !== false)
455
- found.push(...discoverSkills(basePath, floor, layout));
456
- if (options.agents !== false)
457
- found.push(...discoverAgents(basePath, floor, layout));
458
- if (options.hooks !== false)
459
- found.push(...discoverHooks(basePath, layout));
534
+ const found = discoverSurfaces(basePath, layout, options);
460
535
  // Hooks were never subject to `ignore` (a compiled hook lives under the
461
536
  // default-ignored `.vigiles/`); keep that, and re-apply exclude to the kinds
462
537
  // whose discovery glob used it โ€” now from `root`, where the patterns live.
463
538
  const surfaces = found
464
539
  .map(toRoot)
465
540
  .filter((s) => s.kind === "hook" || !excluded(s.path));
466
- // Every skill/agent/hook is held to the requirement โ€” only an explicit
467
- // `vigiles:ignore-test` marker exempts a surface (a visible, deliberate skip).
541
+ // Only an explicit `vigiles:ignore-test` marker exempts a surface.
468
542
  const considered = surfaces.filter((s) => !s.ignored);
469
- const exempt = surfaces.length - considered.length;
470
543
  const tests = discoverTests(root, globs, ignore);
471
544
  const split = partitionTests(tests);
472
545
  // The run record, if there is one. NO artifact โ‡’ an empty index โ‡’ every
@@ -487,7 +560,7 @@ function findUntestedSurfaces(options) {
487
560
  total: considered.length,
488
561
  covered: union.covered,
489
562
  untested: union.untested,
490
- exempt,
563
+ ...exemptOf(surfaces),
491
564
  staleRuns: staleRunsFor(considered, runIndex),
492
565
  testExt: (0, test_file_ext_js_1.testFileExt)({
493
566
  configured: options.testExtension,
@@ -683,6 +756,19 @@ function evalTierQuestion(kind) {
683
756
  `the tool contract, \`assertAgentOk\` for the typed result. Those two ` +
684
757
  `calls drive Claude Code and take no \`evalDriver\`, so on another ` +
685
758
  `harness this tier has no public dispatch yet.`);
759
+ case "output-style":
760
+ // A style is not selected by the model, so there is no firing to
761
+ // measure; the real-model question is whether a model handed the style
762
+ // FOLLOWS it. The deterministic test that covers it drove a scripted
763
+ // model, which proves delivery and nothing about following.
764
+ return (`A deterministic test covers it, but that test drove a SCRIPTED model: ` +
765
+ `it can show the style reached the model, never that a real model ` +
766
+ `follows it.\n` +
767
+ `See the \`test-harness\` skill: compare runs with and without the style ` +
768
+ `(\`measureArms\`), check the output with deterministic checks where the ` +
769
+ `rule has a checkable shape and \`judged\` where it does not, and gate ` +
770
+ `with \`assertRates\`. NOT \`measureTriggerRate\`: a style is not ` +
771
+ `selected by the model, so there is no trigger to measure.`);
686
772
  case "hook":
687
773
  // Nothing. A hook is deterministic by construction: `runHook` answers
688
774
  // "does it block event X?" at the unit tier, free, for every event type โ€”
@@ -859,8 +945,8 @@ function formatUntestedReport(report) {
859
945
  // model budget.
860
946
  lines.push(` Two gaps, two costs: ${String(report.harness.untested.length)} with no ` +
861
947
  `deterministic harness (free, every push) ยท ` +
862
- `${String(report.evals.untested.length)} whose firing was never measured ` +
863
- `(needs a real model, run on a schedule).`);
948
+ `${String(report.evals.untested.length)} never measured with a real model ` +
949
+ `(paid, run on a schedule).`);
864
950
  // What the surfaces that DID pass are resting on.
865
951
  if (provenance)
866
952
  lines.push(` ${provenance}`);
package/dist/test.d.ts CHANGED
@@ -62,7 +62,7 @@ export { loadHook } from "./load-hook.js";
62
62
  export { experimental_hookState } from "./hook-state-store.js";
63
63
  export type { HookStateHandle, SeedStateOptions } from "./hook-state-store.js";
64
64
  export type { StateFact, Duration } from "./core/hook-state.js";
65
- export { evalChecks, assertChecks, tool, toolWith, notTool, onlyTools, skill, output, hookFired, received, turns, wrote, didNotWrite, subagent, blocked, allowed, mcp, cost, latency, tokens, inputTokens, outputTokens, cacheTokens, } from "./check.js";
65
+ export { evalChecks, assertChecks, tool, toolWith, notTool, onlyTools, skill, output, experimental_eachReply, experimental_replyCount, hookFired, received, turns, wrote, didNotWrite, subagent, blocked, allowed, mcp, cost, latency, tokens, inputTokens, outputTokens, cacheTokens, } from "./check.js";
66
66
  export type { ArgMatcher, Check, CheckJSON, CheckResult, JudgeFn, } from "./check.js";
67
67
  export { DISASTER_CATALOG, verifyGuardrail, unblockedDisasters, assertBlocksDisasters, formatGuardrailReport, experimental_alternateSpellings, } from "./guardrail-check.js";
68
68
  export type { DisasterEvent, DisasterCategory, GuardrailResult, VerifyGuardrailOptions, } from "./guardrail-check.js";
@@ -70,6 +70,8 @@ export { experimental_verifyPluginGuards, experimental_formatPluginGuardReport,
70
70
  export type { PluginGuardReport, SweptHook, SweptHookOutcome, NonCommandHookAction, VerifyPluginGuardsOptions, } from "./verify-plugin-guards.js";
71
71
  export * from "./tool-stub.js";
72
72
  export { runHarnessTest, runHarness, parseToolCalls, parseSubagents, parseResultEvent, parseOutput, parseHooks, decideSandbox, specTrusted, sandboxAvailable, } from "./harness-test.js";
73
+ export { experimental_outputStyleArms } from "./output-style-arms.js";
74
+ export type { OutputStyleArms } from "./output-style-arms.js";
73
75
  export type { HarnessTestSpec, Trace, SubagentTrace, HarnessTestResult, RunHarnessTestOptions, ModelTurn, ModelRequest, ToolCall, HookFire, HarnessTestDriver, SandboxMode, } from "./harness-test.js";
74
76
  export { commandsIn, mustInclude, mustNotInclude } from "./doc-commands.js";
75
77
  export type { DocCommand } from "./doc-commands.js";
package/dist/test.js CHANGED
@@ -71,8 +71,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
71
71
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
72
72
  };
73
73
  Object.defineProperty(exports, "__esModule", { value: true });
74
- exports.parseSubagents = exports.parseToolCalls = exports.runHarness = exports.runHarnessTest = exports.experimental_formatPluginGuardReport = exports.experimental_verifyPluginGuards = exports.experimental_alternateSpellings = exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblockedDisasters = exports.verifyGuardrail = exports.DISASTER_CATALOG = exports.cacheTokens = exports.outputTokens = exports.inputTokens = exports.tokens = exports.latency = exports.cost = exports.mcp = exports.allowed = exports.blocked = exports.subagent = exports.didNotWrite = exports.wrote = exports.turns = exports.received = exports.hookFired = exports.output = exports.skill = exports.onlyTools = exports.notTool = exports.toolWith = exports.tool = exports.assertChecks = exports.evalChecks = exports.experimental_hookState = exports.loadHook = exports.experimental_assertEmittedOk = exports.experimental_parseEmitted = exports.experimental_emitTool = exports.egressRoutes = exports.fileToolEvents = exports.propertyHook = exports.decideHook = exports.parseHookOutput = exports.runHook = exports.runScript = exports.cleanupTmpDir = exports.makeTmpDir = exports.recordCheck = void 0;
75
- exports.experimental_makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = exports.stubSkillBody = exports.parseClaudeRun = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.defineEval = exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.sandboxAvailable = exports.specTrusted = exports.decideSandbox = exports.parseHooks = exports.parseOutput = exports.parseResultEvent = void 0;
74
+ exports.runHarness = exports.runHarnessTest = exports.experimental_formatPluginGuardReport = exports.experimental_verifyPluginGuards = exports.experimental_alternateSpellings = exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblockedDisasters = exports.verifyGuardrail = exports.DISASTER_CATALOG = exports.cacheTokens = exports.outputTokens = exports.inputTokens = exports.tokens = exports.latency = exports.cost = exports.mcp = exports.allowed = exports.blocked = exports.subagent = exports.didNotWrite = exports.wrote = exports.turns = exports.received = exports.hookFired = exports.experimental_replyCount = exports.experimental_eachReply = exports.output = exports.skill = exports.onlyTools = exports.notTool = exports.toolWith = exports.tool = exports.assertChecks = exports.evalChecks = exports.experimental_hookState = exports.loadHook = exports.experimental_assertEmittedOk = exports.experimental_parseEmitted = exports.experimental_emitTool = exports.egressRoutes = exports.fileToolEvents = exports.propertyHook = exports.decideHook = exports.parseHookOutput = exports.runHook = exports.runScript = exports.cleanupTmpDir = exports.makeTmpDir = exports.recordCheck = void 0;
75
+ exports.experimental_makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = exports.stubSkillBody = exports.parseClaudeRun = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.defineEval = exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.experimental_outputStyleArms = exports.sandboxAvailable = exports.specTrusted = exports.decideSandbox = exports.parseHooks = exports.parseOutput = exports.parseResultEvent = exports.parseSubagents = exports.parseToolCalls = void 0;
76
76
  // --- reporting: how much did this script actually do? ---
77
77
  // `vigiles test` can otherwise see only an exit code, so a file that runs NOTHING
78
78
  // prints the same `โœ“` as one that ran and passed (measured 2026-08-08 on a file
@@ -159,6 +159,8 @@ Object.defineProperty(exports, "notTool", { enumerable: true, get: function () {
159
159
  Object.defineProperty(exports, "onlyTools", { enumerable: true, get: function () { return check_js_1.onlyTools; } });
160
160
  Object.defineProperty(exports, "skill", { enumerable: true, get: function () { return check_js_1.skill; } });
161
161
  Object.defineProperty(exports, "output", { enumerable: true, get: function () { return check_js_1.output; } });
162
+ Object.defineProperty(exports, "experimental_eachReply", { enumerable: true, get: function () { return check_js_1.experimental_eachReply; } });
163
+ Object.defineProperty(exports, "experimental_replyCount", { enumerable: true, get: function () { return check_js_1.experimental_replyCount; } });
162
164
  Object.defineProperty(exports, "hookFired", { enumerable: true, get: function () { return check_js_1.hookFired; } });
163
165
  Object.defineProperty(exports, "received", { enumerable: true, get: function () { return check_js_1.received; } });
164
166
  Object.defineProperty(exports, "turns", { enumerable: true, get: function () { return check_js_1.turns; } });
@@ -213,6 +215,10 @@ Object.defineProperty(exports, "parseHooks", { enumerable: true, get: function (
213
215
  Object.defineProperty(exports, "decideSandbox", { enumerable: true, get: function () { return harness_test_js_1.decideSandbox; } });
214
216
  Object.defineProperty(exports, "specTrusted", { enumerable: true, get: function () { return harness_test_js_1.specTrusted; } });
215
217
  Object.defineProperty(exports, "sandboxAvailable", { enumerable: true, get: function () { return harness_test_js_1.sandboxAvailable; } });
218
+ // The two arms of an output-style eval, after one free run proves the style
219
+ // reaches the model. Free: the run uses the scripted model.
220
+ var output_style_arms_js_1 = require("./output-style-arms.js");
221
+ Object.defineProperty(exports, "experimental_outputStyleArms", { enumerable: true, get: function () { return output_style_arms_js_1.experimental_outputStyleArms; } });
216
222
  // A document's own rule about its own COMMANDS, made checkable. vigiles cannot
217
223
  // infer "always pass -g" from prose; what was missing was a cheap way to declare
218
224
  // it โ€” measured at ~95 lines of markdown parsing around ~5 lines of rule, which