vigiles 23.0.0 → 25.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.
- package/dist/adapters/claude-code/run-scripts.js +26 -2
- package/dist/cli-flag-check.js +1 -1
- package/dist/cli.d.ts +121 -0
- package/dist/cli.js +352 -47
- package/dist/core/compile-generator.d.ts +6 -0
- package/dist/core/compile-generator.js +4 -1
- package/dist/core/compile.d.ts +53 -1
- package/dist/core/compile.js +20 -4
- package/dist/core/repo-path.d.ts +41 -0
- package/dist/core/repo-path.js +19 -0
- package/dist/core/rule-meta.d.ts +1 -1
- package/dist/core/rule-meta.js +16 -0
- package/dist/core/types.d.ts +44 -0
- package/dist/core/validate.js +18 -0
- package/dist/harness-resolve-hooks.d.mts +13 -0
- package/dist/harness-resolve-hooks.mjs +50 -0
- package/dist/run-hook.d.ts +27 -0
- package/dist/run-hook.js +31 -12
- package/dist/test.d.ts +1 -1
- package/package.json +1 -1
package/dist/core/compile.d.ts
CHANGED
|
@@ -10,7 +10,44 @@ import type { HarnessDialect } from "./dialect.js";
|
|
|
10
10
|
/** @internal Compute SHA-256 hash of content (excluding any existing hash line). */
|
|
11
11
|
export declare function computeHash(content: string): string;
|
|
12
12
|
/** @internal Prepend a hash comment to compiled content. */
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* A compiled body that carries a valid integrity stamp — mintable ONLY by
|
|
15
|
+
* {@link addHash}, and by construction only handed out for a CLEAN compile.
|
|
16
|
+
*
|
|
17
|
+
* 🔴 WHY A BRAND AND NOT A CHECK AT THE WRITE SITE. #173 was a `CLAUDE.md`
|
|
18
|
+
* written while its refs were known-dead: `compile` printed the errors, exited
|
|
19
|
+
* 1, and wrote the file anyway, stamped. `lint` then verified the stamp and
|
|
20
|
+
* exited 0 over an artifact that names files which do not exist. The fix that
|
|
21
|
+
* shipped moved the write behind an error check in ONE compiler — and the same
|
|
22
|
+
* three lines sat unchanged in four siblings (skill, subagent, railway,
|
|
23
|
+
* generator), where the lint-side backstop does not even reach because
|
|
24
|
+
* `spec-refs` only inspects `claude` specs. Reproduced end to end after that
|
|
25
|
+
* fix: a skill spec with a stale ref still produced a stamped `SKILL.md` and a
|
|
26
|
+
* green `lint`.
|
|
27
|
+
*
|
|
28
|
+
* Fixing five write sites leaves the class writable — the sixth compiler would
|
|
29
|
+
* be written the same way. Branding the STAMP moves the guarantee to where it is
|
|
30
|
+
* produced: `writeArtifact` accepts nothing else, and an erroring compile has no
|
|
31
|
+
* stamp to give it.
|
|
32
|
+
*/
|
|
33
|
+
export type StampedMarkdown = string & {
|
|
34
|
+
readonly __stamped: unique symbol;
|
|
35
|
+
};
|
|
36
|
+
export declare function addHash(content: string, specFile: string): StampedMarkdown;
|
|
37
|
+
/**
|
|
38
|
+
* How every compiler finishes: the rendered body, plus a STAMPED artifact that
|
|
39
|
+
* exists only when the compile is clean.
|
|
40
|
+
*
|
|
41
|
+
* `markdown` stays a plain string because callers legitimately want the draft
|
|
42
|
+
* even when it is wrong — `adoptDiff` diffs "what the spec WOULD produce"
|
|
43
|
+
* against the file on disk, and a stale ref must not stop that. `artifact` is
|
|
44
|
+
* what a writer needs, and it is `null` the moment there is an error, so the
|
|
45
|
+
* write cannot happen without narrowing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function seal(body: string, errors: readonly CompileError[], specFile: string): {
|
|
48
|
+
markdown: string;
|
|
49
|
+
artifact: StampedMarkdown | null;
|
|
50
|
+
};
|
|
14
51
|
/** @internal Check if a file's hash matches its content. Returns null if no hash found. */
|
|
15
52
|
export declare function verifyHash(content: string): {
|
|
16
53
|
valid: boolean;
|
|
@@ -74,6 +111,11 @@ export interface CompileClaudeOptions {
|
|
|
74
111
|
*/
|
|
75
112
|
export declare function compileClaude(spec: ClaudeSpec, options?: CompileClaudeOptions): CompileClaudeResult;
|
|
76
113
|
export interface CompileSkillResult {
|
|
114
|
+
/**
|
|
115
|
+
* The stamped artifact — present ONLY when `errors` is empty. `null` is what
|
|
116
|
+
* makes a failed compile unwritable: `writeArtifact` accepts nothing else.
|
|
117
|
+
*/
|
|
118
|
+
artifact: StampedMarkdown | null;
|
|
77
119
|
markdown: string;
|
|
78
120
|
errors: CompileError[];
|
|
79
121
|
/** Non-blocking advisories (e.g. an over-long inline code block). */
|
|
@@ -90,6 +132,11 @@ export declare function compileSkill(spec: SkillSpec, options?: {
|
|
|
90
132
|
dialect?: HarnessDialect;
|
|
91
133
|
}): CompileSkillResult;
|
|
92
134
|
export interface CompileAgentResult {
|
|
135
|
+
/**
|
|
136
|
+
* The stamped artifact — present ONLY when `errors` is empty. `null` is what
|
|
137
|
+
* makes a failed compile unwritable: `writeArtifact` accepts nothing else.
|
|
138
|
+
*/
|
|
139
|
+
artifact: StampedMarkdown | null;
|
|
93
140
|
markdown: string;
|
|
94
141
|
errors: CompileError[];
|
|
95
142
|
/** Non-blocking advisories (e.g. an over-long inline code block). */
|
|
@@ -113,6 +160,11 @@ export interface CompileRailwayOptions {
|
|
|
113
160
|
specFile?: string;
|
|
114
161
|
}
|
|
115
162
|
export interface CompileRailwayResult {
|
|
163
|
+
/**
|
|
164
|
+
* The stamped artifact — present ONLY when `errors` is empty. `null` is what
|
|
165
|
+
* makes a failed compile unwritable: `writeArtifact` accepts nothing else.
|
|
166
|
+
*/
|
|
167
|
+
artifact: StampedMarkdown | null;
|
|
116
168
|
markdown: string;
|
|
117
169
|
errors: CompileError[];
|
|
118
170
|
}
|
package/dist/core/compile.js
CHANGED
|
@@ -11,6 +11,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.computeHash = computeHash;
|
|
13
13
|
exports.addHash = addHash;
|
|
14
|
+
exports.seal = seal;
|
|
14
15
|
exports.verifyHash = verifyHash;
|
|
15
16
|
exports.estimateTokens = estimateTokens;
|
|
16
17
|
exports.validateFileRef = validateFileRef;
|
|
@@ -53,7 +54,6 @@ const DEFAULT_TARGET = "CLAUDE.md";
|
|
|
53
54
|
function computeHash(content) {
|
|
54
55
|
return (0, hash_js_1.sha256short)((0, integrity_js_1.findIntegrityHeader)(content)?.withoutHeader ?? content);
|
|
55
56
|
}
|
|
56
|
-
/** @internal Prepend a hash comment to compiled content. */
|
|
57
57
|
function addHash(content, specFile) {
|
|
58
58
|
// 🔴 THE LAST GATE BEFORE A COMPILED FILE IS WRITTEN. Every compile path returns through here
|
|
59
59
|
// (four call sites), which makes it the one place a whole class of defect can be stopped.
|
|
@@ -72,8 +72,24 @@ function addHash(content, specFile) {
|
|
|
72
72
|
`where a string was expected, and JavaScript stringified it. Check the arguments to ` +
|
|
73
73
|
`input()/file()/cmd() and friends: they take strings, not option objects.`);
|
|
74
74
|
}
|
|
75
|
+
// The ONE mint. Every stamped artifact in the codebase originates here, which
|
|
76
|
+
// is what makes the brand meaningful rather than decorative.
|
|
75
77
|
return (0, integrity_js_1.placeIntegrityHeader)(content, computeHash(content), specFile);
|
|
76
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* How every compiler finishes: the rendered body, plus a STAMPED artifact that
|
|
81
|
+
* exists only when the compile is clean.
|
|
82
|
+
*
|
|
83
|
+
* `markdown` stays a plain string because callers legitimately want the draft
|
|
84
|
+
* even when it is wrong — `adoptDiff` diffs "what the spec WOULD produce"
|
|
85
|
+
* against the file on disk, and a stale ref must not stop that. `artifact` is
|
|
86
|
+
* what a writer needs, and it is `null` the moment there is an error, so the
|
|
87
|
+
* write cannot happen without narrowing.
|
|
88
|
+
*/
|
|
89
|
+
function seal(body, errors, specFile) {
|
|
90
|
+
const stamped = addHash(body, specFile);
|
|
91
|
+
return { markdown: stamped, artifact: errors.length === 0 ? stamped : null };
|
|
92
|
+
}
|
|
77
93
|
/** @internal Check if a file's hash matches its content. Returns null if no hash found. */
|
|
78
94
|
function verifyHash(content) {
|
|
79
95
|
const found = (0, integrity_js_1.findIntegrityHeader)(content);
|
|
@@ -859,7 +875,7 @@ function compileSkill(spec, options = {}) {
|
|
|
859
875
|
(marker ? marker + "\n\n" : "") +
|
|
860
876
|
sections.trim() +
|
|
861
877
|
"\n";
|
|
862
|
-
return {
|
|
878
|
+
return { ...seal(content, errors, specFile), errors, warnings };
|
|
863
879
|
}
|
|
864
880
|
// ---------------------------------------------------------------------------
|
|
865
881
|
// Compile a subagent spec → agents/<name>.md
|
|
@@ -1058,7 +1074,7 @@ function compileAgent(spec, options) {
|
|
|
1058
1074
|
(marker ? marker + "\n\n" : "") +
|
|
1059
1075
|
body.trim() +
|
|
1060
1076
|
"\n";
|
|
1061
|
-
return {
|
|
1077
|
+
return { ...seal(content, errors, specFile), errors, warnings };
|
|
1062
1078
|
}
|
|
1063
1079
|
/** Verify a railway: non-empty, bounded recovery, every delegate target real. */
|
|
1064
1080
|
function validateRailway(rw, knownAgents) {
|
|
@@ -1126,7 +1142,7 @@ function compileRailway(rw, options = {}) {
|
|
|
1126
1142
|
const errors = validateRailway(rw, options.knownAgents);
|
|
1127
1143
|
const specFile = options.specFile ?? `${rw.name}.railway.spec.ts`;
|
|
1128
1144
|
const content = renderRailwayMarkdown(rw) + "\n";
|
|
1129
|
-
return {
|
|
1145
|
+
return { ...seal(content, errors, specFile), errors };
|
|
1130
1146
|
}
|
|
1131
1147
|
/** Check if a generated file's hash is intact. */
|
|
1132
1148
|
function checkFileHash(filePath) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RepoRelativePath` — a path that is provably INSIDE the repository.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 WHY A TYPE AND NOT A CHECK. `audit --out=/tmp/x` appended entries like
|
|
5
|
+
* `../../../../private/tmp/x/vigiles-report.json` to the user's `.gitignore`
|
|
6
|
+
* (#176.8). Those ignore nothing — `.gitignore` does not reach outside its own
|
|
7
|
+
* tree — and accumulate one dead block per output path, in a file the tool is
|
|
8
|
+
* documented as never writing. The fix that shipped was a guard at the write
|
|
9
|
+
* site, which works and leaves the bug WRITABLE: the next caller to build an
|
|
10
|
+
* entry list gets a `string[]` and no reason to think twice.
|
|
11
|
+
*
|
|
12
|
+
* This makes it unwritable instead. `ensureReportGitignored` accepts only
|
|
13
|
+
* `RepoRelativePath[]`, and the only way to obtain one is `repoRelative()`,
|
|
14
|
+
* which returns `null` for anything that escapes the root. There is no cast at
|
|
15
|
+
* the call site and no second guard to keep in sync — a path that leaves the
|
|
16
|
+
* repo cannot reach the writer, because it cannot be given the type.
|
|
17
|
+
*
|
|
18
|
+
* The brand is the pattern this codebase already uses for `VerifiedPath` and
|
|
19
|
+
* friends: a nominal marker on `string` that only a smart constructor mints.
|
|
20
|
+
*/
|
|
21
|
+
declare const REPO_RELATIVE: unique symbol;
|
|
22
|
+
/** A POSIX-separated path known to resolve inside the repository root. */
|
|
23
|
+
export type RepoRelativePath = string & {
|
|
24
|
+
readonly [REPO_RELATIVE]: true;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Mint a {@link RepoRelativePath}, or `null` when the target escapes `root`.
|
|
28
|
+
*
|
|
29
|
+
* Rejects an absolute path and anything whose relative form starts with `..`.
|
|
30
|
+
* Normalizes to POSIX separators, because `.gitignore` patterns are POSIX and a
|
|
31
|
+
* Windows `reports\x` would never match `reports/x` — a second silent-miss that
|
|
32
|
+
* lived next to the first.
|
|
33
|
+
*/
|
|
34
|
+
export declare function repoRelative(root: string, target: string, io: {
|
|
35
|
+
relative: (from: string, to: string) => string;
|
|
36
|
+
resolve: (...parts: string[]) => string;
|
|
37
|
+
isAbsolute: (p: string) => boolean;
|
|
38
|
+
sep: string;
|
|
39
|
+
}): RepoRelativePath | null;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=repo-path.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.repoRelative = repoRelative;
|
|
4
|
+
/**
|
|
5
|
+
* Mint a {@link RepoRelativePath}, or `null` when the target escapes `root`.
|
|
6
|
+
*
|
|
7
|
+
* Rejects an absolute path and anything whose relative form starts with `..`.
|
|
8
|
+
* Normalizes to POSIX separators, because `.gitignore` patterns are POSIX and a
|
|
9
|
+
* Windows `reports\x` would never match `reports/x` — a second silent-miss that
|
|
10
|
+
* lived next to the first.
|
|
11
|
+
*/
|
|
12
|
+
function repoRelative(root, target, io) {
|
|
13
|
+
const rel = io.relative(root, io.resolve(root, target));
|
|
14
|
+
if (rel === "" || rel.startsWith("..") || io.isAbsolute(rel))
|
|
15
|
+
return null;
|
|
16
|
+
const posix = io.sep === "/" ? rel : rel.split(io.sep).join("/");
|
|
17
|
+
return posix;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=repo-path.js.map
|
package/dist/core/rule-meta.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export type RuleSurface = "instruction" | "skill" | "subagent" | "hook" | "mcp"
|
|
|
41
41
|
/** Where a rule sits by default — `"off"` is the normalized form of `false`. */
|
|
42
42
|
export type RuleDefaultSeverity = "error" | "warn" | "off";
|
|
43
43
|
/** Every named rule: the `RulesConfig` keys plus the built-in `orphan-docs`. */
|
|
44
|
-
export type RuleName = keyof RulesConfig
|
|
44
|
+
export type RuleName = keyof RulesConfig;
|
|
45
45
|
/**
|
|
46
46
|
* The declared shape of one rule — co-located metadata in the ESLint `meta`
|
|
47
47
|
* sense, gathered into one registry because vigiles shares detectors.
|
package/dist/core/rule-meta.js
CHANGED
|
@@ -262,6 +262,22 @@ exports.RULE_META = {
|
|
|
262
262
|
detector: "delegationTrifectaIssues",
|
|
263
263
|
},
|
|
264
264
|
// --- Docs hygiene ---------------------------------------------------------
|
|
265
|
+
"duplicate-rules": {
|
|
266
|
+
id: "duplicate-rules",
|
|
267
|
+
bucket: "heuristic-behavioral",
|
|
268
|
+
surface: ["instruction"],
|
|
269
|
+
defaultSeverity: "warn",
|
|
270
|
+
summary: "Near-duplicate rules within one spec (NCD similarity) — two rules saying the same thing.",
|
|
271
|
+
detector: "findDuplicateRules",
|
|
272
|
+
},
|
|
273
|
+
"spec-refs": {
|
|
274
|
+
id: "spec-refs",
|
|
275
|
+
bucket: "external-decidable",
|
|
276
|
+
surface: ["instruction"],
|
|
277
|
+
defaultSeverity: "error",
|
|
278
|
+
summary: "A compiled instruction file whose spec references a file/script that no longer exists.",
|
|
279
|
+
detector: "compileClaude",
|
|
280
|
+
},
|
|
265
281
|
"orphan-docs": {
|
|
266
282
|
id: "orphan-docs",
|
|
267
283
|
bucket: "heuristic-behavioral",
|
package/dist/core/types.d.ts
CHANGED
|
@@ -99,6 +99,36 @@ export interface TestCoverageConfig {
|
|
|
99
99
|
testExtension?: string;
|
|
100
100
|
}
|
|
101
101
|
export interface RulesConfig {
|
|
102
|
+
/**
|
|
103
|
+
* Opt-in: a doc in a configured dir (default `docs/`) that no other `.md`
|
|
104
|
+
* references. The `orphans` block turns the SCAN on; this turns the FINDING
|
|
105
|
+
* into a warning or an error.
|
|
106
|
+
*
|
|
107
|
+
* It lived outside this interface until 2026-09 and therefore could not be
|
|
108
|
+
* set at all: `"warn"` and `"off"` were both ignored and the check always
|
|
109
|
+
* exited 1, so a repo's only choice was an always-blocking check or deleting
|
|
110
|
+
* the `orphans` block (#181). Default: "error", matching the old behaviour.
|
|
111
|
+
*/
|
|
112
|
+
"orphan-docs"?: RuleSeverity;
|
|
113
|
+
/**
|
|
114
|
+
* Re-derive a compiled instruction file's references from its `.spec.ts` and
|
|
115
|
+
* report the dead ones.
|
|
116
|
+
*
|
|
117
|
+
* The integrity hash answers "is this file still what the spec compiled to";
|
|
118
|
+
* it says nothing about whether the paths and scripts it NAMES still exist. So
|
|
119
|
+
* an artifact committed while its refs were live stayed green forever after
|
|
120
|
+
* the target was deleted — `compile` errored, `lint` said "hash valid" and
|
|
121
|
+
* exited 0 (#173). Default: "error", matching `compile`.
|
|
122
|
+
*/
|
|
123
|
+
"spec-refs"?: RuleSeverity;
|
|
124
|
+
/**
|
|
125
|
+
* Near-duplicate rules WITHIN one spec, by NCD similarity — spec bloat, two
|
|
126
|
+
* rules saying the same thing in different words.
|
|
127
|
+
*
|
|
128
|
+
* Also previously untierable, and worse: it had no rule id at all, so there
|
|
129
|
+
* was no name a config could even mention (#181). Default: "error".
|
|
130
|
+
*/
|
|
131
|
+
"duplicate-rules"?: RuleSeverity;
|
|
102
132
|
/**
|
|
103
133
|
* Require a `.spec.ts` behind each instruction file (CLAUDE.md / AGENTS.md) —
|
|
104
134
|
* the file must be compiled from a typed spec, not hand-written. NARROW: only a
|
|
@@ -363,6 +393,20 @@ export interface VigilesConfig {
|
|
|
363
393
|
rulesDir?: string | string[];
|
|
364
394
|
}>;
|
|
365
395
|
/** Orphan-docs check configuration. Include/exclude globs, tsconfig-style. */
|
|
396
|
+
/**
|
|
397
|
+
* Which bundles `lint` scores: `"root"` (default) or `"all"`.
|
|
398
|
+
*
|
|
399
|
+
* A monorepo holding `skills/` plus `plugins/ * /skills/` had its nested skills
|
|
400
|
+
* silently uncounted — the counters looked complete while whole surfaces were
|
|
401
|
+
* never read (#185). `"all"` scores every discovered bundle in one pass, so a
|
|
402
|
+
* CI gate keeps ONE exit code over the whole repo.
|
|
403
|
+
*
|
|
404
|
+
* Root-only remains the default because descending unconditionally would score
|
|
405
|
+
* vendored third-party plugins (a repo may keep a pinned corpus on disk) as if
|
|
406
|
+
* they were the project's own. The default no longer hides the skip: `lint`
|
|
407
|
+
* names the bundles it did not score.
|
|
408
|
+
*/
|
|
409
|
+
bundles?: "root" | "all";
|
|
366
410
|
orphans?: OrphansConfig;
|
|
367
411
|
/**
|
|
368
412
|
* Glob patterns of instruction/skill files to EXCLUDE from `lint` discovery
|
package/dist/core/validate.js
CHANGED
|
@@ -32,6 +32,24 @@ const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md"];
|
|
|
32
32
|
// The default instruction file to validate when no config names one.
|
|
33
33
|
const DEFAULT_FILES = [INSTRUCTION_FILES[0]];
|
|
34
34
|
exports.DEFAULT_RULES = {
|
|
35
|
+
// 🔴 BOTH DROP TO "warn", and that is a deliberate behaviour change.
|
|
36
|
+
//
|
|
37
|
+
// They used to feed the exit code directly and could not be tiered at all
|
|
38
|
+
// (#181), so a single unreferenced doc turned a PR red with no way to say
|
|
39
|
+
// "report it, do not block". Naming them as rules made the contradiction
|
|
40
|
+
// visible: both are HEURISTIC-BEHAVIORAL (an NCD similarity proxy, an
|
|
41
|
+
// "unreferenced" guess that an OSS sweep measured at ~100% false positives on
|
|
42
|
+
// nav-managed doc sites), and this repo's own calibration rule is that a
|
|
43
|
+
// heuristic never defaults to `error` because it cries wolf. `orphan-docs`
|
|
44
|
+
// already DECLARED `warn` in its meta while behaving as `error` — the gate
|
|
45
|
+
// caught that disagreement the moment the rule was registered properly.
|
|
46
|
+
//
|
|
47
|
+
// Set either to `"error"` to keep the old blocking behaviour.
|
|
48
|
+
// Hard error, like `compile` itself: a dead reference is decidable from the
|
|
49
|
+
// filesystem, not a proxy — the calibration rule's `external-decidable` tier.
|
|
50
|
+
"spec-refs": "error",
|
|
51
|
+
"orphan-docs": "warn",
|
|
52
|
+
"duplicate-rules": "warn",
|
|
35
53
|
"require-instructions-spec": "warn",
|
|
36
54
|
// Default OFF — the consistent `require-<surface>-spec` parallel. Skills are
|
|
37
55
|
// legitimately hand-written, so requiring a .spec.ts per SKILL.md is the wrong
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type ResolveContext = {
|
|
2
|
+
parentURL?: string;
|
|
3
|
+
conditions: string[];
|
|
4
|
+
};
|
|
5
|
+
type Resolved = {
|
|
6
|
+
url: string;
|
|
7
|
+
format?: string | null;
|
|
8
|
+
shortCircuit?: boolean;
|
|
9
|
+
};
|
|
10
|
+
type NextResolve = (specifier: string, context: ResolveContext) => Resolved | Promise<Resolved>;
|
|
11
|
+
export declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): Promise<Resolved>;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=harness-resolve-hooks.d.mts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-resolution hook for HARNESS scripts: make a bare `vigiles` import
|
|
3
|
+
* resolve to the CLI's OWN installation.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 WHY. A harness file does `import { runHook } from "vigiles"`, so the
|
|
6
|
+
* package has to sit in a `node_modules` Node can reach from that file. In a
|
|
7
|
+
* repo that already has a `package.json`, the obvious way to put it there is
|
|
8
|
+
* `npm install` in the root — which installs the whole dependency tree. Measured
|
|
9
|
+
* by an adopter (#184): **840 packages in 2 minutes** where vigiles alone is 42
|
|
10
|
+
* and about 90 MB; the other 798 were a model-eval framework and an agent SDK
|
|
11
|
+
* that the gate — `lint` and `test`, both deterministic reads — never touches.
|
|
12
|
+
* One run sat 11 minutes in that step before being cancelled. Their workaround
|
|
13
|
+
* was installing into a directory outside the workspace and symlinking the tree
|
|
14
|
+
* back in, which works and is not something every adopter should reinvent.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ `NODE_PATH` does NOT solve this, and that was measured rather than assumed:
|
|
17
|
+
* Node ignores it for ESM resolution, and a harness is ESM. So the only ways to
|
|
18
|
+
* resolve a bare specifier from elsewhere are a real `node_modules` entry (the
|
|
19
|
+
* symlink) or a resolver hook. This is the hook.
|
|
20
|
+
*
|
|
21
|
+
* Scope is deliberately narrow: ONLY the `vigiles` specifier and its subpaths,
|
|
22
|
+
* and only when the normal resolution fails. A harness that has vigiles
|
|
23
|
+
* installed locally keeps resolving to the local copy, so nothing changes for a
|
|
24
|
+
* repo that already worked — this only fills the hole where resolution would
|
|
25
|
+
* otherwise throw.
|
|
26
|
+
*/
|
|
27
|
+
import { createRequire } from "node:module";
|
|
28
|
+
import { pathToFileURL } from "node:url";
|
|
29
|
+
/** The CLI's own package root, handed in by the parent process. */
|
|
30
|
+
const SELF = process.env.VIGILES_SELF_ROOT ?? "";
|
|
31
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
32
|
+
try {
|
|
33
|
+
return await nextResolve(specifier, context);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
// Only rescue OUR specifier, and only after normal resolution failed, so a
|
|
37
|
+
// locally installed vigiles always wins and no other package is affected.
|
|
38
|
+
if (!SELF)
|
|
39
|
+
throw err;
|
|
40
|
+
if (specifier !== "vigiles" && !specifier.startsWith("vigiles/"))
|
|
41
|
+
throw err;
|
|
42
|
+
const require = createRequire(pathToFileURL(`${SELF}/package.json`));
|
|
43
|
+
// Resolve through the package's own `exports` map rather than guessing a
|
|
44
|
+
// file path, so a subpath like `vigiles/eval` obeys the same contract it
|
|
45
|
+
// would from a normal install.
|
|
46
|
+
const target = require.resolve(specifier, { paths: [SELF] });
|
|
47
|
+
return { url: pathToFileURL(target).href, shortCircuit: true };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=harness-resolve-hooks.mjs.map
|
package/dist/run-hook.d.ts
CHANGED
|
@@ -187,6 +187,13 @@ export interface HookRunResult extends ScriptRunResult {
|
|
|
187
187
|
* a halt sets both.
|
|
188
188
|
*/
|
|
189
189
|
readonly haltsTurn: boolean;
|
|
190
|
+
/**
|
|
191
|
+
* Every block mechanism that fired, from the closed {@link BlockMechanism}
|
|
192
|
+
* set. Reported so the next mechanism needs no new boolean on this interface —
|
|
193
|
+
* `haltsTurn` exists because the halt case was added as a one-off, and this is
|
|
194
|
+
* the shape that stops the pattern repeating.
|
|
195
|
+
*/
|
|
196
|
+
readonly blockedBy: readonly BlockMechanism[];
|
|
190
197
|
/**
|
|
191
198
|
* The decision the hook expressed, preferring the structured
|
|
192
199
|
* `permissionDecision` ("allow"|"deny"|"ask") then legacy `decision`
|
|
@@ -196,14 +203,34 @@ export interface HookRunResult extends ScriptRunResult {
|
|
|
196
203
|
}
|
|
197
204
|
/** Parse stdout as a hook JSON decision (pure, testable without a process). */
|
|
198
205
|
export declare function parseHookOutput(stdout: string): HookOutput | null;
|
|
206
|
+
/**
|
|
207
|
+
* The ways a hook can stop an action — a CLOSED set, listed once.
|
|
208
|
+
*
|
|
209
|
+
* 🔴 WHY A TABLE AND NOT THREE `||` TERMS. `decideHook` used to be a boolean
|
|
210
|
+
* expression over three mechanisms while `HookOutput` declared a fourth,
|
|
211
|
+
* `continue`, that nothing read (#174). The consequence was not a missed
|
|
212
|
+
* detection but an INVERTED verdict in the flagship feature: a real guard that
|
|
213
|
+
* stopped every command in the disaster battery was reported by
|
|
214
|
+
* `assertBlocksDisasters` as blocking none of them.
|
|
215
|
+
*
|
|
216
|
+
* A `||` chain has no shape that can be incomplete — every term is optional by
|
|
217
|
+
* construction, so nothing can say "you declared a mechanism and did not handle
|
|
218
|
+
* it". `Record<BlockMechanism, …>` can: adding a member to the union without
|
|
219
|
+
* adding its row is a tsc error, the same device that keeps `RULE_META` honest.
|
|
220
|
+
*/
|
|
221
|
+
export type BlockMechanism = "exit-code" | "deny-decision" | "halt-field";
|
|
199
222
|
/**
|
|
200
223
|
* Decide whether a hook result blocked, and the normalized decision. Pure, so
|
|
201
224
|
* the policy is unit-testable independent of spawning anything.
|
|
225
|
+
*
|
|
226
|
+
* Folds the closed {@link BLOCK_MECHANISMS} table rather than testing three
|
|
227
|
+
* conditions inline, so a mechanism cannot be declared and left unhandled.
|
|
202
228
|
*/
|
|
203
229
|
export declare function decideHook(exitCode: number, json: HookOutput | null, protocol?: HookProtocol): {
|
|
204
230
|
blocked: boolean;
|
|
205
231
|
decision: HookRunResult["decision"];
|
|
206
232
|
haltsTurn: boolean;
|
|
233
|
+
blockedBy: readonly BlockMechanism[];
|
|
207
234
|
};
|
|
208
235
|
/**
|
|
209
236
|
* The hook layer over {@link runScriptWith}: serialize the event to stdin, run
|
package/dist/run-hook.js
CHANGED
|
@@ -123,23 +123,42 @@ function parseHookOutput(stdout) {
|
|
|
123
123
|
return null;
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
|
+
const BLOCK_MECHANISMS = {
|
|
127
|
+
"exit-code": ({ exitCode, protocol }) => exitCode === protocol.blockExitCode,
|
|
128
|
+
"deny-decision": ({ json, protocol }) => {
|
|
129
|
+
const decision = json?.hookSpecificOutput?.permissionDecision ?? json?.decision;
|
|
130
|
+
return (decision !== undefined && protocol.denyDecisionValues.includes(decision));
|
|
131
|
+
},
|
|
132
|
+
// Read from the PORT, never hard-coded: `"continue"` is a documented Claude
|
|
133
|
+
// Code fact and unverified for Codex, so the harness that has it declares it
|
|
134
|
+
// (core ⊄ adapter). `=== false` and not falsy — an absent field is not a halt.
|
|
135
|
+
"halt-field": ({ json, protocol }) => {
|
|
136
|
+
const field = protocol.haltsTurnField;
|
|
137
|
+
return field !== undefined && json?.[field] === false;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
126
140
|
/**
|
|
127
141
|
* Decide whether a hook result blocked, and the normalized decision. Pure, so
|
|
128
142
|
* the policy is unit-testable independent of spawning anything.
|
|
143
|
+
*
|
|
144
|
+
* Folds the closed {@link BLOCK_MECHANISMS} table rather than testing three
|
|
145
|
+
* conditions inline, so a mechanism cannot be declared and left unhandled.
|
|
129
146
|
*/
|
|
130
147
|
function decideHook(exitCode, json, protocol = hook_protocol_js_1.claudeCodeHookProtocol) {
|
|
131
148
|
const permission = json?.hookSpecificOutput?.permissionDecision;
|
|
132
149
|
const decision = permission ?? json?.decision;
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
150
|
+
const ctx = { exitCode, json, protocol };
|
|
151
|
+
// EVERY mechanism is evaluated, not the first match: a hook may both exit 2
|
|
152
|
+
// and halt the turn, and reporting only the first would make `haltsTurn`
|
|
153
|
+
// depend on the table's order.
|
|
154
|
+
const blockedBy = Object.keys(BLOCK_MECHANISMS).filter((kind) => BLOCK_MECHANISMS[kind](ctx));
|
|
155
|
+
return {
|
|
156
|
+
blocked: blockedBy.length > 0,
|
|
157
|
+
decision,
|
|
158
|
+
// Derived, not computed twice — the next mechanism needs no new boolean.
|
|
159
|
+
haltsTurn: blockedBy.includes("halt-field"),
|
|
160
|
+
blockedBy,
|
|
161
|
+
};
|
|
143
162
|
}
|
|
144
163
|
/**
|
|
145
164
|
* The hook layer over {@link runScriptWith}: serialize the event to stdin, run
|
|
@@ -150,8 +169,8 @@ function decideHook(exitCode, json, protocol = hook_protocol_js_1.claudeCodeHook
|
|
|
150
169
|
function runHookWith(command, input, opts, deps) {
|
|
151
170
|
const res = (0, run_script_js_1.runScriptWith)(command, JSON.stringify(input), opts, deps);
|
|
152
171
|
const json = parseHookOutput(res.stdout);
|
|
153
|
-
const { blocked, decision, haltsTurn } = decideHook(res.exitCode, json);
|
|
154
|
-
return { ...res, json, blocked, decision, haltsTurn };
|
|
172
|
+
const { blocked, decision, haltsTurn, blockedBy } = decideHook(res.exitCode, json);
|
|
173
|
+
return { ...res, json, blocked, decision, haltsTurn, blockedBy };
|
|
155
174
|
}
|
|
156
175
|
/**
|
|
157
176
|
* Run a hook command, piping `input` as JSON to its stdin, and report the exit
|
package/dist/test.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export { recordCheck } from "./check-count.js";
|
|
|
54
54
|
export { runScript } from "./run-script.js";
|
|
55
55
|
export type { RunScriptOptions, ScriptRunResult } from "./run-script.js";
|
|
56
56
|
export { runHook, parseHookOutput, decideHook, propertyHook, fileToolEvents, egressRoutes, } from "./run-hook.js";
|
|
57
|
-
export type { HookRunResult, RunHookOptions, HookInput, HookOutput, HookPropertyResult, FileToolEventOptions, } from "./run-hook.js";
|
|
57
|
+
export type { HookRunResult, BlockMechanism, RunHookOptions, HookInput, HookOutput, HookPropertyResult, FileToolEventOptions, } from "./run-hook.js";
|
|
58
58
|
export * from "./harness-assert.js";
|
|
59
59
|
export { experimental_emitTool, experimental_parseEmitted, experimental_assertEmittedOk, type EmitFieldSchema, type EmitObjectSchema, type EmitPropertySchema, type EmitTrackSchema, type EmitToolDefinition, type ExperimentalEmitTool, } from "./experimental-emit.js";
|
|
60
60
|
export { loadHook } from "./load-hook.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.0.0",
|
|
4
4
|
"description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|