vigiles 5.2.0 → 7.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 (78) hide show
  1. package/README.md +99 -48
  2. package/dist/action-gate.js +1 -1
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +64 -4
  4. package/dist/adapters/claude-code/agent-runtime.js +131 -17
  5. package/dist/adapters/claude-code/dialect.d.ts +34 -0
  6. package/dist/adapters/claude-code/dialect.js +46 -33
  7. package/dist/adapters/claude-code/effect-region.js +1 -1
  8. package/dist/adapters/claude-code/skill-runtime.d.ts +1 -1
  9. package/dist/adapters/claude-code/skill-runtime.js +1 -9
  10. package/dist/adapters/claude-code/typed-spec.d.ts +58 -0
  11. package/dist/adapters/claude-code/typed-spec.js +55 -0
  12. package/dist/adapters/codex/hook-protocol.js +3 -0
  13. package/dist/adapters/codex/mock-model.js +1 -1
  14. package/dist/claude-code.d.ts +1 -0
  15. package/dist/claude-code.js +8 -1
  16. package/dist/cli-commands.d.ts +19 -0
  17. package/dist/cli-commands.js +51 -0
  18. package/dist/cli.js +735 -76
  19. package/dist/core/bash-effects.d.ts +12 -0
  20. package/dist/core/bash-effects.js +31 -0
  21. package/dist/core/capability-diff.d.ts +46 -0
  22. package/dist/core/capability-diff.js +97 -0
  23. package/dist/core/compile.d.ts +1 -1
  24. package/dist/core/compile.js +14 -0
  25. package/dist/core/generate-harness.d.ts +187 -0
  26. package/dist/core/generate-harness.js +337 -0
  27. package/dist/core/guards.d.ts +126 -0
  28. package/dist/core/guards.js +309 -0
  29. package/dist/core/harness-driver.d.ts +1 -1
  30. package/dist/core/hook-program.d.ts +459 -0
  31. package/dist/core/hook-program.js +468 -0
  32. package/dist/core/hook-protocol.d.ts +7 -0
  33. package/dist/core/hook-providers.d.ts +138 -0
  34. package/dist/core/hook-providers.js +155 -0
  35. package/dist/core/hook-spec.d.ts +74 -0
  36. package/dist/core/hook-spec.js +130 -0
  37. package/dist/core/inline.js +1 -1
  38. package/dist/core/mcp-tool.d.ts +12 -0
  39. package/dist/core/mcp-tool.js +20 -0
  40. package/dist/core/mcp.d.ts +13 -0
  41. package/dist/core/mcp.js +67 -0
  42. package/dist/core/spec.d.ts +290 -8
  43. package/dist/core/spec.js +118 -3
  44. package/dist/core/types.d.ts +8 -0
  45. package/dist/dialect-drift.d.ts +65 -0
  46. package/dist/dialect-drift.js +216 -0
  47. package/dist/eval.d.ts +40 -5
  48. package/dist/eval.js +59 -5
  49. package/dist/guardrail-check.d.ts +85 -0
  50. package/dist/guardrail-check.js +152 -0
  51. package/dist/harness-assert.d.ts +10 -0
  52. package/dist/harness-assert.js +30 -0
  53. package/dist/hook-install.d.ts +43 -0
  54. package/dist/hook-install.js +91 -0
  55. package/dist/hook.d.ts +52 -0
  56. package/dist/hook.js +98 -0
  57. package/dist/leaderboard.d.ts +6 -0
  58. package/dist/leaderboard.js +43 -1
  59. package/dist/linting.d.ts +9 -5
  60. package/dist/linting.js +17 -5
  61. package/dist/optimize.js +1 -1
  62. package/dist/scaffold-test.d.ts +28 -0
  63. package/dist/scaffold-test.js +134 -15
  64. package/dist/scan-behavioral.d.ts +60 -0
  65. package/dist/scan-behavioral.js +239 -1
  66. package/dist/scan.d.ts +14 -0
  67. package/dist/scan.js +33 -1
  68. package/dist/score-explainer.js +1 -1
  69. package/dist/self-command-refs.d.ts +21 -0
  70. package/dist/self-command-refs.js +125 -0
  71. package/dist/testing.d.ts +5 -3
  72. package/dist/testing.js +37 -23
  73. package/dist/tool-intercept.d.ts +4 -4
  74. package/dist/tool-intercept.js +5 -5
  75. package/dist/unit.d.ts +2 -0
  76. package/dist/unit.js +8 -1
  77. package/hooks/refs-nudge.sh +1 -1
  78. package/package.json +5 -3
package/dist/cli.js CHANGED
@@ -14,13 +14,17 @@ const node_fs_1 = require("node:fs");
14
14
  const node_path_1 = require("node:path");
15
15
  const glob_1 = require("glob");
16
16
  const generate_types_js_1 = require("./core/generate-types.js");
17
+ const generate_harness_js_1 = require("./core/generate-harness.js");
18
+ const capability_diff_js_1 = require("./core/capability-diff.js");
17
19
  const validate_js_1 = require("./core/validate.js");
18
20
  const cli_flags_js_1 = require("./cli-flags.js");
19
21
  const setup_plan_js_1 = require("./setup-plan.js");
20
22
  const types_js_1 = require("./core/types.js");
21
23
  const test_coverage_js_1 = require("./test-coverage.js");
22
24
  const scaffold_test_js_1 = require("./scaffold-test.js");
25
+ const effects_js_1 = require("./core/effects.js");
23
26
  const scan_js_1 = require("./scan.js");
27
+ const dialect_drift_js_1 = require("./dialect-drift.js");
24
28
  const score_explainer_js_1 = require("./score-explainer.js");
25
29
  const scan_behavioral_js_1 = require("./scan-behavioral.js");
26
30
  const adapter_registry_js_1 = require("./adapter-registry.js");
@@ -35,6 +39,11 @@ const generate_schema_js_1 = require("./core/generate-schema.js");
35
39
  const compose_js_1 = require("./core/compose.js");
36
40
  const compile_generator_js_1 = require("./core/compile-generator.js");
37
41
  const action_gate_js_1 = require("./action-gate.js");
42
+ const guards_js_1 = require("./core/guards.js");
43
+ const hook_program_js_1 = require("./core/hook-program.js");
44
+ const hook_install_js_1 = require("./hook-install.js");
45
+ const hook_providers_js_1 = require("./core/hook-providers.js");
46
+ const toml_1 = require("@iarna/toml");
38
47
  const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
39
48
  const effect_region_js_1 = require("./adapters/claude-code/effect-region.js");
40
49
  const tool_intercept_js_1 = require("./tool-intercept.js");
@@ -869,7 +878,7 @@ async function runLint(restArgs, flags, config) {
869
878
  const summary = flags.includes("--summary");
870
879
  const json = flags.includes("--json");
871
880
  const silent = summary || json;
872
- const files = findInstructionFiles(restArgs);
881
+ const files = findInstructionFiles(restArgs, config?.exclude);
873
882
  // Resolve the active harness ONCE so the harness-specific checks below run
874
883
  // against the right adapter's dialect (tool/event catalogs) and surfaces —
875
884
  // not a hard-coded Claude Code default. A subagent-surface rule reports n/a
@@ -2439,13 +2448,19 @@ async function countGuidanceRules(silent = false) {
2439
2448
  // ---------------------------------------------------------------------------
2440
2449
  // Command handlers for main()
2441
2450
  // ---------------------------------------------------------------------------
2442
- function findInstructionFiles(restArgs) {
2451
+ function findInstructionFiles(restArgs, exclude = []) {
2443
2452
  if (restArgs.length > 0)
2444
2453
  return restArgs;
2445
2454
  const patterns = ["**/CLAUDE.md", "**/AGENTS.md", "**/SKILL.md"];
2446
2455
  const files = [];
2447
2456
  for (const pattern of patterns) {
2448
- files.push(...(0, glob_1.globSync)(pattern, { ignore: IGNORE_NODE_MODULES, cwd: process.cwd() }));
2457
+ files.push(...(0, glob_1.globSync)(pattern, {
2458
+ // `exclude` (from .vigilesrc.json) drops vendored/benchmark fixtures the
2459
+ // repo's own lint shouldn't police — a third-party CLAUDE.md isn't held
2460
+ // to require-spec. node_modules/dist/.git stay always-excluded.
2461
+ ignore: [...IGNORE_NODE_MODULES, "dist/**", ".git/**", ...exclude],
2462
+ cwd: process.cwd(),
2463
+ }));
2449
2464
  }
2450
2465
  return files;
2451
2466
  }
@@ -2454,14 +2469,26 @@ function flagValue(args, name) {
2454
2469
  return args.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1);
2455
2470
  }
2456
2471
  /**
2457
- * The `scan --trigger` behavioral column: load the author-supplied per-skill
2458
- * prompt sets, probe the plugin's model-invocable skills, print the column.
2459
- * Model-gated and opt-in the structural scan above stays deterministic.
2472
+ * `vigiles measure <dir>` — the MODEL-GATED behavioral report on a plugin (the paid
2473
+ * tier; `scan` stays free/deterministic). Loads the author-supplied per-skill prompt
2474
+ * sets (`--prompts`) and reports BOTH behavioral columns: trigger-rate (does each
2475
+ * skill actually FIRE — recall + precision) and the selection-collision matrix (does
2476
+ * one skill HIJACK a sibling's prompt — the behavioral confirmation of the
2477
+ * deterministic `description-overlap` rule). Needs the harness CLI + model auth;
2478
+ * degrades honestly ("unavailable") when absent. The OSS-testing front door:
2479
+ * `vigiles measure ./plugin --prompts=p.json`.
2460
2480
  */
2461
- async function handleScanTrigger(root, args, json, harness) {
2481
+ async function handleMeasure(restArgs, args) {
2482
+ const dir = (0, node_path_1.resolve)(restArgs[0] ?? ".");
2483
+ const json = args.includes("--json");
2484
+ const harnessFlag = harnessFlagFrom(args);
2485
+ const adapter = harnessFlag
2486
+ ? (0, adapter_registry_js_1.resolveAdapter)(dir, harnessFlag)
2487
+ : (0, adapter_registry_js_1.detectAdapterResult)(dir).adapter;
2488
+ const harness = adapter.name === "codex" ? "codex" : "claude-code";
2462
2489
  const promptsPath = flagValue(args, "--prompts");
2463
2490
  if (!promptsPath) {
2464
- console.error("scan --trigger needs --prompts=<file.json> (a map of skill name → { prompts, irrelevant }).");
2491
+ console.error("measure needs --prompts=<file.json> (a map of skill name → { prompts, irrelevant }).");
2465
2492
  process.exitCode = 2;
2466
2493
  return;
2467
2494
  }
@@ -2470,21 +2497,37 @@ async function handleScanTrigger(root, args, json, harness) {
2470
2497
  promptSet = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.resolve)(promptsPath), "utf-8"));
2471
2498
  }
2472
2499
  catch (e) {
2473
- console.error(`scan --trigger: could not read --prompts file "${promptsPath}": ${e instanceof Error ? e.message : String(e)}`);
2500
+ console.error(`measure: could not read --prompts file "${promptsPath}": ${e instanceof Error ? e.message : String(e)}`);
2474
2501
  process.exitCode = 2;
2475
2502
  return;
2476
2503
  }
2477
- const concurrencyRaw = flagValue(args, "--concurrency");
2478
- const minPromptsRaw = flagValue(args, "--min-prompts");
2479
- const report = await (0, scan_behavioral_js_1.probePluginTriggers)(root, promptSet, {
2480
- concurrency: concurrencyRaw ? Number(concurrencyRaw) : undefined,
2481
- minPrompts: minPromptsRaw ? Number(minPromptsRaw) : undefined,
2482
- model: flagValue(args, "--model"),
2504
+ const num = (f) => {
2505
+ const v = flagValue(args, f);
2506
+ return v ? Number(v) : undefined;
2507
+ };
2508
+ const model = flagValue(args, "--model");
2509
+ const concurrency = num("--concurrency");
2510
+ // Trigger-rate (recall + precision) AND the selection-collision matrix — the two
2511
+ // behavioral columns, one report. Collisions report n/a where they don't apply
2512
+ // (a single skill, or Codex — no skill-selection event), never a false pass.
2513
+ const trigger = await (0, scan_behavioral_js_1.probePluginTriggers)(dir, promptSet, {
2514
+ concurrency,
2515
+ minPrompts: num("--min-prompts"),
2516
+ model,
2517
+ harness,
2518
+ });
2519
+ const collisions = await (0, scan_behavioral_js_1.measurePluginSelection)(dir, promptSet, {
2520
+ concurrency,
2521
+ trials: num("--trials"),
2522
+ model,
2483
2523
  harness,
2484
2524
  });
2485
- console.log(json
2486
- ? JSON.stringify(report, null, 2)
2487
- : `\n${(0, scan_behavioral_js_1.formatBehavioralReport)(report)}`);
2525
+ if (json) {
2526
+ console.log(JSON.stringify({ trigger, collisions }, null, 2));
2527
+ return;
2528
+ }
2529
+ console.log(`\n${(0, scan_behavioral_js_1.formatBehavioralReport)(trigger)}`);
2530
+ console.log(`\n${(0, scan_behavioral_js_1.formatSelectionReport)(collisions)}`);
2488
2531
  }
2489
2532
  function handleGenerateTypes(args, restArgs) {
2490
2533
  const checkOnly = args.includes("--check");
@@ -2573,6 +2616,78 @@ function handleGenerateSchema(args, restArgs) {
2573
2616
  console.log(" Add to your markdown frontmatter:\n" +
2574
2617
  ` # yaml-language-server: $schema=./${outPath}`);
2575
2618
  }
2619
+ /**
2620
+ * `vigiles generate-harness [dir] [out]` — emit one typed registry over every
2621
+ * `*.spec.ts` under `dir`, so a single `tsc --noEmit` cross-checks the whole
2622
+ * harness (dangling delegates → a tsc error; duplicate names → this command
2623
+ * exits non-zero; the capability lattice → a computed export). The third
2624
+ * generated artifact beside `generate-types` / `generate-schema`. See
2625
+ * docs/cli.md and research/whole-harness-codegen.md.
2626
+ */
2627
+ async function handleGenerateHarness(args, restArgs) {
2628
+ const checkOnly = args.includes("--check");
2629
+ const dir = (0, node_path_1.resolve)(restArgs[0] ?? ".");
2630
+ const outPath = restArgs[1] ?? (0, node_path_1.resolve)(dir, generate_harness_js_1.HARNESS_GEN_FILENAME);
2631
+ const fullOut = (0, node_path_1.resolve)(process.cwd(), outPath);
2632
+ const specImport = args
2633
+ .filter((a) => a.startsWith("--spec-import="))
2634
+ .map((a) => a.split("=")[1])
2635
+ .filter(Boolean)[0] ?? undefined;
2636
+ // Resolve the harness ONCE (honour --harness / config / auto-detect) so the
2637
+ // capability lattice is computed against the right dialect — never defaulting
2638
+ // to Claude Code in core. The dialect is INJECTED into the generator.
2639
+ const harnessFlag = harnessFlagFrom(args);
2640
+ const adapter = harnessFlag
2641
+ ? (0, adapter_registry_js_1.resolveAdapter)(dir, harnessFlag)
2642
+ : (0, adapter_registry_js_1.detectAdapterResult)(dir).adapter;
2643
+ console.log(`Scanning ${(0, generate_harness_js_1.labelFor)(process.cwd(), dir)} for *.spec.ts...\n`);
2644
+ const model = await (0, generate_harness_js_1.loadHarnessModel)(dir, (abs) => loadSpec(abs));
2645
+ const result = (0, generate_harness_js_1.generateHarness)(model, {
2646
+ dialect: adapter.dialect,
2647
+ outDir: (0, node_path_1.dirname)(fullOut),
2648
+ specImport,
2649
+ });
2650
+ console.log(` ${String(result.agentCount)} agent(s), ${String(result.edgeCount)} delegate edge(s)` +
2651
+ (result.handoffCount > 0
2652
+ ? `, ${String(result.handoffCount)} handoff check(s)`
2653
+ : ""));
2654
+ console.log(` capabilities: ${result.capabilities.purity} (` +
2655
+ `${String(result.capabilities.sideEffecting.length)} side-effecting, ` +
2656
+ `${String(result.capabilities.unknown.length)} unknown)`);
2657
+ // DUPLICATE NAME — the O(N) JS check (never a type). Exit non-zero, no write.
2658
+ if (result.duplicate) {
2659
+ console.log(`\n✗ ${result.duplicate.message}`);
2660
+ console.log(`::error::${result.duplicate.message}`);
2661
+ process.exit(2);
2662
+ }
2663
+ if (checkOnly) {
2664
+ if (!(0, node_fs_1.existsSync)(fullOut)) {
2665
+ console.log(`\n✗ ${outPath} does not exist. Run \`vigiles generate-harness\` to create it.`);
2666
+ process.exit(1);
2667
+ }
2668
+ const existing = (0, node_fs_1.readFileSync)(fullOut, "utf-8");
2669
+ const normalize = (s) => s
2670
+ .split("\n")
2671
+ .map((l) => l.trimEnd())
2672
+ .join("\n")
2673
+ .replace(/\n{3,}/g, "\n\n")
2674
+ .trim();
2675
+ if (normalize(existing) === normalize(result.gen)) {
2676
+ console.log(`\n✓ ${outPath} is up to date`);
2677
+ }
2678
+ else {
2679
+ console.log(`\n✗ ${outPath} is stale. Run \`vigiles generate-harness\` to update.`);
2680
+ process.exit(1);
2681
+ }
2682
+ return;
2683
+ }
2684
+ const outDir = (0, node_path_1.dirname)(fullOut);
2685
+ if (!(0, node_fs_1.existsSync)(outDir))
2686
+ (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
2687
+ (0, node_fs_1.writeFileSync)(fullOut, result.gen);
2688
+ console.log(`\n✓ Generated ${(0, generate_harness_js_1.labelFor)(process.cwd(), fullOut)}`);
2689
+ console.log(" `tsc --noEmit` over this file now checks every delegate target resolves.");
2690
+ }
2576
2691
  /**
2577
2692
  * `vigiles test` / `vigiles eval` — discover and run the two-tier harness
2578
2693
  * scripts (deterministic `*.harness.mjs` / real-model `*.eval.mjs`) as child
@@ -2664,6 +2779,19 @@ function handleExplain(restArgs, args) {
2664
2779
  console.log(`Explaining "${surface}":\n`);
2665
2780
  console.log((0, score_explainer_js_1.formatExplanations)(exps));
2666
2781
  }
2782
+ /**
2783
+ * Whole-harness capability lattice from a scanned plugin's agents (no `tools:` line →
2784
+ * inherits-all). The substrate `scan --capability-diff` diffs. Reused for both the
2785
+ * already-scanned "after" report and the freshly-scanned "before" dir.
2786
+ */
2787
+ function capabilitiesOfReport(report, dialect) {
2788
+ const agents = report.agents.map((a) => ({
2789
+ name: a.name,
2790
+ tools: a.tools ?? undefined,
2791
+ file: a.path,
2792
+ }));
2793
+ return (0, generate_harness_js_1.computeHarnessCapabilities)(agents, dialect);
2794
+ }
2667
2795
  /**
2668
2796
  * The plugin's declared name for the namespaced skill id, read from the layout's
2669
2797
  * manifest (adapter-aware path, not a hardcoded `.claude-plugin/`), falling back to
@@ -2681,7 +2809,34 @@ function pluginNameFor(dir, manifestPath) {
2681
2809
  return (0, node_path_1.basename)(dir);
2682
2810
  }
2683
2811
  /** Enrich an untested Surface with the metadata the right template needs. */
2684
- function scaffoldInputFor(s, report, pluginName) {
2812
+ /** Extract `"name": type` fields from one rendered `vigiles:ok`/`err` shape block. */
2813
+ function parseContractFields(block) {
2814
+ const fields = [];
2815
+ const re = /"([^"]+)"\s*:\s*(string\[\]|string|number|boolean)/g;
2816
+ let m;
2817
+ while ((m = re.exec(block)) !== null) {
2818
+ fields.push({ name: m[1], type: m[2] });
2819
+ }
2820
+ return fields;
2821
+ }
2822
+ /**
2823
+ * Parse a subagent's compiled `## Output contract` (the `vigiles:ok` / `vigiles:err`
2824
+ * blocks the compiler emits) back into a typed `ResultContract`, so the generator
2825
+ * can write an `assertAgentOk` test against the real fields. Returns null when the
2826
+ * agent has no result() contract.
2827
+ */
2828
+ function parseResultContract(md) {
2829
+ const ok = /```vigiles:ok\n([\s\S]*?)```/.exec(md);
2830
+ const err = /```vigiles:err\n([\s\S]*?)```/.exec(md);
2831
+ if (!ok && !err)
2832
+ return null;
2833
+ const okFields = ok ? parseContractFields(ok[1]) : [];
2834
+ const errFields = err ? parseContractFields(err[1]) : [];
2835
+ if (okFields.length === 0 && errFields.length === 0)
2836
+ return null;
2837
+ return { ok: okFields, err: errFields };
2838
+ }
2839
+ function scaffoldInputFor(s, report, pluginName, dir, dialect) {
2685
2840
  const base = { kind: s.kind, name: s.name, path: s.path };
2686
2841
  switch (s.kind) {
2687
2842
  case "skill": {
@@ -2690,7 +2845,18 @@ function scaffoldInputFor(s, report, pluginName) {
2690
2845
  }
2691
2846
  case "agent": {
2692
2847
  const ag = report.agents.find((x) => x.name === s.name);
2693
- return { ...base, tools: ag?.tools ?? null };
2848
+ const tools = ag?.tools ?? null;
2849
+ const sideEffectingTools = tools
2850
+ ? (0, effects_js_1.effectSurface)(tools, dialect).sideEffecting
2851
+ : undefined;
2852
+ let resultContract = null;
2853
+ try {
2854
+ resultContract = parseResultContract((0, node_fs_1.readFileSync)((0, node_path_1.resolve)(dir, s.path), "utf-8"));
2855
+ }
2856
+ catch {
2857
+ // agent .md unreadable → no contract to generate against
2858
+ }
2859
+ return { ...base, tools, sideEffectingTools, resultContract };
2694
2860
  }
2695
2861
  case "hook":
2696
2862
  return { ...base, hookCommand: `bash ${s.path}` };
@@ -2719,7 +2885,7 @@ function handleScaffoldTest(restArgs, args) {
2719
2885
  });
2720
2886
  const report = (0, scan_js_1.scanPlugin)(dir, adapter.layout, adapter.dialect);
2721
2887
  const pluginName = pluginNameFor(dir, adapter.layout.manifestPath);
2722
- const scaffolds = untested.map((s) => (0, scaffold_test_js_1.scaffoldTest)(scaffoldInputFor(s, report, pluginName)));
2888
+ const scaffolds = untested.map((s) => (0, scaffold_test_js_1.scaffoldTest)(scaffoldInputFor(s, report, pluginName, dir, adapter.dialect)));
2723
2889
  if (json) {
2724
2890
  console.log(JSON.stringify(scaffolds, null, 2));
2725
2891
  return;
@@ -2762,6 +2928,8 @@ function printUsage(command) {
2762
2928
  console.log(" vigiles init [flags] Setup project (--lint, --test, --harness=, --strict, --no-gha, --force)");
2763
2929
  console.log(" vigiles compile [files...] Compile .spec.ts → .md");
2764
2930
  console.log(" vigiles lint [files...] Verify references, find gaps in instruction files");
2931
+ console.log(" vigiles scan [dir...] Report what a plugin ships + what's broken (free; 2+ dirs → leaderboard)");
2932
+ console.log(" vigiles measure <dir> Model-gated: does each skill FIRE / COLLIDE? (--prompts=, real model)");
2765
2933
  console.log(" vigiles test [files...] Run *.harness.mjs deterministic harness tests");
2766
2934
  console.log(" vigiles eval [files...] Run *.eval.mjs real-model harness evals (--trials=N, --min=N, --no-skip)");
2767
2935
  console.log(" vigiles explain <dir> [name] Deterministic WHY a skill/agent underperforms + the fix (--json, --harness=)");
@@ -2777,6 +2945,8 @@ function printUsage(command) {
2777
2945
  console.log(" vigiles generate-types --check Verify .d.ts is up to date");
2778
2946
  console.log(" vigiles generate-schema [out] Emit JSON Schema for vigiles: frontmatter");
2779
2947
  console.log(" vigiles generate-schema --check Verify schema.json is up to date");
2948
+ console.log(" vigiles generate-harness [dir] Emit harness.gen.ts — one typed registry");
2949
+ console.log(" vigiles generate-harness --check Verify harness.gen.ts is up to date");
2780
2950
  console.log(" vigiles --version Print the version number");
2781
2951
  if (command && command !== "--help") {
2782
2952
  console.log(`\nUnknown command: "${command}"`);
@@ -2814,7 +2984,7 @@ function annotateLintForGitHub(report, flags) {
2814
2984
  */
2815
2985
  function runSkillCommand(target) {
2816
2986
  if (!target) {
2817
- console.error("Usage: vigiles run-skill <SKILL.md>");
2987
+ console.error("Usage: vigiles hook-runtime run-skill <SKILL.md>");
2818
2988
  process.exit(2);
2819
2989
  }
2820
2990
  const path = (0, node_path_1.resolve)(process.cwd(), target);
@@ -2869,7 +3039,7 @@ function skillHookCommand() {
2869
3039
  /** Mark a skill active so the Stop hook enforces its result gate. */
2870
3040
  function skillStartCommand(target) {
2871
3041
  if (!target) {
2872
- console.error("Usage: vigiles skill-start <SKILL.md>");
3042
+ console.error("Usage: vigiles hook-runtime skill-start <SKILL.md>");
2873
3043
  process.exit(2);
2874
3044
  }
2875
3045
  (0, skill_runtime_js_1.setActiveSkill)(process.cwd(), target);
@@ -2927,9 +3097,13 @@ function agentHookCommand() {
2927
3097
  }
2928
3098
  let tool = "";
2929
3099
  let command;
3100
+ let event = "";
3101
+ let toolInput;
2930
3102
  try {
2931
3103
  const parsed = JSON.parse(raw);
3104
+ event = parsed.hook_event_name ?? "";
2932
3105
  tool = parsed.tool_name ?? "";
3106
+ toolInput = parsed.tool_input;
2933
3107
  if (typeof parsed.tool_input?.command === "string") {
2934
3108
  command = parsed.tool_input.command;
2935
3109
  }
@@ -2937,16 +3111,44 @@ function agentHookCommand() {
2937
3111
  catch {
2938
3112
  /* malformed input → no tool, allow */
2939
3113
  }
3114
+ const cwd = process.cwd();
3115
+ // EXPERIMENTAL (parked P3 — do NOT auto-wire). The spawn/SubagentStop bracketing
3116
+ // is now nesting-safe: a depth-aware STACK (push on dispatch, POP on SubagentStop)
3117
+ // closes the contract-escape the flat single-slot model allowed under CC v2.1.172
3118
+ // depth-5 nesting. See research/effect-boundary-design.md + AgentWindowStack.tla.
3119
+ //
3120
+ // SubagentStop → CLOSE the window deterministically (no model `agent-done`): the
3121
+ // subagent returned, so POP its frame — control returns to its PARENT, whose
3122
+ // contract the gate enforces again (NOT a full clear, which would drop the parent).
3123
+ if (event === "SubagentStop") {
3124
+ (0, agent_runtime_js_1.popActiveAgent)(cwd);
3125
+ (0, effect_region_js_1.clearEffectActive)(cwd);
3126
+ return;
3127
+ }
3128
+ // PreToolUse(spawn) → OPEN the window deterministically (no model `agent-start` /
3129
+ // `effect-enter`): the parent is dispatching a subagent, so PUSH that subagent's
3130
+ // compiled contract for the tool calls it is about to make. Recognize both spawn
3131
+ // tool names — `Task` (top-level dispatch) and `Agent` (nested-spawn, CC v2.1.172)
3132
+ // — gated on a resolvable `subagent_type` so a non-spawn call never opens a frame.
3133
+ // The dispatch itself is the PARENT's action — don't gate it; just open + allow.
3134
+ if (tool === "Task" || tool === "Agent") {
3135
+ const agentPath = (0, agent_runtime_js_1.decideTaskDispatch)(toolInput, cwd, process.env.CLAUDE_PLUGIN_ROOT);
3136
+ if (agentPath) {
3137
+ (0, agent_runtime_js_1.pushActiveAgent)(cwd, agentPath);
3138
+ (0, effect_region_js_1.setEffectActive)(cwd);
3139
+ }
3140
+ return;
3141
+ }
2940
3142
  if (!tool)
2941
3143
  return;
2942
- const decision = (0, agent_runtime_js_1.evaluatePreToolUse)(process.cwd(), tool, command);
3144
+ const decision = (0, agent_runtime_js_1.evaluatePreToolUse)(cwd, tool, command);
2943
3145
  if (!decision.allow) {
2944
3146
  console.error(decision.message);
2945
3147
  process.exit(2);
2946
3148
  }
2947
3149
  }
2948
3150
  /**
2949
- * `vigiles intercept-tool-hook` — the PreToolUse interception hook for the
3151
+ * `vigiles hook-runtime intercept-tool` — the PreToolUse interception hook for the
2950
3152
  * tool-call spy. Reads the intercept list from `VIGILES_INTERCEPT_TOOLS`, decides
2951
3153
  * whether the called tool should be intercepted, and if so denies the real
2952
3154
  * execution (exit 2) with a block message — the call is intercepted (prevented),
@@ -2969,63 +3171,99 @@ function interceptToolHookCommand() {
2969
3171
  process.exit(2);
2970
3172
  }
2971
3173
  }
3174
+ /**
3175
+ * `vigiles hook-runtime guard` — the PreToolUse gate for typed safe-by-construction guards
3176
+ * (EXPERIMENTAL). Reads the live event on stdin, runs the declared guard set
3177
+ * (`.vigiles/guards.json`) against the session ledger (`.vigiles/guard-ledger.json`),
3178
+ * and blocks (exit 2 + reason) or records the allowed call. The command in the
3179
+ * generated hooks block IS this gate — not user shell — so the enforcement is
3180
+ * safe-by-construction. See src/core/guards.ts.
3181
+ */
3182
+ function guardHookCommand() {
3183
+ let raw = "";
3184
+ try {
3185
+ raw = (0, node_fs_1.readFileSync)(0, "utf-8");
3186
+ }
3187
+ catch {
3188
+ /* no stdin */
3189
+ }
3190
+ const { decision } = (0, guards_js_1.runGuardHook)(process.cwd(), raw);
3191
+ if (!decision.allow) {
3192
+ console.error(decision.reason ?? "Blocked by a vigiles guard.");
3193
+ process.exit(2);
3194
+ }
3195
+ }
2972
3196
  /** Mark a subagent active so the PreToolUse hook enforces its tool contract. */
2973
3197
  function agentStartCommand(target) {
2974
3198
  if (!target) {
2975
- console.error("Usage: vigiles agent-start <agents/<name>.md>");
3199
+ console.error("Usage: vigiles hook-runtime agent-start <agents/<name>.md>");
2976
3200
  process.exit(2);
2977
3201
  }
2978
- (0, agent_runtime_js_1.setActiveAgent)(process.cwd(), target);
3202
+ (0, agent_runtime_js_1.pushActiveAgent)(process.cwd(), target);
2979
3203
  console.log(`Active agent: ${target}`);
2980
3204
  }
2981
3205
  /** Dispatch the skill-runtime subcommands. Returns false if unrecognized. */
2982
- function handleSkillCommand(command, restArgs) {
2983
- switch (command) {
2984
- case "run-skill":
2985
- runSkillCommand(restArgs[0]);
2986
- return true;
3206
+ /**
3207
+ * `vigiles hook-runtime <kind> [args]` — the hidden umbrella for RUNTIME
3208
+ * entrypoints: the executables the harness invokes via a block `vigiles compile`
3209
+ * emits into your hooks config, NEVER typed by a human. They stay off the help
3210
+ * surface by design — verbs are typed, runtime entrypoints are emitted (the
3211
+ * cohesive-cli-surface rule). Renaming a `<kind>` breaks every already-emitted
3212
+ * block, so it is a breaking change.
3213
+ */
3214
+ async function handleHookRuntime(kind, restArgs) {
3215
+ switch (kind) {
3216
+ case "run-program":
3217
+ await runHookProgramCommand(restArgs[0]);
3218
+ return;
3219
+ case "agent":
3220
+ agentHookCommand();
3221
+ return;
3222
+ case "agent-start":
3223
+ agentStartCommand(restArgs[0]);
3224
+ return;
3225
+ case "agent-done":
3226
+ (0, agent_runtime_js_1.popActiveAgent)(process.cwd());
3227
+ return;
3228
+ case "skill":
3229
+ skillHookCommand();
3230
+ return;
3231
+ case "skill-tool":
3232
+ skillToolHookCommand();
3233
+ return;
2987
3234
  case "skill-start":
2988
3235
  skillStartCommand(restArgs[0]);
2989
- return true;
3236
+ return;
2990
3237
  case "skill-done":
2991
3238
  (0, skill_runtime_js_1.clearActiveSkill)(process.cwd());
2992
- return true;
2993
- case "skill-hook":
2994
- skillHookCommand();
2995
- return true;
2996
- case "skill-tool-hook":
2997
- skillToolHookCommand();
2998
- return true;
2999
- case "agent-start":
3000
- agentStartCommand(restArgs[0]);
3001
- return true;
3002
- case "agent-done":
3003
- (0, agent_runtime_js_1.clearActiveAgent)(process.cwd());
3004
- return true;
3005
- case "agent-hook":
3006
- agentHookCommand();
3007
- return true;
3008
- case "intercept-tool-hook":
3239
+ return;
3240
+ case "run-skill":
3241
+ runSkillCommand(restArgs[0]);
3242
+ return;
3243
+ case "intercept-tool":
3009
3244
  interceptToolHookCommand();
3010
- return true;
3011
- case "action-hook":
3245
+ return;
3246
+ case "guard":
3247
+ guardHookCommand();
3248
+ return;
3249
+ case "action":
3012
3250
  actionHookCommand();
3013
- return true;
3251
+ return;
3014
3252
  case "refs":
3015
- refsCommand(restArgs[0]);
3016
- return true;
3017
- case "refs-hook":
3018
3253
  refsHookCommand();
3019
- return true;
3254
+ return;
3020
3255
  case "effect-enter":
3021
3256
  (0, effect_region_js_1.setEffectActive)(process.cwd());
3022
3257
  console.log("Effect boundary entered.");
3023
- return true;
3258
+ return;
3024
3259
  case "effect-exit":
3025
3260
  (0, effect_region_js_1.clearEffectActive)(process.cwd());
3026
- return true;
3261
+ return;
3027
3262
  default:
3028
- return false;
3263
+ console.error(`vigiles hook-runtime: unknown runtime entrypoint "${kind ?? ""}". ` +
3264
+ `These are emitted into your hooks config by \`vigiles compile\` — ` +
3265
+ `you don't run them by hand.`);
3266
+ process.exit(2);
3029
3267
  }
3030
3268
  }
3031
3269
  /**
@@ -3158,6 +3396,378 @@ function refsHookCommand() {
3158
3396
  },
3159
3397
  }) + "\n");
3160
3398
  }
3399
+ // ---------------------------------------------------------------------------
3400
+ // Compiled hooks (`vigiles/hook`) — author a hook as a pure typed program,
3401
+ // compile it to a harness block, run it as the hooks-block command.
3402
+ // ---------------------------------------------------------------------------
3403
+ /**
3404
+ * Load a compiled-hook program's default export. JS module formats
3405
+ * (`.mjs`/`.cjs`/`.js`) load via dynamic import directly; a TypeScript hook
3406
+ * loads only under a TS-capable runtime (tsx / Node >= 23.6) — otherwise an
3407
+ * actionable error points at authoring it as `.mjs`.
3408
+ */
3409
+ async function loadHookProgram(file) {
3410
+ const abs = (0, node_path_1.resolve)(process.cwd(), file);
3411
+ const { pathToFileURL } = require("node:url");
3412
+ let mod;
3413
+ try {
3414
+ mod = (await import(pathToFileURL(abs).href));
3415
+ }
3416
+ catch (e) {
3417
+ if (/\.(?:m|c)?ts$/.test(file)) {
3418
+ throw new hook_program_js_1.HookCompileError(`Cannot load TypeScript hook "${file}" in this Node runtime. Run under ` +
3419
+ `tsx (npx tsx …) / Node >= 23.6, or author the hook as a .mjs file.`);
3420
+ }
3421
+ throw new hook_program_js_1.HookCompileError(`Cannot load hook "${file}": ${e.message}`);
3422
+ }
3423
+ // Unwrap the ESM/CJS double-default that `export default` can produce.
3424
+ const program = mod.default?.default ?? mod.default;
3425
+ if (!program || typeof program !== "object") {
3426
+ throw new hook_program_js_1.HookCompileError(`${file} has no default-exported hook program ` +
3427
+ `(use \`export default defineHook({…})\`).`);
3428
+ }
3429
+ return program;
3430
+ }
3431
+ /** Load a registered provider (`.vigiles/providers/<name>`) → its definition. */
3432
+ async function loadProvider(file) {
3433
+ const abs = (0, node_path_1.resolve)(process.cwd(), file);
3434
+ const { pathToFileURL } = require("node:url");
3435
+ let mod;
3436
+ try {
3437
+ mod = (await import(pathToFileURL(abs).href));
3438
+ }
3439
+ catch (e) {
3440
+ throw new hook_program_js_1.HookCompileError(`Cannot load provider "${file}": ${e.message}`);
3441
+ }
3442
+ const def = mod.default?.default ?? mod.default;
3443
+ if (!def ||
3444
+ typeof def !== "object" ||
3445
+ def.kind !== "provider-def") {
3446
+ throw new hook_program_js_1.HookCompileError(`${file} has no default-exported provider ` +
3447
+ `(use \`export default defineProvider({…})\`).`);
3448
+ }
3449
+ return def;
3450
+ }
3451
+ /**
3452
+ * Compile (validate) the registered providers under `.vigiles/providers/`: each
3453
+ * must load and be read-only unless it opted into `dangerous`. Returns the set of
3454
+ * valid provider NAMES (so a hook's `provider()` ref can resolve at compile).
3455
+ * Throws HookCompileError on an unsafe provider — the same fail-the-build contract
3456
+ * as a hook.
3457
+ */
3458
+ async function compileProviders() {
3459
+ const names = [];
3460
+ for (const file of (0, hook_install_js_1.discoverProviderFiles)(process.cwd())) {
3461
+ const def = await loadProvider(file);
3462
+ if ((0, hook_providers_js_1.unsafeProvider)(def)) {
3463
+ throw new hook_program_js_1.HookCompileError(`provider ${file} ("${def.run}") is not provably read-only — ` +
3464
+ `pass dangerous:true to defineProvider to acknowledge it.`);
3465
+ }
3466
+ names.push(def.name);
3467
+ }
3468
+ return names;
3469
+ }
3470
+ /** Path of the tamper-evident stamp sidecar for a hook file. */
3471
+ function hookStampPath(file) {
3472
+ return (0, node_path_1.resolve)(process.cwd(), ".vigiles/hooks", (0, node_path_1.basename)(file) + ".json");
3473
+ }
3474
+ /**
3475
+ * Compile ONE typed hook program (authored against `vigiles/hook`) and MERGE it
3476
+ * into the active harness's native config — the hook half of `vigiles compile`
3477
+ * (there is no `compile-hook` verb; the cohesive-cli-surface rule). An import
3478
+ * outside the sanctioned API does NOT compile (capability = API surface). The
3479
+ * emitted block routes the live event to `hook-runtime run-program`; a
3480
+ * tamper-evident stamp sidecar lets the runtime refuse a hand-edited artifact.
3481
+ * The merge is idempotent (keyed by the hook PATH), so recompiling updates in
3482
+ * place and never clobbers the user's own hooks.
3483
+ */
3484
+ async function installHookFile(file, adapter, registeredProviders = []) {
3485
+ const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
3486
+ const program = await loadHookProgram(file);
3487
+ const compiled = (0, hook_program_js_1.compileHookProgram)(source, program, {
3488
+ gateCommand: `npx vigiles hook-runtime run-program ${file}`,
3489
+ dialect: adapter.dialect,
3490
+ hookProtocol: adapter.hookProtocol,
3491
+ settingsFormat: adapter.layout.settingsFormat,
3492
+ registeredProviders,
3493
+ });
3494
+ // Tamper-evident stamp beside the source (one dir → basename is unique).
3495
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(hookStampPath(file)), { recursive: true });
3496
+ (0, node_fs_1.writeFileSync)(hookStampPath(file), JSON.stringify({ file, stamp: compiled.stamp }, null, 2) + "\n");
3497
+ // Merge into the harness's native config, idempotently.
3498
+ const format = adapter.layout.settingsFormat;
3499
+ const settingsAbs = (0, node_path_1.resolve)(process.cwd(), adapter.layout.settingsPath);
3500
+ const existing = (0, node_fs_1.existsSync)(settingsAbs)
3501
+ ? format === "toml"
3502
+ ? (0, toml_1.parse)((0, node_fs_1.readFileSync)(settingsAbs, "utf-8"))
3503
+ : JSON.parse((0, node_fs_1.readFileSync)(settingsAbs, "utf-8"))
3504
+ : {};
3505
+ const merged = format === "toml"
3506
+ ? (0, hook_install_js_1.mergeHooksToml)(existing, compiled.hooks, file)
3507
+ : (0, hook_install_js_1.mergeHooksJson)(existing, compiled.hooks, file);
3508
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(settingsAbs), { recursive: true });
3509
+ (0, node_fs_1.writeFileSync)(settingsAbs, (0, hook_install_js_1.serializeConfig)(merged, format));
3510
+ // Honest gap (no silent skips): the gate/deny path is exit-2 and cross-harness,
3511
+ // but an inject/react hook's OUTPUT shape is confirmed only for Claude Code. On
3512
+ // another harness it would emit CC-shaped output that may not be read — exactly
3513
+ // the silent failure this feature exists to prevent. Flag it, loudly.
3514
+ const role = (0, hook_program_js_1.dispatchKind)(program);
3515
+ const warning = adapter.name !== "claude-code" && (role === "inject" || role === "react")
3516
+ ? `${role} output is only confirmed for Claude Code. On ${adapter.name}, ` +
3517
+ `the gate (deny→exit 2) path works, but this hook's ${role} output is ` +
3518
+ `CC-shaped and unverified — it may silently not apply. Use a gate hook on ` +
3519
+ `${adapter.name} for now, or confirm against the real binary first ` +
3520
+ `(research/compiled-hooks-codex.md §Deferred).`
3521
+ : undefined;
3522
+ return { role, settingsPath: adapter.layout.settingsPath, warning };
3523
+ }
3524
+ /**
3525
+ * Compile + install every hook (explicit paths, else discovered under
3526
+ * `.vigiles/hooks/`) into the active harness's config. Returns false if any
3527
+ * hook failed to compile.
3528
+ */
3529
+ async function installHooks(hookFiles, harnessFlag) {
3530
+ if (hookFiles.length === 0)
3531
+ return true;
3532
+ const adapter = harnessFlag
3533
+ ? (0, adapter_registry_js_1.resolveAdapter)(process.cwd(), harnessFlag)
3534
+ : (0, adapter_registry_js_1.detectAdapterResult)(process.cwd()).adapter;
3535
+ // Validate registered providers first → the names a hook's provider() ref may
3536
+ // resolve to (an unsafe provider fails the whole compile, like a bad hook).
3537
+ let registeredProviders;
3538
+ try {
3539
+ registeredProviders = await compileProviders();
3540
+ }
3541
+ catch (e) {
3542
+ if (e instanceof hook_program_js_1.HookCompileError) {
3543
+ console.error(`✗ ${e.message}`);
3544
+ return false;
3545
+ }
3546
+ throw e;
3547
+ }
3548
+ let ok = true;
3549
+ for (const file of hookFiles) {
3550
+ try {
3551
+ const r = await installHookFile(file, adapter, registeredProviders);
3552
+ console.log(`✓ ${file} → ${r.settingsPath} (role: ${r.role}, harness: ${adapter.name})`);
3553
+ if (r.warning)
3554
+ console.warn(`⚠ ${r.warning}`);
3555
+ }
3556
+ catch (e) {
3557
+ if (e instanceof hook_program_js_1.HookCompileError) {
3558
+ console.error(`✗ ${file} — ${e.message}`);
3559
+ const src = (() => {
3560
+ try {
3561
+ return (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
3562
+ }
3563
+ catch {
3564
+ return "";
3565
+ }
3566
+ })();
3567
+ if ((0, hook_program_js_1.checkHookImports)(src).length > 0) {
3568
+ console.error(" A compiled hook may import ONLY `vigiles/hook` — that is its " +
3569
+ "entire capability surface.");
3570
+ }
3571
+ ok = false;
3572
+ }
3573
+ else {
3574
+ throw e;
3575
+ }
3576
+ }
3577
+ }
3578
+ return ok;
3579
+ }
3580
+ /**
3581
+ * Gather a gate's DECLARED context providers (the trusted-host I/O step). Runs
3582
+ * each declared read-only command via execSync in the hook's cwd; a provider
3583
+ * that can't resolve yields its default (never throws). The pure registry +
3584
+ * decision logic live in core/hook-providers.ts — this only injects the real IO.
3585
+ */
3586
+ async function gatherHookContext(program) {
3587
+ const needs = (0, hook_program_js_1.hookNeeds)(program);
3588
+ if (needs.length === 0)
3589
+ return {};
3590
+ // Only load the registered-provider registry if a provider() ref is declared.
3591
+ const hasRef = needs.some((n) => typeof n !== "string" && n.kind === "provider-ref");
3592
+ const registry = hasRef ? await loadProviderRegistry() : {};
3593
+ const { execSync } = require("node:child_process");
3594
+ const { isCI } = require("ci-info");
3595
+ return (0, hook_providers_js_1.gatherContext)(needs, {
3596
+ exec: (command) => execSync(command, {
3597
+ encoding: "utf-8",
3598
+ stdio: ["ignore", "pipe", "ignore"],
3599
+ }),
3600
+ cwd: process.cwd(),
3601
+ platform: process.platform,
3602
+ isCI,
3603
+ }, registry);
3604
+ }
3605
+ /**
3606
+ * Load the registered providers (`.vigiles/providers/`) into a name→def registry
3607
+ * for `provider()` ref resolution. A bad/unloadable provider file is skipped (the
3608
+ * ref then yields its default ""), never crashes a live session.
3609
+ */
3610
+ async function loadProviderRegistry() {
3611
+ const registry = {};
3612
+ for (const file of (0, hook_install_js_1.discoverProviderFiles)(process.cwd())) {
3613
+ try {
3614
+ const def = await loadProvider(file);
3615
+ registry[def.name] = def;
3616
+ }
3617
+ catch {
3618
+ /* skip an unloadable provider file */
3619
+ }
3620
+ }
3621
+ return registry;
3622
+ }
3623
+ /** Append an observe-mode record to `.vigiles/hook-observations.jsonl` (best-effort). */
3624
+ function recordObservation(file, on, would, reason) {
3625
+ try {
3626
+ const dir = (0, node_path_1.resolve)(process.cwd(), ".vigiles");
3627
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
3628
+ const line = JSON.stringify({
3629
+ ts: new Date().toISOString(),
3630
+ hook: file,
3631
+ event: on,
3632
+ would,
3633
+ reason,
3634
+ }) + "\n";
3635
+ (0, node_fs_1.appendFileSync)((0, node_path_1.resolve)(dir, "hook-observations.jsonl"), line);
3636
+ }
3637
+ catch {
3638
+ /* recording is best-effort — never let it break a live session */
3639
+ }
3640
+ }
3641
+ /**
3642
+ * Emit a gate Decision in the harness protocol — the author never writes it.
3643
+ * `observe` mode turns a would-be block/ask into a recorded no-op (exit 0): the
3644
+ * shadow/rollout path. Harness-neutral — exit 2 / exit 0 are identical on Claude
3645
+ * Code and Codex; the record is vigiles-local.
3646
+ */
3647
+ function emitGate(decision, on, mode, file) {
3648
+ const action = (0, hook_program_js_1.gateAction)(decision, mode);
3649
+ switch (action.kind) {
3650
+ case "block":
3651
+ console.error(action.reason);
3652
+ process.exit(2);
3653
+ return;
3654
+ case "ask":
3655
+ process.stdout.write(JSON.stringify({
3656
+ hookSpecificOutput: {
3657
+ hookEventName: on,
3658
+ permissionDecision: "ask",
3659
+ permissionDecisionReason: action.reason,
3660
+ },
3661
+ }) + "\n");
3662
+ return;
3663
+ case "observe":
3664
+ recordObservation(file, on, action.would, action.reason);
3665
+ console.error(`⚠ [vigiles observe] ${on}: would ${action.would} — ${action.reason}`);
3666
+ return; // exit 0 — observe never blocks
3667
+ case "allow":
3668
+ return; // emit nothing, exit 0
3669
+ }
3670
+ }
3671
+ /**
3672
+ * Fail closed if a stamp sidecar exists and the on-disk source no longer
3673
+ * matches it — a hand-edit that smuggles in a capability breaks the stamp.
3674
+ * No sidecar → run uncompiled (e.g. a test fixture or a not-yet-compiled hook).
3675
+ */
3676
+ function verifyStampOrRefuse(file) {
3677
+ const stampPath = hookStampPath(file);
3678
+ if (!(0, node_fs_1.existsSync)(stampPath))
3679
+ return;
3680
+ try {
3681
+ const { stamp } = JSON.parse((0, node_fs_1.readFileSync)(stampPath, "utf-8"));
3682
+ const source = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), "utf-8");
3683
+ if (stamp && !(0, hook_program_js_1.verifyHookStamp)(source, stamp)) {
3684
+ console.error(`vigiles: hook ${file} does not match its compiled stamp (tampered).`);
3685
+ process.exit(2);
3686
+ }
3687
+ }
3688
+ catch {
3689
+ /* unreadable sidecar → don't block a live session on it */
3690
+ }
3691
+ }
3692
+ /**
3693
+ * `vigiles hook-runtime run-program <file>` — the runtime the compiled hooks block
3694
+ * points at. Reads the live event on stdin, loads the typed program, verifies
3695
+ * its stamp, and dispatches by role: a gate exits 2 + reason on `deny`; an
3696
+ * inject prints `additionalContext`; a react runs its effect-classified
3697
+ * command. A hook that won't load fails CLOSED (exit 2), never silent-allow.
3698
+ */
3699
+ async function runHookProgramCommand(file) {
3700
+ if (!file) {
3701
+ console.error("Usage: vigiles hook-runtime run-program <hook-file>");
3702
+ process.exit(2);
3703
+ return;
3704
+ }
3705
+ let raw = "";
3706
+ try {
3707
+ raw = (0, node_fs_1.readFileSync)(0, "utf-8");
3708
+ }
3709
+ catch {
3710
+ /* no stdin */
3711
+ }
3712
+ let event = {};
3713
+ try {
3714
+ event = JSON.parse(raw);
3715
+ }
3716
+ catch {
3717
+ /* malformed → empty event */
3718
+ }
3719
+ let program;
3720
+ try {
3721
+ program = await loadHookProgram(file);
3722
+ }
3723
+ catch {
3724
+ console.error(`vigiles: cannot load hook program ${file}`);
3725
+ process.exit(2);
3726
+ return;
3727
+ }
3728
+ verifyStampOrRefuse(file);
3729
+ switch ((0, hook_program_js_1.dispatchKind)(program)) {
3730
+ case "inject": {
3731
+ const out = (0, hook_program_js_1.runInject)(program, { source: event.source });
3732
+ process.stdout.write(JSON.stringify(out) + "\n");
3733
+ return;
3734
+ }
3735
+ case "react": {
3736
+ const reaction = (0, hook_program_js_1.runReact)(program, event);
3737
+ if (reaction.kind === "run") {
3738
+ const { spawnSync } = require("node:child_process");
3739
+ const res = spawnSync(reaction.command, {
3740
+ shell: true,
3741
+ stdio: "inherit",
3742
+ });
3743
+ process.exit(res.status ?? 0);
3744
+ }
3745
+ if (reaction.kind === "notice")
3746
+ console.error(reaction.message);
3747
+ return;
3748
+ }
3749
+ case "file-gate": {
3750
+ const ctx = await gatherHookContext(program);
3751
+ emitGate((0, hook_program_js_1.decideFileGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
3752
+ return;
3753
+ }
3754
+ case "bash-gate": {
3755
+ const ctx = await gatherHookContext(program);
3756
+ emitGate((0, hook_program_js_1.decideProgram)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
3757
+ return;
3758
+ }
3759
+ case "prompt-gate": {
3760
+ const ctx = await gatherHookContext(program);
3761
+ emitGate((0, hook_program_js_1.decidePromptGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
3762
+ return;
3763
+ }
3764
+ case "stop-gate": {
3765
+ const ctx = await gatherHookContext(program);
3766
+ emitGate((0, hook_program_js_1.decideStopGate)(program, event, ctx), program.on, (0, hook_program_js_1.hookMode)(program), file);
3767
+ return;
3768
+ }
3769
+ }
3770
+ }
3161
3771
  async function main() {
3162
3772
  const args = process.argv.slice(2);
3163
3773
  const command = args[0];
@@ -3188,14 +3798,25 @@ async function main() {
3188
3798
  break;
3189
3799
  }
3190
3800
  case "compile": {
3191
- const specs = restArgs.length > 0 ? restArgs : findSpecs();
3192
- if (specs.length === 0) {
3193
- console.log("No .spec.ts files found.");
3801
+ const harnessFlag = harnessFlagFrom(args);
3802
+ // One verb compiles every typed authoring artifact: a .spec.ts markdown,
3803
+ // a hook program → its harness config + stamp (cohesive-cli-surface). With
3804
+ // explicit args, partition by extension; bare, discover both.
3805
+ const specs = restArgs.length > 0
3806
+ ? restArgs.filter((f) => f.endsWith(".spec.ts"))
3807
+ : findSpecs();
3808
+ const hooks = restArgs.length > 0
3809
+ ? restArgs.filter((f) => !f.endsWith(".spec.ts"))
3810
+ : (0, hook_install_js_1.discoverHookFiles)(process.cwd());
3811
+ if (specs.length === 0 && hooks.length === 0) {
3812
+ console.log("No .spec.ts or .vigiles/hooks/ hook files found.");
3194
3813
  console.log("Run `vigiles init` to create one.");
3195
3814
  process.exit(0);
3196
3815
  }
3197
- const harnessFlag = harnessFlagFrom(args);
3198
- const valid = await compile(specs, config, { harnessFlag });
3816
+ let valid = true;
3817
+ if (specs.length > 0)
3818
+ valid = (await compile(specs, config, { harnessFlag })) && valid;
3819
+ valid = (await installHooks(hooks, harnessFlag)) && valid;
3199
3820
  console.log("");
3200
3821
  if (valid) {
3201
3822
  console.log("Compilation complete.");
@@ -3230,7 +3851,6 @@ async function main() {
3230
3851
  // under one marketplace.json) expands into its members and ranks them.
3231
3852
  const market = dirs.length === 1 ? (0, scan_js_1.inspectMarketplace)((0, node_path_1.resolve)(dirs[0])) : null;
3232
3853
  const targets = market && market.onDisk.length > 0 ? [...market.onDisk] : dirs;
3233
- const wantTrigger = args.includes("--trigger");
3234
3854
  if (market && market.onDisk.length === 0 && market.total > 0) {
3235
3855
  // A CURATED marketplace — every member is an external git/url plugin, so
3236
3856
  // there's nothing on disk to scan. Say so honestly instead of falling
@@ -3247,12 +3867,13 @@ async function main() {
3247
3867
  }
3248
3868
  }
3249
3869
  else if (targets.length > 1) {
3250
- // Multiple targets → rank them (the leaderboard engine).
3870
+ // Multiple targets → rank them (the leaderboard engine). `--md` emits the
3871
+ // publishable Markdown table (a README / gist / the leaderboard site).
3251
3872
  const scores = (0, leaderboard_js_1.rankPlugins)(targets);
3252
- console.log(json ? JSON.stringify(scores, null, 2) : (0, leaderboard_js_1.formatLeaderboard)(scores));
3253
- if (wantTrigger) {
3254
- console.log("\n⚠ --trigger (behavioral column) runs per single plugin; not yet wired into the leaderboard. Scan one plugin dir to probe it.");
3255
- }
3873
+ const text = args.includes("--md")
3874
+ ? (0, leaderboard_js_1.formatLeaderboardMarkdown)(scores)
3875
+ : (0, leaderboard_js_1.formatLeaderboard)(scores);
3876
+ console.log(json ? JSON.stringify(scores, null, 2) : text);
3256
3877
  }
3257
3878
  else {
3258
3879
  const root = (0, node_path_1.resolve)(targets[0]);
@@ -3267,6 +3888,13 @@ async function main() {
3267
3888
  if (!harnessFlag && det.ambiguousWith.length > 0) {
3268
3889
  console.log(`⚠ repo also matches: ${det.ambiguousWith.join(", ")} — override with --harness=<name>`);
3269
3890
  }
3891
+ // Freshness: warn if our hand-maintained CC catalog drifted from the
3892
+ // user's INSTALLED claude-code (read-local, best-effort, never throws).
3893
+ if (adapter.name === "claude-code") {
3894
+ const drift = (0, dialect_drift_js_1.formatDialectDrift)((0, dialect_drift_js_1.checkDialectDrift)());
3895
+ if (drift)
3896
+ console.log(drift);
3897
+ }
3270
3898
  console.log("");
3271
3899
  }
3272
3900
  if (args.includes("--fix-plan")) {
@@ -3278,9 +3906,27 @@ async function main() {
3278
3906
  else {
3279
3907
  console.log(json ? JSON.stringify(report, null, 2) : (0, scan_js_1.formatScanReport)(report));
3280
3908
  }
3281
- if (wantTrigger) {
3282
- const harness = adapter.name === "codex" ? "codex" : "claude-code";
3283
- await handleScanTrigger(root, args, json, harness);
3909
+ if (args.includes("--verify-mcp")) {
3910
+ // Opt-in LIVE MCP tool resolution: starts each declared server and checks
3911
+ // the agent's mcp__server__tool refs actually exist (the dynamic check no
3912
+ // static linter can do). Side-effecting (spawns servers) → opt-in only.
3913
+ const mcpErrs = await (0, scan_js_1.verifyLiveMcpTools)(report, adapter.layout, adapter.dialect);
3914
+ console.log(json
3915
+ ? JSON.stringify({ mcpContractTools: mcpErrs }, null, 2)
3916
+ : "\n" + (0, scan_js_1.formatMcpContractReport)(mcpErrs));
3917
+ }
3918
+ const capBase = flagValue(args, "--capability-diff");
3919
+ if (capBase) {
3920
+ // Did this version WIDEN the agent's blast radius vs <before>? Diffs the
3921
+ // two whole-harness capability lattices (moat #2). Informational by
3922
+ // default; `--fail-on-widen` exits non-zero (the opt-in CI gate).
3923
+ const beforeReport = (0, scan_js_1.scanPlugin)((0, node_path_1.resolve)(capBase), adapter.layout, adapter.dialect);
3924
+ const diff = (0, capability_diff_js_1.diffCapabilities)(capabilitiesOfReport(beforeReport, adapter.dialect), capabilitiesOfReport(report, adapter.dialect));
3925
+ console.log(json
3926
+ ? JSON.stringify({ capabilityDiff: diff }, null, 2)
3927
+ : "\n" + (0, capability_diff_js_1.formatCapabilityDiff)(diff));
3928
+ if (args.includes("--fail-on-widen") && diff.widened)
3929
+ process.exitCode = 1;
3284
3930
  }
3285
3931
  }
3286
3932
  break;
@@ -3291,6 +3937,9 @@ async function main() {
3291
3937
  case "scaffold-test":
3292
3938
  handleScaffoldTest(restArgs, args);
3293
3939
  break;
3940
+ case "measure":
3941
+ await handleMeasure(restArgs, args);
3942
+ break;
3294
3943
  // --- Plumbing ---
3295
3944
  case "generate-types":
3296
3945
  handleGenerateTypes(args, restArgs);
@@ -3298,9 +3947,19 @@ async function main() {
3298
3947
  case "generate-schema":
3299
3948
  handleGenerateSchema(args, restArgs);
3300
3949
  break;
3950
+ case "generate-harness":
3951
+ await handleGenerateHarness(args, restArgs);
3952
+ break;
3953
+ case "refs":
3954
+ refsCommand(restArgs[0]);
3955
+ break;
3956
+ // Hidden umbrella for runtime entrypoints emitted into hooks configs — never
3957
+ // typed by a human. See handleHookRuntime / the cohesive-cli-surface rule.
3958
+ case "hook-runtime":
3959
+ await handleHookRuntime(restArgs[0], restArgs.slice(1));
3960
+ break;
3301
3961
  default:
3302
- if (!handleSkillCommand(command, restArgs))
3303
- printUsage(command);
3962
+ printUsage(command);
3304
3963
  break;
3305
3964
  }
3306
3965
  }