vigiles 18.1.2 → 19.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.
@@ -61,7 +61,7 @@ export declare const SKIP_EXIT_CODE = 77;
61
61
  * `assertNoWrite` already draws: "nobody looked" must not read as "nothing
62
62
  * happened".
63
63
  */
64
- export declare function statusFor(code: number, checks: number | undefined): ScriptStatus;
64
+ export declare function statusFor(code: number, checks: number | undefined, output?: string): ScriptStatus;
65
65
  /** Filename extensions accepted for harness/eval scripts (JS and TS). */
66
66
  export declare const SCRIPT_EXTS: readonly ["mjs", "cjs", "js", "mts", "cts", "ts"];
67
67
  /** Glob suffix matching every accepted script extension, e.g. `harness`. */
@@ -60,13 +60,47 @@ exports.SKIP_EXIT_CODE = 77;
60
60
  * `assertNoWrite` already draws: "nobody looked" must not read as "nothing
61
61
  * happened".
62
62
  */
63
- function statusFor(code, checks) {
63
+ function statusFor(code, checks, output) {
64
64
  if (code === exports.SKIP_EXIT_CODE)
65
65
  return "skip";
66
66
  if (code !== 0)
67
- return "fail";
67
+ return didNotLoad(output) ? "skip" : "fail";
68
68
  return checks === 0 ? "vacuous" : "pass";
69
69
  }
70
+ /**
71
+ * Did the script fail to LOAD, rather than fail?
72
+ *
73
+ * 🔴 The distinction is not cosmetic, because `fail` RETRACTS coverage while
74
+ * `skip` does not (see the table on `runsFromResults`). The rule for `fail` —
75
+ * "it ran and proved nothing, so it must not keep yesterday's green record
76
+ * alive" — is right, and it does not describe a file the runtime never
77
+ * evaluated. A module that cannot resolve executed no assertion; it is the same
78
+ * "did not run" as a missing `claude` CLI, arriving through a different door.
79
+ *
80
+ * MEASURED, twice in one session on 2026-08-20: a container restore left an old
81
+ * `node_modules` behind, every harness in the consumer repo died on `Named export
82
+ * 'recordCheck' not found`, and the ledger dropped from 48 records to 34 and from
83
+ * 47 to 33. Nothing about those surfaces had changed — the machine had.
84
+ *
85
+ * ⚠️ This does NOT make a broken environment quiet. A skip still prints `⊘
86
+ * SKIPPED`, and `--no-skip` — which this repo's own CI passes — still fails the
87
+ * run. What changes is only whether a machine problem is allowed to delete a
88
+ * measurement taken on a machine that worked.
89
+ *
90
+ * Deliberately literal, and only the loader's own vocabulary: these strings come
91
+ * from Node's module resolution, not from user code. A test that legitimately
92
+ * asserts on one of them exits 0 or asserts, and never reaches here.
93
+ */
94
+ function didNotLoad(output) {
95
+ if (output === undefined || output === "")
96
+ return false;
97
+ return (output.includes("ERR_MODULE_NOT_FOUND") ||
98
+ output.includes("Cannot find package") ||
99
+ output.includes("Cannot find module") ||
100
+ /SyntaxError: Named export '[^']*' not found/.test(output) ||
101
+ output.includes("ERR_UNSUPPORTED_DIR_IMPORT") ||
102
+ output.includes("ERR_PACKAGE_PATH_NOT_EXPORTED"));
103
+ }
70
104
  /** Filename extensions accepted for harness/eval scripts (JS and TS). */
71
105
  exports.SCRIPT_EXTS = ["mjs", "cjs", "js", "mts", "cts", "ts"];
72
106
  /** Glob suffix matching every accepted script extension, e.g. `harness`. */
@@ -225,12 +259,13 @@ async function runScripts(files, cwd, env = {}, opts = {}) {
225
259
  child.stdout.on("data", (c) => chunks.push(c));
226
260
  child.stderr.on("data", (c) => chunks.push(c));
227
261
  const finish = (code) => {
228
- process.stdout.write(Buffer.concat(chunks));
262
+ const output = Buffer.concat(chunks).toString("utf8");
263
+ process.stdout.write(output);
229
264
  const report = readCheckReport(countFile);
230
265
  resolveRun({
231
266
  file,
232
267
  code,
233
- status: statusFor(code, report?.checks),
268
+ status: statusFor(code, report?.checks, output),
234
269
  checks: report?.checks,
235
270
  ...(report ? { surfaces: report.surfaces } : {}),
236
271
  });
package/dist/cli.js CHANGED
@@ -701,11 +701,14 @@ function lintExitCode(report) {
701
701
  report.hookBlockErrors > 0 ||
702
702
  report.hookMatcherErrors > 0 ||
703
703
  report.symbolRefErrors > 0 ||
704
- report.mcpRefErrors > 0)
705
- return 2;
706
- if (report.duplicatePairs > 0 ||
707
- report.orphanCount > 0 ||
704
+ report.mcpRefErrors > 0 ||
705
+ // `doc-refs` is opt-in and this counter is only non-zero when the user set
706
+ // it to "error", so it belongs in the hard tier with every other explicit
707
+ // error it used to sit at exit 1 because it fired unasked and could not be
708
+ // turned off.
708
709
  report.docRefErrors > 0)
710
+ return 2;
711
+ if (report.duplicatePairs > 0 || report.orphanCount > 0)
709
712
  return 1;
710
713
  // Guidance counts are informational, not failures
711
714
  return 0;
@@ -1135,11 +1138,28 @@ async function runLint(restArgs, flags, config) {
1135
1138
  // typo, an uncompilable or unreachable MCP pattern, one too narrow for real
1136
1139
  // server naming, or an undeclared MCP server).
1137
1140
  const hookMatcher = checkHookMatcher(config, silent, adapter, scanRoot);
1138
- // 8. Validate vigiles builder calls inside markdown code blocks. Default
1139
- // is to validate every ref; illustrative blocks opt out via
1140
- // `<!-- vigiles:ignore -->` (single block) or
1141
- // `<!-- vigiles:ignore-file -->` (whole file). Same engine as spec.ts.
1142
- if (!silent)
1141
+ // 8. Validate vigiles builder calls inside markdown code blocks — the
1142
+ // `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
1143
+ // `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
1144
+ // (whole file). Same engine as spec.ts.
1145
+ //
1146
+ // 🔴 WHY OFF BY DEFAULT — the measurement, not a preference. Run across two
1147
+ // real repositories on 2026-08-19: 2 582 markdown files, 52 builder refs,
1148
+ // **0 true positives**, and every error it had ever raised was false — a
1149
+ // design note writing `cmd("npm test")` for a package that doesn't have that
1150
+ // script yet, a third-party `CLAUDE.md` captured verbatim as benchmark data.
1151
+ // The cause is structural: a fenced block in prose is a DRAWING of config, and
1152
+ // this pass read it as config. The consumer repo reached a clean `lint` only by
1153
+ // excluding a third of itself, after which the pass walked 604 files and found
1154
+ // 0 refs — fully inert, still paying for the walk. So it is now opt-in, and
1155
+ // when off the walk does not happen at all (that is the whole subtraction).
1156
+ //
1157
+ // ⚠️ Known gap for whoever improves it before flipping this back: the walker
1158
+ // globs without `dot`, so `.claude/**` — where real skills and agents live —
1159
+ // has never been scanned. The rule has never once looked at a deployed
1160
+ // instruction file; every ref it has ever judged was in prose.
1161
+ const docRefSeverity = (0, types_js_1.ruleSeverity)(config?.rules?.["doc-refs"]);
1162
+ if (!silent && docRefSeverity)
1143
1163
  console.log("\nMarkdown code block refs:\n");
1144
1164
  // 🔴 `config.exclude` REACHES THIS PASS. It did not, and that single omission is
1145
1165
  // why `lint` could not exit 0 on a repository that vendors other people's
@@ -1154,12 +1174,20 @@ async function runLint(restArgs, flags, config) {
1154
1174
  // With no way to reach 0 the step was made `continue-on-error: true`, and a lint
1155
1175
  // whose exit code is discarded gates nothing — after which hand-written CI steps
1156
1176
  // 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
- });
1161
- if (!silent) {
1162
- for (const line of (0, doc_refs_js_1.formatDocRefReport)(docRefReport).split("\n")) {
1177
+ const docRefReport = docRefSeverity
1178
+ ? (0, doc_refs_js_1.findDocRefs)({ basePath: process.cwd(), ignore: config?.exclude })
1179
+ : {
1180
+ filesScanned: 0,
1181
+ filesIgnored: 0,
1182
+ blocksIgnored: 0,
1183
+ refs: [],
1184
+ errors: [],
1185
+ unverified: 0,
1186
+ placeholders: 0,
1187
+ };
1188
+ if (!silent && docRefSeverity) {
1189
+ const rendered = (0, doc_refs_js_1.formatDocRefReport)(docRefReport, docRefSeverity === "error" ? "error" : "warn");
1190
+ for (const line of rendered.split("\n")) {
1163
1191
  console.log(` ${line}`);
1164
1192
  }
1165
1193
  }
@@ -1168,9 +1196,9 @@ async function runLint(restArgs, flags, config) {
1168
1196
  // Previously this check reported to stdout only; the inline/spec checks already
1169
1197
  // annotate per-line, so this closes the gap that left doc-ref findings invisible
1170
1198
  // on the PR. CI-only (isGitHubActions); skipped under --json/--summary.
1171
- if (isGitHubActions() && !silent) {
1199
+ if (isGitHubActions() && !silent && docRefSeverity) {
1172
1200
  for (const e of docRefReport.errors) {
1173
- ghAnnotate("error", `${e.kind}("${e.value}") — ${e.message}`, e.file, e.line);
1201
+ ghAnnotate(docRefSeverity === "error" ? "error" : "warning", `${e.kind}("${e.value}") — ${e.message}`, e.file, e.line);
1174
1202
  }
1175
1203
  }
1176
1204
  // 9. Verify code-shaped symbol references live (see src/refs.ts).
@@ -1234,7 +1262,10 @@ async function runLint(restArgs, flags, config) {
1234
1262
  hookBlockErrors: hookBlock.errors,
1235
1263
  hookMatcherIssues: hookMatcher.issues,
1236
1264
  hookMatcherErrors: hookMatcher.errors,
1237
- docRefErrors: docRefReport.errors.length,
1265
+ // Only the "error" tier gates. At "warn" the findings are printed and
1266
+ // annotated, and the exit code is untouched — same contract as every other
1267
+ // opt-in rule here.
1268
+ docRefErrors: docRefSeverity === "error" ? docRefReport.errors.length : 0,
1238
1269
  symbolRefErrors,
1239
1270
  mcpRefErrors,
1240
1271
  files,
@@ -55,6 +55,12 @@ interface ExtractResult {
55
55
  */
56
56
  export declare function findDocRefs(options?: FindDocRefsOptions): DocRefReport;
57
57
  /** Format a DocRefReport as human-readable text. */
58
- export declare function formatDocRefReport(report: DocRefReport): string;
58
+ export declare function formatDocRefReport(report: DocRefReport,
59
+ /**
60
+ * The severity `doc-refs` is configured at. A `✗` next to an exit code of 0
61
+ * reads as a gate that failed to gate, so the marker follows the severity the
62
+ * user actually set rather than always claiming the hard tier.
63
+ */
64
+ severity?: "error" | "warn"): string;
59
65
  export {};
60
66
  //# sourceMappingURL=doc-refs.d.ts.map
@@ -272,7 +272,13 @@ function findDocRefs(options = {}) {
272
272
  };
273
273
  }
274
274
  /** Format a DocRefReport as human-readable text. */
275
- function formatDocRefReport(report) {
275
+ function formatDocRefReport(report,
276
+ /**
277
+ * The severity `doc-refs` is configured at. A `✗` next to an exit code of 0
278
+ * reads as a gate that failed to gate, so the marker follows the severity the
279
+ * user actually set rather than always claiming the hard tier.
280
+ */
281
+ severity = "error") {
276
282
  const lines = [];
277
283
  const meta = [];
278
284
  if (report.filesIgnored > 0)
@@ -288,7 +294,7 @@ function formatDocRefReport(report) {
288
294
  lines.push(`${String(report.refs.length)} vigiles refs in code blocks${report.errors.length === 0 ? " — all valid" : ""}`);
289
295
  if (report.errors.length === 0)
290
296
  return lines.join("\n");
291
- lines.push(`✗ ${String(report.errors.length)} broken ref(s):`);
297
+ lines.push(`${severity === "error" ? "✗" : "ℹ"} ${String(report.errors.length)} broken ref(s):`);
292
298
  for (const e of report.errors.slice(0, 12)) {
293
299
  const trunc = e.message.length > 80 ? `${e.message.slice(0, 80)}…` : e.message;
294
300
  lines.push(` ${e.file}:${String(e.line)} ${e.kind}("${e.value}") — ${trunc}`);
@@ -262,6 +262,10 @@ export interface HookProgram<N extends readonly NeedSpec[] = readonly ProviderNa
262
262
  export declare const tool: (name: string) => {
263
263
  tool: string;
264
264
  };
265
+ /**
266
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
267
+ * Imported as `experimental_defineHook`; alias it at the import site.
268
+ */
265
269
  export declare function defineHook<const N extends readonly NeedSpec[] = readonly []>(p: HookProgram<N>): HookProgram<N>;
266
270
  /**
267
271
  * Build the typed event from a raw PreToolUse event, then decide.
@@ -506,6 +510,10 @@ export interface FileGateHook<N extends readonly NeedSpec[] = readonly ProviderN
506
510
  export declare const tools: (...names: string[]) => {
507
511
  tools: string[];
508
512
  };
513
+ /**
514
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
515
+ * Imported as `experimental_defineFileGate`; alias it at the import site.
516
+ */
509
517
  export declare function defineFileGate<const N extends readonly NeedSpec[] = readonly []>(p: Omit<FileGateHook<N>, "role">): FileGateHook<N>;
510
518
  /**
511
519
  * Run a file-tool gate against a raw PreToolUse event (reads `file_path`).
@@ -542,6 +550,10 @@ export interface PromptGateHook<N extends readonly NeedSpec[] = readonly Provide
542
550
  /** Decide over the prompt. `deny` blocks/erases the prompt; `ask` defers to the user. */
543
551
  readonly decide: (e: PromptEvent<N>) => Decision;
544
552
  }
553
+ /**
554
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
555
+ * Imported as `experimental_definePromptGate`; alias it at the import site.
556
+ */
545
557
  export declare function definePromptGate<const N extends readonly NeedSpec[] = readonly []>(p: Omit<PromptGateHook<N>, "role">): PromptGateHook<N>;
546
558
  /** Run a prompt gate against a raw UserPromptSubmit event (reads `prompt`). */
547
559
  export declare function decidePromptGate<N extends readonly NeedSpec[]>(hook: PromptGateHook<N>, raw: {
@@ -573,6 +585,10 @@ export interface StopGateHook<N extends readonly NeedSpec[] = readonly ProviderN
573
585
  /** Decide whether the agent may stop. `deny` keeps it going; `allow` lets it stop. */
574
586
  readonly decide: (e: StopEvent<N>) => Decision;
575
587
  }
588
+ /**
589
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
590
+ * Imported as `experimental_defineStopGate`; alias it at the import site.
591
+ */
576
592
  export declare function defineStopGate<const N extends readonly NeedSpec[] = readonly []>(p: Omit<StopGateHook<N>, "role">): StopGateHook<N>;
577
593
  /** Run a Stop gate against a raw Stop/SubagentStop event (reads `stop_hook_active`). */
578
594
  export declare function decideStopGate<N extends readonly NeedSpec[]>(hook: StopGateHook<N>, raw: {
@@ -618,6 +634,10 @@ export interface InjectHook<N extends readonly NeedSpec[] = readonly ProviderNam
618
634
  /** Produces context to add. Its return type (Injection) has no `deny` — by design. */
619
635
  readonly produce: (e: SessionEvent<N>) => Injection;
620
636
  }
637
+ /**
638
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
639
+ * Imported as `experimental_defineInject`; alias it at the import site.
640
+ */
621
641
  export declare function defineInject<const N extends readonly NeedSpec[] = readonly []>(p: Omit<InjectHook<N>, "role">): InjectHook<N>;
622
642
  /**
623
643
  * Run an inject hook → the CC JSON the author never hand-writes. The compiler
@@ -719,6 +739,10 @@ export interface ReactHook<N extends readonly NeedSpec[] = readonly ProviderName
719
739
  /** Reacts to a tool that already ran. Returns a Reaction — NO `deny` exists here. */
720
740
  readonly react: (e: ReactEvent<N>) => Reaction;
721
741
  }
742
+ /**
743
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
744
+ * Imported as `experimental_defineReact`; alias it at the import site.
745
+ */
722
746
  export declare function defineReact<const N extends readonly NeedSpec[] = readonly []>(p: Omit<ReactHook<N>, "role">): ReactHook<N>;
723
747
  /**
724
748
  * Run a react hook against a raw PostToolUse event → the (classified) Reaction.
@@ -535,6 +535,10 @@ function commandView(raw, root) {
535
535
  }
536
536
  const tool = (name) => ({ tool: name });
537
537
  exports.tool = tool;
538
+ /**
539
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
540
+ * Imported as `experimental_defineHook`; alias it at the import site.
541
+ */
538
542
  function defineHook(p) {
539
543
  return p;
540
544
  }
@@ -913,6 +917,10 @@ const tools = (...names) => ({
913
917
  tools: names,
914
918
  });
915
919
  exports.tools = tools;
920
+ /**
921
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
922
+ * Imported as `experimental_defineFileGate`; alias it at the import site.
923
+ */
916
924
  function defineFileGate(p) {
917
925
  return { role: "gate", ...p };
918
926
  }
@@ -940,6 +948,10 @@ function decideFileGate(hook, raw, ctx = {}, root = typeof raw.cwd === "string"
940
948
  ctx: ctx,
941
949
  });
942
950
  }
951
+ /**
952
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
953
+ * Imported as `experimental_definePromptGate`; alias it at the import site.
954
+ */
943
955
  function definePromptGate(p) {
944
956
  return { role: "prompt-gate", ...p };
945
957
  }
@@ -952,6 +964,10 @@ function decidePromptGate(hook, raw, ctx = {}) {
952
964
  ctx: ctx,
953
965
  });
954
966
  }
967
+ /**
968
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
969
+ * Imported as `experimental_defineStopGate`; alias it at the import site.
970
+ */
955
971
  function defineStopGate(p) {
956
972
  return { role: "stop-gate", ...p };
957
973
  }
@@ -977,6 +993,10 @@ const inject = (context, ...records) => ({
977
993
  records,
978
994
  });
979
995
  exports.inject = inject;
996
+ /**
997
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
998
+ * Imported as `experimental_defineInject`; alias it at the import site.
999
+ */
980
1000
  function defineInject(p) {
981
1001
  return { role: "inject", ...p };
982
1002
  }
@@ -1055,6 +1075,10 @@ const nothing = (...records) => ({
1055
1075
  records,
1056
1076
  });
1057
1077
  exports.nothing = nothing;
1078
+ /**
1079
+ * @experimental Compiled hooks are provisional — see docs/experimental.md.
1080
+ * Imported as `experimental_defineReact`; alias it at the import site.
1081
+ */
1058
1082
  function defineReact(p) {
1059
1083
  return { role: "react", ...p };
1060
1084
  }
@@ -143,6 +143,14 @@ exports.RULE_META = {
143
143
  detector: "hookMatcherIssues",
144
144
  upstreamPrevention: "compiled hook tool()/tools() matcher is typed",
145
145
  },
146
+ "doc-refs": {
147
+ id: "doc-refs",
148
+ bucket: "external-decidable",
149
+ surface: ["docs"],
150
+ defaultSeverity: "off",
151
+ summary: "Builder calls quoted in markdown ```ts fences resolve for real (opt-in: a fence is usually a drawing of config, not config).",
152
+ detector: "findDocRefs",
153
+ },
146
154
  "prefer-compiled-hooks": {
147
155
  id: "prefer-compiled-hooks",
148
156
  bucket: "heuristic-behavioral",
@@ -210,6 +210,44 @@ const ILLUSTRATIVE_CUE = /\b(example|examples|e\.g\.?|i\.e\.?|such as|for instan
210
210
  * net is affordable where a blanket one is not.
211
211
  */
212
212
  const MD_HEADING = /^\s{0,3}#{1,6}\s/;
213
+ /**
214
+ * Whether the line describes a file the skill PRODUCES rather than one it ships.
215
+ *
216
+ * 🔴 WHY THIS IS SEPARATE FROM {@link ILLUSTRATIVE_CUE}. That one vetoes a
217
+ * HYPOTHETICAL mention — "a `scripts/rotate.py` would be helpful". This one
218
+ * vetoes a mention that is entirely real and entirely concrete, and still names
219
+ * a file that cannot be a bundled resource, because the skill's own prose says
220
+ * the file is written at runtime. Same veto, different reason, and collapsing
221
+ * them into one list would make the next reader think a gitignored cache is a
222
+ * kind of example.
223
+ *
224
+ * It is the read the module already takes elsewhere: {@link USE_DIRECTIVE}
225
+ * deliberately EXCLUDES authoring verbs (store/create/add/move/write) because
226
+ * "a file to CREATE" is not "a file the skill already ships". A cache written
227
+ * by the skill's own script is that same case; the verb list simply never
228
+ * covered the writing side.
229
+ *
230
+ * MEASURED 2026-08-20 on a real consumer skill (`verify-citations`), which read:
231
+ *
232
+ * API responses are cached to `scripts/.cite-cache.json` (gitignored) so
233
+ * re-runs are cheap.
234
+ *
235
+ * That produced `bundled resource not found`. Isolated to one word with a
236
+ * three-line fixture differing only in its tail: the same sentence ending
237
+ * "so subsequent invocations are cheap" stayed silent, and the same sentence
238
+ * with no tail stayed silent. The culprit is `re-runs` — JavaScript's `\b`
239
+ * counts a hyphen as a word boundary, so `USE_DIRECTIVE`'s `runs` matches
240
+ * INSIDE it, and a plural noun about repetition was read as an instruction to
241
+ * run the file.
242
+ *
243
+ * Fixing the hyphen instead was rejected by that same measurement: "re-run
244
+ * `scripts/x.sh` before submitting" is a genuine directive, so a rule about
245
+ * hyphens would trade this false positive for a false negative. The honest
246
+ * discriminator is not the hyphen, it is that the line says the file is made,
247
+ * not read. `gitignored` is the strongest form — a file the author states is
248
+ * outside the repository cannot be shipped inside it.
249
+ */
250
+ const GENERATED_FILE_CUE = /\bgit-?ignored\b|\.gitignore\b|\bcached? to\b|\bcache file\b|\bwrit(?:ten|es) to\b|\b(?:generated|created) at runtime\b/i;
213
251
  /**
214
252
  * Whether a bundle-path reference on this line reads as a REAL reference (the
215
253
  * agent is told to use the file) rather than an illustrative mention. Requires
@@ -222,7 +260,7 @@ const MD_HEADING = /^\s{0,3}#{1,6}\s/;
222
260
  * real dead ref.
223
261
  */
224
262
  function inlinePathIsUsed(line) {
225
- if (ILLUSTRATIVE_CUE.test(line))
263
+ if (ILLUSTRATIVE_CUE.test(line) || GENERATED_FILE_CUE.test(line))
226
264
  return false;
227
265
  return USE_DIRECTIVE.test(line) || MD_HEADING.test(line);
228
266
  }
@@ -241,7 +279,7 @@ function candidateFor(ref, line) {
241
279
  // Suppress it ONLY on an illustrative cue; do NOT also require a use
242
280
  // directive, or a plain `Resources: [API](references/api.md)` (no verb)
243
281
  // goes unchecked (Codex review — that under-detection).
244
- if (ILLUSTRATIVE_CUE.test(line))
282
+ if (ILLUSTRATIVE_CUE.test(line) || GENERATED_FILE_CUE.test(line))
245
283
  return null;
246
284
  const resolved = localResourceTarget(ref.value);
247
285
  if (resolved === null)
@@ -321,6 +321,25 @@ export interface RulesConfig {
321
321
  * docs/rules/hook-matcher.md.
322
322
  */
323
323
  "hook-matcher"?: RuleSeverity;
324
+ /**
325
+ * Validate vigiles-builder calls quoted inside ```ts fences in markdown —
326
+ * `enforce()` / `file()` / `cmd()` / `ref()` — against the real linter catalog,
327
+ * filesystem and package scripts.
328
+ *
329
+ * DEFAULT OFF, and the default is the finding. Measured on two repositories
330
+ * 2026-08-19 (2 582 markdown files, 52 refs): **zero true positives, and every
331
+ * error it has ever raised was a false one** — a design sketch writing
332
+ * `cmd("npm test")` for a package that doesn't exist yet, or a third-party
333
+ * `CLAUDE.md` captured verbatim as benchmark data. The reason is structural,
334
+ * not calibration: a fenced block in prose is a DRAWING of config, and this
335
+ * rule reads it as config. The consumer repo could only reach a clean `lint`
336
+ * by excluding a third of itself, after which the pass scanned 604 files and
337
+ * found 0 refs — inert, and still paying for the walk.
338
+ *
339
+ * Turn it on where markdown really is the source (a docs site whose fences are
340
+ * copy-pasted into live specs). See docs/rules/doc-refs.md.
341
+ */
342
+ "doc-refs"?: RuleSeverity;
324
343
  }
325
344
  /** Extract severity from a rule value (handles both simple and tuple forms). */
326
345
  export declare function ruleSeverity<T>(rule: RuleWithOptions<T> | undefined): RuleSeverity;
@@ -98,6 +98,12 @@ exports.DEFAULT_RULES = {
98
98
  // that matches no tool name, or one too narrow for real server names) — WARN
99
99
  // by default (high-precision); raise to error to gate CI.
100
100
  "hook-matcher": "warn",
101
+ // Builder calls quoted inside ```ts fences in markdown — default OFF. Measured
102
+ // over 2 582 markdown files across two repos: 52 refs, 0 true positives, and
103
+ // every error raised was a false one (a design sketch's `cmd("npm test")`, a
104
+ // vendored third-party CLAUDE.md). A fence in prose is a DRAWING of config;
105
+ // reading it as config is the defect. Opt in where markdown IS the source.
106
+ "doc-refs": false,
101
107
  };
102
108
  const DEFAULT_CONFIG = {
103
109
  ruleMarkers: ["headings", "checkboxes"],
package/dist/hook.d.ts CHANGED
@@ -45,7 +45,7 @@
45
45
  * tool calls. A gate is a strong default, never an unbypassable wall. See
46
46
  * `docs/compiled-hooks.md`.
47
47
  */
48
- export { defineHook, defineFileGate, definePromptGate, defineStopGate, tool, tools, allow, deny, ask, commandView, pathView, gateAction, hookMode, defineInject, inject, defineReact, run, notice, nothing, responseView, decideProgram, decideFileGate, decidePromptGate, decideStopGate, runInject, runReact, runHookProgram, decisionExitCode, dispatchKind, hookRouting, hookNeeds, injectionOf, outcomeWrites, matchesTool, invalidToolPatterns, compileHookProgram, checkHookImports, stampHook, verifyHookStamp, HookCompileError, } from "./core/hook-program.js";
48
+ export { defineHook as experimental_defineHook, defineFileGate as experimental_defineFileGate, definePromptGate as experimental_definePromptGate, defineStopGate as experimental_defineStopGate, tool, tools, allow, deny, ask, commandView, pathView, gateAction, hookMode, defineInject as experimental_defineInject, inject, defineReact as experimental_defineReact, run, notice, nothing, responseView, decideProgram, decideFileGate, decidePromptGate, decideStopGate, runInject, runReact, runHookProgram, decisionExitCode, dispatchKind, hookRouting, hookNeeds, injectionOf, outcomeWrites, matchesTool, invalidToolPatterns, compileHookProgram, checkHookImports, stampHook, verifyHookStamp, HookCompileError, } from "./core/hook-program.js";
49
49
  export type { Decision, HookMode, GateAction, CommandView, PathView, ResponseView, BashToolEvent, FileToolEvent, PromptEvent, StopEvent, ReactEvent, SessionEvent, HookProgram, FileGateHook, PromptGateHook, StopGateHook, InjectHook, ReactHook, AnyHook, DispatchKind, Injection, Reaction, RunReaction, CompiledHookProgram, CompileHookOptions, RawHookEvent, HookProgramOutcome, } from "./core/hook-program.js";
50
50
  export { provide, dangerously, defineProvider, provider, } from "./core/hook-providers.js";
51
51
  export { state, record, stateFact, isValidStateKey, isStateNeed, isStateWrite, admissibleWrites, durationSeconds, HookStateError, } from "./core/hook-state.js";
package/dist/hook.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isStateWrite = exports.isStateNeed = exports.isValidStateKey = exports.stateFact = exports.record = exports.state = exports.provider = exports.defineProvider = exports.dangerously = exports.provide = exports.HookCompileError = exports.verifyHookStamp = exports.stampHook = exports.checkHookImports = exports.compileHookProgram = exports.invalidToolPatterns = exports.matchesTool = exports.outcomeWrites = exports.injectionOf = exports.hookNeeds = exports.hookRouting = exports.dispatchKind = exports.decisionExitCode = exports.runHookProgram = exports.runReact = exports.runInject = exports.decideStopGate = exports.decidePromptGate = exports.decideFileGate = exports.decideProgram = exports.responseView = exports.nothing = exports.notice = exports.run = exports.defineReact = exports.inject = exports.defineInject = exports.hookMode = exports.gateAction = exports.pathView = exports.commandView = exports.ask = exports.deny = exports.allow = exports.tools = exports.tool = exports.defineStopGate = exports.definePromptGate = exports.defineFileGate = exports.defineHook = void 0;
3
+ exports.isStateWrite = exports.isStateNeed = exports.isValidStateKey = exports.stateFact = exports.record = exports.state = exports.provider = exports.defineProvider = exports.dangerously = exports.provide = exports.HookCompileError = exports.verifyHookStamp = exports.stampHook = exports.checkHookImports = exports.compileHookProgram = exports.invalidToolPatterns = exports.matchesTool = exports.outcomeWrites = exports.injectionOf = exports.hookNeeds = exports.hookRouting = exports.dispatchKind = exports.decisionExitCode = exports.runHookProgram = exports.runReact = exports.runInject = exports.decideStopGate = exports.decidePromptGate = exports.decideFileGate = exports.decideProgram = exports.responseView = exports.nothing = exports.notice = exports.run = exports.experimental_defineReact = exports.inject = exports.experimental_defineInject = exports.hookMode = exports.gateAction = exports.pathView = exports.commandView = exports.ask = exports.deny = exports.allow = exports.tools = exports.tool = exports.experimental_defineStopGate = exports.experimental_definePromptGate = exports.experimental_defineFileGate = exports.experimental_defineHook = void 0;
4
4
  exports.leafCommandsNormalized = exports.HookStateError = exports.durationSeconds = exports.admissibleWrites = void 0;
5
5
  /**
6
6
  * `vigiles/hook` — the **closed vocabulary** for authoring a compiled hook.
@@ -50,11 +50,19 @@ exports.leafCommandsNormalized = exports.HookStateError = exports.durationSecond
50
50
  * `docs/compiled-hooks.md`.
51
51
  */
52
52
  var hook_program_js_1 = require("./core/hook-program.js");
53
- // gate vocabulary
54
- Object.defineProperty(exports, "defineHook", { enumerable: true, get: function () { return hook_program_js_1.defineHook; } });
55
- Object.defineProperty(exports, "defineFileGate", { enumerable: true, get: function () { return hook_program_js_1.defineFileGate; } });
56
- Object.defineProperty(exports, "definePromptGate", { enumerable: true, get: function () { return hook_program_js_1.definePromptGate; } });
57
- Object.defineProperty(exports, "defineStopGate", { enumerable: true, get: function () { return hook_program_js_1.defineStopGate; } });
53
+ // ── the six ENTRY POINTS carry the experimental marking ─────────────────────
54
+ // Same shape as `experimental_skill`, and for the same stated reason: every
55
+ // other name in this file `allow`, `deny`, `tool`, `pathView`, `state`,
56
+ // `record` is reachable ONLY from inside a `define*` call, so prefixing the
57
+ // entry points makes the marking structural for the whole vocabulary. A
58
+ // per-name prefix could not guarantee that; a chokepoint can.
59
+ //
60
+ // Import them aliased, so the word crosses the package boundary exactly once:
61
+ // import { experimental_defineInject as defineInject, state } from "vigiles/hook";
62
+ Object.defineProperty(exports, "experimental_defineHook", { enumerable: true, get: function () { return hook_program_js_1.defineHook; } });
63
+ Object.defineProperty(exports, "experimental_defineFileGate", { enumerable: true, get: function () { return hook_program_js_1.defineFileGate; } });
64
+ Object.defineProperty(exports, "experimental_definePromptGate", { enumerable: true, get: function () { return hook_program_js_1.definePromptGate; } });
65
+ Object.defineProperty(exports, "experimental_defineStopGate", { enumerable: true, get: function () { return hook_program_js_1.defineStopGate; } });
58
66
  Object.defineProperty(exports, "tool", { enumerable: true, get: function () { return hook_program_js_1.tool; } });
59
67
  Object.defineProperty(exports, "tools", { enumerable: true, get: function () { return hook_program_js_1.tools; } });
60
68
  Object.defineProperty(exports, "allow", { enumerable: true, get: function () { return hook_program_js_1.allow; } });
@@ -65,10 +73,10 @@ Object.defineProperty(exports, "pathView", { enumerable: true, get: function ()
65
73
  Object.defineProperty(exports, "gateAction", { enumerable: true, get: function () { return hook_program_js_1.gateAction; } });
66
74
  Object.defineProperty(exports, "hookMode", { enumerable: true, get: function () { return hook_program_js_1.hookMode; } });
67
75
  // inject vocabulary
68
- Object.defineProperty(exports, "defineInject", { enumerable: true, get: function () { return hook_program_js_1.defineInject; } });
76
+ Object.defineProperty(exports, "experimental_defineInject", { enumerable: true, get: function () { return hook_program_js_1.defineInject; } });
69
77
  Object.defineProperty(exports, "inject", { enumerable: true, get: function () { return hook_program_js_1.inject; } });
70
78
  // react vocabulary
71
- Object.defineProperty(exports, "defineReact", { enumerable: true, get: function () { return hook_program_js_1.defineReact; } });
79
+ Object.defineProperty(exports, "experimental_defineReact", { enumerable: true, get: function () { return hook_program_js_1.defineReact; } });
72
80
  Object.defineProperty(exports, "run", { enumerable: true, get: function () { return hook_program_js_1.run; } });
73
81
  Object.defineProperty(exports, "notice", { enumerable: true, get: function () { return hook_program_js_1.notice; } });
74
82
  Object.defineProperty(exports, "nothing", { enumerable: true, get: function () { return hook_program_js_1.nothing; } });
@@ -123,7 +123,7 @@ export declare const WORKFLOW_RULES: readonly ["require-instructions-spec", "unt
123
123
  * keep their own default severities. Named for the group taxonomy
124
124
  * (research/install-enforcement-dx.md).
125
125
  */
126
- export declare const NUDGE_RULES: readonly ["skill-description-budget", "frontmatter-valid", "skill-frontmatter", "prefer-compiled-hooks", "unmarked-refs", "lethal-trifecta", "skill-resource-resolves", "skill-missing-fence", "plugin-dir-layout", "delegation-trifecta", "hook-block-ineffective", "hook-matcher"];
126
+ export declare const NUDGE_RULES: readonly ["skill-description-budget", "frontmatter-valid", "skill-frontmatter", "prefer-compiled-hooks", "unmarked-refs", "lethal-trifecta", "skill-resource-resolves", "skill-missing-fence", "plugin-dir-layout", "delegation-trifecta", "hook-block-ineffective", "hook-matcher", "doc-refs"];
127
127
  export declare function mergeProjectConfig(existing: Record<string, unknown>, opts: {
128
128
  harness: string | string[];
129
129
  strict: boolean;
@@ -140,6 +140,10 @@ exports.NUDGE_RULES = [
140
140
  "delegation-trifecta",
141
141
  "hook-block-ineffective",
142
142
  "hook-matcher",
143
+ // Default OFF for a measured reason, not a rollout one: 0 true positives over
144
+ // 2 582 markdown files, because a fence in prose is a drawing of config rather
145
+ // than config. See docs/rules/doc-refs.md.
146
+ "doc-refs",
143
147
  ];
144
148
  function mergeProjectConfig(existing, opts) {
145
149
  const config = { ...existing };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "18.1.2",
3
+ "version": "19.0.0",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",