vigiles 32.1.0 → 33.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.
@@ -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
@@ -1,12 +1,100 @@
1
- import type { DispatchKind } from "./core/hook-program.js";
1
+ import { type CompilableMarker, type SourceKind } from "./source-kinds.js";
2
2
  /** The agnostic, committed home for hook SOURCE — one dir, cross-adapter. */
3
3
  export declare const HOOKS_DIR = ".vigiles/hooks";
4
4
  /** The committed home for registered context-provider SOURCE (v2). */
5
5
  export declare const PROVIDERS_DIR = ".vigiles/providers";
6
- /** Discover hook source files under {@link HOOKS_DIR} (stamps excluded). */
7
- export declare function discoverHookFiles(cwd: string): string[];
8
- /** Discover registered-provider source files under {@link PROVIDERS_DIR}. */
9
- export declare function discoverProviderFiles(cwd: string): string[];
6
+ /** What discovery found in one source directory, split by what `compile` owes each part. */
7
+ export interface Discovered {
8
+ /** Files this directory's role compiles: `.hook.` files in `.vigiles/hooks/`, `.provider.` files in `.vigiles/providers/`. */
9
+ readonly claimed: readonly string[];
10
+ /**
11
+ * Runnable files nothing claims here. `compile` REFUSES these out loud
12
+ * ({@link unclaimedMessage}); dropping them would make a hook that lost its
13
+ * marker vanish from the build without a word.
14
+ */
15
+ readonly unclaimed: readonly string[];
16
+ }
17
+ /** Discover hook sources under {@link HOOKS_DIR}: `.hook.` files, and the runnable files that are nobody's. */
18
+ export declare function discoverHookFiles(cwd: string): Discovered;
19
+ /** Discover registered-provider sources under {@link PROVIDERS_DIR}. */
20
+ export declare function discoverProviderFiles(cwd: string): Discovered;
21
+ /** Why an explicit `compile` argument is an error rather than a hook, a refusal or a skip. */
22
+ export type InvalidArgReason = "missing" | "directory" | "not-a-source";
23
+ /** What `vigiles compile <files…>` makes of the paths it was handed. */
24
+ export interface HookArgs {
25
+ readonly hooks: readonly string[];
26
+ /** Runnable files nothing claims (or a marked file in the wrong directory): refused, with the fix. */
27
+ readonly unclaimed: readonly string[];
28
+ /** Named on purpose, not compilable (a test, a stamp…): said out loud, not an error. */
29
+ readonly skipped: readonly {
30
+ readonly path: string;
31
+ readonly kind: SourceKind["kind"];
32
+ }[];
33
+ /** Not a file at all, or not a source: an error, the way it always was. */
34
+ readonly invalid: readonly {
35
+ readonly path: string;
36
+ readonly reason: InvalidArgReason;
37
+ }[];
38
+ }
39
+ /** What the file system says a path is. Injected so the rules below stay pure. */
40
+ export type PathProbe = (path: string) => "file" | "directory" | "missing" | "other";
41
+ /**
42
+ * Sort explicit `compile` arguments by the same classifier discovery uses, so
43
+ * `compile guard.mjs` and a bare `compile` give the same answer about it — for a
44
+ * path ANYWHERE, not just under `.vigiles/hooks/`: `compile .claude/hooks/x.mjs`
45
+ * or a plugin's `hooks/guard.mjs` is refused unless it carries `.hook.`.
46
+ *
47
+ * A path that is missing, a directory or not a regular file is an error (it used
48
+ * to fail, and the first version of the marker rule turned it into a green run).
49
+ * The one lenient case exists for a shell glob (`compile .vigiles/hooks/*`): a
50
+ * test, declaration or stamp is reported as skipped, and so is a README or
51
+ * `.gitkeep` — but only inside the two vigiles source directories, where a glob
52
+ * would sweep one up. A `/etc/passwd` is not a README.
53
+ */
54
+ export declare function partitionHookArgs(args: readonly string[], probe?: PathProbe): HookArgs;
55
+ /** The line `compile` prints for an argument it cannot treat as a hook source at all. */
56
+ export declare function invalidArgMessage(path: string, reason: InvalidArgReason): string;
57
+ /**
58
+ * Discovery, shaped like {@link partitionHookArgs}'s answer, so `compile` handles
59
+ * "no arguments" and "these paths" through one code path afterwards.
60
+ */
61
+ export declare function discoveredHookArgs(cwd: string): HookArgs;
62
+ /**
63
+ * The ONE shell command that renames an unclaimed file to its marked name and
64
+ * (for a hook) recompiles it. Every path is single-quoted: this is printed by a
65
+ * tool and pasted by a person, and a repository can plant a file name that is a
66
+ * command.
67
+ *
68
+ * 🔴 THE HOOK COMMAND COPIES FIRST AND DELETES LAST, and that order is the
69
+ * point. Already-wired hooks keep running after an upgrade: the runtime loads
70
+ * whatever path `settings.json` names, and never asks what a file is called. So
71
+ * the break is the NEXT compile, and a plain `mv` + `compile` has one bad
72
+ * moment — the old path is gone and the wiring still names it. For a gate
73
+ * wired `|| exit 2` that is a hook that cannot load, which blocks every Bash
74
+ * call (the repair is a file write, not a command). Compiling the renamed copy
75
+ * while the old file still exists, and deleting the old one only after that
76
+ * compile succeeded, has no such moment: if anything fails the old hook is
77
+ * still on disk and still wired. `compile` replaces the old wiring itself
78
+ * ({@link ownedRefs}), so nothing is left naming the deleted path.
79
+ *
80
+ * 🔴 `cp -n`, because the marked name may already exist: a plain `cp` replaced
81
+ * an edited `guard.hook.mjs` with the stale `guard.mjs`, and the weaker hook
82
+ * became the wired gate. {@link unclaimedMessage} checks first and prints no
83
+ * command at all in that case; `-n` is for the file that appears in between.
84
+ */
85
+ export declare function renameCommand(path: string, role: CompilableMarker): string;
86
+ /**
87
+ * {@link unclaimedMessage} with the one fact it cannot know, read off the disk:
88
+ * whether the marked name is already there.
89
+ */
90
+ export declare function unclaimedReport(path: string, role: CompilableMarker, cwd?: string): string;
91
+ /**
92
+ * The text `compile` prints for one file it will not compile, with the exact
93
+ * command to fix it. `targetExists` is whether the marked name is already on
94
+ * disk: then there is nothing safe to copy, and the message says to merge by
95
+ * hand instead.
96
+ */
97
+ export declare function unclaimedMessage(path: string, role: CompilableMarker, targetExists?: boolean): string;
10
98
  interface CommandHook {
11
99
  readonly type: "command";
12
100
  readonly command: string;
@@ -73,41 +161,6 @@ export declare function normalizeHookRef(hookPath: string, cwd?: string): string
73
161
  * it can be given — see {@link hookGateRef} for the same fallback.
74
162
  */
75
163
  export declare function hookRuntimeRef(projectRootTokens: readonly string[] | undefined): string;
76
- /**
77
- * What the shell must do when the runtime above CANNOT START — a missing
78
- * `node_modules/vigiles`, an unreadable file, an interpreter that dies before a
79
- * single line of ours runs. No code of ours executes in that case, so the policy
80
- * has to be expressed in the emitted command or not at all.
81
- *
82
- * 🔴 THIS IS NOT A NEW POLICY. `runHookProgramCommand`'s load-failure branch has
83
- * decided it since 2026-08: *"an inject's purpose is to ADD context, not to
84
- * ENFORCE a decision … Gates (file, bash, prompt, stop) remain conservative and
85
- * fail closed."* That branch only reaches failures that happen AFTER the runtime
86
- * starts. This carries the same rule one layer out, to the failures that happen
87
- * before it.
88
- *
89
- * WHY THE SPLIT, RATHER THAN ONE ANSWER FOR EVERYTHING — the two failures are
90
- * not comparable:
91
- *
92
- * A GATE THAT SILENTLY PASSES IS WORSE THAN NO GATE. Its whole value is the
93
- * refusal, and a harness that reports protection it is not providing is the
94
- * one state worse than admitting it has none. So a gate whose runtime is
95
- * missing exits 2: loud, blocking, and the cause is on stderr.
96
- *
97
- * A NUDGE THAT BLOCKS COSTS THE WHOLE REPOSITORY. Measured here 2026-08-10:
98
- * merge-conflict markers in `package.json` stopped every hook loading, the
99
- * Bash gate then refused `git merge --abort` — the one command that undoes the
100
- * cause — and the session could not be repaired from inside. A reminder is
101
- * never worth that, so a nudge exits 0 and says nothing it cannot say.
102
- *
103
- * The role is not a flag someone can flip: `Reaction` has no `deny` and an
104
- * inject returns context, so "nudge" is a fact about the TYPE the author chose.
105
- *
106
- * (Industry does not agree on one answer either — husky and lefthook skip,
107
- * pre-commit fails. Which is itself the argument for deciding by role instead
108
- * of picking one and imposing it on both.)
109
- */
110
- export declare function hookRuntimeMissingExit(kind: DispatchKind): 0 | 2;
111
164
  export declare function hookGateRef(ref: string, projectRootTokens: readonly string[] | undefined): string;
112
165
  /**
113
166
  * Idempotently merge a compiled hook's block into an existing `settings.json`