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.
- package/dist/adapter-conformance.js +15 -3
- package/dist/adapters/claude-code/dialect.js +30 -0
- package/dist/adapters/claude-code/event-capability.d.ts +20 -0
- package/dist/adapters/claude-code/event-capability.js +81 -0
- package/dist/adapters/claude-code/hook-protocol.js +26 -3
- package/dist/adapters/claude-code/run-scripts.js +47 -8
- package/dist/adapters/codex/dialect.js +19 -0
- package/dist/cli-main.js +100 -11
- package/dist/core/adopt.js +23 -5
- package/dist/core/compile.d.ts +40 -1
- package/dist/core/compile.js +76 -2
- package/dist/core/dialect.d.ts +43 -1
- package/dist/core/event-capability.d.ts +128 -0
- package/dist/core/event-capability.js +114 -0
- package/dist/core/hook-program.d.ts +38 -0
- package/dist/core/hook-program.js +103 -0
- package/dist/core/hook-protocol.d.ts +19 -3
- package/dist/core/instruction-weight.d.ts +86 -0
- package/dist/core/instruction-weight.js +86 -0
- package/dist/core/linters.js +3 -3
- package/dist/core/test-utils.d.ts +1 -2
- package/dist/core/test-utils.js +7 -9
- package/dist/core/tmp-root.d.ts +12 -0
- package/dist/core/tmp-root.js +59 -0
- package/dist/core/vocabulary-consistency.js +10 -0
- package/dist/eval.js +6 -5
- package/dist/harness-test.js +2 -2
- package/dist/hook-install.d.ts +9 -7
- package/dist/hook-install.js +75 -16
- package/dist/hook-runtime.js +4 -1
- package/dist/posix-path.js +1 -1
- package/dist/run-script.js +3 -3
- package/dist/sandbox.js +2 -2
- package/dist/scan-behavioral.js +2 -2
- package/dist/scan-files.js +10 -3
- package/dist/scan.d.ts +9 -1
- package/dist/scan.js +96 -3
- package/dist/setup-plan.d.ts +8 -0
- package/dist/test.d.ts +1 -0
- package/dist/test.js +17 -2
- package/package.json +1 -1
- package/skills/adopt-spec/SKILL.md +7 -1
- 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);
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
package/dist/core/linters.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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;
|
package/dist/core/test-utils.js
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
1741
|
+
const cwd = (0, tmp_root_js_1.makeTmpDir)("trigger");
|
|
1741
1742
|
try {
|
|
1742
1743
|
if (cfg.fixture)
|
|
1743
1744
|
writeFiles(cwd, cfg.fixture);
|
package/dist/harness-test.js
CHANGED
|
@@ -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,
|
|
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,
|
package/dist/hook-install.d.ts
CHANGED
|
@@ -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.
|
|
62
|
-
* `hookPath`) are replaced; every unrelated
|
|
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>]]`). */
|