vigiles 18.0.0 → 18.1.1

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,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.surfaceSource = surfaceSource;
4
+ exports.scopeKey = scopeKey;
5
+ exports.assertDistinctScopeKeys = assertDistinctScopeKeys;
6
+ exports.multiScopeWarning = multiScopeWarning;
7
+ /**
8
+ * Classify the target and list every scope to read, HIGHEST-PRECEDENCE FIRST.
9
+ *
10
+ * Precedence decides only one thing: which scope keeps the canonical
11
+ * `<materializeRoot>/…` key. The project scope takes it, because that key IS
12
+ * where a project skill lives — `.claude/skills/deploy/SKILL.md` is loaded from
13
+ * exactly that path and answers to `/deploy`. A plugin scope keeps its own real
14
+ * location (`skills/deploy/SKILL.md`), which is likewise where the harness reads
15
+ * it from, under `/plugin:deploy`. Nothing is relocated on top of something else.
16
+ *
17
+ * 🔴 THE COLLISION IS STRUCTURAL, NOT CHECKED. Only the FIRST scope is relocated;
18
+ * every later one keeps `base` as its prefix. Since the first scope is the only
19
+ * one that can produce a `<materializeRoot>/…` key, and every other prefix is a
20
+ * distinct real directory, two scopes cannot mint the same key — there is no
21
+ * ordering, no "if already taken", and no last-write-wins to get wrong.
22
+ * {@link assertDistinctScopeKeys} is the LOUD backstop for a future
23
+ * `PluginLayout` that breaks the premise (e.g. one naming `.claude` as BOTH its
24
+ * `materializeRoot` and a second scope's base).
25
+ */
26
+ function surfaceSource(layout, probe) {
27
+ if (layout.skillDir && probe.hasRootSkillFile) {
28
+ return { kind: "single-skill", skillName: probe.skillName };
29
+ }
30
+ const scopes = [];
31
+ if (layout.userSurfaceRoot !== undefined && probe.userHasLoadable) {
32
+ scopes.push({
33
+ base: layout.userSurfaceRoot,
34
+ materializeUnder: layout.materializeRoot,
35
+ label: "project",
36
+ });
37
+ }
38
+ if (probe.rootHasLoadable || probe.isPluginShaped) {
39
+ scopes.push({
40
+ base: "",
41
+ materializeUnder: scopes.length === 0 ? layout.materializeRoot : "",
42
+ label: "plugin",
43
+ });
44
+ }
45
+ // A layout with no user root and nothing at the root still has to read the
46
+ // project shape, or a plain repo would load as an empty machine — the reason
47
+ // `userSurfaceRoot` exists. An empty list means genuinely nothing loadable.
48
+ if (scopes.length === 0 && layout.userSurfaceRoot !== undefined) {
49
+ scopes.push({
50
+ base: layout.userSurfaceRoot,
51
+ materializeUnder: layout.materializeRoot,
52
+ label: "project",
53
+ });
54
+ }
55
+ return { kind: "scopes", scopes };
56
+ }
57
+ /** The `LoadedPlugin.files` key for one file under one scope. */
58
+ function scopeKey(scope, surface, rel) {
59
+ return [scope.materializeUnder, surface, rel]
60
+ .filter((s) => s !== "")
61
+ .join("/");
62
+ }
63
+ /**
64
+ * Throw when two scopes would mint the same key prefix. Unreachable for every
65
+ * shipped layout (see {@link surfaceSource}) — it exists so a NEW layout that
66
+ * breaks the premise fails loudly at load, rather than silently dropping a
67
+ * surface file the way the shadowing bug did for a year.
68
+ */
69
+ function assertDistinctScopeKeys(scopes, layoutName) {
70
+ const seen = new Map();
71
+ for (const s of scopes) {
72
+ const prev = seen.get(s.materializeUnder);
73
+ if (prev !== undefined) {
74
+ throw new Error(`layout "${layoutName}": surface scopes "${prev}" and "${s.base}" both materialize under ` +
75
+ `"${s.materializeUnder || "<repo root>"}" — one would silently shadow the other. ` +
76
+ `Give each scope a distinct materialize prefix (see src/core/surface-scopes.ts).`);
77
+ }
78
+ seen.set(s.materializeUnder, s.base);
79
+ }
80
+ }
81
+ /**
82
+ * The warning to emit when more than one scope is present. Both scopes load in a
83
+ * real session under DIFFERENT names, but the deterministic sandbox is a project
84
+ * dir — it registers the project scope only, so a plugin-scope skill sitting at
85
+ * `skills/…` in the fixture never activates there (the footgun
86
+ * `unregisteredSkillFiles` already warns about for inline arm files). Say so,
87
+ * rather than quietly relocating one on top of the other.
88
+ */
89
+ function multiScopeWarning(scopes, counts) {
90
+ if (scopes.length < 2)
91
+ return undefined;
92
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
93
+ return (`repo carries surfaces at TWO discovery levels (${scopes
94
+ .map((s) => `${s.label} → ${s.base === "" ? "<repo root>" : s.base}/`)
95
+ .join(", ")}); ${String(total)} file(s) were read from both. Claude Code loads both — ` +
96
+ `a plugin skill as \`/<plugin>:<name>\`, a project skill as \`/<name>\` — so a name in both ` +
97
+ `places is TWO surfaces, not one. The deterministic sandbox is a project dir and registers ` +
98
+ `the project scope only; install the plugin scope with \`pluginDir\` to exercise it.`);
99
+ }
100
+ //# sourceMappingURL=surface-scopes.js.map
@@ -68,8 +68,11 @@ export type EmitFieldSchema = {
68
68
  readonly items: {
69
69
  readonly type: "string";
70
70
  };
71
+ } | {
72
+ readonly type: "string";
73
+ readonly enum: readonly string[];
71
74
  };
72
- /** The `track` discriminator's schema — the only enum this surface emits. */
75
+ /** The `track` discriminator's schema — the enum this module owns, not the author's. */
73
76
  export interface EmitTrackSchema {
74
77
  readonly type: "string";
75
78
  readonly enum: readonly ["ok", "err"];
@@ -62,6 +62,11 @@ const agent_result_js_1 = require("./adapters/claude-code/agent-result.js");
62
62
  /** The default tool name, when `options.name` is not given. */
63
63
  const DEFAULT_EMIT_TOOL = "emit_result";
64
64
  function fieldSchema(type) {
65
+ // An enum reaches the model as a JSON-Schema `enum`, which is the whole point: the
66
+ // permitted values travel WITH the tool definition instead of living in prose the
67
+ // model may or may not have read.
68
+ if (typeof type !== "string")
69
+ return { type: "string", enum: [...type] };
65
70
  switch (type) {
66
71
  case "string":
67
72
  return { type: "string" };
@@ -189,8 +194,41 @@ function isEmitCall(observed, name) {
189
194
  */
190
195
  function experimental_parseEmitted(toolCalls, contract, options = {}) {
191
196
  const name = options.name ?? DEFAULT_EMIT_TOOL;
192
- const calls = toolCalls.filter((c) => isEmitCall(c.name, name));
197
+ const all = toolCalls.filter((c) => isEmitCall(c.name, name));
198
+ // 🔴 A CALL THAT ERRORED IS NOT AN EMISSION, AND USED TO PARSE AS ONE.
199
+ //
200
+ // Measured 2026-08-19: a permission-denied call carrying a perfectly valid payload
201
+ // returned `{"kind":"ok", …}`, because this function filtered by NAME and never looked
202
+ // at `ToolCall.isError` — a field that has been on the type all along. The call never
203
+ // reached the server; the reader reported success. That is the exact shape of defect
204
+ // this channel exists to remove from the fenced rail, reproduced inside the channel.
205
+ //
206
+ // Denial is not hypothetical: it is what a wrong `allowedTools` spelling produces, and
207
+ // MCP tool names mangle per host (`mcp__plugin_<plugin>_<server>__emit_result` on Claude
208
+ // Code, two segments on Codex), so mis-spelling it is the likely case, not the exotic one.
209
+ //
210
+ // The errored calls get their OWN branch rather than being dropped or counted, because
211
+ // all three collapses lie in a different direction:
212
+ // - counting them → this defect, success for a call nobody received;
213
+ // - dropping them silently → "no tool call in the run", which sends the reader to the
214
+ // skill's instructions when the fault is in permissions;
215
+ // - lumping them with "called twice" → a model that retries a denied call produces a
216
+ // true signal under a false name. (Observed: two denials in one run.)
217
+ // A successful call ALONGSIDE a denied one is one successful emission; the denial is
218
+ // mentioned, not fatal, because the contract was in fact satisfied.
219
+ // `isError` is a required boolean on ToolCall, so truthiness is exact here.
220
+ const errored = all.filter((c) => c.isError);
221
+ const calls = all.filter((c) => !c.isError);
193
222
  if (calls.length === 0) {
223
+ if (errored.length > 0) {
224
+ return {
225
+ kind: "malformed",
226
+ reason: `the \`${name}\` call itself errored or was denied ` +
227
+ `(${String(errored.length)} attempt${errored.length === 1 ? "" : "s"}); nothing reached the ` +
228
+ `server, so nothing was emitted. Check the tool's permissions and the exact spelling ` +
229
+ `in \`allowedTools\` — MCP names are host-mangled.`,
230
+ };
231
+ }
194
232
  return { kind: "malformed", reason: `no \`${name}\` tool call in the run` };
195
233
  }
196
234
  if (calls.length > 1) {
@@ -39,6 +39,7 @@ const toml_1 = require("@iarna/toml");
39
39
  const hash_js_1 = require("./core/hash.js");
40
40
  const fs_walk_js_1 = require("./fs-walk.js");
41
41
  const source_refs_js_1 = require("./core/source-refs.js");
42
+ const surface_scopes_js_1 = require("./core/surface-scopes.js");
42
43
  const MAX_SKILL_FILE_BYTES = 256 * 1024;
43
44
  /** Parse a JSON file, or null on any error (missing / malformed). */
44
45
  function safeReadJson(path) {
@@ -177,19 +178,35 @@ function loadPlugin(pluginPath, layout) {
177
178
  files[layout.instructionFile] = (0, node_fs_1.readFileSync)(instructions, "utf-8");
178
179
  sources[layout.instructionFile] = instructions;
179
180
  }
180
- const counts = materializeSurfaces(root, layout, files, sources);
181
+ const surfaces = materializeSurfaces(root, layout, files, sources);
181
182
  return {
182
183
  settings: resolvedHooks ? { hooks: resolvedHooks } : {},
183
184
  files,
184
185
  sources,
185
- warnings: pluginWarnings(root, counts, resolvedHooks, files, layout),
186
+ warnings: pluginWarnings(root, surfaces, resolvedHooks, files, layout),
186
187
  };
187
188
  }
189
+ /**
190
+ * Materialize every model surface (skills/agents/commands) into `files`, and
191
+ * record each file's real on-disk path in `sources`. Best-effort (headless
192
+ * activation of plugin skills/subagents/commands is not guaranteed; the body is
193
+ * present to read).
194
+ *
195
+ * EVERY discovery level present is read — the repo-root `<surface>` (the
196
+ * published-plugin / skills-library shape) AND the project-level
197
+ * `<userSurfaceRoot>/<surface>` (the shape a plain Claude Code user has). The
198
+ * loader used to read one OR the other and materialize the winner under the
199
+ * loser's canonical key; `src/core/surface-scopes.ts` carries the measurement
200
+ * that killed that, and the vendor quote that settles which one the harness
201
+ * loads (both, in different namespaces). The KEY now comes from the scope, so
202
+ * two files can no longer claim one. Plus the single-skill-directory case
203
+ * (`<root>/SKILL.md`), so pointing at one skill dir works. Returns the
204
+ * per-surface counts (drives the surface warnings).
205
+ */
188
206
  /**
189
207
  * A surface holds a LOADABLE file — a `<name>/SKILL.md` for skills, a `.md` for
190
208
  * agents/commands. A stray non-surface file (`skills/README.md`, `.gitkeep`) does
191
- * NOT count, else it would mark the root populated and shadow a plain user's real
192
- * `.claude/skills`.
209
+ * NOT count, else an empty-but-present dir would mark a scope populated.
193
210
  */
194
211
  function surfaceHasLoadable(layout, surface, tree) {
195
212
  const keys = Object.keys(tree);
@@ -197,30 +214,6 @@ function surfaceHasLoadable(layout, surface, tree) {
197
214
  ? keys.some((k) => (0, node_path_1.basename)(k) === "SKILL.md")
198
215
  : keys.some((k) => k.endsWith(".md"));
199
216
  }
200
- /**
201
- * Classify the repo shape from disk, with EXPLICIT precedence:
202
- * 1. a `<root>/SKILL.md` → the target IS one skill dir (single-skill).
203
- * 2. any root-surface with LOADABLE content, OR a plugin manifest / hooks
204
- * convention → read the ROOT surfaces. A plugin ships from its manifest even
205
- * with no root surface dirs, so its dev `.claude/…` is never a fallback.
206
- * 3. else, if the layout declares a user-surface root → a plain user repo.
207
- * 4. else → nothing loadable.
208
- * Pure over the pre-read `rootTrees` + a few existence checks — one place to test.
209
- */
210
- function classifySurfaceSource(root, layout, rootTrees) {
211
- if (layout.skillDir && (0, node_fs_1.existsSync)((0, node_path_1.join)(root, "SKILL.md"))) {
212
- return { kind: "single-skill", skillName: (0, node_path_1.basename)(root) };
213
- }
214
- const rootHasLoadable = layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, rootTrees.get(s) ?? {}));
215
- const isPluginShaped = (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.manifestPath)) ||
216
- (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.hooksConventionPath));
217
- if (rootHasLoadable || isPluginShaped)
218
- return { kind: "root" };
219
- if (layout.userSurfaceRoot !== undefined) {
220
- return { kind: "user", sub: layout.userSurfaceRoot };
221
- }
222
- return { kind: "none" };
223
- }
224
217
  function materializeSurfaces(root, layout, files, sources) {
225
218
  const counts = {};
226
219
  const isDir = (p) => (0, node_fs_1.existsSync)(p) && (0, node_fs_1.statSync)(p).isDirectory();
@@ -234,15 +227,41 @@ function materializeSurfaces(root, layout, files, sources) {
234
227
  * outside is still read.
235
228
  */
236
229
  const surfaceTree = (dir) => isDir(dir) && (0, fs_walk_js_1.walkableRoot)(dir, root) ? readTree(dir, dir) : {};
237
- // Read each ROOT-level surface tree once (keys relative to the surface dir).
238
- const rootTrees = new Map();
239
- for (const surface of layout.surfaceDirs)
240
- rootTrees.set(surface, surfaceTree((0, node_path_1.join)(root, surface)));
230
+ /** Every surface tree of one scope, read once, keyed by surface dir. */
231
+ const scopeTrees = (base) => {
232
+ const trees = new Map();
233
+ for (const surface of layout.surfaceDirs)
234
+ trees.set(surface, surfaceTree((0, node_path_1.join)(root, base, surface)));
235
+ return trees;
236
+ };
237
+ const hasLoadable = (trees) => layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, trees.get(s) ?? {}));
241
238
  const add = (key, content, onDisk) => {
242
239
  files[key] = content;
243
240
  sources[key] = onDisk;
244
241
  };
245
- const source = classifySurfaceSource(root, layout, rootTrees);
242
+ // Both candidate scopes are read ONCE, up front, because the decision needs to
243
+ // know whether each holds anything — and then the same trees are materialized.
244
+ const rootTrees = scopeTrees("");
245
+ const userTrees = layout.userSurfaceRoot !== undefined
246
+ ? scopeTrees(layout.userSurfaceRoot)
247
+ : new Map();
248
+ /** Copy one scope's already-read trees into `files`, keyed by that scope. */
249
+ const materializeScope = (scope, trees) => {
250
+ for (const surface of layout.surfaceDirs) {
251
+ const tree = trees.get(surface) ?? {};
252
+ for (const [rel, content] of Object.entries(tree))
253
+ add((0, surface_scopes_js_1.scopeKey)(scope, surface, rel), content, (0, node_path_1.join)(root, scope.base, surface, rel));
254
+ counts[surface] = (counts[surface] ?? 0) + Object.keys(tree).length;
255
+ }
256
+ };
257
+ const source = (0, surface_scopes_js_1.surfaceSource)(layout, {
258
+ hasRootSkillFile: (0, node_fs_1.existsSync)((0, node_path_1.join)(root, "SKILL.md")),
259
+ skillName: (0, node_path_1.basename)(root),
260
+ rootHasLoadable: hasLoadable(rootTrees),
261
+ isPluginShaped: (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.manifestPath)) ||
262
+ (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.hooksConventionPath)),
263
+ userHasLoadable: hasLoadable(userTrees),
264
+ });
246
265
  switch (source.kind) {
247
266
  case "single-skill": {
248
267
  // Materialize the WHOLE skill dir under the canonical skills key, so its
@@ -255,31 +274,18 @@ function materializeSurfaces(root, layout, files, sources) {
255
274
  add((0, node_path_1.join)(layout.materializeRoot, layout.skillDir, source.skillName, rel), content, (0, node_path_1.join)(root, rel));
256
275
  }
257
276
  counts[layout.skillDir] = Object.keys(tree).length;
258
- break;
277
+ return { counts, scopes: [] };
259
278
  }
260
- case "root":
261
- case "user": {
262
- const base = source.kind === "user" ? (0, node_path_1.join)(root, source.sub) : root;
263
- for (const surface of layout.surfaceDirs) {
264
- const dir = (0, node_path_1.join)(base, surface);
265
- // Root surfaces were pre-read; user surfaces are read fresh here.
266
- const tree = source.kind === "root"
267
- ? (rootTrees.get(surface) ?? {})
268
- : surfaceTree(dir);
269
- for (const [rel, content] of Object.entries(tree)) {
270
- add((0, node_path_1.join)(layout.materializeRoot, surface, rel), content, (0, node_path_1.join)(dir, rel));
271
- }
272
- counts[surface] = Object.keys(tree).length;
273
- }
274
- break;
279
+ case "scopes": {
280
+ (0, surface_scopes_js_1.assertDistinctScopeKeys)(source.scopes, layout.name);
281
+ for (const scope of source.scopes)
282
+ materializeScope(scope, scope.base === "" ? rootTrees : userTrees);
283
+ return { counts, scopes: source.scopes };
275
284
  }
276
- case "none":
277
- break;
278
285
  /* v8 ignore next 2 -- exhaustiveness guard, unreachable given SurfaceSource */
279
286
  default:
280
- (0, hash_js_1.assertNever)(source);
287
+ return (0, hash_js_1.assertNever)(source);
281
288
  }
282
- return counts;
283
289
  }
284
290
  /**
285
291
  * Flag surfaces present-but-not-deterministically-exercisable. Subagents
@@ -288,8 +294,11 @@ function materializeSurfaces(root, layout, files, sources) {
288
294
  * the eval tier. MCP servers aren't wired by the loader at all. And a plugin
289
295
  * that yields neither hooks nor files would otherwise be a silent empty machine.
290
296
  */
291
- function pluginWarnings(root, counts, hooks, files, layout) {
297
+ function pluginWarnings(root, { counts, scopes }, hooks, files, layout) {
292
298
  const warnings = [];
299
+ const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes, counts);
300
+ if (multiScope !== undefined)
301
+ warnings.push(multiScope);
293
302
  if (counts.agents) {
294
303
  warnings.push(`plugin defines ${String(counts.agents)} subagent file(s) under agents/ — these run only under a real model; test them at the eval tier (runEval), not the deterministic mock.`);
295
304
  }
@@ -118,6 +118,10 @@ assertTriggerRate(report, { min: 0.8, maxFalsePositive: 0.3 });
118
118
  }
119
119
  /** A JSON value placeholder for an `OutputFieldType`, for the `vigiles:ok` block. */
120
120
  function placeholderFor(type) {
121
+ // An enum's placeholder must be a MEMBER, or the scaffolded test fails the moment it
122
+ // is run — a generated test that cannot pass teaches the author to distrust the tool.
123
+ if (Array.isArray(type) && type.length > 0)
124
+ return type[0];
121
125
  switch (type) {
122
126
  case "number":
123
127
  return 1;
@@ -109,7 +109,12 @@ export declare function skillRefSources(files: Record<string, string>, cls: Surf
109
109
  readonly root: string;
110
110
  readonly sources?: Record<string, string>;
111
111
  }): SkillRefSource[];
112
- export declare function descriptionOverlapsFor(files: Record<string, string>, cls: SurfaceClassifier): DescriptionOverlap[];
112
+ /** Where a materialized key really lives — for reporting a surface by path. */
113
+ export interface SurfacePathContext {
114
+ readonly root: string;
115
+ readonly sources?: Record<string, string>;
116
+ }
117
+ export declare function descriptionOverlapsFor(files: Record<string, string>, cls: SurfaceClassifier, where?: SurfacePathContext): DescriptionOverlap[];
113
118
  /**
114
119
  * Model-invocable skills whose description is so long the trigger signal is
115
120
  * buried (heuristic proxy; degrades recall + precision). Same surfaces as the
package/dist/scan-core.js CHANGED
@@ -408,7 +408,7 @@ function skillRefSources(files, cls, ctx) {
408
408
  * logic as `scanSkills` (frontmatter `description` ← first body paragraph), then
409
409
  * the NCD precision-proxy. See description-overlap.ts.
410
410
  */
411
- function modelInvocableSkillSurfaces(files, cls) {
411
+ function modelInvocableSkillSurfaces(files, cls, where) {
412
412
  const surfaces = [];
413
413
  for (const [path, md] of Object.entries(files)) {
414
414
  if (!cls.isSkill(path))
@@ -419,12 +419,24 @@ function modelInvocableSkillSurfaces(files, cls) {
419
419
  const description = fm.description ?? firstBodyParagraph(md);
420
420
  if (!description || description.length < 20)
421
421
  continue;
422
- surfaces.push({ name: fm.name ?? skillName(path), description });
422
+ surfaces.push({
423
+ name: fm.name ?? skillName(path),
424
+ description,
425
+ // The NAME alone stopped identifying a surface once the loader began
426
+ // reading both discovery levels: a repo carrying `skills/x` AND
427
+ // `.claude/skills/x` has two skills called `x`. Report the real path
428
+ // (never the synthetic key) so the pair is actionable.
429
+ ...(where
430
+ ? {
431
+ where: reportedSurfacePath(path, where.sources?.[path], where.root),
432
+ }
433
+ : {}),
434
+ });
423
435
  }
424
436
  return surfaces;
425
437
  }
426
- function descriptionOverlapsFor(files, cls) {
427
- return (0, description_overlap_js_1.findDescriptionOverlaps)(modelInvocableSkillSurfaces(files, cls));
438
+ function descriptionOverlapsFor(files, cls, where) {
439
+ return (0, description_overlap_js_1.findDescriptionOverlaps)(modelInvocableSkillSurfaces(files, cls, where));
428
440
  }
429
441
  /**
430
442
  * Model-invocable skills whose description is so long the trigger signal is
@@ -834,9 +846,31 @@ function collectDelegationTrifecta(agents, dialect) {
834
846
  delegatesTo: canDispatch ? allNames.filter((n) => n !== a.name) : [],
835
847
  };
836
848
  });
837
- const pathByName = new Map(agents.map((a) => [a.name, a.path]));
849
+ // 🔴 A NAME IS NOT AN IDENTITY HERE, and this map used to assume it was.
850
+ // `new Map(agents.map((a) => [a.name, a.path]))` keeps the LAST entry for a
851
+ // repeated key, so when the same agent name exists in two discovery scopes —
852
+ // `agents/foo.md` and `.claude/agents/foo.md`, which Claude registers in
853
+ // DIFFERENT namespaces — a finding about the first was reported against the
854
+ // second's file. A lethal-trifecta finding that names the wrong file is worse
855
+ // than one that names none: it sends the reader to audit innocent code and
856
+ // leaves the real surface unexamined.
857
+ //
858
+ // Until identity is scope-qualified through the whole delegation pipeline (the
859
+ // proper fix, and a larger one — `finding.name` itself carries only the bare
860
+ // name), an ambiguous name resolves to NO path rather than to an arbitrary one.
861
+ // Nothing changes for the overwhelmingly common case of distinct names.
862
+ const pathByName = new Map();
863
+ const ambiguous = new Set();
864
+ for (const a of agents) {
865
+ if (pathByName.has(a.name))
866
+ ambiguous.add(a.name);
867
+ else
868
+ pathByName.set(a.name, a.path);
869
+ }
838
870
  return (0, delegation_trifecta_js_1.delegationTrifectaIssues)(nodes, dialect).map((finding) => ({
839
- path: pathByName.get(finding.name) ?? "",
871
+ path: ambiguous.has(finding.name)
872
+ ? ""
873
+ : (pathByName.get(finding.name) ?? ""),
840
874
  finding,
841
875
  }));
842
876
  }
@@ -48,6 +48,7 @@ const mcp_config_js_1 = require("./core/mcp-config.js");
48
48
  const agent_plugins_js_1 = require("./core/agent-plugins.js");
49
49
  const mcp_hook_js_1 = require("./core/mcp-hook.js");
50
50
  const plugin_dir_layout_js_1 = require("./core/plugin-dir-layout.js");
51
+ const surface_scopes_js_1 = require("./core/surface-scopes.js");
51
52
  const hook_block_ineffective_js_1 = require("./core/hook-block-ineffective.js");
52
53
  const hook_matcher_js_1 = require("./core/hook-matcher.js");
53
54
  const test_coverage_files_js_1 = require("./test-coverage-files.js");
@@ -209,49 +210,63 @@ function hasMcp(files, layout) {
209
210
  return true;
210
211
  return readManifest(files, layout)?.[layout.mcpManifestKey] !== undefined;
211
212
  }
212
- /** Mirror of plugin-loader.ts `surfaceHasLoadable`. */
213
+ // ---------------------------------------------------------------------------
214
+ // Surface materialization (mirrors plugin-loader.ts materializeSurfaces)
215
+ // ---------------------------------------------------------------------------
216
+ /**
217
+ * Mirror of plugin-loader.ts `surfaceHasLoadable`. The SCOPING DECISION itself is
218
+ * not mirrored — it lives once, IO-free, in `src/core/surface-scopes.ts`, and both
219
+ * engines call it. That pair used to be two copies of the same precedence rules,
220
+ * which is exactly the shape this repo keeps getting bitten by fixing on one side.
221
+ */
213
222
  function surfaceHasLoadable(layout, surface, tree) {
214
223
  const keys = Object.keys(tree);
215
224
  return surface === layout.skillDir
216
225
  ? keys.some((k) => (0, posix_path_js_1.basename)(k) === "SKILL.md")
217
226
  : keys.some((k) => k.endsWith(".md"));
218
227
  }
219
- /** Mirror of plugin-loader.ts `classifySurfaceSource`, over the file map. */
220
- function classifySurfaceSource(files, layout, rootTrees, repoName) {
221
- if (layout.skillDir && hasFile(files, "SKILL.md")) {
222
- // Disk mirrors the CLI: a nameless root SKILL.md takes the audited dir's
223
- // basename. In-browser there's no real dir, so use the repo name when the
224
- // caller (runAudit) supplies it, else the synthetic BROWSER_ROOT basename.
225
- return {
226
- kind: "single-skill",
227
- skillName: repoName ?? (0, posix_path_js_1.basename)(exports.BROWSER_ROOT),
228
- };
229
- }
230
- const rootHasLoadable = layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, rootTrees.get(s) ?? {}));
231
- const isPluginShaped = hasFile(files, layout.manifestPath) ||
232
- hasFile(files, layout.hooksConventionPath);
233
- if (rootHasLoadable || isPluginShaped)
234
- return { kind: "root" };
235
- if (layout.userSurfaceRoot !== undefined) {
236
- return { kind: "user", sub: layout.userSurfaceRoot };
237
- }
238
- return { kind: "none" };
239
- }
240
228
  /** Mirror of plugin-loader.ts `materializeSurfaces`, over the file map. */
241
229
  function materializeSurfaces(files, layout, acc, repoName) {
242
230
  const { out, sources } = acc;
243
231
  const counts = {};
244
- const rootTrees = new Map();
245
- for (const surface of layout.surfaceDirs) {
246
- if (isDirRel(files, surface)) {
247
- rootTrees.set(surface, readTreeUnder(files, surface, surface));
232
+ const scopeTrees = (base) => {
233
+ const trees = new Map();
234
+ for (const surface of layout.surfaceDirs) {
235
+ const dirRel = base === "" ? surface : `${base}/${surface}`;
236
+ trees.set(surface, isDirRel(files, dirRel) ? readTreeUnder(files, dirRel, dirRel) : {});
248
237
  }
249
- }
238
+ return trees;
239
+ };
240
+ const hasLoadable = (trees) => layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, trees.get(s) ?? {}));
250
241
  const add = (key, content, onDisk) => {
251
242
  out[key] = content;
252
243
  sources[key] = onDisk;
253
244
  };
254
- const source = classifySurfaceSource(files, layout, rootTrees, repoName);
245
+ const rootTrees = scopeTrees("");
246
+ const userTrees = layout.userSurfaceRoot !== undefined
247
+ ? scopeTrees(layout.userSurfaceRoot)
248
+ : new Map();
249
+ /** Mirror of the disk loader's `materializeScope`. */
250
+ const materializeScope = (scope, trees) => {
251
+ for (const surface of layout.surfaceDirs) {
252
+ const tree = trees.get(surface) ?? {};
253
+ const dirRel = scope.base === "" ? surface : `${scope.base}/${surface}`;
254
+ for (const [rel, content] of Object.entries(tree))
255
+ add((0, surface_scopes_js_1.scopeKey)(scope, surface, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, dirRel, rel));
256
+ counts[surface] = (counts[surface] ?? 0) + Object.keys(tree).length;
257
+ }
258
+ };
259
+ const source = (0, surface_scopes_js_1.surfaceSource)(layout, {
260
+ hasRootSkillFile: Boolean(layout.skillDir) && hasFile(files, "SKILL.md"),
261
+ // Disk mirrors the CLI: a nameless root SKILL.md takes the audited dir's
262
+ // basename. In-browser there's no real dir, so use the repo name when the
263
+ // caller (runAudit) supplies it, else the synthetic BROWSER_ROOT basename.
264
+ skillName: repoName ?? (0, posix_path_js_1.basename)(exports.BROWSER_ROOT),
265
+ rootHasLoadable: hasLoadable(rootTrees),
266
+ isPluginShaped: hasFile(files, layout.manifestPath) ||
267
+ hasFile(files, layout.hooksConventionPath),
268
+ userHasLoadable: hasLoadable(userTrees),
269
+ });
255
270
  switch (source.kind) {
256
271
  case "single-skill": {
257
272
  const tree = readTreeUnder(files, "", "");
@@ -259,29 +274,15 @@ function materializeSurfaces(files, layout, acc, repoName) {
259
274
  add((0, posix_path_js_1.join)(layout.materializeRoot, layout.skillDir, source.skillName, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, rel));
260
275
  }
261
276
  counts[layout.skillDir] = Object.keys(tree).length;
262
- break;
277
+ return { counts, scopes: [] };
263
278
  }
264
- case "root":
265
- case "user": {
266
- const baseRel = source.kind === "user" ? source.sub : "";
267
- for (const surface of layout.surfaceDirs) {
268
- const dirRel = baseRel === "" ? surface : `${baseRel}/${surface}`;
269
- const tree = source.kind === "root"
270
- ? (rootTrees.get(surface) ?? {})
271
- : isDirRel(files, dirRel)
272
- ? readTreeUnder(files, dirRel, dirRel)
273
- : {};
274
- for (const [rel, content] of Object.entries(tree)) {
275
- add((0, posix_path_js_1.join)(layout.materializeRoot, surface, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, dirRel, rel));
276
- }
277
- counts[surface] = Object.keys(tree).length;
278
- }
279
- break;
279
+ case "scopes": {
280
+ (0, surface_scopes_js_1.assertDistinctScopeKeys)(source.scopes, layout.name);
281
+ for (const scope of source.scopes)
282
+ materializeScope(scope, scope.base === "" ? rootTrees : userTrees);
283
+ return { counts, scopes: source.scopes };
280
284
  }
281
- case "none":
282
- break;
283
285
  }
284
- return counts;
285
286
  }
286
287
  // ---------------------------------------------------------------------------
287
288
  // Dangling intra-plugin refs (mirrors plugin-loader.ts danglingRefs)
@@ -368,8 +369,11 @@ function danglingRefs(files, layout, rootName) {
368
369
  // ---------------------------------------------------------------------------
369
370
  // Warnings (mirrors plugin-loader.ts pluginWarnings)
370
371
  // ---------------------------------------------------------------------------
371
- function pluginWarnings(files, layout, counts, hooks, materialized, rootName) {
372
+ function pluginWarnings(files, layout, { counts, scopes }, hooks, materialized, rootName) {
372
373
  const warnings = [];
374
+ const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes, counts);
375
+ if (multiScope !== undefined)
376
+ warnings.push(multiScope);
373
377
  if (counts.agents) {
374
378
  warnings.push(`plugin defines ${String(counts.agents)} subagent file(s) under agents/ — these run only under a real model; test them at the eval tier (runEval), not the deterministic mock.`);
375
379
  }
@@ -411,12 +415,12 @@ function loadPluginFromFiles(files, layout, repoName) {
411
415
  out[layout.instructionFile] = instructionText;
412
416
  sources[layout.instructionFile] = (0, posix_path_js_1.join)(exports.BROWSER_ROOT, layout.instructionFile);
413
417
  }
414
- const counts = materializeSurfaces(files, layout, { out, sources }, repoName);
418
+ const surfaces = materializeSurfaces(files, layout, { out, sources }, repoName);
415
419
  return {
416
420
  settings: resolvedHooks ? { hooks: resolvedHooks } : {},
417
421
  files: out,
418
422
  sources,
419
- warnings: pluginWarnings(files, layout, counts, resolvedHooks, out, repoName ?? (0, posix_path_js_1.basename)(exports.BROWSER_ROOT)),
423
+ warnings: pluginWarnings(files, layout, surfaces, resolvedHooks, out, repoName ?? (0, posix_path_js_1.basename)(exports.BROWSER_ROOT)),
420
424
  };
421
425
  }
422
426
  // ---------------------------------------------------------------------------
@@ -536,7 +540,10 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
536
540
  skillMetaIssues: remap((0, scan_core_js_1.skillMetaIssuesFor)(loaded.files, cls)),
537
541
  mcpIssues: (0, mcp_config_js_1.verifyMcpServers)(mcpServers),
538
542
  mcpHookIssues: (0, mcp_hook_js_1.verifyMcpHookTargets)(loaded.settings.hooks, declaredServers, dialect),
539
- descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls),
543
+ descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls, {
544
+ root: exports.BROWSER_ROOT,
545
+ sources: loaded.sources,
546
+ }),
540
547
  descriptionBudgetIssues: (0, scan_core_js_1.descriptionBudgetFor)(loaded.files, cls),
541
548
  trifectaFindings,
542
549
  skillResourceIssues: skillResourceFindings,
package/dist/scan.js CHANGED
@@ -219,7 +219,10 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
219
219
  skillMetaIssues: remap((0, scan_core_js_1.skillMetaIssuesFor)(loaded.files, cls)),
220
220
  mcpIssues: (0, mcp_config_js_1.verifyMcpServers)(mcpServers),
221
221
  mcpHookIssues: (0, mcp_hook_js_1.verifyMcpHookTargets)(loaded.settings.hooks, declaredServers, dialect),
222
- descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls),
222
+ descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls, {
223
+ root: (0, node_path_1.resolve)(dir),
224
+ sources: loaded.sources,
225
+ }),
223
226
  descriptionBudgetIssues: (0, scan_core_js_1.descriptionBudgetFor)(loaded.files, cls),
224
227
  trifectaFindings,
225
228
  skillResourceIssues: skillResourceFindings,
@@ -41,7 +41,10 @@ function overlapExplanations(report) {
41
41
  symptom: "wrong-skill-fires",
42
42
  cause: o.message,
43
43
  detector: "description-overlap",
44
- fix: `Differentiate the descriptions of "${o.a}" and "${o.b}" (${o.similarity} similar) — the selector picks by description, so near-identical text makes it fire the wrong one.`,
44
+ // `o.a`/`o.b` are already display LABELS (quoted name, plus the file path
45
+ // when the caller had one) — a bare name is ambiguous now that both
46
+ // discovery levels are read and the same name can appear twice.
47
+ fix: `Differentiate the descriptions of ${o.a} and ${o.b} (${o.similarity} similar) — the selector picks by description, so near-identical text makes it fire the wrong one.`,
45
48
  confidence: "possible",
46
49
  }));
47
50
  }