vigiles 27.1.7 → 27.3.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 (43) hide show
  1. package/dist/adapter-conformance.js +15 -3
  2. package/dist/adapters/claude-code/dialect.js +30 -0
  3. package/dist/adapters/claude-code/event-capability.d.ts +20 -0
  4. package/dist/adapters/claude-code/event-capability.js +81 -0
  5. package/dist/adapters/claude-code/hook-protocol.js +26 -3
  6. package/dist/adapters/claude-code/run-scripts.js +47 -8
  7. package/dist/adapters/codex/dialect.js +19 -0
  8. package/dist/cli-main.js +100 -11
  9. package/dist/core/adopt.js +23 -5
  10. package/dist/core/compile.d.ts +40 -1
  11. package/dist/core/compile.js +76 -2
  12. package/dist/core/dialect.d.ts +43 -1
  13. package/dist/core/event-capability.d.ts +128 -0
  14. package/dist/core/event-capability.js +114 -0
  15. package/dist/core/hook-program.d.ts +38 -0
  16. package/dist/core/hook-program.js +103 -0
  17. package/dist/core/hook-protocol.d.ts +19 -3
  18. package/dist/core/instruction-weight.d.ts +86 -0
  19. package/dist/core/instruction-weight.js +86 -0
  20. package/dist/core/linters.js +3 -3
  21. package/dist/core/test-utils.d.ts +1 -2
  22. package/dist/core/test-utils.js +7 -9
  23. package/dist/core/tmp-root.d.ts +12 -0
  24. package/dist/core/tmp-root.js +59 -0
  25. package/dist/core/vocabulary-consistency.js +10 -0
  26. package/dist/eval.js +6 -5
  27. package/dist/harness-test.js +2 -2
  28. package/dist/hook-install.d.ts +9 -7
  29. package/dist/hook-install.js +75 -16
  30. package/dist/hook-runtime.js +4 -1
  31. package/dist/posix-path.js +1 -1
  32. package/dist/run-script.js +3 -3
  33. package/dist/sandbox.js +2 -2
  34. package/dist/scan-behavioral.js +2 -2
  35. package/dist/scan-files.js +10 -3
  36. package/dist/scan.d.ts +9 -1
  37. package/dist/scan.js +96 -3
  38. package/dist/setup-plan.d.ts +8 -0
  39. package/dist/test.d.ts +1 -0
  40. package/dist/test.js +17 -2
  41. package/package.json +1 -1
  42. package/skills/adopt-spec/SKILL.md +7 -1
  43. package/skills/edit-spec/SKILL.md +13 -0
@@ -15,6 +15,7 @@ exports.dispatchKind = dispatchKind;
15
15
  exports.hookMode = hookMode;
16
16
  exports.hookNeeds = hookNeeds;
17
17
  exports.hookRouting = hookRouting;
18
+ exports.checkRoleEventFit = checkRoleEventFit;
18
19
  exports.compileHookProgram = compileHookProgram;
19
20
  exports.stampHook = stampHook;
20
21
  exports.verifyHookStamp = verifyHookStamp;
@@ -88,6 +89,7 @@ const hash_js_1 = require("./hash.js");
88
89
  * fails if `@iarna/toml` reappears in a decision's module graph.
89
90
  */
90
91
  const stringifyToml = (value) => require("@iarna/toml").stringify(value);
92
+ const event_capability_js_1 = require("./event-capability.js");
91
93
  const hook_events_js_1 = require("./hook-events.js");
92
94
  const tool_contract_js_1 = require("./tool-contract.js");
93
95
  const merge_conflict_js_1 = require("./merge-conflict.js");
@@ -743,6 +745,79 @@ function renderSettingsBlock(on, matcher, gateCommand, format) {
743
745
  : { matcher, hooks: [{ type: "command", command: gateCommand }] };
744
746
  return JSON.stringify({ hooks: { [on]: [entry] } }, null, 2);
745
747
  }
748
+ /** What payload a dispatch kind must be handed to be able to decide at all. */
749
+ function requiredPayload(kind) {
750
+ switch (kind) {
751
+ case "bash-gate":
752
+ case "file-gate":
753
+ return "tool";
754
+ case "prompt-gate":
755
+ return "prompt";
756
+ case "stop-gate":
757
+ return "stop";
758
+ // An inject or a react reads whatever the event carries; neither claims a
759
+ // field that might be absent, so neither constrains the payload.
760
+ case "inject":
761
+ case "react":
762
+ return undefined;
763
+ }
764
+ }
765
+ /** Whether this dispatch kind's whole purpose is a block decision. */
766
+ function isGate(kind) {
767
+ return (kind === "bash-gate" ||
768
+ kind === "file-gate" ||
769
+ kind === "prompt-gate" ||
770
+ kind === "stop-gate");
771
+ }
772
+ function checkRoleEventFit(kind, on, hasMatcher, table) {
773
+ const verdict = (0, event_capability_js_1.capabilityOf)(table, on);
774
+ if (verdict.kind === "unknown")
775
+ return { kind: "unknown" };
776
+ const cap = verdict.capability;
777
+ const needs = requiredPayload(kind);
778
+ if (needs !== undefined && cap.carries !== needs) {
779
+ return {
780
+ kind: "dead",
781
+ message: `a ${kind} on \`${on}\` can never decide: the role reads the event's ` +
782
+ `${needs}, and ${on} carries ${cap.carries === "none" ? "nothing" : cap.carries}. ` +
783
+ `The field it reads is absent, so the hook runs and waves everything through.`,
784
+ };
785
+ }
786
+ if (hasMatcher && !cap.matcher) {
787
+ return {
788
+ kind: "dead",
789
+ message: `a tool matcher is meaningless on \`${on}\` — it carries ` +
790
+ `${cap.carries === "none" ? "nothing" : cap.carries}, not a tool, so the ` +
791
+ `matcher can match nothing and the hook never fires.`,
792
+ };
793
+ }
794
+ if (isGate(kind) && !cap.honours.includes("veto")) {
795
+ // The degraded case: it still reaches the model, it just does not block.
796
+ if (cap.honours.includes("feedback")) {
797
+ return {
798
+ kind: "degraded",
799
+ message: `\`${on}\` does not honour a veto — a deny there reaches the model as ` +
800
+ `FEEDBACK after the action already happened. Fine as a nudge, but this ` +
801
+ `is a ${kind}, so it stops nothing. Use a react, or gate on an event ` +
802
+ `that vetoes.`,
803
+ };
804
+ }
805
+ return {
806
+ kind: "dead",
807
+ message: `a ${kind} on \`${on}\` blocks nothing: the event honours ` +
808
+ `${cap.honours.length === 0 ? "no channel at all" : cap.honours.join(", ")}, ` +
809
+ `so a deny is discarded silently — no veto, and no feedback to the model.`,
810
+ };
811
+ }
812
+ if (kind === "inject" && !cap.honours.includes("inject")) {
813
+ return {
814
+ kind: "dead",
815
+ message: `an inject on \`${on}\` reaches nobody — the event does not honour ` +
816
+ `additionalContext, so the text goes to the debug log.`,
817
+ };
818
+ }
819
+ return { kind: "ok" };
820
+ }
746
821
  /**
747
822
  * Compile a hook program from its source. Runs the capability check FIRST (an
748
823
  * out-of-API import does NOT compile), validates the event against the target
@@ -761,6 +836,26 @@ function compileHookProgram(source, hook, opts = {}) {
761
836
  if (violations.length > 0) {
762
837
  throw new HookCompileError(`hook program uses capabilities outside \`${ALLOWED_IMPORT}\`: ${violations.join(", ")} — only the sanctioned API is allowed (capability = API surface).`);
763
838
  }
839
+ // A BARE gate is a Bash gate by construction — `hookRouting` emits the matcher
840
+ // `Bash` for it unconditionally, and `HookProgram` has no `match` field. So a
841
+ // `match` here is a field the author wrote and the compiler ignores, and the
842
+ // result is the WORST of the six measured fixtures (d): not a dead hook but a
843
+ // LIVE one guarding the wrong thing — `defineHook({match: tools("mcp__…")})`
844
+ // compiled to a matcher of `Bash`, asking on every shell command while the MCP
845
+ // call it was written for went straight through.
846
+ //
847
+ // `tsc` rejects the excess property, which is why this looked covered. It is
848
+ // not: a compiled hook is often `.mjs` (this repo's own two are), and
849
+ // `vigiles compile` does not run `tsc` at all — so for a JS author the type
850
+ // was never in the path. Same defect, different population; the rule the
851
+ // repo already states as prevent-at-stage-1 AND detect-at-stage-3.
852
+ if (!("role" in hook) && "match" in hook) {
853
+ throw new HookCompileError(`a bare hook gate is a BASH gate — it matches \`Bash\` by construction, so ` +
854
+ `the \`match\` you passed is ignored and the hook would guard shell ` +
855
+ `commands instead of what you named. For a file tool use ` +
856
+ `experimental_defineFileGate; for any other tool matcher use ` +
857
+ `experimental_defineReact, whose event carries the tool.`);
858
+ }
764
859
  const { on, matcher: rawMatcher } = hookRouting(hook);
765
860
  // A tool pattern that is not a valid regex cannot match under the semantics the
766
861
  // emitted matcher is read with, so it would compile to a hook that never fires.
@@ -804,6 +899,13 @@ function compileHookProgram(source, hook, opts = {}) {
804
899
  throw new HookCompileError(fatal[0].message);
805
900
  }
806
901
  }
902
+ // …and an event the harness DOES fire can still be one this role cannot work
903
+ // on. Checked against the dialect's capability table; silent where the table
904
+ // has no row, so our gaps never become the author's error.
905
+ const fit = checkRoleEventFit(dispatchKind(hook), on, rawMatcher !== undefined, opts.dialect?.eventCapabilities);
906
+ if (fit.kind === "dead")
907
+ throw new HookCompileError(fit.message);
908
+ const fitWarnings = fit.kind === "degraded" ? [fit.message] : [];
807
909
  // A `needs` entry that isn't a built-in provider never resolves — reject it
808
910
  // (the typo-won't-compile guarantee, for JS authors the type can't reach).
809
911
  const needs = hookNeeds(hook);
@@ -830,6 +932,7 @@ function compileHookProgram(source, hook, opts = {}) {
830
932
  hooks: { [on]: [entry] },
831
933
  settingsBlock: renderSettingsBlock(on, matcher, gateCommand, opts.settingsFormat ?? "json"),
832
934
  stamp: stampHook(source),
935
+ ...(fitWarnings.length > 0 ? { warnings: fitWarnings } : {}),
833
936
  };
834
937
  }
835
938
  /** Stamp a hook's source (the integrity.ts pattern, applied to a hook artifact). */
@@ -40,11 +40,27 @@ export interface HookProtocol {
40
40
  * **which events honor it** — and encoding it here is what makes "this harness
41
41
  * can deliver an inject hook" a TESTED contract instead of an assumption. Both
42
42
  * Claude Code and Codex support the main lifecycle events (SessionStart,
43
- * UserPromptSubmit, PostToolUse); a few (Stop, SubagentStop, PreCompact) carry
44
- * no context on either. An empty list means the harness cannot inject context
45
- * from a hook at all. Verified for Codex against the official hooks docs
43
+ * UserPromptSubmit, PreToolUse, PostToolUse); beyond that they DIVERGE, which
44
+ * is why this is a port and not a core constant: Claude Code also honors
45
+ * `Stop` (measured 2026-09-15 on 2.1.273, headless), Codex instead honors
46
+ * `SubagentStart`. An empty list means the harness cannot inject context from
47
+ * a hook at all. Verified for Codex against the official hooks docs
46
48
  * (developers.openai.com/codex/hooks). The conformance kit asserts a
47
49
  * shell-hook harness declares a non-empty set.
50
+ *
51
+ * ⚠️ This comment previously asserted that "a few (Stop, SubagentStop,
52
+ * PreCompact) carry no context on EITHER" harness. For Claude Code's `Stop`
53
+ * that was wrong, and the cost was structural rather than cosmetic: an
54
+ * unmeasured claim in a doc-comment became the runtime's emit gate, so three
55
+ * react hooks were reported undeliverable-by-vocabulary when the harness
56
+ * would have delivered them. A per-harness fact belongs in the adapter WITH
57
+ * its measurement; the residue (`SubagentStop`, `PreCompact`) stays unclaimed
58
+ * here rather than re-asserted. *
59
+ * @deprecated Superseded by `HarnessDialect.eventCapabilities`
60
+ * (`honours: "inject"`), which is asserted to reproduce this list exactly. It
61
+ * lives on the DIALECT rather than here because three sibling event facts
62
+ * already did, and because the browser-side engine is handed a dialect and
63
+ * never a protocol. Kept for third-party adapters; goes in the next major.
48
64
  */
49
65
  readonly injectableEvents: readonly string[];
50
66
  /**
@@ -0,0 +1,86 @@
1
+ /**
2
+ * How heavy are the instructions this harness loads WITHOUT BEING ASKED — and
3
+ * what does the harness do when that is too much.
4
+ *
5
+ * WHY THIS IS NOT "the size of CLAUDE.md". Measured 2026-09-16 in a consumer
6
+ * repo: a 4 101-line root instruction file was "cut" to 2 665 lines by moving
7
+ * 225 837 characters of it into a sibling directory, and the cost of a request
8
+ * did not move at all — because the harness loads that directory
9
+ * unconditionally too. Splitting a file that is loaded either way relocates
10
+ * bytes; it does not remove them. So the number that matters is the SUM over
11
+ * everything loaded without a decision, and a per-file check silently INVITES
12
+ * the evasion (it rewards the split that changes nothing).
13
+ *
14
+ * WHY THE UNIT IS PER-HARNESS AND NOT TOKENS. The harnesses measure different
15
+ * things and neither gates on tokens:
16
+ *
17
+ * - Claude Code counts CHARACTERS and WARNS ("Large file will impact
18
+ * performance"); the instructions still reach the model.
19
+ * - Codex counts BYTES (`project_doc_max_bytes`, default 32 KiB) and
20
+ * TRUNCATES — silently. Its own source says so: "Maximum number of bytes of
21
+ * the documentation that will be embedded. Larger files are *silently
22
+ * truncated*" (openai/codex#7138, CLOSED AS NOT PLANNED, so this is the
23
+ * standing behaviour rather than a bug in flight).
24
+ *
25
+ * That asymmetry is the whole point of reporting `onExceed`: over budget on
26
+ * Claude Code costs money and attention, over budget on Codex means some of
27
+ * your rules DO NOT EXIST for the model and nothing tells you which. The same
28
+ * number carries a different severity per harness, so the harness must supply
29
+ * it — hence a port field, not a constant.
30
+ *
31
+ * NOT A GATE, AND THAT IS MEASURED. Both corpora this was built against sit at
32
+ * roughly four times the Claude Code threshold. A rule that fails every real
33
+ * repo on day one is switched off on day one (`lint-rule-calibration`: severity
34
+ * tracks confidence, and a check nobody leaves on catches nothing). So the
35
+ * first consumer is `audit`, as a REPORT. It earns a severity when a corpus
36
+ * exists that it would not immediately fail.
37
+ */
38
+ /** What the harness counts, and what it does when the count is exceeded. */
39
+ export interface InstructionBudget {
40
+ /** Claude Code counts characters; Codex counts bytes. Never tokens. */
41
+ readonly unit: "chars" | "bytes";
42
+ /** The harness's own threshold, in `unit`. */
43
+ readonly limit: number;
44
+ /**
45
+ * `warns` — the instructions still reach the model (Claude Code).
46
+ * `truncates` — everything past the limit DOES NOT EXIST for the model, with
47
+ * no signal in the session (Codex). The difference is losing money versus
48
+ * losing rules.
49
+ */
50
+ readonly onExceed: "warns" | "truncates";
51
+ /** The vendor artifact this was read from, version included. */
52
+ readonly capturedFrom: string;
53
+ /**
54
+ * Globs the harness loads WITHOUT the user asking — the set the SUM is taken
55
+ * over. A file reachable only by an explicit read does not belong here; that
56
+ * is exactly the distinction the relocation trick exploits.
57
+ */
58
+ readonly alwaysLoaded: readonly string[];
59
+ }
60
+ /** One file's contribution, so a report can say WHERE the weight is. */
61
+ export interface WeighedFile {
62
+ readonly path: string;
63
+ readonly size: number;
64
+ }
65
+ export interface InstructionWeight {
66
+ readonly unit: "chars" | "bytes";
67
+ readonly limit: number;
68
+ readonly onExceed: "warns" | "truncates";
69
+ /** Heaviest first — a report's first line should name the biggest payer. */
70
+ readonly files: readonly WeighedFile[];
71
+ /** The number that matters: everything loaded without a decision. */
72
+ readonly total: number;
73
+ /** `null` when within budget; otherwise how far over, in `unit`. */
74
+ readonly overBy: number | null;
75
+ }
76
+ /** Size in the harness's own unit. Bytes and chars differ on any non-ASCII text. */
77
+ export declare function sizeIn(text: string, unit: "chars" | "bytes"): number;
78
+ /**
79
+ * Weigh every unconditionally-loaded file in a file map.
80
+ *
81
+ * Takes a MAP rather than a directory so the same function serves the CLI and
82
+ * the browser engine (the `scan-files.ts` split), and so a test states its
83
+ * input instead of building a tree.
84
+ */
85
+ export declare function weighInstructions(files: Readonly<Record<string, string>>, budget: InstructionBudget): InstructionWeight;
86
+ //# sourceMappingURL=instruction-weight.d.ts.map
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ /**
3
+ * How heavy are the instructions this harness loads WITHOUT BEING ASKED — and
4
+ * what does the harness do when that is too much.
5
+ *
6
+ * WHY THIS IS NOT "the size of CLAUDE.md". Measured 2026-09-16 in a consumer
7
+ * repo: a 4 101-line root instruction file was "cut" to 2 665 lines by moving
8
+ * 225 837 characters of it into a sibling directory, and the cost of a request
9
+ * did not move at all — because the harness loads that directory
10
+ * unconditionally too. Splitting a file that is loaded either way relocates
11
+ * bytes; it does not remove them. So the number that matters is the SUM over
12
+ * everything loaded without a decision, and a per-file check silently INVITES
13
+ * the evasion (it rewards the split that changes nothing).
14
+ *
15
+ * WHY THE UNIT IS PER-HARNESS AND NOT TOKENS. The harnesses measure different
16
+ * things and neither gates on tokens:
17
+ *
18
+ * - Claude Code counts CHARACTERS and WARNS ("Large file will impact
19
+ * performance"); the instructions still reach the model.
20
+ * - Codex counts BYTES (`project_doc_max_bytes`, default 32 KiB) and
21
+ * TRUNCATES — silently. Its own source says so: "Maximum number of bytes of
22
+ * the documentation that will be embedded. Larger files are *silently
23
+ * truncated*" (openai/codex#7138, CLOSED AS NOT PLANNED, so this is the
24
+ * standing behaviour rather than a bug in flight).
25
+ *
26
+ * That asymmetry is the whole point of reporting `onExceed`: over budget on
27
+ * Claude Code costs money and attention, over budget on Codex means some of
28
+ * your rules DO NOT EXIST for the model and nothing tells you which. The same
29
+ * number carries a different severity per harness, so the harness must supply
30
+ * it — hence a port field, not a constant.
31
+ *
32
+ * NOT A GATE, AND THAT IS MEASURED. Both corpora this was built against sit at
33
+ * roughly four times the Claude Code threshold. A rule that fails every real
34
+ * repo on day one is switched off on day one (`lint-rule-calibration`: severity
35
+ * tracks confidence, and a check nobody leaves on catches nothing). So the
36
+ * first consumer is `audit`, as a REPORT. It earns a severity when a corpus
37
+ * exists that it would not immediately fail.
38
+ */
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.sizeIn = sizeIn;
41
+ exports.weighInstructions = weighInstructions;
42
+ /** Size in the harness's own unit. Bytes and chars differ on any non-ASCII text. */
43
+ function sizeIn(text, unit) {
44
+ return unit === "chars" ? text.length : Buffer.byteLength(text, "utf8");
45
+ }
46
+ /**
47
+ * Match a path against one glob. Deliberately tiny: the patterns here are
48
+ * `alwaysLoaded` entries an ADAPTER writes, not user input — `CLAUDE.md`,
49
+ * `.claude/rules/**`. `*` stops at a separator, `**` crosses them.
50
+ */
51
+ function matchesGlob(path, glob) {
52
+ const rx = glob
53
+ .split(/(\*\*\/|\*\*|\*)/)
54
+ .map((part) => part === "**/"
55
+ ? "(?:.*/)?"
56
+ : part === "**"
57
+ ? ".*"
58
+ : part === "*"
59
+ ? "[^/]*"
60
+ : part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
61
+ .join("");
62
+ return new RegExp(`^${rx}$`).test(path);
63
+ }
64
+ /**
65
+ * Weigh every unconditionally-loaded file in a file map.
66
+ *
67
+ * Takes a MAP rather than a directory so the same function serves the CLI and
68
+ * the browser engine (the `scan-files.ts` split), and so a test states its
69
+ * input instead of building a tree.
70
+ */
71
+ function weighInstructions(files, budget) {
72
+ const weighed = Object.entries(files)
73
+ .filter(([path]) => budget.alwaysLoaded.some((g) => matchesGlob(path, g)))
74
+ .map(([path, text]) => ({ path, size: sizeIn(text, budget.unit) }))
75
+ .sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
76
+ const total = weighed.reduce((sum, f) => sum + f.size, 0);
77
+ return {
78
+ unit: budget.unit,
79
+ limit: budget.limit,
80
+ onExceed: budget.onExceed,
81
+ files: weighed,
82
+ total,
83
+ overBy: total > budget.limit ? total - budget.limit : null,
84
+ };
85
+ }
86
+ //# sourceMappingURL=instruction-weight.js.map
@@ -36,7 +36,6 @@ exports.parseGolangciEnabledLinters = parseGolangciEnabledLinters;
36
36
  exports.clearCedarCache = clearCedarCache;
37
37
  exports.checkLinterRule = checkLinterRule;
38
38
  const node_fs_1 = require("node:fs");
39
- const node_os_1 = require("node:os");
40
39
  const node_path_1 = require("node:path");
41
40
  const edit_distance_js_1 = require("./edit-distance.js");
42
41
  Object.defineProperty(exports, "editDistance", { enumerable: true, get: function () { return edit_distance_js_1.editDistance; } });
@@ -72,6 +71,7 @@ function augmentToolPath() {
72
71
  }
73
72
  }
74
73
  augmentToolPath();
74
+ const tmp_root_js_1 = require("./tmp-root.js");
75
75
  // ---------------------------------------------------------------------------
76
76
  // Parsing enforcement references
77
77
  // ---------------------------------------------------------------------------
@@ -267,7 +267,7 @@ function getDetektDefaultRules() {
267
267
  if (DETEKT_DEFAULT_RULE_CACHE)
268
268
  return DETEKT_DEFAULT_RULE_CACHE;
269
269
  let rules = new Set();
270
- const tmp = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-detekt-"));
270
+ const tmp = (0, tmp_root_js_1.makeTmpDir)("detekt");
271
271
  try {
272
272
  const target = (0, node_path_1.join)(tmp, "generated-default.yml");
273
273
  (0, node_child_process_1.execSync)(`detekt --generate-config --config ${target}`, {
@@ -481,7 +481,7 @@ function runCheckstyleProbe(configPath, probePath) {
481
481
  * either placement instantiates.
482
482
  */
483
483
  function checkstyleModuleInstantiates(ruleName) {
484
- const tmp = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-checkstyle-"));
484
+ const tmp = (0, tmp_root_js_1.makeTmpDir)("checkstyle");
485
485
  try {
486
486
  const probe = (0, node_path_1.join)(tmp, "Probe.java");
487
487
  (0, node_fs_1.writeFileSync)(probe, "class Probe {}\n");
@@ -1,6 +1,5 @@
1
1
  import type { ClaudeSpec } from "./spec.js";
2
- export declare function makeTmpDir(suffix?: string): string;
3
- export declare function cleanupTmpDir(dir: string): void;
2
+ export { makeTmpDir, cleanupTmpDir } from "./tmp-root.js";
4
3
  export declare function makeSpec(overrides?: Partial<ClaudeSpec>): ClaudeSpec;
5
4
  declare function git(cwd: string, cmd: string): string;
6
5
  export declare function initGitRepo(dir: string): void;
@@ -1,20 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.makeTmpDir = makeTmpDir;
4
- exports.cleanupTmpDir = cleanupTmpDir;
3
+ exports.cleanupTmpDir = exports.makeTmpDir = void 0;
5
4
  exports.makeSpec = makeSpec;
6
5
  exports.initGitRepo = initGitRepo;
7
6
  exports.git = git;
8
7
  const node_fs_1 = require("node:fs");
9
8
  const node_path_1 = require("node:path");
10
- const node_os_1 = require("node:os");
11
9
  const node_child_process_1 = require("node:child_process");
12
- function makeTmpDir(suffix = "test") {
13
- return (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), `vigiles-${suffix}-`));
14
- }
15
- function cleanupTmpDir(dir) {
16
- (0, node_fs_1.rmSync)(dir, { recursive: true, force: true });
17
- }
10
+ // Re-exported, not redefined: the temp root lives in `tmp-root.ts` because the
11
+ // runtime modules that need one must not pull in `makeSpec`/`initGitRepo` and
12
+ // their dependencies. One definition, two doors.
13
+ var tmp_root_js_1 = require("./tmp-root.js");
14
+ Object.defineProperty(exports, "makeTmpDir", { enumerable: true, get: function () { return tmp_root_js_1.makeTmpDir; } });
15
+ Object.defineProperty(exports, "cleanupTmpDir", { enumerable: true, get: function () { return tmp_root_js_1.cleanupTmpDir; } });
18
16
  function makeSpec(overrides) {
19
17
  return {
20
18
  _specType: "claude",
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Create a temporary directory and return the path with every symlink resolved.
3
+ *
4
+ * The `realpathSync` wrapper is the whole point: it must stay OUTSIDE
5
+ * `mkdtempSync`, because the directory has to exist before it can be resolved.
6
+ *
7
+ * @param suffix distinguishes roots in a listing; the name is `vigiles-<suffix>-*`
8
+ */
9
+ export declare function makeTmpDir(suffix?: string): string;
10
+ /** Remove a root made by {@link makeTmpDir}. Safe on a path that is already gone. */
11
+ export declare function cleanupTmpDir(dir: string): void;
12
+ //# sourceMappingURL=tmp-root.d.ts.map
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.makeTmpDir = makeTmpDir;
4
+ exports.cleanupTmpDir = cleanupTmpDir;
5
+ /**
6
+ * The ONE way to make a temporary fixture root, with its symlinks resolved.
7
+ *
8
+ * ── THE TRAP THIS EXISTS TO ABOLISH (issue #241, measured) ──────────────────────
9
+ * On macOS `os.tmpdir()` returns a path under `/var/folders/…`, and `/var` is
10
+ * itself a symlink to `/private/var`. Node resolves a module's own URL to the
11
+ * REALPATH but leaves `process.argv[1]`, and any path a test composed itself,
12
+ * exactly as typed. A fixture built under `tmpdir()` therefore carries two
13
+ * spellings of one directory, and anything comparing them is red on macOS and
14
+ * green on Linux:
15
+ *
16
+ * const d = mkdtempSync(join(tmpdir(), "probe-"));
17
+ * // import.meta.url → "file:///private/var/folders/…/probe.mjs"
18
+ * // process.argv[1] → "/var/folders/…/probe.mjs"
19
+ *
20
+ * A consumer hit that three times in one suite: a resolver's return value against
21
+ * a composed expectation, an `isMain` control case, and a git fixture whose
22
+ * repository root git reported realpath'd while the relative path was computed
23
+ * against the other spelling (`zernie/research-paper-pipeline#9`).
24
+ *
25
+ * 🔴 WHY A SHIPPED HELPER AND NOT THREE FIXED CALL SITES. Those call sites were
26
+ * hand-rolled because the product shipped nothing to roll: `mkdtempSync(join(
27
+ * tmpdir(), …))` is the shape a harness author reaches for, and it is the shape
28
+ * that carries the trap. Fixing our own sites leaves every future author to
29
+ * rediscover it. So this is exported from the harness surface (`vigiles`), where
30
+ * `recordCheck` and `skip` already live.
31
+ *
32
+ * 🔴 WHY ITS OWN MODULE. `core/test-utils.ts` also carries `makeSpec` and
33
+ * `initGitRepo`, which pull in spec types and `execSync`; the runtime modules
34
+ * that need a temp root must not drag those in. `test-utils` re-exports these two
35
+ * so existing imports keep working, and there is still exactly one definition.
36
+ *
37
+ * ⚠️ On Linux `realpathSync` is the identity here, so no behavioural test on this
38
+ * platform can hold the fix in place. What holds it is the regression test beside
39
+ * this file, which builds its own symlink rather than relying on the platform's.
40
+ */
41
+ const node_fs_1 = require("node:fs");
42
+ const node_path_1 = require("node:path");
43
+ const node_os_1 = require("node:os");
44
+ /**
45
+ * Create a temporary directory and return the path with every symlink resolved.
46
+ *
47
+ * The `realpathSync` wrapper is the whole point: it must stay OUTSIDE
48
+ * `mkdtempSync`, because the directory has to exist before it can be resolved.
49
+ *
50
+ * @param suffix distinguishes roots in a listing; the name is `vigiles-<suffix>-*`
51
+ */
52
+ function makeTmpDir(suffix = "test") {
53
+ return (0, node_fs_1.realpathSync)((0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), `vigiles-${suffix}-`)));
54
+ }
55
+ /** Remove a root made by {@link makeTmpDir}. Safe on a path that is already gone. */
56
+ function cleanupTmpDir(dir) {
57
+ (0, node_fs_1.rmSync)(dir, { recursive: true, force: true });
58
+ }
59
+ //# sourceMappingURL=tmp-root.js.map
@@ -22,17 +22,27 @@ function dialectVocabularyProblems(dialect) {
22
22
  // A block-semantics subset that names an event the dialect doesn't fire is a
23
23
  // rule about nothing.
24
24
  const events = new Set(dialect.hookEvents);
25
+ // 🔴 READ THE DECLARED FIELDS, not the effective answer. This check is about
26
+ // junk IN a dialect's own declarations, so routing it through the
27
+ // capability-table readers (which prefer the table) made it stop looking at
28
+ // the very field it polices — caught by `vocabulary.test.ts` the moment the
29
+ // Claude Code dialect gained a table. The readers are for CONSUMERS asking
30
+ // "what does this harness do?"; a consistency check is not one of those.
31
+ /* eslint-disable @typescript-eslint/no-deprecated -- policing the legacy
32
+ fields themselves is this function's entire job. */
25
33
  for (const [field, list] of [
26
34
  ["noEffectHookEvents", dialect.noEffectHookEvents ?? []],
27
35
  [
28
36
  "permissionDecisionHookEvents",
29
37
  dialect.permissionDecisionHookEvents ?? [],
30
38
  ],
39
+ ["eventCapabilities", Object.keys(dialect.eventCapabilities?.events ?? {})],
31
40
  ])
32
41
  for (const event of list)
33
42
  if (!events.has(event))
34
43
  problems.push(`hook event "${event}" is in ${field} but not in hookEvents — ` +
35
44
  `it describes an event this dialect says never fires`);
45
+ /* eslint-enable @typescript-eslint/no-deprecated */
36
46
  return problems;
37
47
  }
38
48
  /**
package/dist/eval.js CHANGED
@@ -84,6 +84,7 @@ const check_count_js_1 = require("./check-count.js");
84
84
  const coverage_probe_js_1 = require("./coverage-probe.js");
85
85
  const tool_intercept_js_1 = require("./tool-intercept.js");
86
86
  const tool_stub_js_1 = require("./tool-stub.js");
87
+ const tmp_root_js_1 = require("./core/tmp-root.js");
87
88
  function writeFiles(cwd, files) {
88
89
  for (const [p, content] of Object.entries(files)) {
89
90
  const full = (0, node_path_1.resolve)(cwd, p);
@@ -650,7 +651,7 @@ function whichSkillsFired(trace) {
650
651
  * falls out of one pass over the prompts (N× cheaper than re-running per pair).
651
652
  */
652
653
  async function runSkillSelectionTrial(args) {
653
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-selection-"));
654
+ const cwd = (0, tmp_root_js_1.makeTmpDir)("selection");
654
655
  try {
655
656
  if (args.fixture)
656
657
  writeFiles(cwd, args.fixture);
@@ -1008,7 +1009,7 @@ function seedEphemeralHome(throwawayHome, realHome, keep = exports.EPHEMERAL_HOM
1008
1009
  }
1009
1010
  /** Execute one trial in a fresh sandbox; returns its metric row + usage. */
1010
1011
  async function executeTrial(spec, arm, trialIndex, runner, cfg) {
1011
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-eval-"));
1012
+ const cwd = (0, tmp_root_js_1.makeTmpDir)("eval");
1012
1013
  try {
1013
1014
  const resolved = (0, plugin_loader_js_1.resolveHarness)({
1014
1015
  plugin: arm.plugin,
@@ -1456,7 +1457,7 @@ function packageSkillsDir(skillsDir, opts = {}) {
1456
1457
  const abs = (0, node_path_1.resolve)(skillsDir);
1457
1458
  if (!(0, node_fs_1.existsSync)(abs))
1458
1459
  throw new Error(`skillsDir not found: ${skillsDir} (resolved ${abs})`);
1459
- const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-skills-"));
1460
+ const root = (0, tmp_root_js_1.makeTmpDir)("skills");
1460
1461
  (0, node_fs_1.mkdirSync)((0, node_path_1.join)(root, ".claude-plugin"), { recursive: true });
1461
1462
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: opts.name ?? "vigiles-loose-skills", version: "0.0.0" }, null, 2));
1462
1463
  const skillsOut = (0, node_path_1.join)(root, "skills");
@@ -1649,7 +1650,7 @@ function copySkillsInto(src, skillsOut, stub, present) {
1649
1650
  * dir. Pure (filesystem only).
1650
1651
  */
1651
1652
  function packageInstallSet(opts) {
1652
- const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-harness-"));
1653
+ const root = (0, tmp_root_js_1.makeTmpDir)("harness");
1653
1654
  try {
1654
1655
  (0, node_fs_1.mkdirSync)((0, node_path_1.join)(root, ".claude-plugin"), { recursive: true });
1655
1656
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: opts.name, version: "0.0.0" }, null, 2));
@@ -1737,7 +1738,7 @@ function resolveTriggerPluginDir(spec) {
1737
1738
  /** Run one prompt set × trials through `runner`, aggregating fired counts. */
1738
1739
  /** Run one trigger trial in a throwaway cwd (fixture seeded) → fired 0/1. */
1739
1740
  async function runTriggerTrial(prompt, cfg, runner) {
1740
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-trigger-"));
1741
+ const cwd = (0, tmp_root_js_1.makeTmpDir)("trigger");
1741
1742
  try {
1742
1743
  if (cfg.fixture)
1743
1744
  writeFiles(cwd, cfg.fixture);
@@ -43,7 +43,6 @@ exports.runHarness = runHarness;
43
43
  */
44
44
  const node_child_process_1 = require("node:child_process");
45
45
  const node_fs_1 = require("node:fs");
46
- const node_os_1 = require("node:os");
47
46
  const node_path_1 = require("node:path");
48
47
  const adapter_conformance_js_1 = require("./adapter-conformance.js");
49
48
  const check_count_js_1 = require("./check-count.js");
@@ -61,6 +60,7 @@ var sandbox_js_2 = require("./sandbox.js");
61
60
  Object.defineProperty(exports, "decideSandbox", { enumerable: true, get: function () { return sandbox_js_2.decideSandbox; } });
62
61
  Object.defineProperty(exports, "specTrusted", { enumerable: true, get: function () { return sandbox_js_2.specTrusted; } });
63
62
  Object.defineProperty(exports, "sandboxAvailable", { enumerable: true, get: function () { return sandbox_js_2.sandboxAvailable; } });
63
+ const tmp_root_js_1 = require("./core/tmp-root.js");
64
64
  function contentText(content) {
65
65
  if (typeof content === "string")
66
66
  return content;
@@ -443,7 +443,7 @@ async function runHarnessTest(spec, opts = {}) {
443
443
  if (decision.action === "sandbox" && !isClaudeCode) {
444
444
  throw new Error(`sandbox not supported for ${driver.runtime.name}: confined execution is Claude Code only. Pass sandbox: false to run ${driver.runtime.name} unconfined (you audited the code, or trust the outer container).`);
445
445
  }
446
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-harness-"));
446
+ const cwd = (0, tmp_root_js_1.makeTmpDir)("harness");
447
447
  const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
448
448
  plugin: spec.plugin,
449
449
  settings: spec.settings,
@@ -17,7 +17,7 @@ interface HookEntry {
17
17
  /** The CC-shaped structured block a compiled hook program carries. */
18
18
  export type CompiledHooks = Record<string, readonly HookEntry[]>;
19
19
  interface SettingsJson {
20
- hooks?: Record<string, HookEntry[]>;
20
+ hooks?: Readonly<Record<string, readonly HookEntry[]>>;
21
21
  [k: string]: unknown;
22
22
  }
23
23
  /**
@@ -58,17 +58,19 @@ export declare function normalizeHookRef(hookPath: string, cwd?: string): string
58
58
  export declare function hookGateRef(ref: string, projectRootTokens: readonly string[] | undefined): string;
59
59
  /**
60
60
  * Idempotently merge a compiled hook's block into an existing `settings.json`
61
- * object. Entries managed by THIS hook file (the runtime command references
62
- * `hookPath`) are replaced; every unrelated entry — including the user's own
63
- * hand-written hooks — is preserved.
61
+ * object. Commands managed by THIS hook file (the runtime command references
62
+ * `hookPath`) are replaced; every unrelated command — including the user's own
63
+ * hand-written hooks SHARING A MATCHER BLOCK with ours — is preserved. See
64
+ * {@link withoutHookCommands} for why the granularity is the command and not
65
+ * the entry.
64
66
  */
65
67
  export declare function mergeHooksJson(existing: SettingsJson, compiled: CompiledHooks, hookPath: string): SettingsJson;
66
68
  interface TomlHookEntry {
67
- matcher?: string;
68
- command: string;
69
+ readonly matcher?: string;
70
+ readonly command: string;
69
71
  }
70
72
  interface ConfigToml {
71
- hooks?: Record<string, TomlHookEntry[]>;
73
+ hooks?: Readonly<Record<string, readonly TomlHookEntry[]>>;
72
74
  [k: string]: unknown;
73
75
  }
74
76
  /** The TOML sibling of {@link mergeHooksJson} (Codex `[[hooks.<event>]]`). */