vigiles 33.2.0 → 34.0.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.
@@ -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
@@ -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));
@@ -1851,6 +1851,12 @@
1851
1851
  "type": "string",
1852
1852
  "const": "dismissed"
1853
1853
  },
1854
+ "extends": {
1855
+ "type": "string",
1856
+ "enum": [
1857
+ "vigiles:recommended"
1858
+ ]
1859
+ },
1854
1860
  "$schema": {
1855
1861
  "type": "string"
1856
1862
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "33.2.0",
3
+ "version": "34.0.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",