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.
@@ -39,8 +39,17 @@ function fieldMatches(value, type) {
39
39
  return typeof value === "boolean";
40
40
  case "string[]":
41
41
  return Array.isArray(value) && value.every((v) => typeof v === "string");
42
+ default:
43
+ // An enum, declared as a readonly tuple of the permitted literals.
44
+ return typeof value === "string" && type.includes(value);
42
45
  }
43
46
  }
47
+ /** How a field type reads in a message to a human: `string`, or `"CUT" | "MERGE"`. */
48
+ function typeName(type) {
49
+ return typeof type === "string"
50
+ ? type
51
+ : type.map((v) => JSON.stringify(v)).join(" | ");
52
+ }
44
53
  /**
45
54
  * Validate a parsed object against a contract track; null when it conforms.
46
55
  *
@@ -54,7 +63,9 @@ function shapeError(obj, shape) {
54
63
  if (!(field in obj))
55
64
  return `missing field "${field}"`;
56
65
  if (!fieldMatches(obj[field], type)) {
57
- return `field "${field}" should be ${type}`;
66
+ // `typeName`, not `type`: an enum interpolated raw renders as `CUT,MERGE,KEEP`,
67
+ // which reads like a value rather than a choice among values.
68
+ return `field "${field}" should be ${typeName(type)}`;
58
69
  }
59
70
  }
60
71
  return null;
@@ -93,6 +93,11 @@ export declare function interpreterArgs(file: string, caps: NodeCaps, entry?: st
93
93
  export declare function discoverScripts(patterns: readonly string[], defaultGlob: string, cwd: string): string[];
94
94
  /** Extra wiring for {@link runScripts}. */
95
95
  export interface RunScriptsOptions {
96
+ /**
97
+ * How many scripts may run at once. Omitted → decided from `entry`: `test`
98
+ * fans out across the cores, `eval` stays at 1. See {@link runScripts}.
99
+ */
100
+ readonly concurrency?: number;
96
101
  /**
97
102
  * A program to run INSTEAD of each script, with the script's path as its one
98
103
  * argument. `vigiles eval` passes `dist/eval-entry.js`; `vigiles test` passes
@@ -116,7 +121,7 @@ export interface RunScriptsOptions {
116
121
  * record them (`.vigiles/coverage.json`) and coverage can answer "tested?" from
117
122
  * execution rather than from a matching file name.
118
123
  */
119
- export declare function runScripts(files: readonly string[], cwd: string, env?: NodeJS.ProcessEnv, opts?: RunScriptsOptions): ScriptRunResult[];
124
+ export declare function runScripts(files: readonly string[], cwd: string, env?: NodeJS.ProcessEnv, opts?: RunScriptsOptions): Promise<ScriptRunResult[]>;
120
125
  /**
121
126
  * Whether any script FAILED. Neither a skip nor a vacuous run counts: the first
122
127
  * declined to run, the second ran and verified nothing, and neither is evidence
@@ -22,9 +22,10 @@ exports.formatScriptSummary = formatScriptSummary;
22
22
  * also run standalone — the CLI just discovers, runs, and aggregates exit codes.
23
23
  */
24
24
  const node_child_process_1 = require("node:child_process");
25
+ const node_os_1 = require("node:os");
25
26
  const node_path_1 = require("node:path");
26
27
  const node_fs_1 = require("node:fs");
27
- const node_os_1 = require("node:os");
28
+ const node_os_2 = require("node:os");
28
29
  const glob_1 = require("glob");
29
30
  const check_count_js_1 = require("../../check-count.js");
30
31
  /**
@@ -178,42 +179,95 @@ function readCheckReport(path) {
178
179
  * record them (`.vigiles/coverage.json`) and coverage can answer "tested?" from
179
180
  * execution rather than from a matching file name.
180
181
  */
181
- function runScripts(files, cwd, env = {}, opts = {}) {
182
+ async function runScripts(files, cwd, env = {}, opts = {}) {
182
183
  const caps = (0, ts_runner_caps_js_2.detectNodeCaps)(cwd);
183
- const results = [];
184
- const countDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-checks-"));
185
- try {
186
- files.forEach((file, i) => {
187
- let argv;
188
- try {
189
- argv = interpreterArgs(file, caps, opts.entry);
190
- }
191
- catch (e) {
192
- console.error(`✗ ${file}: ${e.message}`);
193
- results.push({ file, code: 1, status: "fail" });
194
- return;
195
- }
196
- const countFile = (0, node_path_1.join)(countDir, `${String(i)}.count`);
197
- const res = (0, node_child_process_1.spawnSync)("node", argv, {
198
- cwd,
199
- stdio: "inherit",
200
- env: { ...process.env, ...env, [check_count_js_1.CHECK_COUNT_ENV]: countFile },
201
- });
202
- const code = res.status ?? 1;
184
+ const countDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_2.tmpdir)(), "vigiles-checks-"));
185
+ // 🔴 THE DEFAULT IS DECIDED BY `entry`, NOT BY A FLAG, because the two commands
186
+ // that share this runner have OPPOSITE right answers and the caller already
187
+ // distinguishes them:
188
+ //
189
+ // `test` passes no entry — every script is a `*.harness.*` file, which is free
190
+ // and deterministic BY CONSTRUCTION (the harness tier drives the agent CLI
191
+ // against a mock model with no API key, and anything that spends money lives
192
+ // behind `vigiles/eval` with a `paid_` prefix). Nothing here can bill, so the
193
+ // only reason to serialize was that we always had.
194
+ //
195
+ // `eval` passes an entry — every script spends real model quota. Running those
196
+ // N-at-a-time multiplies spend and collides with rate limits, so it stays at 1.
197
+ //
198
+ // Measured motivation: 48 harness files in one consumer repo took 1m48s in CI as
199
+ // a strict queue, on a runner with cores sitting idle.
200
+ const parallel = Math.max(1, opts.concurrency ?? (opts.entry ? 1 : Math.min(8, (0, node_os_1.availableParallelism)())));
201
+ // Output is BUFFERED per child and printed when that child exits, rather than
202
+ // inherited. This is the real cost of concurrency and the reason it was not
203
+ // free: with `stdio: "inherit"` two children write to the same terminal at once
204
+ // and 48 reports shred into each other. Buffering keeps each report whole and
205
+ // attributable; what it gives up is live streaming, which only matters when one
206
+ // script is slow AND alone — i.e. exactly the `eval` case, where parallel is 1
207
+ // and the buffer is flushed as soon as the single child ends anyway.
208
+ const runOne = (file, i) => new Promise((resolveRun) => {
209
+ let argv;
210
+ try {
211
+ argv = interpreterArgs(file, caps, opts.entry);
212
+ }
213
+ catch (e) {
214
+ console.error(`✗ ${file}: ${e.message}`);
215
+ resolveRun({ file, code: 1, status: "fail" });
216
+ return;
217
+ }
218
+ const countFile = (0, node_path_1.join)(countDir, `${String(i)}.count`);
219
+ const child = (0, node_child_process_1.spawn)("node", argv, {
220
+ cwd,
221
+ stdio: ["ignore", "pipe", "pipe"],
222
+ env: { ...process.env, ...env, [check_count_js_1.CHECK_COUNT_ENV]: countFile },
223
+ });
224
+ const chunks = [];
225
+ child.stdout.on("data", (c) => chunks.push(c));
226
+ child.stderr.on("data", (c) => chunks.push(c));
227
+ const finish = (code) => {
228
+ process.stdout.write(Buffer.concat(chunks));
203
229
  const report = readCheckReport(countFile);
204
- results.push({
230
+ resolveRun({
205
231
  file,
206
232
  code,
207
233
  status: statusFor(code, report?.checks),
208
234
  checks: report?.checks,
209
235
  ...(report ? { surfaces: report.surfaces } : {}),
210
236
  });
237
+ };
238
+ // `error` fires when the process could not be spawned at all; without this
239
+ // the promise would never settle and the whole run would hang silently —
240
+ // which is worse than any failure it could report.
241
+ child.on("error", (e) => {
242
+ chunks.push(Buffer.from(`✗ ${file}: ${e.message}\n`));
243
+ finish(1);
244
+ });
245
+ child.on("close", (code) => {
246
+ finish(code ?? 1);
211
247
  });
248
+ });
249
+ try {
250
+ // Results are stored BY INDEX so the reported order is the discovery order,
251
+ // whatever order the children happen to finish in. A run whose output reorders
252
+ // itself between invocations reads as flaky even when every result is stable.
253
+ const results = new Array(files.length);
254
+ let next = 0;
255
+ const worker = async () => {
256
+ for (;;) {
257
+ const i = next++;
258
+ if (i >= files.length)
259
+ return;
260
+ results[i] = await runOne(files[i], i);
261
+ }
262
+ };
263
+ await Promise.all(Array.from({ length: Math.min(parallel, files.length) }, () => {
264
+ return worker();
265
+ }));
266
+ return results;
212
267
  }
213
268
  finally {
214
269
  (0, node_fs_1.rmSync)(countDir, { recursive: true, force: true });
215
270
  }
216
- return results;
217
271
  }
218
272
  /**
219
273
  * Whether any script FAILED. Neither a skip nor a vacuous run counts: the first
package/dist/cli.js CHANGED
@@ -1141,7 +1141,23 @@ async function runLint(restArgs, flags, config) {
1141
1141
  // `<!-- vigiles:ignore-file -->` (whole file). Same engine as spec.ts.
1142
1142
  if (!silent)
1143
1143
  console.log("\nMarkdown code block refs:\n");
1144
- const docRefReport = (0, doc_refs_js_1.findDocRefs)({ basePath: process.cwd() });
1144
+ // 🔴 `config.exclude` REACHES THIS PASS. It did not, and that single omission is
1145
+ // why `lint` could not exit 0 on a repository that vendors other people's
1146
+ // markdown: this walker globs `**/*.md` from the repo root, so a third-party
1147
+ // `CLAUDE.md` captured verbatim as benchmark data was held to the same ref
1148
+ // validation as the repo's own docs — and the one config field documented to
1149
+ // stop exactly that ("vendored or benchmark fixtures the repo's own lint
1150
+ // shouldn't police") was never handed over.
1151
+ //
1152
+ // Measured on a consumer repo 2026-08-19: 9 broken refs, 8 of them inside a
1153
+ // directory the user had explicitly listed in `exclude`, all 9 still reported.
1154
+ // With no way to reach 0 the step was made `continue-on-error: true`, and a lint
1155
+ // whose exit code is discarded gates nothing — after which hand-written CI steps
1156
+ // grew to do the gating instead. One unpassed argument, that whole chain.
1157
+ const docRefReport = (0, doc_refs_js_1.findDocRefs)({
1158
+ basePath: process.cwd(),
1159
+ ignore: config?.exclude,
1160
+ });
1145
1161
  if (!silent) {
1146
1162
  for (const line of (0, doc_refs_js_1.formatDocRefReport)(docRefReport).split("\n")) {
1147
1163
  console.log(` ${line}`);
@@ -4366,7 +4382,7 @@ async function handleRunScripts(kind, args, restArgs) {
4366
4382
  // An eval file DESCRIBES its eval; `dist/eval-entry.js` is what imports the
4367
4383
  // description and runs what it declares. A harness script is still its own
4368
4384
  // program (it is free, so "import spends money" never applied to it).
4369
- const results = (0, run_scripts_js_1.runScripts)(files, cwd, env, {
4385
+ const results = await (0, run_scripts_js_1.runScripts)(files, cwd, env, {
4370
4386
  ...(kind === "eval" ? { entry: (0, node_path_1.resolve)(__dirname, "eval-entry.js") } : {}),
4371
4387
  });
4372
4388
  // Write down WHAT the run exercised, so `lint`/`audit` can answer "tested?"
@@ -5,6 +5,9 @@
5
5
  * Reads .spec.ts files, validates references, and produces
6
6
  * markdown instruction files with integrity hashes.
7
7
  */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
8
11
  Object.defineProperty(exports, "__esModule", { value: true });
9
12
  exports.computeHash = computeHash;
10
13
  exports.addHash = addHash;
@@ -25,6 +28,7 @@ exports.checkFileHash = checkFileHash;
25
28
  exports.adoptDiff = adoptDiff;
26
29
  const node_fs_1 = require("node:fs");
27
30
  const glob_1 = require("glob");
31
+ const js_yaml_1 = __importDefault(require("js-yaml"));
28
32
  const node_path_1 = require("node:path");
29
33
  const hash_js_1 = require("./hash.js");
30
34
  const integrity_js_1 = require("./integrity.js");
@@ -626,12 +630,52 @@ function collectSkillRefs(spec) {
626
630
  * argument-hint, tools). Default is `"claude-code"` so callers that pass no
627
631
  * dialect get byte-identical output to before.
628
632
  */
633
+ /**
634
+ * A frontmatter scalar, quoted only when leaving it bare would not survive YAML.
635
+ *
636
+ * 🔴 WHY THIS EXISTS. `description: ${spec.description}` interpolated the value
637
+ * raw, so a description containing a colon-space — `"…измеряет, где замер врёт:
638
+ * неаналоги в выборке"` — emitted frontmatter that is not valid YAML. Measured
639
+ * 2026-08-19 on two real skills: right after `vigiles compile`, `skillContract()`
640
+ * reported `malformed: true` and `declared: []`. The file LOOKS like it declares
641
+ * `allowed-tools`; a strict parser reads nothing, so the skill silently inherits
642
+ * every tool the session grants.
643
+ *
644
+ * That is the exact defect `frontmatter-valid` exists to report — i.e. the
645
+ * blessed path produced the thing the product hunts for. Worse, it PUNISHED the
646
+ * fix: a human who quoted the value by hand got a hash mismatch on the next lint
647
+ * ("manually edited after compilation"), and recompiling silently reverted them.
648
+ *
649
+ * The test is a ROUND TRIP rather than a list of dangerous characters: emit it,
650
+ * read it back with the same loader the linter uses, and quote only if what comes
651
+ * back is not the string that went in. Consequences of that choice:
652
+ * - a value that was already safe is emitted byte-identically, so no existing
653
+ * compiled file churns and no integrity hash moves;
654
+ * - the set of "dangerous" inputs never has to be enumerated or maintained —
655
+ * YAML itself decides, so `#`, `[`, `&`, `*`, leading/trailing space, `yes`,
656
+ * `null` and everything else are covered without being listed.
657
+ */
658
+ function yamlScalar(value) {
659
+ try {
660
+ const parsed = js_yaml_1.default.load(`v: ${value}`);
661
+ if (parsed !== null &&
662
+ typeof parsed === "object" &&
663
+ parsed.v === value) {
664
+ return value;
665
+ }
666
+ }
667
+ catch {
668
+ // Did not parse at all — definitively needs quoting.
669
+ }
670
+ // A JSON string IS a YAML double-quoted scalar, escaping included.
671
+ return JSON.stringify(value);
672
+ }
629
673
  function renderSkillFrontmatter(spec, profile = "claude-code") {
630
674
  const fm = [
631
675
  "---",
632
676
  "",
633
- `name: ${spec.name}`,
634
- `description: ${spec.description}`,
677
+ `name: ${yamlScalar(spec.name)}`,
678
+ `description: ${yamlScalar(spec.description)}`,
635
679
  ];
636
680
  // The CC-only keys below are inert in a minimal (Codex/OpenCode) SKILL.md, so
637
681
  // they're omitted entirely under that profile.
@@ -645,7 +689,7 @@ function renderSkillFrontmatter(spec, profile = "claude-code") {
645
689
  ? renderArgumentHint(spec.inputs)
646
690
  : spec.argumentHint;
647
691
  if (argHint)
648
- fm.push(`argument-hint: ${argHint}`);
692
+ fm.push(`argument-hint: ${yamlScalar(argHint)}`);
649
693
  if (spec.tools && spec.tools.length > 0) {
650
694
  // A Claude Code SKILL declares its tool contract under `allowed-tools`
651
695
  // (NOT `tools:` — that's the SUBAGENT key), as a real YAML sequence. Flow
@@ -839,11 +883,14 @@ function validateAgentTools(tools, dialect) {
839
883
  }
840
884
  /** Build the subagent YAML frontmatter (name / description / model / tools). */
841
885
  function renderAgentFrontmatter(spec) {
886
+ // Same round-trip quoting as the skill renderer — a subagent description is
887
+ // written just as freely and breaks its frontmatter the same way. See
888
+ // {@link yamlScalar}.
842
889
  const fm = [
843
890
  "---",
844
891
  "",
845
- `name: ${spec.name}`,
846
- `description: ${spec.description}`,
892
+ `name: ${yamlScalar(spec.name)}`,
893
+ `description: ${yamlScalar(spec.description)}`,
847
894
  ];
848
895
  if (spec.model !== undefined)
849
896
  fm.push(`model: ${spec.model}`);
@@ -899,7 +946,9 @@ function renderAgentSections(sections, basePath) {
899
946
  /** Render a result-contract track shape as a compact `{ "f": type, … }` line. */
900
947
  function renderShape(shape) {
901
948
  const fields = Object.entries(shape)
902
- .map(([k, t]) => `"${k}": ${t}`)
949
+ // An enum renders as the choice itself — `"verdict": "CUT" | "MERGE" | "KEEP"` — so
950
+ // the fenced rail shows a worker the same permitted values the tool schema shows.
951
+ .map(([k, t]) => `"${k}": ${typeof t === "string" ? t : t.map((v) => JSON.stringify(v)).join(" | ")}`)
903
952
  .join(", ");
904
953
  return fields ? `{ ${fields} }` : "{}";
905
954
  }
@@ -2,9 +2,21 @@
2
2
  export interface DescribedSurface {
3
3
  readonly name: string;
4
4
  readonly description: string;
5
+ /**
6
+ * Repo-relative path to report the surface at. Optional for callers that have
7
+ * no path, but REQUIRED in practice to keep the message actionable: since the
8
+ * loader started reading BOTH discovery levels, a repo that keeps a copy of a
9
+ * skill in `skills/` and `.claude/skills/` yields two surfaces with the SAME
10
+ * name, and "skills \"dup\" and \"dup\" have near-identical descriptions" names
11
+ * neither file. Measured on `nyldn/claude-octopus`: 50 such pairs. The path is
12
+ * the only thing that distinguishes them.
13
+ */
14
+ readonly where?: string;
5
15
  }
6
16
  export interface DescriptionOverlap {
17
+ /** Display label for the first skill — `"name"`, or `"name" (path)`. */
7
18
  readonly a: string;
19
+ /** Display label for the second skill — `"name"`, or `"name" (path)`. */
8
20
  readonly b: string;
9
21
  /** 0–1, higher = more alike (1 − NCD), rounded to 2 dp. */
10
22
  readonly similarity: number;
@@ -17,11 +29,5 @@ export interface DescriptionOverlap {
17
29
  * the calibrated value.
18
30
  */
19
31
  export declare const OVERLAP_NCD_CUTOFF = 0.2;
20
- /**
21
- * Find near-duplicate description pairs among `surfaces`. Returns one
22
- * {@link DescriptionOverlap} per pair whose NCD is below `cutoff`, most-similar
23
- * first. Pure; pass only the surfaces that actually compete for auto-selection
24
- * (model-invocable, described) so a user-invoked pair isn't a false alarm.
25
- */
26
32
  export declare function findDescriptionOverlaps(surfaces: readonly DescribedSurface[], cutoff?: number): DescriptionOverlap[];
27
33
  //# sourceMappingURL=description-overlap.d.ts.map
@@ -31,6 +31,10 @@ exports.OVERLAP_NCD_CUTOFF = 0.2;
31
31
  * first. Pure; pass only the surfaces that actually compete for auto-selection
32
32
  * (model-invocable, described) so a user-invoked pair isn't a false alarm.
33
33
  */
34
+ /** `"name"`, or `"name" (path)` when the caller supplied one. */
35
+ function label(s) {
36
+ return s.where === undefined ? `"${s.name}"` : `"${s.name}" (${s.where})`;
37
+ }
34
38
  function findDescriptionOverlaps(surfaces, cutoff = exports.OVERLAP_NCD_CUTOFF) {
35
39
  const overlaps = [];
36
40
  for (let i = 0; i < surfaces.length; i++) {
@@ -38,13 +42,13 @@ function findDescriptionOverlaps(surfaces, cutoff = exports.OVERLAP_NCD_CUTOFF)
38
42
  const d = (0, ncd_js_1.ncd)(surfaces[i].description, surfaces[j].description);
39
43
  if (d >= cutoff)
40
44
  continue;
41
- const a = surfaces[i].name;
42
- const b = surfaces[j].name;
45
+ const a = label(surfaces[i]);
46
+ const b = label(surfaces[j]);
43
47
  overlaps.push({
44
48
  a,
45
49
  b,
46
50
  similarity: Math.round((1 - d) * 100) / 100,
47
- message: `skills "${a}" and "${b}" have near-identical descriptions (${String(Math.round((1 - d) * 100))}% alike) — the model can't reliably tell them apart, so the wrong one may fire. Differentiate their descriptions.`,
51
+ message: `skills ${a} and ${b} have near-identical descriptions (${String(Math.round((1 - d) * 100))}% alike) — the model can't reliably tell them apart, so the wrong one may fire. Differentiate their descriptions.`,
48
52
  });
49
53
  }
50
54
  }
@@ -18,11 +18,15 @@
18
18
  * in markdown is explicitly out of scope — use eslint-plugin-markdown or
19
19
  * twoslash for that.
20
20
  */
21
+ var __importDefault = (this && this.__importDefault) || function (mod) {
22
+ return (mod && mod.__esModule) ? mod : { "default": mod };
23
+ };
21
24
  Object.defineProperty(exports, "__esModule", { value: true });
22
25
  exports.extractDocRefs = extractDocRefs;
23
26
  exports.findDocRefs = findDocRefs;
24
27
  exports.formatDocRefReport = formatDocRefReport;
25
28
  const node_fs_1 = require("node:fs");
29
+ const typescript_1 = __importDefault(require("typescript"));
26
30
  const node_path_1 = require("node:path");
27
31
  const glob_1 = require("glob");
28
32
  const linters_js_1 = require("./linters.js");
@@ -44,7 +48,7 @@ const TS_LANGS = new Set(["ts", "typescript", "js", "javascript"]);
44
48
  // doesn't accidentally disable validation.
45
49
  const IGNORE_BLOCK_RE = /^\s{0,3}<!--\s*vigiles:ignore\s*-->\s*$/;
46
50
  const IGNORE_FILE_RE = /^\s{0,3}<!--\s*vigiles:ignore-file\s*-->\s*$/m;
47
- const CALL_RE = /\b(enforce|file|cmd|ref)\(\s*["']([^"'\n]+)["']/g;
51
+ const KINDS = new Set(["enforce", "file", "cmd", "ref"]);
48
52
  const PLACEHOLDER_RE = /[<>]/;
49
53
  /**
50
54
  * Error-message patterns that mean "tool not available in this env" rather
@@ -57,6 +61,66 @@ const UNVERIFIABLE_PATTERNS = [
57
61
  /not found on PATH/i,
58
62
  /No Cedar policies found/i,
59
63
  ];
64
+ /**
65
+ * The builder calls in one fenced block, found by PARSING it rather than by
66
+ * matching text.
67
+ *
68
+ * 🔴 WHY THIS IS NOT A REGEX ANY MORE. The previous form was
69
+ * `/\b(enforce|file|cmd|ref)\(\s*["']([^"'\n]+)["']/g`, and four of these six
70
+ * inputs were classified wrongly (measured 2026-08-19):
71
+ *
72
+ * ctx.file("OUT") → matched, though it is a method on some other
73
+ * object. A live instance of this sat in a real
74
+ * repository's notes and was reported as a broken
75
+ * ref for weeks.
76
+ * obj.cmd("npm test") → matched, same reason
77
+ * // cmd("npm test") → matched, though it is a comment
78
+ * const s = 'cmd("x")' → matched, though it is a string
79
+ * myFile("x") → correctly skipped
80
+ * cmd("npm test") → correctly matched
81
+ *
82
+ * `\b` sits happily after a `.`, so every member call read as a builder call,
83
+ * and a regex has no notion of comments or string literals at all. Parsing makes
84
+ * all four INEXPRESSIBLE rather than individually patched: the AST only offers a
85
+ * call whose callee is a bare identifier, and comments and string bodies are not
86
+ * call expressions in the first place.
87
+ *
88
+ * `typescript` is already a runtime dependency of this package, so this costs no
89
+ * new install — the parser was in the box the whole time.
90
+ */
91
+ function callsIn(blockLines, file) {
92
+ if (blockLines.length === 0)
93
+ return [];
94
+ const src = blockLines.map((b) => b.text).join("\n");
95
+ const firstLine = blockLines[0].lineNo;
96
+ const sf = typescript_1.default.createSourceFile("block.ts", src, typescript_1.default.ScriptTarget.Latest,
97
+ /* setParentNodes */ true, typescript_1.default.ScriptKind.TS);
98
+ const out = [];
99
+ const visit = (node) => {
100
+ if (typescript_1.default.isCallExpression(node) && typescript_1.default.isIdentifier(node.expression)) {
101
+ const kind = node.expression.text;
102
+ if (KINDS.has(kind)) {
103
+ const arg = node.arguments[0];
104
+ // Only a plain string literal is a ref we can resolve. A template with
105
+ // substitutions, a variable, or a computed value is not something this
106
+ // pass can check, and guessing at it is how false reports start.
107
+ if (arg &&
108
+ (typescript_1.default.isStringLiteral(arg) || typescript_1.default.isNoSubstitutionTemplateLiteral(arg))) {
109
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
110
+ out.push({
111
+ file,
112
+ line: firstLine + line,
113
+ kind: kind,
114
+ value: arg.text,
115
+ });
116
+ }
117
+ }
118
+ }
119
+ typescript_1.default.forEachChild(node, visit);
120
+ };
121
+ visit(sf);
122
+ return out;
123
+ }
60
124
  /** @internal */ function extractDocRefs(content, file) {
61
125
  const lines = content.split("\n");
62
126
  const refs = [];
@@ -88,16 +152,7 @@ const UNVERIFIABLE_PATTERNS = [
88
152
  blocksIgnored++;
89
153
  }
90
154
  else {
91
- for (const { lineNo, text } of blockLines) {
92
- for (const m of text.matchAll(CALL_RE)) {
93
- refs.push({
94
- file,
95
- line: lineNo,
96
- kind: m[1],
97
- value: m[2],
98
- });
99
- }
100
- }
155
+ refs.push(...callsIn(blockLines, file));
101
156
  }
102
157
  }
103
158
  fenceChar = null;
@@ -695,8 +695,22 @@ export type OkOf<T> = T extends TypedOutcome<infer Ok, Shape> ? Ok : Shape;
695
695
  * erased `Shape`, and the value is still a plain `AgentSpec` — backwards-compatible.
696
696
  */
697
697
  export declare function agent<const P extends AuthoredPurity | undefined = undefined, V extends ToolVocabulary = OpenToolVocabulary, Ok extends Shape = Shape, Err extends Shape = Shape>(spec: AgentSpecInput<P, V, Ok, Err>): TypedAgentSpec<Ok, Err>;
698
- /** The field types a result contract can declare (kept tiny + dependency-free). */
699
- export type OutputFieldType = "string" | "number" | "boolean" | "string[]";
698
+ /**
699
+ * The field types a result contract can declare (kept tiny + dependency-free).
700
+ *
701
+ * The literal-array member is an ENUM: `["CUT", "MERGE", "KEEP"] as const` declares a
702
+ * field whose value must be one of those strings. It is the ONLY extension to this union,
703
+ * and it was added because a measured failure had no other cure: across 14 real payloads
704
+ * a `verdict: "string"` field held 3 mutually incomparable invented categories over 3
705
+ * runs, and vocabulary compliance was 3/19 — 16%. `string` cannot express "one of these",
706
+ * so nothing downstream could notice.
707
+ *
708
+ * Nothing RELATIONAL follows it — no `object[]`, no tuples, no per-element enums.
709
+ * Declaring one throws rather than rendering an unsatisfiable schema, because the body of
710
+ * a result is prose by decision: on those same 14 payloads, 23 scalar values carried
711
+ * every assertion anyone made while 80,981 characters of prose carried none.
712
+ */
713
+ export type OutputFieldType = "string" | "number" | "boolean" | "string[]" | readonly [string, ...string[]];
700
714
  /** A field SHAPE — a record of field-name → field-type, kept in the TYPE so a
701
715
  * typed pipeline can cross-reference one agent's `ok` against the next agent's
702
716
  * `needs`. The erased runtime form is `Record<string, OutputFieldType>`. */
@@ -0,0 +1,110 @@
1
+ /**
2
+ * WHERE a repo's model surfaces (skills/agents/commands) live — and the key each
3
+ * one is materialized under.
4
+ *
5
+ * 🔴 THIS EXISTS BECAUSE THE LOADER USED TO CHOOSE. It read the repo-root
6
+ * `skills/` **or** the project-level `.claude/skills/` — never both — and
7
+ * materialized whichever it picked under the SAME canonical
8
+ * `<materializeRoot>/<surface>/…` key. Two real files, one key: the loser was
9
+ * never read, and the winner's content sat under the loser's name. Measured
10
+ * 2026-08-18 on `nyldn/claude-octopus` (pinned corpus): **50 skill names exist in
11
+ * both `skills/` and `.claude/skills/`, and all 50 pairs differ** — the `.claude/`
12
+ * copies carry multi-line unquoted `description:` blocks that a strict YAML
13
+ * loader rejects. vigiles reported those fifty skills as clean without ever
14
+ * having opened the files it named.
15
+ *
16
+ * The vendor settles it — Claude Code loads BOTH, in two namespaces:
17
+ *
18
+ * > Plugin skills use a `plugin-name:skill-name` namespace, so they can't
19
+ * > conflict with other levels.
20
+ * > For example, `my-plugin/skills/deploy/SKILL.md` becomes `/my-plugin:deploy`
21
+ * > and loads alongside a `deploy` skill in your project's `.claude/skills/`.
22
+ * > — https://code.claude.com/docs/en/skills § "Where skills live"
23
+ *
24
+ * So "pick one" was never a tie-break to get right; it was a question that has no
25
+ * answer, asked because the key shape forced one. The fix removes the question:
26
+ * every scope present is read, and **a scope's key prefix is derived from the
27
+ * scope, not from a winner**, so two files can no longer claim one key.
28
+ *
29
+ * Node-free and IO-free on purpose: the disk loader (`src/plugin-loader.ts`) and
30
+ * the browser file-map twin (`src/scan-files.ts`) each probe their own storage
31
+ * and call THIS for the decision, so the pair that this repo has repeatedly been
32
+ * bitten by fixing on one side only cannot disagree about scoping.
33
+ */
34
+ import type { PluginLayout } from "./layout.js";
35
+ /**
36
+ * One discovery level, and the prefix its files are materialized under.
37
+ *
38
+ * `base` is the repo-relative dir the surfaces really live under (`""` = the repo
39
+ * root, the published-plugin shape; `.claude` = the plain-user project shape).
40
+ * `materializeUnder` is the prefix prepended to `<surface>/<rel>` to form the
41
+ * `LoadedPlugin.files` key.
42
+ */
43
+ export interface SurfaceScope {
44
+ /** Repo-relative dir holding `<surface>/…`; `""` for the repo root. */
45
+ readonly base: string;
46
+ /** Key prefix for this scope's files; `""` for none. */
47
+ readonly materializeUnder: string;
48
+ /** Human label for warnings — `plugin` (root) or `project` (`.claude/`). */
49
+ readonly label: string;
50
+ }
51
+ /** Which shape the audited target is, and every scope to read from it. */
52
+ export type SurfaceSource = {
53
+ readonly kind: "single-skill";
54
+ readonly skillName: string;
55
+ } | {
56
+ readonly kind: "scopes";
57
+ readonly scopes: readonly SurfaceScope[];
58
+ };
59
+ /** What the caller must probe on its own storage for {@link surfaceSource}. */
60
+ export interface SurfaceProbe {
61
+ /** A `<root>/SKILL.md` exists — the target IS one skill dir. */
62
+ readonly hasRootSkillFile: boolean;
63
+ /** Name to give that single skill (the target dir's basename). */
64
+ readonly skillName: string;
65
+ /** Some `<root>/<surface>/` holds a loadable file. */
66
+ readonly rootHasLoadable: boolean;
67
+ /** A plugin manifest or the hooks convention path exists. */
68
+ readonly isPluginShaped: boolean;
69
+ /** Some `<root>/<userSurfaceRoot>/<surface>/` holds a loadable file. */
70
+ readonly userHasLoadable: boolean;
71
+ }
72
+ /**
73
+ * Classify the target and list every scope to read, HIGHEST-PRECEDENCE FIRST.
74
+ *
75
+ * Precedence decides only one thing: which scope keeps the canonical
76
+ * `<materializeRoot>/…` key. The project scope takes it, because that key IS
77
+ * where a project skill lives — `.claude/skills/deploy/SKILL.md` is loaded from
78
+ * exactly that path and answers to `/deploy`. A plugin scope keeps its own real
79
+ * location (`skills/deploy/SKILL.md`), which is likewise where the harness reads
80
+ * it from, under `/plugin:deploy`. Nothing is relocated on top of something else.
81
+ *
82
+ * 🔴 THE COLLISION IS STRUCTURAL, NOT CHECKED. Only the FIRST scope is relocated;
83
+ * every later one keeps `base` as its prefix. Since the first scope is the only
84
+ * one that can produce a `<materializeRoot>/…` key, and every other prefix is a
85
+ * distinct real directory, two scopes cannot mint the same key — there is no
86
+ * ordering, no "if already taken", and no last-write-wins to get wrong.
87
+ * {@link assertDistinctScopeKeys} is the LOUD backstop for a future
88
+ * `PluginLayout` that breaks the premise (e.g. one naming `.claude` as BOTH its
89
+ * `materializeRoot` and a second scope's base).
90
+ */
91
+ export declare function surfaceSource(layout: PluginLayout, probe: SurfaceProbe): SurfaceSource;
92
+ /** The `LoadedPlugin.files` key for one file under one scope. */
93
+ export declare function scopeKey(scope: SurfaceScope, surface: string, rel: string): string;
94
+ /**
95
+ * Throw when two scopes would mint the same key prefix. Unreachable for every
96
+ * shipped layout (see {@link surfaceSource}) — it exists so a NEW layout that
97
+ * breaks the premise fails loudly at load, rather than silently dropping a
98
+ * surface file the way the shadowing bug did for a year.
99
+ */
100
+ export declare function assertDistinctScopeKeys(scopes: readonly SurfaceScope[], layoutName: string): void;
101
+ /**
102
+ * The warning to emit when more than one scope is present. Both scopes load in a
103
+ * real session under DIFFERENT names, but the deterministic sandbox is a project
104
+ * dir — it registers the project scope only, so a plugin-scope skill sitting at
105
+ * `skills/…` in the fixture never activates there (the footgun
106
+ * `unregisteredSkillFiles` already warns about for inline arm files). Say so,
107
+ * rather than quietly relocating one on top of the other.
108
+ */
109
+ export declare function multiScopeWarning(scopes: readonly SurfaceScope[], counts: Record<string, number>): string | undefined;
110
+ //# sourceMappingURL=surface-scopes.d.ts.map