vigiles 32.0.2 → 32.1.1
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/instruction-chain.js +36 -2
- package/dist/cli-flag-check.js +7 -1
- package/dist/cli-main.d.ts +2 -0
- package/dist/cli-main.js +38 -5
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +1 -0
- package/dist/core/hook-program.d.ts +67 -1
- package/dist/core/hook-program.js +73 -1
- package/dist/core/instruction-baseline.d.ts +139 -0
- package/dist/core/instruction-baseline.js +224 -0
- package/dist/core/instruction-weight.d.ts +17 -7
- package/dist/core/instruction-weight.js +25 -9
- package/dist/core/rule-meta.js +8 -0
- package/dist/core/types.d.ts +8 -0
- package/dist/coverage-probe.d.ts +15 -0
- package/dist/coverage-probe.js +29 -0
- package/dist/hook-check-install.d.ts +3 -0
- package/dist/hook-check-install.js +57 -0
- package/dist/hook-check.d.ts +36 -0
- package/dist/hook-check.js +125 -0
- package/dist/hook-install.d.ts +0 -36
- package/dist/hook-install.js +0 -38
- package/dist/hook-runtime.js +17 -24
- package/dist/instruction-weight-ratchet.d.ts +53 -0
- package/dist/instruction-weight-ratchet.js +132 -0
- package/dist/local-files.js +2 -0
- package/dist/scan.d.ts +10 -0
- package/dist/scan.js +30 -4
- package/dist/vigilesrc.schema.json +36 -0
- package/package.json +1 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.INSTRUCTION_WEIGHT_MEASURE = exports.INSTRUCTION_BASELINE_VERSION = exports.UPDATE_BASELINE_COMMAND = exports.INSTRUCTION_BASELINE_FILE = void 0;
|
|
4
|
+
exports.parseInstructionBaseline = parseInstructionBaseline;
|
|
5
|
+
exports.entryFor = entryFor;
|
|
6
|
+
exports.compareToBaseline = compareToBaseline;
|
|
7
|
+
exports.recordEntries = recordEntries;
|
|
8
|
+
exports.serializeBaseline = serializeBaseline;
|
|
9
|
+
exports.formatVerdict = formatVerdict;
|
|
10
|
+
exports.formatWeightLine = formatWeightLine;
|
|
11
|
+
/**
|
|
12
|
+
* The instruction-weight RATCHET: the always-loaded weight may not grow past
|
|
13
|
+
* what the repository itself last recorded.
|
|
14
|
+
*
|
|
15
|
+
* WHY A RATCHET AND NOT THE BUDGET. `./instruction-weight.ts` explains why the
|
|
16
|
+
* harness's own threshold cannot gate: real repositories sit several times over
|
|
17
|
+
* it, and a rule that fails every repo on day one is switched off on day one.
|
|
18
|
+
* What CAN gate is the repository's own past. The baseline is whatever the repo
|
|
19
|
+
* weighed when it was last recorded, so a repo four times over budget starts
|
|
20
|
+
* green — and stays green only while it does not get heavier. The measured case
|
|
21
|
+
* this exists for: a root instruction file cut from ~4 100 lines to ~1 900, then
|
|
22
|
+
* regrown by ~1 000 lines over ten ordinary commits, with nothing in CI to say so.
|
|
23
|
+
*
|
|
24
|
+
* WHY THE SUM, AGAIN. The baseline records `committedTotal`, the same number the
|
|
25
|
+
* audit report scores: everything the harness loads without being asked that a
|
|
26
|
+
* teammate on the same commit also loads. A per-file baseline would read text
|
|
27
|
+
* moved into an always-loaded sibling as a shrink — the evasion the weight
|
|
28
|
+
* module was built to see through. The per-file sizes are recorded too, but only
|
|
29
|
+
* so a message can say WHICH file moved; the verdict is decided on the total.
|
|
30
|
+
*
|
|
31
|
+
* WHY A SHRINK IS A FINDING TOO. A baseline left above the tree is headroom, and
|
|
32
|
+
* headroom is exactly where the regrowth above landed: every character up to the
|
|
33
|
+
* old number would pass. So, like ESLint's unpruned suppressions and `betterer
|
|
34
|
+
* ci`, a tree LIGHTER than its baseline asks for the baseline to be lowered —
|
|
35
|
+
* one command, in the same change that made the cut. The baseline then always
|
|
36
|
+
* equals the tree on the default branch, and any growth at all is a diff a
|
|
37
|
+
* reviewer sees.
|
|
38
|
+
*
|
|
39
|
+
* WHO WRITES IT. Never `lint` on its own: a read never writes. The file is
|
|
40
|
+
* written only by the explicit `vigiles lint --update-baseline`, the same shape
|
|
41
|
+
* as `eslint --suppress-all` / `--prune-suppressions` and `jest -u`. Raising the
|
|
42
|
+
* baseline is allowed and is the point: growth is not forbidden, it is made
|
|
43
|
+
* visible as a reviewed diff of a committed file.
|
|
44
|
+
*
|
|
45
|
+
* Pure: parsing, comparison and formatting only. Reading and writing the file
|
|
46
|
+
* is the caller's (`src/instruction-weight-ratchet.ts`).
|
|
47
|
+
*/
|
|
48
|
+
const zod_1 = require("zod");
|
|
49
|
+
/** Where the baseline lives, relative to the directory `.vigilesrc.json` is read from. */
|
|
50
|
+
exports.INSTRUCTION_BASELINE_FILE = ".vigiles/instruction-weight.json";
|
|
51
|
+
/** The command that writes the baseline — named once, quoted by every message. */
|
|
52
|
+
exports.UPDATE_BASELINE_COMMAND = "vigiles lint --update-baseline";
|
|
53
|
+
/** Bumped only on an incompatible change to the on-disk shape. */
|
|
54
|
+
exports.INSTRUCTION_BASELINE_VERSION = 1;
|
|
55
|
+
/**
|
|
56
|
+
* Which MEASUREMENT produced a recorded number — bumped whenever a release
|
|
57
|
+
* changes what counts (chain membership in any adapter, how a file is sized),
|
|
58
|
+
* even when the file shape stays the same.
|
|
59
|
+
*
|
|
60
|
+
* Without it, a vigiles fix that moves a file into or out of the chain moves
|
|
61
|
+
* `committedTotal` on every consumer with a committed baseline, and they see
|
|
62
|
+
* `grew`/`shrank` attributed to their own content. With it, they see one
|
|
63
|
+
* "re-record" verdict that names the cause. `version` cannot do this job: it
|
|
64
|
+
* describes the file, not the number in it.
|
|
65
|
+
*/
|
|
66
|
+
exports.INSTRUCTION_WEIGHT_MEASURE = 1;
|
|
67
|
+
const entrySchema = zod_1.z
|
|
68
|
+
.object({
|
|
69
|
+
unit: zod_1.z.enum(["chars", "bytes"]),
|
|
70
|
+
measure: zod_1.z.number().int().nonnegative(),
|
|
71
|
+
total: zod_1.z.number().int().nonnegative(),
|
|
72
|
+
files: zod_1.z.record(zod_1.z.string(), zod_1.z.number().int().nonnegative()),
|
|
73
|
+
})
|
|
74
|
+
.strict()
|
|
75
|
+
.refine((e) => Object.values(e.files).reduce((a, b) => a + b, 0) === e.total, {
|
|
76
|
+
message: "`total` is not the sum of `files` — the file was edited by hand; re-record it",
|
|
77
|
+
});
|
|
78
|
+
const baselineSchema = zod_1.z
|
|
79
|
+
.object({
|
|
80
|
+
version: zod_1.z.literal(exports.INSTRUCTION_BASELINE_VERSION),
|
|
81
|
+
/** Keyed by bundle location from the config root; `.` is the root itself. */
|
|
82
|
+
bundles: zod_1.z.record(zod_1.z.string(), entrySchema),
|
|
83
|
+
})
|
|
84
|
+
.strict();
|
|
85
|
+
function parseInstructionBaseline(text) {
|
|
86
|
+
const json = (() => {
|
|
87
|
+
try {
|
|
88
|
+
return { ok: true, value: JSON.parse(text) };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { ok: false };
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
if (!json.ok)
|
|
95
|
+
return { kind: "invalid", message: "not valid JSON" };
|
|
96
|
+
const parsed = baselineSchema.safeParse(json.value);
|
|
97
|
+
return parsed.success
|
|
98
|
+
? { kind: "ok", baseline: parsed.data }
|
|
99
|
+
: {
|
|
100
|
+
kind: "invalid",
|
|
101
|
+
message: parsed.error.issues
|
|
102
|
+
.map((i) => `${i.path.join(".") || "(top level)"}: ${i.message}`)
|
|
103
|
+
.join("; "),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** What a measured weight would be recorded as: the SCORED files only. */
|
|
107
|
+
function entryFor(weight) {
|
|
108
|
+
const scored = weight.files.filter((f) => f.scope === "repo");
|
|
109
|
+
return {
|
|
110
|
+
unit: weight.unit,
|
|
111
|
+
measure: exports.INSTRUCTION_WEIGHT_MEASURE,
|
|
112
|
+
total: weight.committedTotal,
|
|
113
|
+
files: Object.fromEntries([...scored]
|
|
114
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
115
|
+
.map((f) => [f.path, f.size])),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function changesBetween(before, after) {
|
|
119
|
+
const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])];
|
|
120
|
+
const size = (m, p) => Object.hasOwn(m, p) ? m[p] : null;
|
|
121
|
+
return paths
|
|
122
|
+
.map((path) => ({ path, from: size(before, path), to: size(after, path) }))
|
|
123
|
+
.filter((c) => c.from !== c.to)
|
|
124
|
+
.sort((a, b) => Math.abs((b.to ?? 0) - (b.from ?? 0)) -
|
|
125
|
+
Math.abs((a.to ?? 0) - (a.from ?? 0)) || a.path.localeCompare(b.path));
|
|
126
|
+
}
|
|
127
|
+
function compareToBaseline(weight, recorded) {
|
|
128
|
+
const current = entryFor(weight);
|
|
129
|
+
if (recorded === undefined)
|
|
130
|
+
return { kind: "unrecorded", current };
|
|
131
|
+
if (recorded.unit !== current.unit) {
|
|
132
|
+
return {
|
|
133
|
+
kind: "unit-changed",
|
|
134
|
+
recorded: recorded.unit,
|
|
135
|
+
current: current.unit,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (recorded.measure !== current.measure) {
|
|
139
|
+
return {
|
|
140
|
+
kind: "measure-changed",
|
|
141
|
+
recorded: recorded.measure,
|
|
142
|
+
current: current.measure,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (current.total === recorded.total) {
|
|
146
|
+
return { kind: "held", total: current.total, unit: current.unit };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
kind: current.total > recorded.total ? "grew" : "shrank",
|
|
150
|
+
from: recorded.total,
|
|
151
|
+
to: current.total,
|
|
152
|
+
unit: current.unit,
|
|
153
|
+
changes: changesBetween(recorded.files, current.files),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/** Replace the measured bundles' entries; `null` removes one. Others are kept. */
|
|
157
|
+
function recordEntries(previous, measured) {
|
|
158
|
+
const removed = new Set(measured.filter(([, e]) => e === null).map(([at]) => at));
|
|
159
|
+
const kept = Object.entries(previous?.bundles ?? {}).filter(([at]) => !removed.has(at));
|
|
160
|
+
const fresh = measured.flatMap(([at, e]) => e === null ? [] : [[at, e]]);
|
|
161
|
+
return {
|
|
162
|
+
version: exports.INSTRUCTION_BASELINE_VERSION,
|
|
163
|
+
bundles: Object.fromEntries([...new Map([...kept, ...fresh]).entries()].sort(([a], [b]) => a.localeCompare(b))),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Stable text: sorted keys, two-space indent, trailing newline, no timestamp. */
|
|
167
|
+
function serializeBaseline(baseline) {
|
|
168
|
+
return `${JSON.stringify(baseline, null, 2)}\n`;
|
|
169
|
+
}
|
|
170
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
171
|
+
const signed = (x) => (x > 0 ? `+${n(x)}` : n(x));
|
|
172
|
+
function describeChange(c) {
|
|
173
|
+
if (c.from === null)
|
|
174
|
+
return `${c.path} +${n(c.to ?? 0)} (new)`;
|
|
175
|
+
if (c.to === null)
|
|
176
|
+
return `${c.path} -${n(c.from)} (gone)`;
|
|
177
|
+
return `${c.path} ${signed(c.to - c.from)}`;
|
|
178
|
+
}
|
|
179
|
+
/** The one-paragraph message a verdict prints as, prefixed by its bundle. */
|
|
180
|
+
function formatVerdict(verdict, at) {
|
|
181
|
+
const where = at === "." ? "" : `${at}: `;
|
|
182
|
+
switch (verdict.kind) {
|
|
183
|
+
case "unrecorded":
|
|
184
|
+
return (`${where}no instruction-weight baseline recorded (${n(verdict.current.total)} ${verdict.current.unit} always loaded) — ` +
|
|
185
|
+
`run \`${exports.UPDATE_BASELINE_COMMAND}\` and commit ${exports.INSTRUCTION_BASELINE_FILE} to stop it growing unnoticed`);
|
|
186
|
+
case "unit-changed":
|
|
187
|
+
return (`${where}the instruction-weight baseline is in ${verdict.recorded} but this harness counts ${verdict.current} — ` +
|
|
188
|
+
`re-record it with \`${exports.UPDATE_BASELINE_COMMAND}\``);
|
|
189
|
+
case "measure-changed":
|
|
190
|
+
return (`${where}the instruction-weight baseline was recorded by measurement ${String(verdict.recorded)}, this vigiles uses ${String(verdict.current)} — ` +
|
|
191
|
+
`what counts changed in vigiles, not in your files. Re-record it with \`${exports.UPDATE_BASELINE_COMMAND}\``);
|
|
192
|
+
case "held":
|
|
193
|
+
return `${where}always-loaded instructions held at ${n(verdict.total)} ${verdict.unit} (baseline)`;
|
|
194
|
+
case "grew":
|
|
195
|
+
case "shrank": {
|
|
196
|
+
const head = `${n(verdict.from)} → ${n(verdict.to)} ${verdict.unit} (${signed(verdict.to - verdict.from)})`;
|
|
197
|
+
const moved = verdict.changes.length > 0
|
|
198
|
+
? ` Changed: ${verdict.changes.map(describeChange).join(", ")}.`
|
|
199
|
+
: "";
|
|
200
|
+
return verdict.kind === "grew"
|
|
201
|
+
? `${where}always-loaded instructions grew ${head} over the recorded baseline.${moved} ` +
|
|
202
|
+
`If the growth is intended, record it with \`${exports.UPDATE_BASELINE_COMMAND}\` — the diff of ${exports.INSTRUCTION_BASELINE_FILE} is the review.`
|
|
203
|
+
: `${where}always-loaded instructions shrank ${head}; the baseline still allows the old weight.${moved} ` +
|
|
204
|
+
`Lock the cut in with \`${exports.UPDATE_BASELINE_COMMAND}\`, or the headroom can be regrown without a finding.`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** The compile-time one-liner: weight now, against the baseline when there is one. */
|
|
209
|
+
function formatWeightLine(verdict) {
|
|
210
|
+
switch (verdict.kind) {
|
|
211
|
+
case "unrecorded":
|
|
212
|
+
return `always-loaded: ${n(verdict.current.total)} ${verdict.current.unit} (no baseline — \`${exports.UPDATE_BASELINE_COMMAND}\`)`;
|
|
213
|
+
case "unit-changed":
|
|
214
|
+
return `always-loaded: baseline is in ${verdict.recorded}, this harness counts ${verdict.current}`;
|
|
215
|
+
case "measure-changed":
|
|
216
|
+
return `always-loaded: baseline recorded by measurement ${String(verdict.recorded)}, this vigiles uses ${String(verdict.current)} — re-record`;
|
|
217
|
+
case "held":
|
|
218
|
+
return `always-loaded: ${n(verdict.total)} ${verdict.unit} (baseline ${n(verdict.total)}, ±0)`;
|
|
219
|
+
case "grew":
|
|
220
|
+
case "shrank":
|
|
221
|
+
return `always-loaded: ${n(verdict.to)} ${verdict.unit} (baseline ${n(verdict.from)}, ${signed(verdict.to - verdict.from)})`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=instruction-baseline.js.map
|
|
@@ -28,12 +28,13 @@
|
|
|
28
28
|
* number carries a different severity per harness, so the harness must supply
|
|
29
29
|
* it — hence a port field, not a constant.
|
|
30
30
|
*
|
|
31
|
-
* NOT A GATE, AND THAT IS MEASURED. Both corpora this was built
|
|
32
|
-
* roughly four times the Claude Code threshold. A rule that
|
|
33
|
-
* repo on day one is switched off on day one
|
|
34
|
-
* tracks confidence, and a check nobody
|
|
35
|
-
*
|
|
36
|
-
*
|
|
31
|
+
* THE BUDGET IS NOT A GATE, AND THAT IS MEASURED. Both corpora this was built
|
|
32
|
+
* against sit at roughly four times the Claude Code threshold. A rule that
|
|
33
|
+
* fails every real repo on day one is switched off on day one
|
|
34
|
+
* (`lint-rule-calibration`: severity tracks confidence, and a check nobody
|
|
35
|
+
* leaves on catches nothing). So `audit` reports the budget, and what `lint`
|
|
36
|
+
* gates is the repo's OWN recorded weight (`./instruction-baseline.ts`): green
|
|
37
|
+
* on day one at any size, red the day it grows.
|
|
37
38
|
*/
|
|
38
39
|
import type { InstructionChain, InstructionRole, InstructionScope } from "./instruction-chain.js";
|
|
39
40
|
/** What the harness counts, and what it does when the count is exceeded. */
|
|
@@ -147,7 +148,16 @@ export interface InstructionWeight {
|
|
|
147
148
|
readonly to: readonly string[];
|
|
148
149
|
}[];
|
|
149
150
|
}
|
|
150
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Size in the harness's own unit. Bytes and chars differ on any non-ASCII text.
|
|
153
|
+
*
|
|
154
|
+
* Line endings are counted as `\n`. A checkout with `core.autocrlf` holds the
|
|
155
|
+
* same commit with one more character per line, and a number that differs
|
|
156
|
+
* between two clones of one commit cannot be committed and compared — a
|
|
157
|
+
* 1 000-line file read +1 000 on Windows with no content change. The harness
|
|
158
|
+
* may count the CR itself; reproducibility is worth more here than that
|
|
159
|
+
* fidelity.
|
|
160
|
+
*/
|
|
151
161
|
export declare function sizeIn(text: string, unit: "chars" | "bytes"): number;
|
|
152
162
|
/**
|
|
153
163
|
* Weigh a harness's LOADED chain against its own budget.
|
|
@@ -29,19 +29,30 @@
|
|
|
29
29
|
* number carries a different severity per harness, so the harness must supply
|
|
30
30
|
* it — hence a port field, not a constant.
|
|
31
31
|
*
|
|
32
|
-
* NOT A GATE, AND THAT IS MEASURED. Both corpora this was built
|
|
33
|
-
* roughly four times the Claude Code threshold. A rule that
|
|
34
|
-
* repo on day one is switched off on day one
|
|
35
|
-
* tracks confidence, and a check nobody
|
|
36
|
-
*
|
|
37
|
-
*
|
|
32
|
+
* THE BUDGET IS NOT A GATE, AND THAT IS MEASURED. Both corpora this was built
|
|
33
|
+
* against sit at roughly four times the Claude Code threshold. A rule that
|
|
34
|
+
* fails every real repo on day one is switched off on day one
|
|
35
|
+
* (`lint-rule-calibration`: severity tracks confidence, and a check nobody
|
|
36
|
+
* leaves on catches nothing). So `audit` reports the budget, and what `lint`
|
|
37
|
+
* gates is the repo's OWN recorded weight (`./instruction-baseline.ts`): green
|
|
38
|
+
* on day one at any size, red the day it grows.
|
|
38
39
|
*/
|
|
39
40
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
41
|
exports.sizeIn = sizeIn;
|
|
41
42
|
exports.weighInstructions = weighInstructions;
|
|
42
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Size in the harness's own unit. Bytes and chars differ on any non-ASCII text.
|
|
45
|
+
*
|
|
46
|
+
* Line endings are counted as `\n`. A checkout with `core.autocrlf` holds the
|
|
47
|
+
* same commit with one more character per line, and a number that differs
|
|
48
|
+
* between two clones of one commit cannot be committed and compared — a
|
|
49
|
+
* 1 000-line file read +1 000 on Windows with no content change. The harness
|
|
50
|
+
* may count the CR itself; reproducibility is worth more here than that
|
|
51
|
+
* fidelity.
|
|
52
|
+
*/
|
|
43
53
|
function sizeIn(text, unit) {
|
|
44
|
-
|
|
54
|
+
const lf = text.replaceAll("\r\n", "\n");
|
|
55
|
+
return unit === "chars" ? lf.length : Buffer.byteLength(lf, "utf8");
|
|
45
56
|
}
|
|
46
57
|
/**
|
|
47
58
|
* Weigh a harness's LOADED chain against its own budget.
|
|
@@ -107,10 +118,15 @@ function weighInstructions(chain, files, budget) {
|
|
|
107
118
|
committedTotal,
|
|
108
119
|
effectiveTotal: sum(weighed.filter((f) => f.notLoadedHere === undefined)),
|
|
109
120
|
overBy: committedTotal > budget.limit ? committedTotal - budget.limit : null,
|
|
121
|
+
// NOT "absent from the map": a second-hop target can be in the map (the
|
|
122
|
+
// import pass read it because its importer was also a candidate) and still
|
|
123
|
+
// not be in the chain, because the second hop is named, never taken. What
|
|
124
|
+
// makes an import unread is that the chain did not classify its target.
|
|
110
125
|
unreadImports: [
|
|
111
126
|
...new Set(chain.imports
|
|
112
127
|
.map((i) => i.path)
|
|
113
|
-
.filter((path) =>
|
|
128
|
+
.filter((path) => !chain.loaded.some((e) => e.path === path) &&
|
|
129
|
+
!chain.unloaded.some((e) => e.path === path))),
|
|
114
130
|
].sort(),
|
|
115
131
|
unweighedPatterns: [
|
|
116
132
|
...new Set(chain.patterns.map((p) => p.pattern)),
|
package/dist/core/rule-meta.js
CHANGED
|
@@ -210,6 +210,14 @@ exports.RULE_META = {
|
|
|
210
210
|
summary: "Two model-invocable skills aren't near-identical (wrong one fires).",
|
|
211
211
|
detector: "findDescriptionOverlaps",
|
|
212
212
|
},
|
|
213
|
+
"instruction-weight": {
|
|
214
|
+
id: "instruction-weight",
|
|
215
|
+
bucket: "external-decidable",
|
|
216
|
+
surface: ["instruction"],
|
|
217
|
+
defaultSeverity: "error",
|
|
218
|
+
summary: "The always-loaded instruction weight did not move off its committed baseline.",
|
|
219
|
+
detector: "compareToBaseline",
|
|
220
|
+
},
|
|
213
221
|
"skill-description-budget": {
|
|
214
222
|
id: "skill-description-budget",
|
|
215
223
|
bucket: "heuristic-behavioral",
|
package/dist/core/types.d.ts
CHANGED
|
@@ -122,6 +122,14 @@ export interface RulesConfig {
|
|
|
122
122
|
* exited 0 (#173). Default: "error", matching `compile`.
|
|
123
123
|
*/
|
|
124
124
|
"spec-refs"?: RuleSeverity;
|
|
125
|
+
/**
|
|
126
|
+
* The always-loaded instruction weight may not move away from the committed
|
|
127
|
+
* baseline in `.vigiles/instruction-weight.json` — a ratchet against the
|
|
128
|
+
* repo's own past, not a fixed budget. No baseline file → a one-line note,
|
|
129
|
+
* never a finding, so turning this on fails no repository on day one.
|
|
130
|
+
* `vigiles lint --update-baseline` records it. Default: "error".
|
|
131
|
+
*/
|
|
132
|
+
"instruction-weight"?: RuleSeverity;
|
|
125
133
|
/**
|
|
126
134
|
* Near-duplicate rules WITHIN one spec, by NCD similarity — spec bloat, two
|
|
127
135
|
* rules saying the same thing in different words.
|
package/dist/coverage-probe.d.ts
CHANGED
|
@@ -106,6 +106,21 @@ import { type SurfaceProbe } from "./check-count.js";
|
|
|
106
106
|
* runner resolves refs against real discovered surfaces or drops them.
|
|
107
107
|
*/
|
|
108
108
|
export declare function commandRefs(command: string, env?: Record<string, string>): string[];
|
|
109
|
+
/**
|
|
110
|
+
* The files that a `hook-runtime run-program` command needs to exist: the hook
|
|
111
|
+
* file it names, and the runtime script when the command starts it by path
|
|
112
|
+
* (`node "$DIR/node_modules/vigiles/dist/cli.js" hook-runtime run-program …`).
|
|
113
|
+
*
|
|
114
|
+
* Narrower than {@link commandRefs}, which lists every program a command runs.
|
|
115
|
+
* This lists only what a compiled hook depends on, because the start-of-session
|
|
116
|
+
* check (`hook-check.ts`) reports these files as missing. `bash scripts/build.sh`
|
|
117
|
+
* is not one of them; reporting it missing in a fresh clone would be a false
|
|
118
|
+
* alarm that `npm install` does not fix.
|
|
119
|
+
*
|
|
120
|
+
* Like `commandRefs`, it only counts a command that really starts vigiles, so
|
|
121
|
+
* `echo vigiles hook-runtime run-program x.mjs` names nothing.
|
|
122
|
+
*/
|
|
123
|
+
export declare function runProgramFiles(command: string): string[];
|
|
109
124
|
/** The transcript shape this module reads — structural, so it imports no tier. */
|
|
110
125
|
export interface ProbeableTrace {
|
|
111
126
|
readonly toolCalls: readonly {
|
package/dist/coverage-probe.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.commandRefs = commandRefs;
|
|
4
|
+
exports.runProgramFiles = runProgramFiles;
|
|
4
5
|
exports.traceRefs = traceRefs;
|
|
5
6
|
exports.probeCommand = probeCommand;
|
|
6
7
|
exports.probeTrace = probeTrace;
|
|
@@ -586,6 +587,34 @@ function commandRefs(command, env) {
|
|
|
586
587
|
}
|
|
587
588
|
return [...out];
|
|
588
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* The files that a `hook-runtime run-program` command needs to exist: the hook
|
|
592
|
+
* file it names, and the runtime script when the command starts it by path
|
|
593
|
+
* (`node "$DIR/node_modules/vigiles/dist/cli.js" hook-runtime run-program …`).
|
|
594
|
+
*
|
|
595
|
+
* Narrower than {@link commandRefs}, which lists every program a command runs.
|
|
596
|
+
* This lists only what a compiled hook depends on, because the start-of-session
|
|
597
|
+
* check (`hook-check.ts`) reports these files as missing. `bash scripts/build.sh`
|
|
598
|
+
* is not one of them; reporting it missing in a fresh clone would be a false
|
|
599
|
+
* alarm that `npm install` does not fix.
|
|
600
|
+
*
|
|
601
|
+
* Like `commandRefs`, it only counts a command that really starts vigiles, so
|
|
602
|
+
* `echo vigiles hook-runtime run-program x.mjs` names nothing.
|
|
603
|
+
*/
|
|
604
|
+
function runProgramFiles(command) {
|
|
605
|
+
const out = new Set();
|
|
606
|
+
for (const argv of (0, bash_effects_js_1.leafArgvSource)(command)) {
|
|
607
|
+
const head = argv[0] ?? "";
|
|
608
|
+
const entry = SCRIPT_RE.test(head) ? head : interpretedScript(argv);
|
|
609
|
+
const hook = runProgramRef(argv, entry);
|
|
610
|
+
if (hook === undefined)
|
|
611
|
+
continue;
|
|
612
|
+
if (entry !== undefined)
|
|
613
|
+
out.add(entry);
|
|
614
|
+
out.add(hook);
|
|
615
|
+
}
|
|
616
|
+
return [...out];
|
|
617
|
+
}
|
|
589
618
|
/**
|
|
590
619
|
* The tools that DISPATCH a subagent. Both spellings, because the live CLI names
|
|
591
620
|
* it `Agent` and older docs name it `Task` — the same pair `parseSubagents`
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.installHookCheck = installHookCheck;
|
|
4
|
+
/**
|
|
5
|
+
* Writes the start-of-session check described in `src/hook-check.ts` and adds its
|
|
6
|
+
* entry to each harness's settings. `vigiles compile` calls this once, after it
|
|
7
|
+
* has merged the hooks.
|
|
8
|
+
*
|
|
9
|
+
* The list of files comes from the settings as they are now, not from the hooks
|
|
10
|
+
* this run compiled. So compiling one hook does not make the check forget the
|
|
11
|
+
* others, and compiling twice changes nothing. The entry is found again by the
|
|
12
|
+
* script's path, so a recompile replaces it in place and leaves any
|
|
13
|
+
* `SessionStart` hook the user wrote alone.
|
|
14
|
+
*/
|
|
15
|
+
const node_fs_1 = require("node:fs");
|
|
16
|
+
const node_path_1 = require("node:path");
|
|
17
|
+
const hook_check_js_1 = require("./hook-check.js");
|
|
18
|
+
const hook_install_js_1 = require("./hook-install.js");
|
|
19
|
+
function isShellAdapter(adapter) {
|
|
20
|
+
return adapter.shellHooks;
|
|
21
|
+
}
|
|
22
|
+
/** One harness's settings as a plain object, or `undefined` if the file does not exist yet. */
|
|
23
|
+
function readSettings(adapter, cwd) {
|
|
24
|
+
const file = (0, node_path_1.resolve)(cwd, adapter.layout.settingsPath);
|
|
25
|
+
return (0, node_fs_1.existsSync)(file)
|
|
26
|
+
? adapter.layout.settings.parse((0, node_fs_1.readFileSync)(file, "utf-8"))
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Add the check's `SessionStart` entry to one harness's settings, or replace it where it already is. */
|
|
30
|
+
function registerCheck(adapter, existing, cwd) {
|
|
31
|
+
const ref = (0, hook_install_js_1.hookGateRef)(hook_check_js_1.HOOK_CHECK_REF, adapter.layout.projectRootTokens);
|
|
32
|
+
const merged = adapter.hookProtocol.mergeRegistrations(existing, {
|
|
33
|
+
SessionStart: [
|
|
34
|
+
{ hooks: [{ type: "command", command: `sh ${ref} || exit 0` }] },
|
|
35
|
+
],
|
|
36
|
+
}, hook_check_js_1.HOOK_CHECK_REF);
|
|
37
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(cwd, adapter.layout.settingsPath), adapter.layout.settings.render(merged));
|
|
38
|
+
}
|
|
39
|
+
function installHookCheck(adapters, cwd = process.cwd()) {
|
|
40
|
+
const found = adapters.filter(isShellAdapter).flatMap((adapter) => {
|
|
41
|
+
const settings = readSettings(adapter, cwd);
|
|
42
|
+
return settings === undefined ? [] : [{ adapter, settings }];
|
|
43
|
+
});
|
|
44
|
+
const { paths, unsafe } = (0, hook_check_js_1.checkPaths)(found.flatMap((f) => (0, hook_check_js_1.commandsIn)(f.settings.hooks)));
|
|
45
|
+
for (const path of unsafe) {
|
|
46
|
+
console.warn(`⚠ hook-check: ${path} has characters the check cannot quote safely, so it is NOT covered.`);
|
|
47
|
+
}
|
|
48
|
+
if (paths.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
const script = (0, node_path_1.resolve)(cwd, hook_check_js_1.HOOK_CHECK_REF);
|
|
51
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(script), { recursive: true });
|
|
52
|
+
(0, node_fs_1.writeFileSync)(script, (0, hook_check_js_1.renderHookCheck)(paths));
|
|
53
|
+
for (const { adapter, settings } of found)
|
|
54
|
+
registerCheck(adapter, settings, cwd);
|
|
55
|
+
console.log(`✓ ${hook_check_js_1.HOOK_CHECK_REF} → SessionStart notice for ${paths.length} wired hook file(s)`);
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=hook-check-install.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Where the generated script is written. It is committed, next to the hook sources. */
|
|
2
|
+
export declare const HOOK_CHECK_REF = ".vigiles/hook-check.sh";
|
|
3
|
+
/**
|
|
4
|
+
* Every `command` string in a parsed hooks block. It looks for the key `command`
|
|
5
|
+
* instead of following one layout, because the layout depends on the harness
|
|
6
|
+
* (Claude Code nests commands under a matcher block, Codex's TOML is flat).
|
|
7
|
+
*/
|
|
8
|
+
export declare function commandsIn(node: unknown): string[];
|
|
9
|
+
export interface CheckPaths {
|
|
10
|
+
/** Paths relative to the project, sorted, no duplicates: what the script tests. */
|
|
11
|
+
readonly paths: readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* Paths the check found but cannot safely write into the script. `compile`
|
|
14
|
+
* warns about each one, because a path left out is a hook the check does not
|
|
15
|
+
* cover.
|
|
16
|
+
*/
|
|
17
|
+
readonly unsafe: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The files that the compiled-hook commands in `commands` need, as paths relative
|
|
21
|
+
* to the project. Two kinds of path are left out on purpose: absolute paths
|
|
22
|
+
* (another machine's checkout will not have them) and paths that use any variable
|
|
23
|
+
* except the project root, such as `$HOME` or a plugin root (we cannot resolve
|
|
24
|
+
* them here).
|
|
25
|
+
*/
|
|
26
|
+
export declare function checkPaths(commands: readonly string[]): CheckPaths;
|
|
27
|
+
/**
|
|
28
|
+
* The text of the script. The same paths always give the same text, so a
|
|
29
|
+
* recompile that finds the same hooks writes the same bytes.
|
|
30
|
+
*
|
|
31
|
+
* The script prints JSON with an `additionalContext` field instead of plain text.
|
|
32
|
+
* That form is confirmed for the start of a session on both Claude Code and
|
|
33
|
+
* Codex; plain text is only confirmed on Claude Code.
|
|
34
|
+
*/
|
|
35
|
+
export declare function renderHookCheck(paths: readonly string[]): string;
|
|
36
|
+
//# sourceMappingURL=hook-check.d.ts.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HOOK_CHECK_REF = void 0;
|
|
4
|
+
exports.commandsIn = commandsIn;
|
|
5
|
+
exports.checkPaths = checkPaths;
|
|
6
|
+
exports.renderHookCheck = renderHookCheck;
|
|
7
|
+
/**
|
|
8
|
+
* A check that runs when a session starts and says "run npm install" if files
|
|
9
|
+
* that the repo's hooks need are missing.
|
|
10
|
+
*
|
|
11
|
+
* Why it exists (#312). A compiled hook lives in two places: the wiring in
|
|
12
|
+
* `.claude/settings.json` (stored in git) and the files that wiring runs (in
|
|
13
|
+
* `node_modules`, stored by npm). A fresh container, a merge that brings new
|
|
14
|
+
* wiring, or a dependency update can change one without the other. Today the
|
|
15
|
+
* first sign is a hook refusing the first command. This check notices one step
|
|
16
|
+
* earlier and names the fix.
|
|
17
|
+
*
|
|
18
|
+
* What it does not do: fix anything. If a hook that can block commands has
|
|
19
|
+
* missing files, it still refuses every shell command, including the install.
|
|
20
|
+
* The check only says so before the first command is tried, so a person can run
|
|
21
|
+
* the install from a terminal outside the session. Removing the cause would need
|
|
22
|
+
* hook files stored in git next to their wiring; that is a separate decision
|
|
23
|
+
* (#312).
|
|
24
|
+
*
|
|
25
|
+
* Three rules shape the code:
|
|
26
|
+
*
|
|
27
|
+
* - It must work when nothing is installed. So the script is plain POSIX `sh`
|
|
28
|
+
* using only built-in commands (no node, no vigiles, no grep), and it is
|
|
29
|
+
* committed, so it arrives together with the wiring.
|
|
30
|
+
* - It only prints and always exits 0. We have not verified what exit code 2
|
|
31
|
+
* does at session start, and a check that could fail would be one more way to
|
|
32
|
+
* break the session it is meant to help. It is wired `|| exit 0`, like any
|
|
33
|
+
* reminder.
|
|
34
|
+
* - It lists only what a compiled hook needs: the hook files named by
|
|
35
|
+
* `hook-runtime run-program` commands in the settings, plus the local runtime
|
|
36
|
+
* when it is started by path. Reporting any other script (say
|
|
37
|
+
* `scripts/build.sh`) as missing in a fresh clone would be a false alarm,
|
|
38
|
+
* because `npm install` does not create it.
|
|
39
|
+
*
|
|
40
|
+
* The list is read from the settings after `compile` has merged into them. So it
|
|
41
|
+
* also covers hooks that another tool wrote there, and it does not shrink when
|
|
42
|
+
* `compile` is run on just one hook. A hook added after the last `compile` is
|
|
43
|
+
* not covered until the next one.
|
|
44
|
+
*
|
|
45
|
+
* Limits: it checks that a file exists, not that it loads. A package that is
|
|
46
|
+
* present but no longer exports what a hook imports passes this check and still
|
|
47
|
+
* breaks. A path is recognised by the same pattern the coverage code uses, which
|
|
48
|
+
* has no room for spaces or quotes, so a hook under such a path is not covered,
|
|
49
|
+
* and the check cannot say so.
|
|
50
|
+
*/
|
|
51
|
+
const coverage_probe_js_1 = require("./coverage-probe.js");
|
|
52
|
+
/** Where the generated script is written. It is committed, next to the hook sources. */
|
|
53
|
+
exports.HOOK_CHECK_REF = ".vigiles/hook-check.sh";
|
|
54
|
+
/**
|
|
55
|
+
* Every `command` string in a parsed hooks block. It looks for the key `command`
|
|
56
|
+
* instead of following one layout, because the layout depends on the harness
|
|
57
|
+
* (Claude Code nests commands under a matcher block, Codex's TOML is flat).
|
|
58
|
+
*/
|
|
59
|
+
function commandsIn(node) {
|
|
60
|
+
if (Array.isArray(node))
|
|
61
|
+
return node.flatMap(commandsIn);
|
|
62
|
+
if (typeof node !== "object" || node === null)
|
|
63
|
+
return [];
|
|
64
|
+
return Object.entries(node).flatMap(([key, value]) => key === "command" && typeof value === "string"
|
|
65
|
+
? [value]
|
|
66
|
+
: commandsIn(value));
|
|
67
|
+
}
|
|
68
|
+
/** How a command may spell the project root. It is removed to leave a relative path. */
|
|
69
|
+
const PROJECT_ROOT_PREFIX = /^\$(?:\{CLAUDE_PROJECT_DIR\}|CLAUDE_PROJECT_DIR)\//;
|
|
70
|
+
/** The characters a path may contain. It is written into a shell string and a JSON string. */
|
|
71
|
+
const SAFE_PATH = /^[A-Za-z0-9._@+/-]+$/;
|
|
72
|
+
/**
|
|
73
|
+
* The files that the compiled-hook commands in `commands` need, as paths relative
|
|
74
|
+
* to the project. Two kinds of path are left out on purpose: absolute paths
|
|
75
|
+
* (another machine's checkout will not have them) and paths that use any variable
|
|
76
|
+
* except the project root, such as `$HOME` or a plugin root (we cannot resolve
|
|
77
|
+
* them here).
|
|
78
|
+
*/
|
|
79
|
+
function checkPaths(commands) {
|
|
80
|
+
const relative = commands
|
|
81
|
+
.flatMap(coverage_probe_js_1.runProgramFiles)
|
|
82
|
+
.map((file) => file.replace(PROJECT_ROOT_PREFIX, ""))
|
|
83
|
+
.filter((file) => !file.startsWith("/") && !file.includes("$"));
|
|
84
|
+
const unique = [...new Set(relative)].sort();
|
|
85
|
+
return {
|
|
86
|
+
paths: unique.filter((p) => SAFE_PATH.test(p)),
|
|
87
|
+
unsafe: unique.filter((p) => !SAFE_PATH.test(p)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The text of the script. The same paths always give the same text, so a
|
|
92
|
+
* recompile that finds the same hooks writes the same bytes.
|
|
93
|
+
*
|
|
94
|
+
* The script prints JSON with an `additionalContext` field instead of plain text.
|
|
95
|
+
* That form is confirmed for the start of a session on both Claude Code and
|
|
96
|
+
* Codex; plain text is only confirmed on Claude Code.
|
|
97
|
+
*/
|
|
98
|
+
function renderHookCheck(paths) {
|
|
99
|
+
const list = paths.map((p) => ` '${p}'`).join(" \\\n");
|
|
100
|
+
return `#!/bin/sh
|
|
101
|
+
# Generated by \`vigiles compile\`. Do not edit; the next compile rewrites it.
|
|
102
|
+
#
|
|
103
|
+
# Start-of-session notice (vigiles #312). If a file that a compiled hook runs is
|
|
104
|
+
# not installed, it says so in the first turn. It uses only plain sh and shell
|
|
105
|
+
# built-ins (no node, no node_modules, no vigiles), because "not installed" is
|
|
106
|
+
# exactly the situation it reports. It only prints and always exits 0.
|
|
107
|
+
#
|
|
108
|
+
# It does not fix anything. A hook that can block commands, with its files
|
|
109
|
+
# missing, refuses every shell command including the install, so the install has
|
|
110
|
+
# to be run from a terminal outside the session.
|
|
111
|
+
root="\${CLAUDE_PROJECT_DIR:-.}"
|
|
112
|
+
missing=""
|
|
113
|
+
for p in \\
|
|
114
|
+
${list}
|
|
115
|
+
do
|
|
116
|
+
[ -e "$root/$p" ] || missing="$missing $p"
|
|
117
|
+
done
|
|
118
|
+
if [ -n "$missing" ]; then
|
|
119
|
+
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\\n' \\
|
|
120
|
+
"vigiles: hook files that this repo runs are not installed:$missing. Run npm ci (or npm install) from a terminal outside this session before anything else. A blocking hook whose files are missing refuses every shell command in a session, the install included; a reminder-only hook that cannot load is skipped. See docs/compiled-hooks.md, When a hook cannot load."
|
|
121
|
+
fi
|
|
122
|
+
exit 0
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=hook-check.js.map
|