vigiles 10.0.0 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/README.md +121 -86
  3. package/action.yml +13 -2
  4. package/dist/adapter-conformance.js +6 -0
  5. package/dist/adapter-registry.d.ts +20 -0
  6. package/dist/adapter-registry.js +27 -0
  7. package/dist/adapters/claude-code/dialect.js +15 -0
  8. package/dist/adapters/claude-code/hook-protocol.js +4 -0
  9. package/dist/adapters/claude-code/runtime.js +12 -0
  10. package/dist/adapters/codex/eval.js +3 -0
  11. package/dist/adapters/codex/hook-protocol.d.ts +9 -1
  12. package/dist/adapters/codex/hook-protocol.js +10 -0
  13. package/dist/adapters/codex/runtime.js +10 -0
  14. package/dist/adapters/opencode/runtime.js +4 -0
  15. package/dist/audit-report.d.ts +1 -1
  16. package/dist/audit-report.template.html +1 -1
  17. package/dist/audit-score.d.ts +19 -12
  18. package/dist/audit-score.js +65 -11
  19. package/dist/cli-commands.d.ts +1 -1
  20. package/dist/cli-commands.js +1 -0
  21. package/dist/cli.js +460 -29
  22. package/dist/core/CLAUDE.md.spec.d.ts +3 -0
  23. package/dist/core/CLAUDE.md.spec.js +26 -0
  24. package/dist/core/delegation-trifecta.d.ts +64 -0
  25. package/dist/core/delegation-trifecta.js +124 -0
  26. package/dist/core/dialect.d.ts +18 -0
  27. package/dist/core/hook-block-ineffective.d.ts +62 -0
  28. package/dist/core/hook-block-ineffective.js +153 -0
  29. package/dist/core/hook-matcher.d.ts +66 -0
  30. package/dist/core/hook-matcher.js +182 -0
  31. package/dist/core/hook-normalize.d.ts +43 -0
  32. package/dist/core/hook-normalize.js +78 -0
  33. package/dist/core/hook-protocol.d.ts +15 -0
  34. package/dist/core/lethal-trifecta.d.ts +100 -0
  35. package/dist/core/lethal-trifecta.js +197 -0
  36. package/dist/core/plugin-dir-layout.d.ts +30 -0
  37. package/dist/core/plugin-dir-layout.js +73 -0
  38. package/dist/core/rule-meta.d.ts +82 -0
  39. package/dist/core/rule-meta.js +266 -0
  40. package/dist/core/runtime.d.ts +20 -0
  41. package/dist/core/skill-missing-fence.d.ts +47 -0
  42. package/dist/core/skill-missing-fence.js +119 -0
  43. package/dist/core/skill-resources.d.ts +27 -0
  44. package/dist/core/skill-resources.js +167 -0
  45. package/dist/core/types.d.ts +83 -0
  46. package/dist/core/validate.d.ts +1 -0
  47. package/dist/core/validate.js +26 -4
  48. package/dist/eval-cache.d.ts +6 -0
  49. package/dist/eval-cache.js +2 -0
  50. package/dist/eval-lock.d.ts +192 -0
  51. package/dist/eval-lock.js +286 -0
  52. package/dist/eval.d.ts +33 -20
  53. package/dist/eval.js +199 -51
  54. package/dist/leaderboard.d.ts +1 -0
  55. package/dist/leaderboard.js +42 -4
  56. package/dist/scan.d.ts +106 -0
  57. package/dist/scan.js +251 -45
  58. package/dist/setup-plan.d.ts +43 -3
  59. package/dist/setup-plan.js +78 -6
  60. package/hooks/eval-lock-nudge.sh +21 -0
  61. package/package.json +1 -1
  62. package/skills/test-harness/SKILL.md +27 -0
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ const generate_types_js_1 = require("./core/generate-types.js");
17
17
  const generate_harness_js_1 = require("./core/generate-harness.js");
18
18
  const capability_diff_js_1 = require("./core/capability-diff.js");
19
19
  const validate_js_1 = require("./core/validate.js");
20
+ const eval_lock_js_1 = require("./eval-lock.js");
20
21
  const cli_flags_js_1 = require("./cli-flags.js");
21
22
  const setup_plan_js_1 = require("./setup-plan.js");
22
23
  const types_js_1 = require("./core/types.js");
@@ -678,6 +679,13 @@ function lintExitCode(report) {
678
679
  report.frontmatterValidErrors > 0 ||
679
680
  report.mcpHookErrors > 0 ||
680
681
  report.preferCompiledHookErrors > 0 ||
682
+ report.lethalTrifectaErrors > 0 ||
683
+ report.skillResourceErrors > 0 ||
684
+ report.skillFenceErrors > 0 ||
685
+ report.pluginLayoutErrors > 0 ||
686
+ report.delegationTrifectaErrors > 0 ||
687
+ report.hookBlockErrors > 0 ||
688
+ report.hookMatcherErrors > 0 ||
681
689
  report.symbolRefErrors > 0 ||
682
690
  report.mcpRefErrors > 0)
683
691
  return 2;
@@ -1028,6 +1036,29 @@ async function runLint(restArgs, flags, config) {
1028
1036
  // 7n. Prefer-compiled-hooks — ONE discovery nudge (not per-hook) toward
1029
1037
  // compiled `vigiles/hook` artifacts when hand-written hooks ship. Recommendation.
1030
1038
  const preferCompiledHooks = checkPreferCompiledHooks(config, silent, adapter);
1039
+ // 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
1040
+ // hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
1041
+ // prompt-injection exfil path (Rule of Two). Capability SET-intersection.
1042
+ const lethalTrifecta = checkLethalTrifecta(config, silent, adapter);
1043
+ // 7p. Skill-resource — a SKILL.md body referencing a bundled file that doesn't
1044
+ // exist on disk under the skill dir (the agent gets nothing). FP-safe.
1045
+ const skillResources = checkSkillResourceResolves(config, silent, adapter);
1046
+ // 7q. Skill-missing-fence — a SKILL.md opening with `name:`/`description:` but no
1047
+ // `---` fence loads as plain body (invisible — no name/description/trigger).
1048
+ const skillFence = checkSkillMissingFence(config, silent, adapter);
1049
+ // 7r. Plugin-dir-layout — functional surface dirs (skills/agents/commands) nested
1050
+ // inside the `.claude-plugin/` manifest dir where the harness can't see them.
1051
+ const pluginLayout = checkPluginDirLayout(config, silent, adapter);
1052
+ // 7s. Delegation-trifecta — a lethal trifecta that emerges across a delegation
1053
+ // edge (a subagent's own ∪ delegated-to capability) though no single unit trips it.
1054
+ const delegationTrifecta = checkDelegationTrifecta(config, silent, adapter);
1055
+ // 7t. Hook-block-ineffective — a hook that looks like it blocks but silently
1056
+ // doesn't (block decision on a non-blocking event, or the legacy `decision`
1057
+ // field on a permission-gated event). The #1 verified hook pain (#19009).
1058
+ const hookBlock = checkHookBlockIneffective(config, silent, adapter);
1059
+ // 7u. Hook-matcher — a hook `matcher` that never fires (tool-name typo, or a
1060
+ // malformed/undeclared MCP form).
1061
+ const hookMatcher = checkHookMatcher(config, silent, adapter);
1031
1062
  // 8. Validate vigiles builder calls inside markdown code blocks. Default
1032
1063
  // is to validate every ref; illustrative blocks opt out via
1033
1064
  // `<!-- vigiles:ignore -->` (single block) or
@@ -1095,6 +1126,20 @@ async function runLint(restArgs, flags, config) {
1095
1126
  mcpHookErrors: mcpHookTargets.errors,
1096
1127
  preferCompiledHookIssues: preferCompiledHooks.issues,
1097
1128
  preferCompiledHookErrors: preferCompiledHooks.errors,
1129
+ lethalTrifectaIssues: lethalTrifecta.issues,
1130
+ lethalTrifectaErrors: lethalTrifecta.errors,
1131
+ skillResourceIssues: skillResources.issues,
1132
+ skillResourceErrors: skillResources.errors,
1133
+ skillFenceIssues: skillFence.issues,
1134
+ skillFenceErrors: skillFence.errors,
1135
+ pluginLayoutIssues: pluginLayout.issues,
1136
+ pluginLayoutErrors: pluginLayout.errors,
1137
+ delegationTrifectaIssues: delegationTrifecta.issues,
1138
+ delegationTrifectaErrors: delegationTrifecta.errors,
1139
+ hookBlockIssues: hookBlock.issues,
1140
+ hookBlockErrors: hookBlock.errors,
1141
+ hookMatcherIssues: hookMatcher.issues,
1142
+ hookMatcherErrors: hookMatcher.errors,
1098
1143
  docRefErrors: docRefReport.errors.length,
1099
1144
  symbolRefErrors,
1100
1145
  mcpRefErrors,
@@ -1557,7 +1602,19 @@ function specReferencedElsewhere(specFile, ejectedFile) {
1557
1602
  /** Full GitHub Actions workflow that wires the production `zernie/vigiles@v1`
1558
1603
  * Action (lint pillar) and, when the test pillar is set up, a deterministic
1559
1604
  * harness job. */
1560
- function vigilesWorkflow(plan) {
1605
+ /** The npm package(s) that provide each harness's CLI binary — the deterministic
1606
+ * harness tier spawns the real agent CLI against a mock model (no API key). A repo
1607
+ * targeting both harnesses installs both. */
1608
+ function harnessTestBinaries(harnesses) {
1609
+ const pkgs = [];
1610
+ if (harnesses.includes("claude"))
1611
+ pkgs.push("@anthropic-ai/claude-code");
1612
+ if (harnesses.includes("codex"))
1613
+ pkgs.push("@openai/codex");
1614
+ // Fall back to Claude Code if the set is somehow empty (back-compatible default).
1615
+ return (pkgs.length > 0 ? pkgs : ["@anthropic-ai/claude-code"]).join(" ");
1616
+ }
1617
+ function vigilesWorkflow(plan, harnesses) {
1561
1618
  const harness = plan.test
1562
1619
  ? `
1563
1620
  harness:
@@ -1571,8 +1628,23 @@ function vigilesWorkflow(plan) {
1571
1628
  with:
1572
1629
  node-version: "20"
1573
1630
  - run: npm install
1574
- - run: npm i -g @anthropic-ai/claude-code # mock tier needs the binary, no API key
1631
+ - run: npm i -g ${harnessTestBinaries(harnesses)} # mock tier needs the binary, no API key
1575
1632
  - run: npx vigiles test
1633
+
1634
+ eval-check:
1635
+ # Eval staleness gate — real-model evals run LOCALLY on your subscription
1636
+ # (\`npx vigiles eval --update\`, which commits a lock); this job VERIFIES those
1637
+ # committed results against the current inputs with NO model call. It stays a
1638
+ # green no-op until you commit your first lock. See docs/harness-testing.md.
1639
+ runs-on: ubuntu-latest
1640
+ steps:
1641
+ - uses: actions/checkout@v4
1642
+ - uses: actions/setup-node@v4
1643
+ with:
1644
+ node-version: "20"
1645
+ - uses: zernie/vigiles@v1
1646
+ with:
1647
+ command: eval-check
1576
1648
  `
1577
1649
  : "";
1578
1650
  return `name: vigiles
@@ -1643,7 +1715,7 @@ function rewriteRemovedSubcommands(content) {
1643
1715
  * commit hint). An existing workflow is never clobbered unless `--force`, but a
1644
1716
  * STALE one (old bare-`npx vigiles` API, or a removed subcommand) is reported
1645
1717
  * loudly instead of silently skipped — and rewritten in place with `--force`. */
1646
- function wireGha(plan) {
1718
+ function wireGha(plan, harnesses) {
1647
1719
  const dir = (0, node_path_1.resolve)(process.cwd(), ".github", "workflows");
1648
1720
  const path = (0, node_path_1.resolve)(dir, "vigiles.yml");
1649
1721
  const rel = ".github/workflows/vigiles.yml";
@@ -1664,7 +1736,7 @@ function wireGha(plan) {
1664
1736
  }
1665
1737
  else if (workflowUsesStaleApi(content)) {
1666
1738
  if (plan.force) {
1667
- (0, node_fs_1.writeFileSync)(path, vigilesWorkflow(plan));
1739
+ (0, node_fs_1.writeFileSync)(path, vigilesWorkflow(plan, harnesses));
1668
1740
  console.log(`✓ Regenerated ${rel} (was a stale bare \`npx vigiles\`)`);
1669
1741
  return [rel];
1670
1742
  }
@@ -1680,7 +1752,7 @@ function wireGha(plan) {
1680
1752
  }
1681
1753
  if (!(0, node_fs_1.existsSync)(dir))
1682
1754
  (0, node_fs_1.mkdirSync)(dir, { recursive: true });
1683
- (0, node_fs_1.writeFileSync)(path, vigilesWorkflow(plan));
1755
+ (0, node_fs_1.writeFileSync)(path, vigilesWorkflow(plan, harnesses));
1684
1756
  console.log("✓ Created .github/workflows/vigiles.yml (uses zernie/vigiles@v1)");
1685
1757
  return [".github/workflows/vigiles.yml"];
1686
1758
  }
@@ -2086,6 +2158,36 @@ function installPlugins(harnesses) {
2086
2158
  console.log("");
2087
2159
  reportInstall(plan, runInstall(plan, exec));
2088
2160
  }
2161
+ // Claude Code gets its hooks from the global marketplace plugin; Codex has no
2162
+ // global store, so wire vigiles's proactive nudge hooks into the repo's
2163
+ // .codex/config.toml directly (the idiomatic, repo-committed place).
2164
+ if (harnesses.includes("codex"))
2165
+ wireCodexHooks();
2166
+ }
2167
+ /**
2168
+ * Wire vigiles's proactive nudge hooks into `.codex/config.toml` (idempotently).
2169
+ * Codex honors `additionalContext` on `PostToolUse`, and these run as direct
2170
+ * `npx vigiles hook-runtime …` commands (no plugin root / vendored script), so a
2171
+ * Codex user gets the same eval-lock + refs nudges a Claude Code user gets from
2172
+ * the marketplace plugin. The pure merge is `applyCodexPluginHooks` (unit-tested
2173
+ * in setup-plan.test.ts) — this only does the read/parse/write IO.
2174
+ */
2175
+ function wireCodexHooks() {
2176
+ const path = (0, node_path_1.resolve)(process.cwd(), ".codex", "config.toml");
2177
+ let config = {};
2178
+ if ((0, node_fs_1.existsSync)(path)) {
2179
+ try {
2180
+ config = (0, toml_1.parse)((0, node_fs_1.readFileSync)(path, "utf-8"));
2181
+ }
2182
+ catch {
2183
+ console.log("⚠ .codex/config.toml is not valid TOML — skipping Codex hook wiring (fix it, then re-run `vigiles init`).");
2184
+ return;
2185
+ }
2186
+ }
2187
+ const merged = (0, setup_plan_js_1.applyCodexPluginHooks)(config);
2188
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
2189
+ (0, node_fs_1.writeFileSync)(path, (0, hook_install_js_1.serializeConfig)(merged, "toml"));
2190
+ console.log("✓ Wired the eval-lock + refs nudge hooks into .codex/config.toml (commit it)");
2089
2191
  }
2090
2192
  /** Add/upgrade `vigiles` in the project's `devDependencies` (and move it out of
2091
2193
  * `dependencies` if it's there). Returns the files it wrote (for the commit
@@ -2249,7 +2351,7 @@ async function setup(args) {
2249
2351
  // CI — the production Action (+ a harness job when Pillar 2 is on).
2250
2352
  if (plan.gha) {
2251
2353
  console.log("");
2252
- written.push(...wireGha(plan));
2354
+ written.push(...wireGha(plan, harnesses));
2253
2355
  }
2254
2356
  // Plugin/skill install — per-harness (Claude marketplace / Codex direct).
2255
2357
  if (plan.plugin) {
@@ -2673,6 +2775,211 @@ function checkDescriptionOverlap(config, silent, adapter) {
2673
2775
  }
2674
2776
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2675
2777
  }
2778
+ /**
2779
+ * Apply the `lethal-trifecta` rule: a unit (subagent / model-invocable skill)
2780
+ * whose declared tools hold all three legs (read-private + ingest-untrusted +
2781
+ * exfiltrate) is a prompt-injection exfil path (Meta's Rule of Two). Reuses
2782
+ * `scanPlugin`'s `trifectaFindings` (a capability SET-intersection, one detector,
2783
+ * no drift). Warning by default; "error" gates CI. Surfaces across BOTH subagents
2784
+ * and skills, so it is NOT gated on the `subagents` capability — a skill-only
2785
+ * harness still has the surface.
2786
+ */
2787
+ function checkLethalTrifecta(config, silent, adapter) {
2788
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["lethal-trifecta"]);
2789
+ if (!sev)
2790
+ return { issues: 0, errors: 0 };
2791
+ let found;
2792
+ try {
2793
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).trifectaFindings;
2794
+ }
2795
+ catch {
2796
+ return { issues: 0, errors: 0 };
2797
+ }
2798
+ if (found.length > 0 && !silent) {
2799
+ console.log("\nLethal-trifecta check:\n");
2800
+ for (const t of found) {
2801
+ const msg = `${t.kind} ${t.name}: ${t.finding.message}`;
2802
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${t.path}: ${msg}`);
2803
+ ghAnnotate(sev === "error" ? "error" : "warning", msg, t.path);
2804
+ }
2805
+ }
2806
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2807
+ }
2808
+ /**
2809
+ * Apply the `skill-resource-resolves` rule: a SKILL.md body referencing a bundled
2810
+ * file (`scripts/`/`references/`/`assets/` or a relative markdown link with an
2811
+ * extension) that doesn't exist on disk — the agent reads the instruction and gets
2812
+ * nothing. Reuses `scanPlugin`'s `skillResourceIssues` (high-precision / FP-safe,
2813
+ * one detector, no drift). Warning by default; "error" gates CI.
2814
+ */
2815
+ function checkSkillResourceResolves(config, silent, adapter) {
2816
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-resource-resolves"]);
2817
+ if (!sev)
2818
+ return { issues: 0, errors: 0 };
2819
+ let found;
2820
+ try {
2821
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).skillResourceIssues;
2822
+ }
2823
+ catch {
2824
+ return { issues: 0, errors: 0 };
2825
+ }
2826
+ if (found.length > 0 && !silent) {
2827
+ console.log("\nSkill-resource check:\n");
2828
+ for (const s of found) {
2829
+ const msg = `${s.name}: bundled resource "${s.finding.ref}" (line ${String(s.finding.line)}) is referenced but missing — the agent reads the instruction and gets nothing.`;
2830
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${s.path}: ${msg}`);
2831
+ ghAnnotate(sev === "error" ? "error" : "warning", msg, s.path);
2832
+ }
2833
+ }
2834
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2835
+ }
2836
+ /**
2837
+ * Apply the `skill-missing-fence` rule: a SKILL.md that opens with
2838
+ * frontmatter-looking keys (`name:`/`description:`) but no `---` fence loads as
2839
+ * pure body — no name, no description, no trigger (the skill is invisible).
2840
+ * Reuses `scanPlugin`'s `skillFenceIssues` (one detector, no drift). Warning by
2841
+ * default; "error" gates CI.
2842
+ */
2843
+ function checkSkillMissingFence(config, silent, adapter) {
2844
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-missing-fence"]);
2845
+ if (!sev)
2846
+ return { issues: 0, errors: 0 };
2847
+ let found;
2848
+ try {
2849
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).skillFenceIssues;
2850
+ }
2851
+ catch {
2852
+ return { issues: 0, errors: 0 };
2853
+ }
2854
+ if (found.length > 0 && !silent) {
2855
+ console.log("\nSkill-missing-fence check:\n");
2856
+ for (const s of found) {
2857
+ const msg = `${s.name}: ${s.finding.message}`;
2858
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${s.path}: ${msg}`);
2859
+ ghAnnotate(sev === "error" ? "error" : "warning", msg, s.path);
2860
+ }
2861
+ }
2862
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2863
+ }
2864
+ /**
2865
+ * Apply the `plugin-dir-layout` rule: functional surface dirs (skills/agents/
2866
+ * commands) nested inside the `.claude-plugin/` manifest dir where the harness
2867
+ * can't see them (the #1 plugin-author mistake). Reuses `scanPlugin`'s
2868
+ * `pluginLayoutIssues` (one detector, no drift). Warning by default; "error"
2869
+ * gates CI.
2870
+ */
2871
+ function checkPluginDirLayout(config, silent, adapter) {
2872
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["plugin-dir-layout"]);
2873
+ if (!sev)
2874
+ return { issues: 0, errors: 0 };
2875
+ let found;
2876
+ try {
2877
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).pluginLayoutIssues;
2878
+ }
2879
+ catch {
2880
+ return { issues: 0, errors: 0 };
2881
+ }
2882
+ if (found.length > 0 && !silent) {
2883
+ console.log("\nPlugin-dir-layout check:\n");
2884
+ for (const p of found) {
2885
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${p.message}`);
2886
+ ghAnnotate(sev === "error" ? "error" : "warning", p.message);
2887
+ }
2888
+ }
2889
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2890
+ }
2891
+ /**
2892
+ * Apply the `delegation-trifecta` rule: a lethal trifecta that EMERGES across a
2893
+ * delegation edge — a subagent whose effective (own ∪ delegated-to) capability
2894
+ * holds all three legs though no single unit does. Reuses `scanPlugin`'s
2895
+ * `delegationTrifecta` (one detector, no drift). Warning by default; "error"
2896
+ * gates CI. Surfaces across the subagent graph, so it is NOT gated on a
2897
+ * capability the way a surface-specific rule is.
2898
+ */
2899
+ function checkDelegationTrifecta(config, silent, adapter) {
2900
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["delegation-trifecta"]);
2901
+ if (!sev)
2902
+ return { issues: 0, errors: 0 };
2903
+ let found;
2904
+ try {
2905
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).delegationTrifecta;
2906
+ }
2907
+ catch {
2908
+ return { issues: 0, errors: 0 };
2909
+ }
2910
+ if (found.length > 0 && !silent) {
2911
+ console.log("\nDelegation-trifecta check:\n");
2912
+ for (const d of found) {
2913
+ const msg = `${d.finding.name}: ${d.finding.message}`;
2914
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${d.path}: ${msg}`);
2915
+ ghAnnotate(sev === "error" ? "error" : "warning", msg, d.path);
2916
+ }
2917
+ }
2918
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2919
+ }
2920
+ /**
2921
+ * Apply the `hook-block-ineffective` rule: a hook that LOOKS like it blocks but
2922
+ * silently doesn't — a block decision (`exit 2` / `decision` / `permissionDecision`)
2923
+ * on a non-blocking event, or the legacy top-level `decision` field on a
2924
+ * permission-gated event (#19009, the #1 verified hook pain). Reuses `scanPlugin`'s
2925
+ * `hookBlockFindings` (one detector, no drift). Warning by default; "error" gates CI.
2926
+ */
2927
+ function checkHookBlockIneffective(config, silent, adapter) {
2928
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-block-ineffective"]);
2929
+ if (!sev)
2930
+ return { issues: 0, errors: 0 };
2931
+ if (!adapter.capabilities.shellHooks) {
2932
+ reportNotApplicable("Hook-block check", "shell hooks", adapter, silent);
2933
+ return { issues: 0, errors: 0 };
2934
+ }
2935
+ let found;
2936
+ try {
2937
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).hookBlockFindings;
2938
+ }
2939
+ catch {
2940
+ return { issues: 0, errors: 0 };
2941
+ }
2942
+ if (found.length > 0 && !silent) {
2943
+ console.log("\nHook-block check:\n");
2944
+ for (const h of found) {
2945
+ const where = h.scriptPath ?? "(inline)";
2946
+ const msg = `[${h.event}] ${where}: ${h.message}`;
2947
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
2948
+ ghAnnotate(sev === "error" ? "error" : "warning", msg, h.scriptPath ?? undefined);
2949
+ }
2950
+ }
2951
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2952
+ }
2953
+ /**
2954
+ * Apply the `hook-matcher` rule: a hook `matcher` string that silently never
2955
+ * fires — a tool-name typo (`bash`→`Bash`) or a malformed/undeclared MCP form.
2956
+ * Reuses `scanPlugin`'s `hookMatcherFindings` (one detector, no drift). Warning
2957
+ * by default; "error" gates CI.
2958
+ */
2959
+ function checkHookMatcher(config, silent, adapter) {
2960
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-matcher"]);
2961
+ if (!sev)
2962
+ return { issues: 0, errors: 0 };
2963
+ if (!adapter.capabilities.shellHooks) {
2964
+ reportNotApplicable("Hook-matcher check", "shell hooks", adapter, silent);
2965
+ return { issues: 0, errors: 0 };
2966
+ }
2967
+ let found;
2968
+ try {
2969
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).hookMatcherFindings;
2970
+ }
2971
+ catch {
2972
+ return { issues: 0, errors: 0 };
2973
+ }
2974
+ if (found.length > 0 && !silent) {
2975
+ console.log("\nHook-matcher check:\n");
2976
+ for (const m of found) {
2977
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${m.message}`);
2978
+ ghAnnotate(sev === "error" ? "error" : "warning", m.message);
2979
+ }
2980
+ }
2981
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2982
+ }
2676
2983
  /**
2677
2984
  * Apply the `mcp-hook-target-resolves` rule: a `type: "mcp_tool"` hook action
2678
2985
  * that's incomplete (no `server`/`tool`) or targets a server the plugin doesn't
@@ -3157,10 +3464,55 @@ async function handleGenerateHarness(args, restArgs) {
3157
3464
  * tier needs it, just like the node:test suite). `--trials=N` is forwarded to
3158
3465
  * eval scripts via the `VIGILES_TRIALS` env var.
3159
3466
  */
3467
+ /**
3468
+ * Resolve the eval LOCK env from the `eval` flags. `--update` records each named
3469
+ * eval's report to a committed `.vigiles/eval-locks/<name>.lock.json` (run locally
3470
+ * on your subscription); `--check` (CI) verifies the committed result against the
3471
+ * current inputs WITHOUT a model call. `--check` is a green NO-OP until the first
3472
+ * lock is committed (smooth adoption). Returns the env to thread, or `"skip"` to
3473
+ * exit green now. `--check`+`--update` together is a usage error (exit 2). The
3474
+ * behavior epoch comes from `.vigilesrc.json` `eval.apiVersion` (committed).
3475
+ */
3476
+ function resolveEvalLockEnv(args) {
3477
+ const wantCheck = args.includes("--check");
3478
+ const wantUpdate = args.includes("--update");
3479
+ if (wantCheck && wantUpdate) {
3480
+ console.error("vigiles eval: --check and --update are mutually exclusive (one verifies, one records).");
3481
+ process.exit(2);
3482
+ }
3483
+ if (wantCheck &&
3484
+ !(0, eval_lock_js_1.anyLocksCommitted)((0, node_path_1.resolve)(process.cwd(), eval_lock_js_1.DEFAULT_LOCK_DIR))) {
3485
+ console.log("ℹ vigiles eval --check: no committed eval locks found — nothing to verify.\n" +
3486
+ " Run `vigiles eval --update` locally (on your subscription) and commit the\n" +
3487
+ " lock to enable the CI staleness gate.");
3488
+ return "skip";
3489
+ }
3490
+ const env = {};
3491
+ if (wantCheck)
3492
+ env.VIGILES_EVAL_LOCK = "check";
3493
+ if (wantUpdate)
3494
+ env.VIGILES_EVAL_LOCK = "update";
3495
+ if (wantCheck || wantUpdate) {
3496
+ const apiVersion = (0, validate_js_1.loadConfig)().eval?.apiVersion;
3497
+ if (apiVersion !== undefined)
3498
+ env.VIGILES_EVAL_API_VERSION = String(apiVersion);
3499
+ }
3500
+ return env;
3501
+ }
3160
3502
  function handleRunScripts(kind, args, restArgs) {
3161
3503
  const cwd = process.cwd();
3162
3504
  // Harness/eval scripts may be authored in JS or TS (see run-scripts.ts).
3163
3505
  const defaultGlob = (0, run_scripts_js_1.scriptGlob)(kind === "test" ? "harness" : "eval");
3506
+ // The eval LOCK flags (`--check`/`--update`) are resolved BEFORE file discovery
3507
+ // so mutual-exclusion + the cold-start no-op are honored regardless of file
3508
+ // count. Returns the env to thread to scripts, or `"skip"` to exit green now.
3509
+ let lockEnv = {};
3510
+ if (kind === "eval") {
3511
+ const r = resolveEvalLockEnv(args);
3512
+ if (r === "skip")
3513
+ return;
3514
+ lockEnv = r;
3515
+ }
3164
3516
  const files = (0, run_scripts_js_1.discoverScripts)(restArgs, defaultGlob, cwd);
3165
3517
  // `--min=N`: a CI gate asserts at least N scripts actually RAN — so a bad path,
3166
3518
  // a renamed file, or a glob that matched nothing fails LOUD instead of passing
@@ -3189,7 +3541,7 @@ function handleRunScripts(kind, args, restArgs) {
3189
3541
  // it's part of the measurement definition, so it belongs in the spec
3190
3542
  // (`model` / `minModel`), version-controlled, not a hidden override.
3191
3543
  const trialsFlag = args.find((a) => a.startsWith("--trials="));
3192
- const env = {};
3544
+ const env = { ...lockEnv };
3193
3545
  if (trialsFlag)
3194
3546
  env.VIGILES_TRIALS = trialsFlag.split("=")[1];
3195
3547
  console.log(`Running ${String(files.length)} ${kind} file(s):\n`);
@@ -3368,6 +3720,8 @@ function printUsage(command) {
3368
3720
  console.log(" --serve opens a LIVE local report whose buttons create specs in one click (own repo only; loopback + token-guarded) · --no-serve to skip the prompt");
3369
3721
  console.log(" vigiles test [files...] Run *.harness.mjs deterministic harness tests");
3370
3722
  console.log(" vigiles eval [files...] Run *.eval.mjs real-model harness evals (--trials=N, --min=N, --no-skip)");
3723
+ console.log(" --update records each named eval's result to a committed lock (run locally on your subscription)");
3724
+ console.log(" --check verifies committed eval results against current inputs WITHOUT a model — the CI staleness gate");
3371
3725
  console.log(" vigiles scaffold-test [dir] Generate a starter test for each untested skill/agent/hook (--write, --json)");
3372
3726
  console.log("");
3373
3727
  console.log("Examples:");
@@ -3683,6 +4037,9 @@ async function handleHookRuntime(kind, restArgs) {
3683
4037
  case "refs":
3684
4038
  refsHookCommand();
3685
4039
  return;
4040
+ case "eval-lock-nudge":
4041
+ evalLockNudgeHookCommand();
4042
+ return;
3686
4043
  case "effect-enter":
3687
4044
  (0, effect_region_js_1.setEffectActive)(process.cwd());
3688
4045
  console.log("Effect boundary entered.");
@@ -3729,6 +4086,45 @@ const INSTRUCTION_FILE = /^(SKILL|CLAUDE|AGENTS)\.md$/;
3729
4086
  function isInstructionFile(file) {
3730
4087
  return INSTRUCTION_FILE.test((0, node_path_1.basename)(file));
3731
4088
  }
4089
+ /**
4090
+ * PostToolUse-hook entrypoint: when the agent edits an eval input (a `SKILL.md`
4091
+ * trigger surface or an `*.eval.*` script), and committed eval locks exist, inject
4092
+ * a NON-BLOCKING reminder to re-run `vigiles eval --update`. Self-gating (silent
4093
+ * until a lock is committed), never blocks, never runs an eval — a reminder, not a
4094
+ * gate (the gate is `eval --check` in CI). The harness-neutral nudge lives in
4095
+ * `evalLockNudge`; both CC and Codex deliver it as `additionalContext` on
4096
+ * `PostToolUse` (confirmed — see docs/harness-testing-codex.md).
4097
+ */
4098
+ function evalLockNudgeHookCommand() {
4099
+ let raw = "";
4100
+ try {
4101
+ raw = (0, node_fs_1.readFileSync)(0, "utf-8");
4102
+ }
4103
+ catch {
4104
+ /* no stdin → nothing to do */
4105
+ }
4106
+ let file = "";
4107
+ try {
4108
+ const j = JSON.parse(raw);
4109
+ file = j.tool_input?.file_path ?? "";
4110
+ }
4111
+ catch {
4112
+ /* malformed → nothing to do */
4113
+ }
4114
+ if (!file)
4115
+ return;
4116
+ const cwd = process.cwd();
4117
+ const target = (0, node_path_1.relative)(cwd, (0, node_path_1.resolve)(cwd, file)) || file;
4118
+ const msg = (0, eval_lock_js_1.evalLockNudge)(target, (0, node_path_1.resolve)(cwd, eval_lock_js_1.DEFAULT_LOCK_DIR));
4119
+ if (!msg)
4120
+ return;
4121
+ process.stdout.write(JSON.stringify({
4122
+ hookSpecificOutput: {
4123
+ hookEventName: "PostToolUse",
4124
+ additionalContext: msg,
4125
+ },
4126
+ }) + "\n");
4127
+ }
3732
4128
  /**
3733
4129
  * PostToolUse-hook entrypoint: when the agent edits an instruction file, force
3734
4130
  * every code reference to carry a file-qualified mark (`path.ext#symbol`) and
@@ -3904,31 +4300,63 @@ async function installHookFile(file, adapter, registeredProviders = []) {
3904
4300
  : (0, hook_install_js_1.mergeHooksJson)(existing, compiled.hooks, file);
3905
4301
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(settingsAbs), { recursive: true });
3906
4302
  (0, node_fs_1.writeFileSync)(settingsAbs, (0, hook_install_js_1.serializeConfig)(merged, format));
3907
- // Honest gap (no silent skips): the gate/deny path is exit-2 and cross-harness,
3908
- // but an inject/react hook's OUTPUT shape is confirmed only for Claude Code. On
3909
- // another harness it would emit CC-shaped output that may not be read — exactly
3910
- // the silent failure this feature exists to prevent. Flag it, loudly.
4303
+ // No silent skips: warn loudly only where a hook's OUTPUT genuinely may not
4304
+ // apply on this harness. INJECT's `additionalContext` shape is now CONFIRMED
4305
+ // shared with Codex (per the official hooks docs), so an inject hook only
4306
+ // warns when its event isn't in the harness's `injectableEvents`. REACT's
4307
+ // output is still Claude-Code-confirmed only. The gate (deny→exit 2) path is
4308
+ // cross-harness and never warns.
3911
4309
  const role = (0, hook_program_js_1.dispatchKind)(program);
3912
- const warning = adapter.name !== "claude-code" && (role === "inject" || role === "react")
3913
- ? `${role} output is only confirmed for Claude Code. On ${adapter.name}, ` +
3914
- `the gate (deny→exit 2) path works, but this hook's ${role} output is ` +
3915
- `CC-shaped and unverified — it may silently not apply. Use a gate hook on ` +
3916
- `${adapter.name} for now, or confirm against the real binary first ` +
3917
- `(research/compiled-hooks-codex.md §Deferred).`
3918
- : undefined;
4310
+ const event = typeof program.on === "string" ? program.on : "";
4311
+ const injectable = adapter.hookProtocol?.injectableEvents ?? [];
4312
+ const matcher = (0, hook_program_js_1.hookRouting)(program).matcher;
4313
+ let warning;
4314
+ if (adapter.name !== "claude-code") {
4315
+ if (role === "inject" && !injectable.includes(event)) {
4316
+ warning =
4317
+ `this inject hook targets "${event}", which ${adapter.name} does not ` +
4318
+ `honor for additionalContext — the injected text won't reach the agent. ` +
4319
+ `Use an event ${adapter.name} supports: ${injectable.join(", ")}.`;
4320
+ }
4321
+ else if (role === "react") {
4322
+ warning =
4323
+ `react output is confirmed only for Claude Code; on ${adapter.name} this ` +
4324
+ `hook's react output is unverified (the gate deny→exit 2 path IS ` +
4325
+ `cross-harness). Confirm against the real binary first.`;
4326
+ }
4327
+ else if (matcher !== undefined) {
4328
+ // A tool-matched gate carries TOOL NAMES in its matcher. vigiles does not
4329
+ // yet translate tool vocabularies across dialects, so a matcher authored
4330
+ // with Claude Code names (`Edit`/`Write`/`Bash`) won't fire on a harness
4331
+ // that names the same tools differently (Codex: `apply_patch`/`shell`).
4332
+ // Warn LOUDLY rather than report a silently-non-firing success.
4333
+ warning =
4334
+ `this hook matches tool(s) "${matcher}" — if those are Claude Code tool ` +
4335
+ `names, they may not match ${adapter.name}'s vocabulary (e.g. ` +
4336
+ `apply_patch/shell), so the hook may not fire. Verify the matcher uses ` +
4337
+ `${adapter.name}'s tool names (cross-dialect matcher translation is not ` +
4338
+ `yet automatic).`;
4339
+ }
4340
+ }
3919
4341
  return { role, settingsPath: adapter.layout.settingsPath, warning };
3920
4342
  }
3921
4343
  /**
3922
4344
  * Compile + install every hook (explicit paths, else discovered under
3923
- * `.vigiles/hooks/`) into the active harness's config. Returns false if any
3924
- * hook failed to compile.
4345
+ * `.vigiles/hooks/`) into EVERY enabled harness's config. A typed hook is
4346
+ * harness-neutral, so when a repo targets both harnesses the SAME hook is merged
4347
+ * into `.claude/settings.json` AND `.codex/config.toml` (each in its native
4348
+ * format, with per-harness warnings) — never just the first. The harness set is
4349
+ * resolved from the `--harness=` flag, else `config.harness`, else auto-detect.
4350
+ * Returns false if any hook failed to compile for any harness.
3925
4351
  */
3926
- async function installHooks(hookFiles, harnessFlag) {
4352
+ async function installHooks(hookFiles, harnessFlag, configHarness) {
3927
4353
  if (hookFiles.length === 0)
3928
4354
  return true;
3929
- const adapter = harnessFlag
3930
- ? (0, adapter_registry_js_1.resolveAdapter)(process.cwd(), harnessFlag)
3931
- : (0, adapter_registry_js_1.detectAdapterResult)(process.cwd()).adapter;
4355
+ const adapters = (0, adapter_registry_js_1.resolveHarnessAdapters)({
4356
+ root: process.cwd(),
4357
+ flag: harnessFlag,
4358
+ configHarness,
4359
+ });
3932
4360
  // Validate registered providers first → the names a hook's provider() ref may
3933
4361
  // resolve to (an unsafe provider fails the whole compile, like a bad hook).
3934
4362
  let registeredProviders;
@@ -3945,10 +4373,13 @@ async function installHooks(hookFiles, harnessFlag) {
3945
4373
  let ok = true;
3946
4374
  for (const file of hookFiles) {
3947
4375
  try {
3948
- const r = await installHookFile(file, adapter, registeredProviders);
3949
- console.log(`✓ ${file} → ${r.settingsPath} (role: ${r.role}, harness: ${adapter.name})`);
3950
- if (r.warning)
3951
- console.warn(`⚠ ${r.warning}`);
4376
+ // Fan out: the same compiled hook lands in each enabled harness's config.
4377
+ for (const adapter of adapters) {
4378
+ const r = await installHookFile(file, adapter, registeredProviders);
4379
+ console.log(`✓ ${file} → ${r.settingsPath} (role: ${r.role}, harness: ${adapter.name})`);
4380
+ if (r.warning)
4381
+ console.warn(`⚠ ${r.warning}`);
4382
+ }
3952
4383
  }
3953
4384
  catch (e) {
3954
4385
  if (e instanceof hook_program_js_1.HookCompileError) {
@@ -4532,7 +4963,7 @@ async function main() {
4532
4963
  let valid = true;
4533
4964
  if (specs.length > 0)
4534
4965
  valid = (await compile(specs, config, { harnessFlag })) && valid;
4535
- valid = (await installHooks(hooks, harnessFlag)) && valid;
4966
+ valid = (await installHooks(hooks, harnessFlag, config.harness)) && valid;
4536
4967
  // Keep an existing whole-harness registry in sync (cheap, opt-in) so the
4537
4968
  // user never hand-runs `generate-harness`. Skipped when no harness.gen.ts.
4538
4969
  if (specs.length > 0)
@@ -0,0 +1,3 @@
1
+ declare const _default: import("./spec.js").ClaudeSpec;
2
+ export default _default;
3
+ //# sourceMappingURL=CLAUDE.md.spec.d.ts.map