vigiles 33.2.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.
- package/README.md +3 -2
- package/dist/adapter.d.ts +1 -1
- package/dist/adapters/claude-code/runtime.js +70 -0
- package/dist/adapters/claude-code/skill-reachability.d.ts +42 -21
- package/dist/adapters/claude-code/skill-reachability.js +91 -28
- package/dist/cli-main.js +224 -17
- package/dist/core/adapter.d.ts +7 -0
- package/dist/core/install-reader.js +15 -0
- package/dist/core/layout.d.ts +10 -0
- package/dist/core/layout.js +17 -0
- package/dist/core/run-env.d.ts +50 -0
- package/dist/core/run-env.js +51 -0
- package/dist/core/runtime.d.ts +44 -0
- package/dist/core/skill-link-target.d.ts +20 -0
- package/dist/core/skill-link-target.js +33 -0
- package/dist/eval.d.ts +39 -39
- package/dist/eval.js +62 -115
- package/dist/harness-test.d.ts +35 -0
- package/dist/harness-test.js +49 -7
- package/dist/output-style-arms.js +7 -2
- package/dist/plugin-declaration.d.ts +6 -6
- package/dist/plugin-declaration.js +6 -6
- package/dist/setup-plan.d.ts +49 -9
- package/dist/setup-plan.js +102 -68
- package/dist/skill-links.d.ts +183 -0
- package/dist/skill-links.js +255 -0
- package/dist/test-coverage.js +37 -1
- 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
|
package/dist/test-coverage.js
CHANGED
|
@@ -147,6 +147,42 @@ function read(path) {
|
|
|
147
147
|
return "";
|
|
148
148
|
}
|
|
149
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
|
+
}
|
|
150
186
|
function discoverSkills(basePath, ignore, layout) {
|
|
151
187
|
const out = [];
|
|
152
188
|
// 🔴 ONE GATE FOR THE WHOLE FUNCTION, and it is structural rather than
|
|
@@ -159,7 +195,7 @@ function discoverSkills(basePath, ignore, layout) {
|
|
|
159
195
|
const skillDir = layout.surfaces.skill;
|
|
160
196
|
if (skillDir === undefined)
|
|
161
197
|
return out;
|
|
162
|
-
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))));
|
|
163
199
|
for (const path of found.sort()) {
|
|
164
200
|
const name = (0, node_path_1.basename)((0, node_path_1.dirname)(path));
|
|
165
201
|
const content = read((0, node_path_1.join)(basePath, path));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "33.
|
|
3
|
+
"version": "33.3.0",
|
|
4
4
|
"description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|