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.
@@ -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 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.
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
- /** Size in the harness's own unit. Bytes and chars differ on any non-ASCII text. */
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 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.
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
- /** Size in the harness's own unit. Bytes and chars differ on any non-ASCII text. */
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
- return unit === "chars" ? text.length : Buffer.byteLength(text, "utf8");
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) => files[path] === undefined)),
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)),
@@ -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",
@@ -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.
@@ -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 {
@@ -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,3 @@
1
+ import type { HarnessAdapter } from "./core/adapter.js";
2
+ export declare function installHookCheck(adapters: readonly HarnessAdapter[], cwd?: string): void;
3
+ //# sourceMappingURL=hook-check-install.d.ts.map
@@ -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