vigiles 14.2.0 → 14.4.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.
package/README.md CHANGED
@@ -278,7 +278,7 @@ Targets Claude Code and Codex out of the box, or [your own harness](docs/authori
278
278
  The **[docs index](docs/README.md)** is the full map, grouped by what you're doing:
279
279
 
280
280
  - **Guides** — [verify instruction files](docs/verifying-instruction-files.md) · [test your harness](docs/harness-testing.md) · [measure a skill](docs/measuring-skills.md) · [ship a plugin](docs/for-plugin-authors.md) · [Codex & other harnesses](docs/harnesses.md)
281
- - **Reference** — [CLI](docs/cli.md) · [rules matrix](docs/verifying-instruction-files.md#the-validation-rules--the-full-matrix) · [testing API](docs/testing-api.md) · [full API](https://zernie.github.io/vigiles/)
281
+ - **Reference** — [CLI](docs/cli.md) · [rules matrix](docs/verifying-instruction-files.md#the-validation-rules--the-full-matrix) · [testing API](docs/testing-api.md) · [full API](https://zernie.github.io/vigiles/api/)
282
282
  - **Explanation** — [what it catches](docs/what-vigiles-catches.md) · [how it compares](docs/comparison.md) · [FAQ](docs/faq.md)
283
283
 
284
284
  **Project** — [Stability](STABILITY.md) · [Related tools](docs/comparison.md#what-vigiles-composes-with)
@@ -68,6 +68,17 @@ export declare function installCodexSkills(pluginDir: string, cwd: string): numb
68
68
  * codexEvalDriver })` dispatches through.
69
69
  */
70
70
  export declare function codexEvalAgentRunner(args: AgentRunArgs): Promise<RunOut>;
71
+ /**
72
+ * Why Codex trigger-rate is EXPERIMENTAL (not a supported measurement). Codex
73
+ * has NO skill-selection event, so `codexSkillFired` infers firing from whether
74
+ * the model READ `skills/<name>/SKILL.md` — which is wrong in BOTH directions: a
75
+ * cached/already-in-context skill isn't re-read (false NEGATIVE), and an
76
+ * exploratory read while listing isn't a real fire (false POSITIVE). So the
77
+ * number can be off either way. Deterministic `vigiles audit` is fully supported
78
+ * on Codex; only this behavioral tier is experimental until a live run measures
79
+ * the oracle's accuracy vs ground truth. See docs/harness-testing-codex.md.
80
+ */
81
+ export declare const CODEX_TRIGGER_RATE_EXPERIMENTAL: string;
71
82
  /**
72
83
  * The Codex eval driver — pass to `measureTriggerRate(spec, { evalDriver:
73
84
  * codexEvalDriver })` to run a trigger-rate eval natively on `codex exec`. Pair
@@ -27,7 +27,7 @@
27
27
  * not be re-read); pair with a behavioral/judged check for certainty.
28
28
  */
29
29
  Object.defineProperty(exports, "__esModule", { value: true });
30
- exports.codexEvalDriver = void 0;
30
+ exports.codexEvalDriver = exports.CODEX_TRIGGER_RATE_EXPERIMENTAL = void 0;
31
31
  exports.parseCodexEvalRun = parseCodexEvalRun;
32
32
  exports.codexRunError = codexRunError;
33
33
  exports.codexSkillFired = codexSkillFired;
@@ -186,6 +186,20 @@ function codexEvalAgentRunner(args) {
186
186
  timeoutMs: args.timeoutMs,
187
187
  }));
188
188
  }
189
+ /**
190
+ * Why Codex trigger-rate is EXPERIMENTAL (not a supported measurement). Codex
191
+ * has NO skill-selection event, so `codexSkillFired` infers firing from whether
192
+ * the model READ `skills/<name>/SKILL.md` — which is wrong in BOTH directions: a
193
+ * cached/already-in-context skill isn't re-read (false NEGATIVE), and an
194
+ * exploratory read while listing isn't a real fire (false POSITIVE). So the
195
+ * number can be off either way. Deterministic `vigiles audit` is fully supported
196
+ * on Codex; only this behavioral tier is experimental until a live run measures
197
+ * the oracle's accuracy vs ground truth. See docs/harness-testing-codex.md.
198
+ */
199
+ exports.CODEX_TRIGGER_RATE_EXPERIMENTAL = "Codex trigger-rate is experimental — Codex has no skill-selection event, so " +
200
+ "firing is inferred from a SKILL.md read (cache → false negative, exploratory " +
201
+ "read → false positive). Treat as directional, not a measurement. Deterministic " +
202
+ "`vigiles audit` is fully supported on Codex.";
189
203
  /**
190
204
  * The Codex eval driver — pass to `measureTriggerRate(spec, { evalDriver:
191
205
  * codexEvalDriver })` to run a trigger-rate eval natively on `codex exec`. Pair
@@ -198,6 +212,8 @@ exports.codexEvalDriver = {
198
212
  // The harness identity → folded into the trigger-rate lock hash, so a report
199
213
  // recorded on Claude Code is STALE if the eval is switched to Codex (and v.v.).
200
214
  harness: "codex",
215
+ // Codex-only: the trigger-rate number is not validated (see the constant above).
216
+ experimental: exports.CODEX_TRIGGER_RATE_EXPERIMENTAL,
201
217
  };
202
218
  /**
203
219
  * Spawn real `codex exec --json` for the eval tier (real model, the user's codex
@@ -14,8 +14,10 @@
14
14
  * (`lethalTrifectaIssues` → `report.trifectaFindings`): a unit holding all three
15
15
  * capability legs is a prompt-injection exfil path detectable from the tool-SET
16
16
  * alone — nothing executes, so it sidesteps the confinement blocker. A `"hard"`
17
- * (explicit all-three) finding is GRADED into the overall; a `"advisory"`
18
- * (inherits-all) finding is SHOWN in the ring but not graded. NB the EXECUTING
17
+ * (explicit all-three) finding is GRADED into the overall at a REDUCED weight
18
+ * (`W_TRIFECTA=10`, HALF the old 20 a DING, not a fail: it dents the grade
19
+ * without a catastrophic F for a pattern official plugins ship by design); a
20
+ * `"advisory"` (inherits-all) finding is SHOWN in the ring but not graded. NB the EXECUTING
19
21
  * "do your hooks actually block?" disaster-battery is STILL not an `audit` ring:
20
22
  * running arbitrary hooks safely needs cross-platform confinement that isn't
21
23
  * shipped yet, so the battery lives in the `vigiles/testing` API via
@@ -18,8 +18,10 @@ exports.formatAuditScore = formatAuditScore;
18
18
  * (`lethalTrifectaIssues` → `report.trifectaFindings`): a unit holding all three
19
19
  * capability legs is a prompt-injection exfil path detectable from the tool-SET
20
20
  * alone — nothing executes, so it sidesteps the confinement blocker. A `"hard"`
21
- * (explicit all-three) finding is GRADED into the overall; a `"advisory"`
22
- * (inherits-all) finding is SHOWN in the ring but not graded. NB the EXECUTING
21
+ * (explicit all-three) finding is GRADED into the overall at a REDUCED weight
22
+ * (`W_TRIFECTA=10`, HALF the old 20 a DING, not a fail: it dents the grade
23
+ * without a catastrophic F for a pattern official plugins ship by design); a
24
+ * `"advisory"` (inherits-all) finding is SHOWN in the ring but not graded. NB the EXECUTING
23
25
  * "do your hooks actually block?" disaster-battery is STILL not an `audit` ring:
24
26
  * running arbitrary hooks safely needs cross-platform confinement that isn't
25
27
  * shipped yet, so the battery lives in the `vigiles/testing` API via
@@ -154,9 +156,10 @@ function structure(r) {
154
156
  };
155
157
  }
156
158
  /**
157
- * SAFETY — fed by the STATIC lethal-trifecta check (`report.trifectaFindings`).
158
- * A `"hard"` finding (an explicit contract naming all three capability legs) is a
159
- * declared prompt-injection exfil path and is GRADED (`W_TRIFECTA` each, the same
159
+ * SAFETY — fed by the STATIC lethal-trifecta check (`report.trifectaFindings`), a
160
+ * GRADED ring. A `"hard"` finding (an explicit contract naming all three
161
+ * capability legs) is a prompt-injection exfil path and is GRADED at a REDUCED
162
+ * weight (`W_TRIFECTA=10` each — HALF the old 20, a DING not a fail; the same
160
163
  * weight `reportDeductions` sums into the overall, so the ring and the headline
161
164
  * agree). A `"advisory"` finding (inherits-all) is SHOWN in the ring's findings
162
165
  * but NOT graded — aligned with the inherits-all-is-advisory stance.
@@ -25,6 +25,15 @@ exports.skillResourceIssues = skillResourceIssues;
25
25
  * MISSING a real ref over emitting a false positive — a noisy resource check
26
26
  * would teach users to ignore it.
27
27
  *
28
+ * The INLINE-CODE path form is weaker than a link, so it carries an extra prose
29
+ * gate: a skill that TEACHES how to build skills mentions bundle paths
30
+ * constantly as EXAMPLES of what a skill *could* ship ("a `scripts/rotate.py`
31
+ * would be helpful to store", "**Examples**: `references/finance.md`"). An
32
+ * inline path is treated as a real reference ONLY when the line DIRECTS the
33
+ * agent to use the file (read/run/see/…) and carries no illustrative cue
34
+ * (example / e.g. / such as / would be / template / →). Markdown links are
35
+ * unchanged — a link is already an act-on-it reference. See `inlinePathIsUsed`.
36
+ *
28
37
  * Pure: the only IO is an injectable `existsSync` (default node:fs), mirroring
29
38
  * core/refs.ts and the loader so the detector is testable with a fake.
30
39
  */
@@ -119,6 +128,46 @@ function isInlineBundlePath(token) {
119
128
  const normalized = t.replace(/^\.\//, "");
120
129
  return BUNDLE_PREFIX.test(normalized) && HAS_EXT.test(normalized);
121
130
  }
131
+ // ---------------------------------------------------------------------------
132
+ // Inline-path prose gate (don't-cry-wolf on TEACHING / illustrative skills)
133
+ // ---------------------------------------------------------------------------
134
+ //
135
+ // An inline-code bundle path (`` `scripts/foo.py` ``) is a much WEAKER signal
136
+ // than a markdown link — skills that TEACH how to build skills (e.g. the
137
+ // official `skill-development` skill) are full of bundle paths used as
138
+ // EXAMPLES of what a skill *could* contain, not as references to a file the
139
+ // skill actually ships: "a `scripts/rotate_pdf.py` would be helpful to store",
140
+ // "**Examples**: `references/finance.md` …", "- **`references/patterns.md`** —
141
+ // Common patterns". Flagging those as "bundled resource not found" cries wolf
142
+ // and graded a clean, correct skill an F.
143
+ //
144
+ // So an inline path is only treated as a real reference when the surrounding
145
+ // prose DIRECTS the agent to ACT on the file (read/run/see/…) AND carries no
146
+ // illustrative/hypothetical cue. Markdown links (`[text](path)`) are unchanged
147
+ // — a link is already a high-confidence, follow-me reference. We bias HARD
148
+ // toward precision here: missing a real dead-ref is far better than a false
149
+ // positive on a teaching skill (the same don't-cry-wolf discipline as the
150
+ // loader's `danglingRefs`).
151
+ // Verbs that direct the agent to CONSUME an existing file. Deliberately EXCLUDES
152
+ // authoring verbs (store/create/add/move/write) — "a `scripts/x.py` would be
153
+ // helpful to STORE in the skill" is describing a resource to CREATE, exactly
154
+ // what a teaching skill illustrates, not a file the skill already ships.
155
+ const USE_DIRECTIVE = /\b(run|runs|execute|executes|read|reads|load|loads|open|opens|source|sources|import|imports|see|view|refer|follow|call|invoke|apply|consult|check)\b/i;
156
+ // Illustrative / hypothetical prose cues — a line carrying one is describing
157
+ // what a skill MIGHT contain (an example, a suggestion, a template), not
158
+ // pointing at a shipped file. Includes the `→`/`->` arrow used in "move detail
159
+ // → `references/x.md`" authoring lists.
160
+ const ILLUSTRATIVE_CUE = /\b(example|examples|e\.g\.?|i\.e\.?|such as|for instance|would be|helpful|useful|template|boilerplate)\b|→|->/i;
161
+ /**
162
+ * Whether an inline bundle-path on this line reads as a REAL reference (the
163
+ * agent is told to use the file) rather than an illustrative mention. Requires
164
+ * a positive use-directive and the absence of an illustrative cue — both
165
+ * evaluated over the whole line for simplicity (a tight, precise rule over a
166
+ * clever one). Only the inline-path branch consults this; markdown links do not.
167
+ */
168
+ function inlinePathIsUsed(line) {
169
+ return USE_DIRECTIVE.test(line) && !ILLUSTRATIVE_CUE.test(line);
170
+ }
122
171
  /** Collect candidate bundled-resource refs from one body line, skipping fences. */
123
172
  function candidatesInLine(line, lineNo) {
124
173
  const out = [];
@@ -129,14 +178,18 @@ function candidatesInLine(line, lineNo) {
129
178
  out.push({ ref: m[1].trim(), resolved, kind: "link", line: lineNo });
130
179
  }
131
180
  }
132
- // Inline-code path mentions: only the high-confidence bundle-dir-prefixed form.
133
- for (const m of line.matchAll(INLINE_SPAN)) {
134
- const token = m[1].trim();
135
- if (!isInlineBundlePath(token))
136
- continue;
137
- const resolved = localResourceTarget(token);
138
- if (resolved !== null) {
139
- out.push({ ref: token, resolved, kind: "path", line: lineNo });
181
+ // Inline-code path mentions: only the high-confidence bundle-dir-prefixed
182
+ // form, AND only when the surrounding prose USES the file (not an illustrative
183
+ // mention on a teaching skill — see `inlinePathIsUsed`).
184
+ if (inlinePathIsUsed(line)) {
185
+ for (const m of line.matchAll(INLINE_SPAN)) {
186
+ const token = m[1].trim();
187
+ if (!isInlineBundlePath(token))
188
+ continue;
189
+ const resolved = localResourceTarget(token);
190
+ if (resolved !== null) {
191
+ out.push({ ref: token, resolved, kind: "path", line: lineNo });
192
+ }
140
193
  }
141
194
  }
142
195
  return out;
@@ -43,9 +43,27 @@ export declare function eventsMissingFromBundle(bundle: string, events: readonly
43
43
  * we only read files the user already installed under their own CC license.
44
44
  */
45
45
  export declare function findClaudeCodePackage(): string | null;
46
+ /**
47
+ * Extract the semver core from a `claude --version` line
48
+ * (e.g. `"2.1.211 (Claude Code)"` → `"2.1.211"`). Pure; null when absent.
49
+ */
50
+ export declare function parseClaudeVersion(raw: string): string | null;
51
+ /**
52
+ * The version of the `claude` binary actually on PATH — which can DIFFER from the
53
+ * package {@link findClaudeCodePackage} locates. In the native-binary era CC ships
54
+ * as a platform binary with no readable `sdk-tools.d.ts`, so a box can carry a
55
+ * stale leftover `@anthropic-ai/claude-code` in a global `node_modules` (readable,
56
+ * but months old and NOT what's running) beside the real, newer CC. Reconciling
57
+ * against this stops the drift alarm crying wolf on that leftover. Real IO; null
58
+ * when `claude` isn't runnable.
59
+ */
60
+ export declare function onPathClaudeVersion(): string | null;
46
61
  /** A runtime drift report: how the INSTALLED CC's tool surface compares to ours. */
47
62
  export interface DialectDriftReport {
63
+ /** Version of the located `@anthropic-ai/claude-code` package (its types we read). */
48
64
  readonly installedVersion: string;
65
+ /** Version of the `claude` binary on PATH, or null — the actually-running CC. */
66
+ readonly runningVersion: string | null;
49
67
  readonly validatedVersion: string;
50
68
  /** Tool-input types present in the install but not in ACKNOWLEDGED (CC added). */
51
69
  readonly newToolTypes: string[];
@@ -5,6 +5,8 @@ exports.parseToolInputTypes = parseToolInputTypes;
5
5
  exports.findClaudeCodeBundle = findClaudeCodeBundle;
6
6
  exports.eventsMissingFromBundle = eventsMissingFromBundle;
7
7
  exports.findClaudeCodePackage = findClaudeCodePackage;
8
+ exports.parseClaudeVersion = parseClaudeVersion;
9
+ exports.onPathClaudeVersion = onPathClaudeVersion;
8
10
  exports.checkDialectDrift = checkDialectDrift;
9
11
  exports.formatDialectDrift = formatDialectDrift;
10
12
  /**
@@ -36,7 +38,13 @@ exports.formatDialectDrift = formatDialectDrift;
36
38
  * NOT a readable `cli.js` JS bundle. So the old "grep hook-event string literals out
37
39
  * of cli.js" check has no bundle to read and degrades to a LOUD SKIP (see
38
40
  * `findClaudeCodeBundle`); `sdk-tools.d.ts` is still shipped, so the tool-type drift
39
- * alarm keeps working.
41
+ * alarm keeps working. But the native binary means a box can carry a STALE leftover
42
+ * `@anthropic-ai/claude-code` JS package (readable `sdk-tools.d.ts`, months old) in a
43
+ * global `node_modules` while the real, newer CC runs from the native binary — so
44
+ * `findClaudeCodePackage` can locate a package that ISN'T what's running. The runtime
45
+ * alarm (`checkDialectDrift`/`formatDialectDrift`) reconciles the located package
46
+ * version against the on-PATH `claude --version` and SUPPRESSES the warning on a
47
+ * mismatch (the located types don't describe the running CC — crying wolf otherwise).
40
48
  *
41
49
  * Pure parsers (testable with fixtures) + a local-install locator. TWO consumers:
42
50
  * the gated CI test in `dialect-drift.test.ts` (fails loud on tool/event drift), and
@@ -167,6 +175,31 @@ function findClaudeCodePackage() {
167
175
  }
168
176
  return null;
169
177
  }
178
+ /**
179
+ * Extract the semver core from a `claude --version` line
180
+ * (e.g. `"2.1.211 (Claude Code)"` → `"2.1.211"`). Pure; null when absent.
181
+ */
182
+ function parseClaudeVersion(raw) {
183
+ const m = raw.match(/\b(\d+\.\d+\.\d+)\b/);
184
+ return m ? m[1] : null;
185
+ }
186
+ /**
187
+ * The version of the `claude` binary actually on PATH — which can DIFFER from the
188
+ * package {@link findClaudeCodePackage} locates. In the native-binary era CC ships
189
+ * as a platform binary with no readable `sdk-tools.d.ts`, so a box can carry a
190
+ * stale leftover `@anthropic-ai/claude-code` in a global `node_modules` (readable,
191
+ * but months old and NOT what's running) beside the real, newer CC. Reconciling
192
+ * against this stops the drift alarm crying wolf on that leftover. Real IO; null
193
+ * when `claude` isn't runnable.
194
+ */
195
+ function onPathClaudeVersion() {
196
+ try {
197
+ return parseClaudeVersion((0, node_child_process_1.execSync)("claude --version", { encoding: "utf-8" }));
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ }
170
203
  /**
171
204
  * Best-effort, read-local drift check for `scan` (and other runtime callers). Reads
172
205
  * only the small `sdk-tools.d.ts` (fast — no `cli.js` bundle scan; events are the
@@ -191,6 +224,7 @@ function checkDialectDrift() {
191
224
  }
192
225
  return {
193
226
  installedVersion,
227
+ runningVersion: onPathClaudeVersion(),
194
228
  validatedVersion: exports.VALIDATED_CC_VERSION,
195
229
  newToolTypes: [...installed].filter((t) => !ack.has(t)).sort(),
196
230
  removedToolTypes: [...ack].filter((t) => !installed.has(t)).sort(),
@@ -202,7 +236,15 @@ function checkDialectDrift() {
202
236
  }
203
237
  /** A one-line freshness warning if the dialect drifted from the install, else null. */
204
238
  function formatDialectDrift(r) {
205
- if (!r || (r.newToolTypes.length === 0 && r.removedToolTypes.length === 0))
239
+ if (!r)
240
+ return null;
241
+ // The located `sdk-tools.d.ts` belongs to a DIFFERENT install than the CC
242
+ // actually running (a stale leftover npm package beside a newer native binary),
243
+ // so its tool set says nothing about the CC you're on — don't cry wolf. The
244
+ // pinned CI drift TEST is where real drift is gated.
245
+ if (r.runningVersion && r.runningVersion !== r.installedVersion)
246
+ return null;
247
+ if (r.newToolTypes.length === 0 && r.removedToolTypes.length === 0)
206
248
  return null;
207
249
  const parts = [];
208
250
  if (r.newToolTypes.length > 0)
package/dist/eval.d.ts CHANGED
@@ -733,6 +733,13 @@ export interface TriggerRateReport {
733
733
  readonly errored?: number;
734
734
  /** Cost / tokens SPENT across all runs (relevant + irrelevant) — feeds the cost summary. */
735
735
  readonly usage: ArmUsage;
736
+ /**
737
+ * Present when the driver's harness measures trigger-rate on an EXPERIMENTAL
738
+ * basis (copied from {@link EvalDriver.experimental}) — the number is not
739
+ * validated and can be wrong. `formatTriggerRateReport` prints it as a loud
740
+ * caveat. Absent = a supported, trustworthy measurement (Claude Code).
741
+ */
742
+ readonly experimental?: string;
736
743
  }
737
744
  /**
738
745
  * An eval-tier transport: how to RUN a real harness turn and PARSE its output.
@@ -754,6 +761,16 @@ export interface EvalDriver {
754
761
  * `"claude-code"`, so an existing single-harness lock is unaffected.
755
762
  */
756
763
  readonly harness?: string;
764
+ /**
765
+ * When set, this driver's trigger-rate number is EXPERIMENTAL and not
766
+ * validated — the string is the human caveat explaining why (e.g. Codex has no
767
+ * skill-selection event, so firing is inferred from a SKILL.md read, which can
768
+ * be wrong in both directions). Absent = supported/trustworthy (the default,
769
+ * Claude Code). `measureTriggerRate` copies it onto the report and warns; the
770
+ * formatter prints it. Precision-first: never let a possibly-wrong number read
771
+ * as a measurement.
772
+ */
773
+ readonly experimental?: string;
757
774
  }
758
775
  /**
759
776
  * The default (Claude Code) eval driver: real `claude` + stream-json parsing.
package/dist/eval.js CHANGED
@@ -1730,7 +1730,16 @@ async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runE
1730
1730
  */
1731
1731
  async function measureTriggerRate(spec, opts = {}) {
1732
1732
  const d = opts.evalDriver ?? exports.claudeEvalDriver;
1733
- const report = await measureTriggerRateWith(spec, d.runner, d.parse, d.runError, d.harness ?? "claude-code");
1733
+ const measured = await measureTriggerRateWith(spec, d.runner, d.parse, d.runError, d.harness ?? "claude-code");
1734
+ // Precision-first: if the driver flags its trigger-rate EXPERIMENTAL (Codex),
1735
+ // carry the caveat onto the report and warn loudly, so the number is never
1736
+ // mistaken for a validated measurement.
1737
+ const report = d.experimental
1738
+ ? { ...measured, experimental: d.experimental }
1739
+ : measured;
1740
+ if (d.experimental) {
1741
+ process.stderr.write(`⚠ EXPERIMENTAL trigger-rate on ${d.harness ?? "?"}: ${d.experimental}\n`);
1742
+ }
1734
1743
  // Surface what the run spent (tokens + API-equivalent $ + metered warning).
1735
1744
  (0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromArm)(report.usage));
1736
1745
  // Feed the flight recorder: recall (+ precision when measured) for this skill.
@@ -1755,7 +1764,11 @@ async function measureTriggerRate(spec, opts = {}) {
1755
1764
  /** Format a trigger-rate report: overall %, then each prompt's rate. */
1756
1765
  function formatTriggerRateReport(report) {
1757
1766
  const pct = (report.rate * 100).toFixed(0);
1758
- const lines = [`trigger-rate: ${pct}% (${String(report.n)} runs)`];
1767
+ const lines = [];
1768
+ if (report.experimental) {
1769
+ lines.push(`⚠ EXPERIMENTAL — ${report.experimental}`);
1770
+ }
1771
+ lines.push(`trigger-rate: ${pct}% (${String(report.n)} runs)`);
1759
1772
  for (const p of report.perPrompt) {
1760
1773
  lines.push(` ${p.rate.toFixed(2)} ${p.prompt.slice(0, 60)}`);
1761
1774
  }
@@ -26,7 +26,7 @@ export declare const W_NO_DESCRIPTION = 10;
26
26
  export declare const W_DANGLING_REF = 8;
27
27
  export declare const W_OVERLAP = 8;
28
28
  export declare const W_NO_CONTRACT = 5;
29
- export declare const W_TRIFECTA = 20;
29
+ export declare const W_TRIFECTA = 10;
30
30
  /** Map a 0–100 structural-health score to its letter grade (A ≥90 … F <60). */
31
31
  export declare function gradeFor(score: number): PluginScore["grade"];
32
32
  /** One deduction: a count, its per-item weight, and the label if non-zero. */
@@ -49,10 +49,12 @@ exports.W_NO_DESCRIPTION = 10; // a skill with no usable description → can't t
49
49
  exports.W_DANGLING_REF = 8; // a referenced intra-plugin file that's missing → broken path
50
50
  exports.W_OVERLAP = 8; // a description collision → the wrong skill fires
51
51
  exports.W_NO_CONTRACT = 5; // generic small-footgun weight (disallowedTools typo, invalid model/color)
52
- exports.W_TRIFECTA = 20; // a HARD lethal-trifecta contract (all three legs, explicit) → a declared prompt-injection exfil path
52
+ exports.W_TRIFECTA = 10; // a HARD lethal-trifecta contract (all three legs, explicit) → a prompt-injection exfil path. HALF the old 20: a DING, not a fail — a trifecta is a real risk worth surfacing in the grade, but official plugins ship the pattern by design, so it dents the score (e.g. feature-dev's 3 hard units → −30 → C) without a catastrophic F.
53
53
  // Two things are advisory, NOT graded penalties (shown, never scored — see scoreReport):
54
54
  // - untested surfaces — a hardening gap, not breakage.
55
55
  // - an agent that inherits all tools (no `tools:` line) — see reportDeductions for why.
56
+ // - an inherits-all (severity "advisory") trifecta finding — shown by the Safety
57
+ // ring but never scored; only the HARD, explicit all-three-legs finding grades.
56
58
  /** Map a 0–100 structural-health score to its letter grade (A ≥90 … F <60). */
57
59
  function gradeFor(score) {
58
60
  if (score >= 90)
@@ -79,9 +81,11 @@ function reportDeductions(r) {
79
81
  const deadMcpTools = r.agents.reduce((n, a) => n + a.mcpToolIssues.length, 0);
80
82
  const deadDisallowed = r.agents.reduce((n, a) => n + a.disallowedToolIssues.length, 0);
81
83
  // HARD lethal-trifecta findings only — an EXPLICIT contract naming all three
82
- // legs (a declared exfil path). Advisory (inherits-all) trifecta findings are
83
- // surfaced but NEVER graded (aligned with the inherits-all stance), so they're
84
- // excluded here.
84
+ // legs (a prompt-injection exfil path). Graded at W_TRIFECTA=10 (HALF the old
85
+ // 20): a DING that surfaces a real risk in the grade without a catastrophic F
86
+ // for an accepted design pattern official plugins ship. Advisory (inherits-all)
87
+ // trifecta findings are surfaced but NEVER graded (aligned with the inherits-all
88
+ // stance), so they're excluded here.
85
89
  const hardTrifecta = r.trifectaFindings.filter((f) => f.finding.severity === "hard").length;
86
90
  return [
87
91
  {
@@ -283,13 +287,13 @@ function formatLeaderboard(scores) {
283
287
  const issue = s.issues.length > 0 ? ` — ${s.issues.join("; ")}` : "";
284
288
  out.push(` ${rank} ${score} ${s.grade} ${s.name}${issue}`);
285
289
  });
286
- out.push("", "Structural health only (no model). Weights: lethal-trifecta unit -20, missing", "hook -15, no-description skill -10, broken intra-plugin ref -8, dead tool/MCP", "ref -8. Inherit-all subagents and untested surfaces are advisory — shown, not", "scored.");
290
+ out.push("", "Structural health only (no model). Weights: missing hook -15, hard lethal-", "trifecta unit -10, no-description skill -10, broken intra-plugin ref -8, dead", "tool/MCP ref -8. Inherit-all subagents, inherits-all trifecta and untested", "surfaces are advisory — shown, not scored.");
287
291
  return out.join("\n");
288
292
  }
289
- const LEADERBOARD_METHOD = "_Structural health only (deterministic, no model): lethal-trifecta unit20, " +
290
- "missing hook15, no-description skill −10, broken intra-plugin / dead-tool ref −8. " +
291
- "Inherit-all subagents and untested surfaces are advisory (shown, not " +
292
- "scored). Behavioural columns (trigger-rate, collisions, egress) stack on top._";
293
+ const LEADERBOARD_METHOD = "_Structural health only (deterministic, no model): missing hook15, hard " +
294
+ "lethal-trifecta unit10, no-description skill −10, broken intra-plugin / dead-tool ref −8. " +
295
+ "Inherit-all subagents, inherits-all trifecta and untested surfaces are advisory " +
296
+ "(shown, not scored). Behavioural columns (trigger-rate, collisions, egress) stack on top._";
293
297
  /**
294
298
  * Format a ranked leaderboard as a Markdown table — the PUBLISHABLE form (a README,
295
299
  * a gist, the leaderboard site). Shows the top 2 deductions per plugin; the full
@@ -40,6 +40,13 @@ export interface BehavioralReport {
40
40
  /** False when the `claude` CLI / auth is absent — the column couldn't run. */
41
41
  readonly available: boolean;
42
42
  readonly results: readonly SkillTriggerResult[];
43
+ /**
44
+ * Set when the driving harness measures trigger-rate on an EXPERIMENTAL basis
45
+ * (copied from {@link EvalDriver.experimental}) — Codex, whose firing is
46
+ * inferred from a SKILL.md read and can be wrong. {@link formatBehavioralReport}
47
+ * prints it as a loud caveat above the numbers. Absent = supported (Claude Code).
48
+ */
49
+ readonly experimental?: string;
43
50
  }
44
51
  export interface ProbeOptions {
45
52
  readonly concurrency?: number;
@@ -165,6 +165,7 @@ async function probePluginTriggersWith(dir, promptSet, probe, opts = {}) {
165
165
  return {
166
166
  available: true,
167
167
  results: relabelTriggerArtifact(dir, probe, results),
168
+ experimental: probe.evalDriver.experimental,
168
169
  };
169
170
  }
170
171
  /** Relabel an all-zero-recall stubbed run on a hooked plugin as unmeasured (Layer 1). */
@@ -197,6 +198,11 @@ function formatBehavioralReport(b) {
197
198
  return "Behavioral (trigger-rate): no model-invocable skills to probe";
198
199
  }
199
200
  const lines = ["Behavioral (trigger-rate):"];
201
+ // Codex-only: the trigger-rate number is not validated (no skill-selection
202
+ // event → firing inferred from a SKILL.md read). Say so loudly, above the numbers.
203
+ if (b.experimental) {
204
+ lines.push(` ⚠ EXPERIMENTAL — ${b.experimental}`);
205
+ }
200
206
  for (const r of b.results) {
201
207
  if (!r.measured) {
202
208
  lines.push(` · ${r.skill} — unmeasured (${r.note ?? "skipped"})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "14.2.0",
3
+ "version": "14.4.0",
4
4
  "description": "Lint & test the harness your AI agent runs on — verify the references in your CLAUDE.md / AGENTS.md and test that your hooks and skills actually work.",
5
5
  "keywords": [
6
6
  "claude-code",