vigiles 27.3.0 โ†’ 29.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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/adapter-registry.d.ts +39 -0
  3. package/dist/adapter-registry.js +45 -0
  4. package/dist/adapter.d.ts +8 -0
  5. package/dist/adapter.js +10 -1
  6. package/dist/adapters/claude-code/adapter.js +9 -2
  7. package/dist/adapters/claude-code/layout.d.ts +5 -0
  8. package/dist/adapters/claude-code/plugin-loader.d.ts +10 -1
  9. package/dist/adapters/claude-code/plugin-loader.js +10 -1
  10. package/dist/adapters/codex/adapter.js +7 -2
  11. package/dist/adapters/codex/layout.d.ts +51 -5
  12. package/dist/adapters/codex/layout.js +13 -4
  13. package/dist/adapters/opencode/adapter.js +6 -0
  14. package/dist/audit-report.template.html +1 -1
  15. package/dist/audit-score.d.ts +7 -0
  16. package/dist/audit-score.js +49 -2
  17. package/dist/cli-main.js +162 -39
  18. package/dist/core/adapter.d.ts +45 -1
  19. package/dist/core/compile.js +11 -1
  20. package/dist/core/config-schema.d.ts +244 -0
  21. package/dist/core/config-schema.js +452 -0
  22. package/dist/core/hook-program.d.ts +43 -0
  23. package/dist/core/hook-program.js +32 -0
  24. package/dist/core/refs.js +10 -1
  25. package/dist/core/surface-discovery.d.ts +270 -0
  26. package/dist/core/surface-discovery.js +425 -0
  27. package/dist/core/surface-scopes.d.ts +38 -1
  28. package/dist/core/surface-scopes.js +73 -1
  29. package/dist/core/symbols.d.ts +24 -2
  30. package/dist/core/symbols.js +66 -18
  31. package/dist/core/types.d.ts +36 -107
  32. package/dist/core/validate.d.ts +46 -18
  33. package/dist/core/validate.js +98 -172
  34. package/dist/exclude.d.ts +20 -0
  35. package/dist/exclude.js +11 -1
  36. package/dist/harness-test.js +3 -3
  37. package/dist/hook-install.d.ts +53 -0
  38. package/dist/hook-install.js +60 -0
  39. package/dist/hook-runtime.d.ts +2 -2
  40. package/dist/hook-runtime.js +82 -63
  41. package/dist/layout-registry.d.ts +14 -0
  42. package/dist/layout-registry.js +40 -0
  43. package/dist/load-hook.d.ts +1 -1
  44. package/dist/load-hook.js +2 -2
  45. package/dist/plugin-loader.d.ts +49 -1
  46. package/dist/plugin-loader.js +120 -14
  47. package/dist/run-hook.js +17 -1
  48. package/dist/scan-core.d.ts +19 -0
  49. package/dist/scan-core.js +30 -0
  50. package/dist/scan-files.js +15 -5
  51. package/dist/scan.d.ts +63 -0
  52. package/dist/scan.js +68 -12
  53. package/dist/score-core.js +8 -0
  54. package/dist/setup-plan.d.ts +2 -1
  55. package/dist/setup-plan.js +7 -2
  56. package/dist/surface-discovery-fs.d.ts +12 -0
  57. package/dist/surface-discovery-fs.js +108 -0
  58. package/dist/vigilesrc.schema.json +1689 -0
  59. package/package.json +10 -6
@@ -0,0 +1,425 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SURFACE_SHAPES = void 0;
4
+ exports.isDiscoveryRoot = isDiscoveryRoot;
5
+ exports.discoverSurfaces = discoverSurfaces;
6
+ exports.layoutLocations = layoutLocations;
7
+ exports.layoutClaims = layoutClaims;
8
+ exports.declaredRootClaims = declaredRootClaims;
9
+ exports.declaredRootDirs = declaredRootDirs;
10
+ exports.unresolvedDeclaredRoots = unresolvedDeclaredRoots;
11
+ exports.unclaimedSurfaces = unclaimedSurfaces;
12
+ exports.unclaimedDirs = unclaimedDirs;
13
+ exports.unclaimedSurfaceFindings = unclaimedSurfaceFindings;
14
+ /**
15
+ * Surface DISCOVERY โ€” WHERE a repo's loadable surfaces are, answered by SHAPE
16
+ * over a BOUNDED set of roots, BEFORE anything asks which harness this repo is.
17
+ *
18
+ * ๐Ÿ”ด WHY THIS MODULE EXISTS โ€” THE ORDER OF THE TWO QUESTIONS WAS BACKWARDS.
19
+ * The tool asked "which ONE harness is this?" first and then trusted the winning
20
+ * {@link PluginLayout} to name the directories. `PluginLayout` does two jobs at
21
+ * once โ€” it is the DIALECT (what the harness understands) and the SEARCH PATH
22
+ * (where to look) โ€” so a repo whose skills sit somewhere no shipped layout names
23
+ * was not merely unread, it was graded anyway. Measured by an outside adopter
24
+ * (#240, vigiles 27.2.0): 37 skills, 24 over-budget descriptions and 14 missing
25
+ * bundled resources under `.ai/` produced
26
+ *
27
+ * Detected harness: codex
28
+ * Harness health: A (100/100)
29
+ * โœ“ no structural issues found
30
+ *
31
+ * while the same binary pointed straight at `<repo>/.ai` graded it F (0/100).
32
+ * Same repo, same commit, same version โ€” the grade decided by one positional
33
+ * argument, and the one nobody would question was the wrong one.
34
+ *
35
+ * โ”€โ”€ THE DEPENDENCY DIRECTION THIS BUYS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
36
+ * Discovery is the DOMAIN's job and an adapter cannot widen it. A harness
37
+ * adapter answers only the pure question `claims(path)` โ€” "is this path mine?" โ€”
38
+ * so it can LABEL what was already found and nothing else. The property:
39
+ * **registering a new adapter cannot make vigiles read more in anyone's
40
+ * repository.** The rejected alternative (each adapter declares roots, the audit
41
+ * walks the union) inverts that: adding a Cursor adapter would start reading
42
+ * `.cursor/rules` in every user's repo, and a surface no adapter declared would
43
+ * stay invisible โ€” the blindness preserved. See `research/audit-harness-dx.md`
44
+ * ยง9 for the four options and why this one.
45
+ *
46
+ * And the corollary that makes it a product statement rather than a refactor: a
47
+ * surface NOBODY claims is a FINDING, not silence. That is this tool's own thesis
48
+ * applied to itself โ€” a passing signal standing in for work nobody did.
49
+ *
50
+ * โ”€โ”€ THE BOUND, AND WHY IT IS NOT A PORT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
51
+ * Walking the whole repo is not an option and the reporter of #240 said why
52
+ * before we did: an unbounded `**\/SKILL.md` in that repo finds 53 VENDORED
53
+ * third-party plugin skills beside the 37 real ones and grades them as one
54
+ * machine. So discovery looks in the repo root, plus each of its DEPTH-1
55
+ * DOT-DIRECTORIES ({@link isDiscoveryRoot}). The reason that set is the right shape โ€”
56
+ * every harness convention puts its tree in a top-level dot-directory
57
+ * (`.claude`, `.codex`, `.agents`, `.cursor`, `.gemini`, `.opencode`,
58
+ * `.windsurf`, and #240's `.ai`), and an ordinary source tree does not. Cost is
59
+ * one `readdir` of the repo root plus one per dot-directory, then descent ONLY
60
+ * into a directory named by {@link SURFACE_SHAPES} โ€” never into `src/`,
61
+ * `packages/` or anything else.
62
+ *
63
+ * It is a DOMAIN CONSTANT, not a port method, and that is load-bearing: if an
64
+ * adapter could add a root we would be back to the inverted dependency above.
65
+ *
66
+ * โš ๏ธ KNOWN LIMITATION, NAMED RATHER THAN FAKED โ€” the Codex WALK-UP is still not
67
+ * expressed. The vendor scans `.agents/skills` in EVERY directory from the cwd up
68
+ * to the repository root; this bound covers `<root>/.agents/skills` only, so a
69
+ * subdirectory's own `.agents/skills` stays invisible. Reaching it means walking
70
+ * arbitrary directories, which is exactly what the paragraph above refuses. For
71
+ * the normal case โ€” `vigiles audit` at the repo root โ€” the root IS the whole
72
+ * walk-up chain, so the gap is a monorepo subpackage, not the common shape.
73
+ *
74
+ * Pure, IO-free and Node-free ON PURPOSE, exactly as `surface-scopes.ts` is: the
75
+ * disk loader (`src/scan.ts` via `src/surface-discovery-fs.ts`) and the browser
76
+ * file-map twin (`src/scan-files.ts`) each enumerate paths from their own storage
77
+ * and call THIS for the decision, so the pair this repo has repeatedly been
78
+ * bitten by fixing on one side only cannot disagree about what a surface is.
79
+ */
80
+ const layout_js_1 = require("./layout.js");
81
+ /**
82
+ * The SHAPE of each surface kind: the directory name that holds it, and the
83
+ * path tail that makes a file inside it loadable.
84
+ *
85
+ * These are the CROSS-VENDOR names, not one harness's: Codex's skills live at
86
+ * `.agents/skills`, Claude Code's at `skills` or `.claude/skills` โ€” the same
87
+ * `skills` segment under a different root, which is why the root is the variable
88
+ * and the shape name is not. The tails quote the classifier the scan already
89
+ * uses (`makeClassifier`, scan-core.ts): a skill is `<name>/SKILL.md`, an agent
90
+ * is read RECURSIVELY per {@link AGENT_FILE_LEAF_RE} (do not hard-code a depth
91
+ * here โ€” that header says why), a command is any `.md` below the dir.
92
+ *
93
+ * Codex's `prompts` is deliberately absent: it is claimed by `codexLayout` and
94
+ * read by the loader, and giving a non-dot word like `prompts` shape status at
95
+ * the repo root would match ordinary prompt-engineering directories that are not
96
+ * a harness surface at all.
97
+ */
98
+ exports.SURFACE_SHAPES = [
99
+ { dir: "skills", kind: "skill", tail: "[^/]+/SKILL\\.md" },
100
+ { dir: "agents", kind: "agent", tail: layout_js_1.AGENT_FILE_LEAF_RE },
101
+ { dir: "commands", kind: "command", tail: ".+\\.md" },
102
+ ];
103
+ /**
104
+ * Is this repo-root-relative DIRECTORY NAME one of the bounded discovery roots?
105
+ *
106
+ * The repo root itself is always one (`""`). Everything else must be a
107
+ * DOT-directory โ€” see the module header for why that is the whole bound. The
108
+ * caller supplies the names (a `readdir` on disk, the key set in the browser),
109
+ * so this function stays IO-free.
110
+ */
111
+ function isDiscoveryRoot(name) {
112
+ return name === "" || (name.startsWith(".") && name !== "." && name !== "..");
113
+ }
114
+ /**
115
+ * `<root>/<shape.dir>/<tail>` for every shape โ€” the one place the anchor lives.
116
+ *
117
+ * Anchored at the START: the shape dir sits DIRECTLY under a discovery root,
118
+ * never at arbitrary depth. That is what stops a React app's `src/hooks/โ€ฆ`-class
119
+ * false positive and a monorepo's `packages/x/skills/โ€ฆ` from being graded as
120
+ * this repo's harness. Built once โ€” these carry no `g` flag, so `exec` keeps no
121
+ * state between calls.
122
+ */
123
+ const SHAPE_MATCHERS = exports.SURFACE_SHAPES.map((s) => ({
124
+ kind: s.kind,
125
+ dir: s.dir,
126
+ re: new RegExp(`^(?:([^/]+)/)?${s.dir}/${s.tail}$`),
127
+ }));
128
+ /**
129
+ * Every surface a set of repo-relative paths holds, by SHAPE โ€” harness-blind.
130
+ *
131
+ * `paths` must already be bounded by the caller's walk; this re-checks the root
132
+ * rule anyway so the browser twin (which hands over the whole fetched key set)
133
+ * gets the same answer as the disk walk.
134
+ */
135
+ function discoverSurfaces(paths) {
136
+ const out = [];
137
+ for (const path of paths) {
138
+ for (const m of SHAPE_MATCHERS) {
139
+ const hit = m.re.exec(path);
140
+ if (hit === null)
141
+ continue;
142
+ const root = hit[1] ?? "";
143
+ if (!isDiscoveryRoot(root))
144
+ continue;
145
+ out.push({
146
+ kind: m.kind,
147
+ path,
148
+ root,
149
+ dir: root === "" ? m.dir : `${root}/${m.dir}`,
150
+ });
151
+ break; // one path is one surface; first shape wins, as the classifier does
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+ /** Trim a trailing `/`, and treat `"."`/`""` as "no location at all". */
157
+ function located(dir) {
158
+ if (dir === undefined)
159
+ return null;
160
+ const d = dir.replace(/\/+$/, "");
161
+ return d === "" || d === "." ? null : d;
162
+ }
163
+ /**
164
+ * Every repo-relative location a layout READS โ€” the raw material for
165
+ * {@link layoutClaims}.
166
+ *
167
+ * Derived from the layout rather than listed per adapter, so a layout that moves
168
+ * (as `codexLayout` just did, `.codex/skills` โ†’ `.agents/skills`) moves its claim
169
+ * with it and the two cannot drift.
170
+ *
171
+ * โš ๏ธ `materializeRoot` goes through {@link located} because `codexLayout` sets it
172
+ * to `""` and a directory prefix of `""` is meaningless. It is DEFENCE IN DEPTH,
173
+ * not the load-bearing guard, and the difference was MEASURED rather than
174
+ * assumed: removing this filter alone leaves `layoutClaims(codexLayout,
175
+ * "src/index.ts")` at `false`, because the boundary form `path.startsWith(`${d}/`)`
176
+ * in {@link layoutClaims} already refuses a `""` prefix (no repo-relative path
177
+ * starts with `/`). Both have to go before Codex claims the whole repository โ€”
178
+ * which is the combined mutation `surface-discovery.test.ts` pins, precisely
179
+ * because removing either one on its own is survivable and would otherwise read
180
+ * as "this line is protecting us".
181
+ */
182
+ function layoutLocations(layout) {
183
+ const user = located(layout.userSurfaceRoot);
184
+ const dirs = new Set();
185
+ const add = (d) => {
186
+ if (d !== null)
187
+ dirs.add(d);
188
+ };
189
+ add(located(layout.materializeRoot));
190
+ add(user);
191
+ for (const s of layout.surfaceDirs) {
192
+ add(located(s));
193
+ if (user !== null)
194
+ add(located(`${user}/${s}`));
195
+ }
196
+ const rules = located(layout.rulesDir);
197
+ if (rules !== null) {
198
+ add(rules);
199
+ if (user !== null)
200
+ add(`${user}/${rules}`);
201
+ }
202
+ for (const p of [
203
+ layout.manifestPath,
204
+ layout.settingsPath,
205
+ layout.hooksConventionPath,
206
+ ]) {
207
+ const at = p.lastIndexOf("/");
208
+ if (at > 0)
209
+ add(p.slice(0, at));
210
+ }
211
+ return {
212
+ dirs: [...dirs],
213
+ files: [
214
+ layout.instructionFile,
215
+ layout.mcpConfigFile,
216
+ layout.manifestPath,
217
+ layout.settingsPath,
218
+ layout.hooksConventionPath,
219
+ ].filter((f) => f !== ""),
220
+ };
221
+ }
222
+ /**
223
+ * Does this layout's harness READ this repo-relative path โ€” "is it mine?"
224
+ *
225
+ * The ONE answer behind every adapter's `claims`, so an adapter cannot express a
226
+ * claim its own layout contradicts. Pure: a string question about a string, with
227
+ * no filesystem and no knowledge of what else exists.
228
+ */
229
+ function layoutClaims(layout, path) {
230
+ const { dirs, files } = layoutLocations(layout);
231
+ if (files.includes(path))
232
+ return true;
233
+ return dirs.some((d) => path === d || path.startsWith(`${d}/`));
234
+ }
235
+ /**
236
+ * Does a repo owner's DECLARED root cause this path to be read, under the
237
+ * layout of the harness it was declared UNDER?
238
+ *
239
+ * ๐Ÿ”ด IT CLAIMS EXACTLY WHAT IT MAKES READABLE, AND NOT ONE PATH MORE. The set is
240
+ * `<root>/<surfaceDir>/โ€ฆ` for that harness's own `surfaceDirs` โ€” which is
241
+ * verbatim the set `materializeSurfaces` reads for a declared scope. Derived
242
+ * from the same field rather than listed twice, so the two cannot drift.
243
+ *
244
+ * The consequence is the point: a declaration silences the finding only where it
245
+ * actually reaches. Declaring `.ai` under `"codex"` does not silence a
246
+ * `.ai/skills/` tree, because Codex's skill dir is `.agents/skills` and
247
+ * `.ai/skills` is still read by nobody. Under the flat `surfaceRoots` key that
248
+ * was where the story ended โ€” the tool stayed quiet about a declaration that
249
+ * changed nothing. Now the harness is part of the declaration, so the same fact
250
+ * is an ERROR the owner can act on ({@link unresolvedDeclaredRoots}).
251
+ *
252
+ * โš ๏ธ A declaration is the REPO OWNER's, never an adapter's. Nothing here lets a
253
+ * harness widen what vigiles reads in someone else's repository โ€” the property
254
+ * the module header exists to protect. See `research/audit-harness-dx.md` ยง9.
255
+ */
256
+ function declaredRootClaims(layout, roots, path) {
257
+ return declaredRootDirs(layout, roots).some((dir) => path === dir || path.startsWith(`${dir}/`));
258
+ }
259
+ /**
260
+ * The repo-relative dirs a declaration REACHES: `<root>/<surfaceDir>` for every
261
+ * declared root crossed with this layout's own surface dirs.
262
+ *
263
+ * ONE derivation, read two ways โ€” {@link declaredRootClaims} asks whether a
264
+ * found path is in the set, {@link unresolvedDeclaredRoots} asks whether any
265
+ * member of the set exists on disk. Two spellings of "where does this
266
+ * declaration reach" is how a declaration could be claimed by one and refused by
267
+ * the other.
268
+ */
269
+ function declaredRootDirs(layout, roots) {
270
+ const out = [];
271
+ for (const r of roots)
272
+ for (const surface of layout.surfaceDirs) {
273
+ const dir = located(`${r}/${surface}`);
274
+ if (dir !== null && !out.includes(dir))
275
+ out.push(dir);
276
+ }
277
+ return out;
278
+ }
279
+ /**
280
+ * Declared roots that reach NO directory on disk โ€” the one silent state the
281
+ * nested shape would otherwise keep (#240).
282
+ *
283
+ * ๐Ÿ”ด WHY THIS IS AN ERROR AND NOT A WARNING. A root under a harness whose layout
284
+ * keeps that surface somewhere else is not a near-miss, it is a statement that
285
+ * cannot be true: `{"codex": {"roots": [".ai"]}}` over a `.ai/skills/` tree makes
286
+ * vigiles look at `.ai/.agents/skills/` and `.ai/prompts/`, finds neither, reads
287
+ * nothing, and changes not one line of the report. The owner wrote a line
288
+ * believing their skills were now graded. Silence there is the tool agreeing.
289
+ *
290
+ * โš ๏ธ IT CHECKS THE DIRECTORY, NOT ITS CONTENTS, and the difference is
291
+ * deliberate: an EMPTY `.ai/skills/` is a real, correctly-declared home that
292
+ * happens to hold nothing today, and failing a build over an empty directory
293
+ * would be a gate on repo state rather than on the declaration. What is refused
294
+ * is a declaration that names no directory at all.
295
+ *
296
+ * `dirExists` is injected (repo-relative path in, boolean out) so the rule is
297
+ * pure, node-free and testable without a filesystem โ€” the same contract every
298
+ * other decision in this module keeps.
299
+ */
300
+ function unresolvedDeclaredRoots(scopes, dirExists) {
301
+ const out = [];
302
+ for (const scope of scopes) {
303
+ for (const root of scope.roots) {
304
+ const looked = declaredRootDirs(scope.layout, [root]);
305
+ if (looked.some((d) => dirExists(d)))
306
+ continue;
307
+ out.push(`.vigilesrc.json: harnesses["${scope.harness}"].roots names "${root}", ` +
308
+ `but ${scope.harness} reads no surface there โ€” nothing would be graded and ` +
309
+ `nothing would be said.\n` +
310
+ ` Looked for: ${looked.length === 0 ? "(this harness declares no surface dirs)" : looked.map((d) => `${d}/`).join(", ")}\n` +
311
+ ` Either create one of those, or declare "${root}" under the harness whose ` +
312
+ `layout does read it.`);
313
+ }
314
+ }
315
+ return out;
316
+ }
317
+ /**
318
+ * The discovered surfaces NO registered harness claims โ€” the finding.
319
+ *
320
+ * `claimers` is every registered adapter's `claims`, so "unclaimed" means "no
321
+ * harness vigiles knows about reads this", not "the detected harness does not".
322
+ * That distinction is the whole point of discovering before detecting: a repo
323
+ * carrying both `.claude/skills` and `.agents/skills` has each half claimed by a
324
+ * different adapter and neither is a finding, while #240's `.ai/skills` is
325
+ * claimed by nobody and becomes one.
326
+ */
327
+ function unclaimedSurfaces(surfaces, claimers) {
328
+ return surfaces.filter((s) => !claimers.some((c) => c(s.path)));
329
+ }
330
+ /**
331
+ * The unclaimed surfaces, grouped into ONE finding per directory.
332
+ *
333
+ * A finding per FILE would put 37 lines in #240's report for one mistake; the
334
+ * actionable unit is the directory nobody reads, so that is the unit counted and
335
+ * the unit graded.
336
+ */
337
+ function unclaimedDirs(unclaimed) {
338
+ const by = new Map();
339
+ for (const s of unclaimed) {
340
+ const prev = by.get(s.dir);
341
+ if (prev === undefined)
342
+ by.set(s.dir, { root: s.root, kind: s.kind, count: 1 });
343
+ else
344
+ prev.count += 1;
345
+ }
346
+ return [...by.entries()]
347
+ .map(([dir, v]) => ({ dir, root: v.root, kind: v.kind, count: v.count }))
348
+ .sort((a, b) => a.dir.localeCompare(b.dir));
349
+ }
350
+ /** Plural-aware surface noun โ€” `1 skill` / `37 skills`. */
351
+ function plural(kind, n) {
352
+ return `${String(n)} ${kind}${n === 1 ? "" : "s"}`;
353
+ }
354
+ /**
355
+ * Where the given layouts DO keep the surface of one kind โ€” the "move it here
356
+ * instead" half of the finding message.
357
+ *
358
+ * ๐Ÿ”ด DERIVED, NOT SPELLED OUT, and the repo's own `core-not-adapter` rule is
359
+ * what forced it: the first draft of the message listed "`.claude/skills/`,
360
+ * `skills/`, `.agents/skills/`" as a literal and the linter rejected it as a
361
+ * Claude Code constant hard-coded in harness-agnostic code. It was right beyond
362
+ * the letter of the rule โ€” a hand-written list of homes is exactly the thing
363
+ * that stops being true when a layout moves (as `codexLayout` did the same day,
364
+ * `.codex/skills` โ†’ `.agents/skills`), and the finding would then tell the
365
+ * reader to move their skills somewhere no harness reads.
366
+ */
367
+ function knownHomes(layouts, kind) {
368
+ const dirOf = (l) => ({ skill: l.skillDir, agent: l.agentDir, command: l.commandDir })[kind];
369
+ const homes = new Set();
370
+ for (const l of layouts) {
371
+ const dir = located(dirOf(l));
372
+ if (dir === null)
373
+ continue;
374
+ homes.add(dir);
375
+ const user = located(l.userSurfaceRoot);
376
+ if (user !== null)
377
+ homes.add(`${user}/${dir}`);
378
+ }
379
+ return [...homes].sort();
380
+ }
381
+ /**
382
+ * The whole discovery verdict in one call: paths in, findings out.
383
+ *
384
+ * The ENTRY POINT both producers use (`src/scan.ts` over a bounded disk walk,
385
+ * `src/scan-files.ts` over the fetched key set), so the wording, the grouping
386
+ * and the claim rule cannot differ between the CLI and the in-browser audit.
387
+ *
388
+ * It takes LAYOUTS rather than bare predicates because the answer needs both
389
+ * halves of the same fact: who claims a path, and where those claimants
390
+ * actually keep that kind of surface. Taking them apart is how the message and
391
+ * the claim rule would come to disagree.
392
+ *
393
+ * `declared` is the repo owner's opt-in: one entry per harness declared in
394
+ * `.vigilesrc.json#harnesses`, each carrying the roots declared under it and the
395
+ * LAYOUT those roots are read with. It joins the claimers rather than filtering
396
+ * the findings afterwards, so "no harness reads this" and "this is read" stay
397
+ * ONE question with one answer. Omitted by the browser twin, which has no
398
+ * config โ€” see {@link declaredRootClaims} for what it does and does not silence.
399
+ *
400
+ * โš ๏ธ `exclude` needs no mention here and that is structural, not an oversight:
401
+ * an excluded path never reaches `paths` (the walk drops it), so it can be
402
+ * neither a finding nor a graded surface however it was declared.
403
+ */
404
+ function unclaimedSurfaceFindings(paths, layouts, declared) {
405
+ const claimers = layouts.map((l) => (path) => layoutClaims(l, path));
406
+ // ONE claimer per DECLARED HARNESS, not one for "the declaration". Under the
407
+ // flat shape there was a single (detected layout, global roots) pair, so a repo
408
+ // serving one tree to two harnesses could silence the finding for at most one
409
+ // of them; each declaration now carries the layout it was made under.
410
+ for (const scope of declared ?? [])
411
+ if (scope.roots.length > 0)
412
+ claimers.push((path) => declaredRootClaims(scope.layout, scope.roots, path));
413
+ return unclaimedDirs(unclaimedSurfaces(discoverSurfaces(paths), claimers)).map((d) => ({
414
+ ...d,
415
+ message: `${d.dir}/ holds ${plural(d.kind, d.count)} that no harness vigiles knows about reads, ` +
416
+ `so none of it is in this grade. Three ways out: keep it where it is and say so in ` +
417
+ `.vigilesrc.json (\`{"harnesses":{"claude-code":{"roots":["${d.root === "" ? "." : d.root}"]}}}\` ` +
418
+ `โ€” see docs/configuration.md), audit it on its own ` +
419
+ `(\`vigiles audit ${d.root === "" ? "." : d.root}\`), or move it somewhere a harness ` +
420
+ `loads from (${knownHomes(layouts, d.kind)
421
+ .map((h) => `\`${h}/\``)
422
+ .join(", ")}).`,
423
+ }));
424
+ }
425
+ //# sourceMappingURL=surface-discovery.js.map
@@ -47,6 +47,14 @@ export interface SurfaceScope {
47
47
  readonly materializeUnder: string;
48
48
  /** Human label for warnings โ€” `plugin` (root) or `project` (`.claude/`). */
49
49
  readonly label: string;
50
+ /**
51
+ * This scope exists only because the REPO OWNER named its base in
52
+ * `.vigilesrc.json#harnesses["<name>"].roots` โ€” it is not a location the harness itself
53
+ * reads. Marked so {@link multiScopeWarning} can stay about the ambiguity it
54
+ * describes (plugin-vs-project, both of which a real session loads) instead of
55
+ * claiming the harness loads a declared root too.
56
+ */
57
+ readonly declared?: boolean;
50
58
  }
51
59
  /** Which shape the audited target is, and every scope to read from it. */
52
60
  export type SurfaceSource = {
@@ -68,7 +76,36 @@ export interface SurfaceProbe {
68
76
  readonly isPluginShaped: boolean;
69
77
  /** Some `<root>/<userSurfaceRoot>/<surface>/` holds a loadable file. */
70
78
  readonly userHasLoadable: boolean;
79
+ /**
80
+ * The repo owner's declared roots, normalized by {@link normalizeSurfaceRoots},
81
+ * in declaration order.
82
+ *
83
+ * Unlike the flags above this is NOT a probe result โ€” the caller passes the
84
+ * declaration as written and does not check whether each root holds anything.
85
+ * It does not have to: a declared scope is appended AFTER the no-scope
86
+ * fallback, so an empty one adds an empty tree and changes nothing, while a
87
+ * filter here would be a guard with no observable behaviour to defend.
88
+ */
89
+ readonly declaredRoots?: readonly string[];
71
90
  }
91
+ /**
92
+ * The repo owner's `.vigilesrc.json#harnesses["<name>"].roots`, normalized โ€” or
93
+ * dropped. (The flat top-level `surfaceRoots` key this once read was removed in
94
+ * the same change that nested it under a harness name.)
95
+ *
96
+ * A DECLARATION BY THE REPO OWNER, never by an adapter: the rejected option B
97
+ * let each harness declare roots, which inverts the dependency (registering a
98
+ * Cursor adapter would start reading `.cursor/rules` in everyone's repo). A key
99
+ * in the repo's own config cannot do that โ€” it widens exactly one repository,
100
+ * the one whose owner wrote it. See `research/audit-harness-dx.md` ยง9.
101
+ *
102
+ * Dropped rather than errored, because the failure of a bad entry is harmless
103
+ * (nothing extra is read) while refusing to audit over a config typo is not:
104
+ * absolute paths, `.`/empty, and anything with a `..` segment, which would reach
105
+ * OUTSIDE the audited repo and is the only entry that could do real damage.
106
+ * Order is kept and duplicates collapse, so the scope list is stable.
107
+ */
108
+ export declare function normalizeSurfaceRoots(roots: readonly string[] | undefined): readonly string[];
72
109
  /**
73
110
  * Classify the target and list every scope to read, HIGHEST-PRECEDENCE FIRST.
74
111
  *
@@ -106,5 +143,5 @@ export declare function assertDistinctScopeKeys(scopes: readonly SurfaceScope[],
106
143
  * `unregisteredSkillFiles` already warns about for inline arm files). Say so,
107
144
  * rather than quietly relocating one on top of the other.
108
145
  */
109
- export declare function multiScopeWarning(scopes: readonly SurfaceScope[], counts: Record<string, number>): string | undefined;
146
+ export declare function multiScopeWarning(allScopes: readonly SurfaceScope[], counts: Record<string, number>): string | undefined;
110
147
  //# sourceMappingURL=surface-scopes.d.ts.map
@@ -1,9 +1,45 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeSurfaceRoots = normalizeSurfaceRoots;
3
4
  exports.surfaceSource = surfaceSource;
4
5
  exports.scopeKey = scopeKey;
5
6
  exports.assertDistinctScopeKeys = assertDistinctScopeKeys;
6
7
  exports.multiScopeWarning = multiScopeWarning;
8
+ /**
9
+ * The repo owner's `.vigilesrc.json#harnesses["<name>"].roots`, normalized โ€” or
10
+ * dropped. (The flat top-level `surfaceRoots` key this once read was removed in
11
+ * the same change that nested it under a harness name.)
12
+ *
13
+ * A DECLARATION BY THE REPO OWNER, never by an adapter: the rejected option B
14
+ * let each harness declare roots, which inverts the dependency (registering a
15
+ * Cursor adapter would start reading `.cursor/rules` in everyone's repo). A key
16
+ * in the repo's own config cannot do that โ€” it widens exactly one repository,
17
+ * the one whose owner wrote it. See `research/audit-harness-dx.md` ยง9.
18
+ *
19
+ * Dropped rather than errored, because the failure of a bad entry is harmless
20
+ * (nothing extra is read) while refusing to audit over a config typo is not:
21
+ * absolute paths, `.`/empty, and anything with a `..` segment, which would reach
22
+ * OUTSIDE the audited repo and is the only entry that could do real damage.
23
+ * Order is kept and duplicates collapse, so the scope list is stable.
24
+ */
25
+ function normalizeSurfaceRoots(roots) {
26
+ const out = [];
27
+ for (const raw of roots ?? []) {
28
+ const r = raw
29
+ .split("\\")
30
+ .join("/")
31
+ .replace(/^(?:\.\/)+/, "")
32
+ .replace(/\/+$/, "")
33
+ .trim();
34
+ if (r === "" || r === "." || r.startsWith("/"))
35
+ continue;
36
+ if (r.split("/").includes(".."))
37
+ continue;
38
+ if (!out.includes(r))
39
+ out.push(r);
40
+ }
41
+ return out;
42
+ }
7
43
  /**
8
44
  * Classify the target and list every scope to read, HIGHEST-PRECEDENCE FIRST.
9
45
  *
@@ -52,6 +88,36 @@ function surfaceSource(layout, probe) {
52
88
  label: "project",
53
89
  });
54
90
  }
91
+ // The repo owner's declared roots, read with the DETECTED layout's surface
92
+ // dirs โ€” `.ai` + `skills` for Claude Code. It does not invent a dialect: the
93
+ // declaration says WHERE, the layout still says what a surface is and how it
94
+ // is read. A root already serving as a scope's base is skipped (declaring
95
+ // `.claude` to Claude Code is a no-op, not a second reading of one tree), and
96
+ // so is one equal to `materializeRoot` โ€” that key belongs to the scope holding
97
+ // it, and two scopes minting one prefix is what {@link assertDistinctScopeKeys}
98
+ // exists to refuse. Each keeps its OWN base as the key prefix, like a non-first
99
+ // plugin scope, so nothing is relocated on top of anything.
100
+ //
101
+ // ๐Ÿ”ด APPENDED LAST, AFTER THE NO-SCOPE FALLBACK, AND THE ORDER IS THE POINT: a
102
+ // declaration may only ADD. Run before the fallback, a declared root makes the
103
+ // list non-empty and CANCELS it โ€” measured on a repo whose `.claude/skills`
104
+ // held only a non-loadable file: adding one config line naming another
105
+ // directory dropped `.claude/skills/alpha/README.md` out of the loaded files.
106
+ // Nobody would connect that to the line they wrote. Pinned by
107
+ // `plugin-loader.test.ts` ("an EMPTY declared root does not cancel the project
108
+ // scope"), which asserts the full key list both before and after filling it.
109
+ for (const base of probe.declaredRoots ?? []) {
110
+ if (base === layout.materializeRoot)
111
+ continue;
112
+ if (scopes.some((sc) => sc.base === base))
113
+ continue;
114
+ scopes.push({
115
+ base,
116
+ materializeUnder: base,
117
+ label: "declared",
118
+ declared: true,
119
+ });
120
+ }
55
121
  return { kind: "scopes", scopes };
56
122
  }
57
123
  /** The `LoadedPlugin.files` key for one file under one scope. */
@@ -86,7 +152,13 @@ function assertDistinctScopeKeys(scopes, layoutName) {
86
152
  * `unregisteredSkillFiles` already warns about for inline arm files). Say so,
87
153
  * rather than quietly relocating one on top of the other.
88
154
  */
89
- function multiScopeWarning(scopes, counts) {
155
+ function multiScopeWarning(allScopes, counts) {
156
+ // Declared roots are excluded, and not for tidiness: every sentence below is
157
+ // about what a REAL SESSION loads under two names. A harness does not load a
158
+ // declared root at all โ€” vigiles reads it because the repo owner said to โ€” so
159
+ // including one here would make the warning state a falsehood about the
160
+ // harness the moment someone declares `harnesses.<name>.roots`.
161
+ const scopes = allScopes.filter((s) => s.declared !== true);
90
162
  if (scopes.length < 2)
91
163
  return undefined;
92
164
  const total = Object.values(counts).reduce((a, b) => a + b, 0);
@@ -1,8 +1,30 @@
1
1
  import { Lang } from "@ast-grep/napi";
2
+ /** Which optional grammars this process actually has. Exported so a report can say so. */
3
+ export declare function installedGrammars(): ReadonlySet<string>;
2
4
  /** A language key accepted by ast-grep's `parse` (core enum or registered id). */
3
5
  type LangKey = Lang | string;
4
- /** The ast-grep language for a file, or null if unsupported (graceful skip). */
5
- export declare function langForFile(file: string): LangKey | null;
6
+ /**
7
+ * Whether this file's language can be parsed HERE, and if not, which of the two reasons.
8
+ *
9
+ * ๐Ÿ”ด THE THREE CASES ARE SEPARATE MEMBERS BECAUSE THEY ARE SEPARATE FACTS. The previous
10
+ * signature was `LangKey | null`, where `null` meant "extension not in the table" and callers
11
+ * printed "Unsupported language for symbol check". Making the grammars optional would have
12
+ * given that same `null` a second meaning โ€” "the language IS ours, the package is simply not
13
+ * installed" โ€” and both callers would have kept printing the first sentence. That is the
14
+ * failure this codebase exists to catch: a check that did not run, reported in the words of a
15
+ * check that did. A union makes the compiler demand the distinction at every call site.
16
+ */
17
+ export type LangSupport = {
18
+ readonly kind: "ready";
19
+ readonly lang: LangKey;
20
+ } | {
21
+ readonly kind: "grammar-missing";
22
+ readonly id: string;
23
+ readonly pkg: string;
24
+ } | {
25
+ readonly kind: "unsupported";
26
+ };
27
+ export declare function langForFile(file: string): LangSupport;
6
28
  /** A symbol definition found in a file. */
7
29
  export interface SymbolDef {
8
30
  /** The defined identifier, e.g. "parseConfig". */