@veda-hq/kavach 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ /** Command-line options of the rule-check CLIs: `--name value` pairs and bare flags. */
2
+ /** The value after `--name`; undefined when the option is absent. Throws when it has no value. */
3
+ export function option(argv, name) {
4
+ const at = argv.indexOf(name);
5
+ if (at === -1)
6
+ return undefined;
7
+ const value = argv[at + 1];
8
+ if (value === undefined || value.startsWith('--'))
9
+ throw new Error(`${name} needs a value`);
10
+ return value;
11
+ }
12
+ /**
13
+ * Refuse any word that is neither one of `options` (with its value) nor one
14
+ * of `flags`, so a mistyped option fails loudly instead of being ignored.
15
+ */
16
+ export function onlyKnown(argv, options, flags = []) {
17
+ let value = false;
18
+ for (const word of argv) {
19
+ if (value)
20
+ value = false;
21
+ else if (options.includes(word))
22
+ value = true;
23
+ else if (!flags.includes(word))
24
+ throw new Error(`unknown argument ${JSON.stringify(word)}`);
25
+ }
26
+ }
@@ -0,0 +1,42 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * Is this module the script node was asked to run, rather than an import?
6
+ *
7
+ * Every CLI needs this: run when invoked, stay inert when a test imports it.
8
+ * Getting it wrong in the "inert" direction is silent gate death: the hook
9
+ * invokes the file, nothing runs, and the call passes with a successful exit
10
+ * status and no output at all. A guard that compares `process.argv[1]` to
11
+ * `import.meta.url` by path equality does exactly that through a symlink.
12
+ *
13
+ * The trap: node resolves symlinks when it builds `import.meta.url`, but
14
+ * `process.argv[1]` stays whatever path the caller typed. Any repo reached
15
+ * through a symlink (a linked worktree, a symlinked checkout, `/tmp` on
16
+ * macOS, a package manager's `node_modules/.bin` link) makes the two unequal
17
+ * for the same file. Comparing real paths is what closes it.
18
+ *
19
+ * @param entryPath `process.argv[1]` from the calling module.
20
+ * @param moduleUrl `import.meta.url` from the calling module.
21
+ */
22
+ export function isCliEntry(entryPath, moduleUrl) {
23
+ if (entryPath === undefined)
24
+ return false;
25
+ const self = fileURLToPath(moduleUrl);
26
+ // Fast path: the common, non-symlinked invocation, and the only one that
27
+ // still works if the module were deleted from disk mid-run.
28
+ if (resolve(entryPath) === self)
29
+ return true;
30
+ try {
31
+ return realpathSync(entryPath) === realpathSync(self);
32
+ }
33
+ catch {
34
+ // An entry path that does not resolve cannot be this file: node resolves
35
+ // argv[1] to a real path before executing it. So this is an import whose
36
+ // host has an exotic argv[1] (a worker thread, a bundler's virtual
37
+ // module), and returning true would run the CLI body during import.
38
+ // Returning false cannot hide a CLI run either: a real invocation always
39
+ // has a resolvable entry.
40
+ return false;
41
+ }
42
+ }
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
10
+ /**
11
+ * The rule-check CLI, `dist/rule-checks/cli.js`.
12
+ *
13
+ * cli.js [--checks <module>] [--registry <json>] [--fires-dir <dir>]
14
+ * The hook. Reads one Claude Code hook event on stdin, runs the
15
+ * consumer's checks on it (runtime.ts) and answers on stdout; exits 0.
16
+ * --checks the consumer's checks module, a file Node can import
17
+ * (`.js`, `.mjs`, or `.ts` that Node strips types from). It
18
+ * exports `CHECKS`, or a default export, a list of `Check`
19
+ * (runtime.ts). Env: KAVACH_RULE_CHECKS.
20
+ * --registry the consumer's rule registry JSON (registry.ts). Optional:
21
+ * without one every rule reads as unregistered and a refusal
22
+ * says to ask "the repository owner". Env: KAVACH_RULE_REGISTRY.
23
+ * --fires-dir the fire-log folder. Default: `.tmp/rule-checks` in the
24
+ * primary checkout of the session's `cwd`. Env:
25
+ * KAVACH_RULE_FIRES_DIR.
26
+ * A flag wins over its variable. Relative paths resolve against
27
+ * CLAUDE_PROJECT_DIR, else the current folder.
28
+ * Fails open: a missing or broken checks module, a bad flag, or any crash
29
+ * is answered with a warning and a crash row in the fire log, and the call
30
+ * goes ahead. A crash it could not catch exits non-zero, which Claude Code
31
+ * reports as a non-blocking error.
32
+ *
33
+ * cli.js count [--registry <json>] [--root <dir>] [--json]
34
+ * How many registry rules no enforcer file in the checkout enforces.
35
+ * cli.js latency [--root <dir>] [--rounds N]
36
+ * Time the checkout's Bash PreToolUse hooks (latency.ts).
37
+ * cli.js push-marker write|clear <git-dir> <pid>
38
+ * Write or clear a push's marker, from a pre-push hook (live-runs.ts).
39
+ *
40
+ * A hook registration, in `.claude/settings.json`:
41
+ * "command": "node node_modules/@veda-hq/kavach/dist/rule-checks/cli.js
42
+ * --checks rules/checks.ts --registry rules/registry.json"
43
+ */
44
+ import { readFileSync } from 'node:fs';
45
+ import { resolve } from 'node:path';
46
+ import { pathToFileURL } from 'node:url';
47
+ import { onlyKnown, option } from './args.js';
48
+ import { isCliEntry } from './cli-entry.js';
49
+ import { FIRES_DIR_ENV } from './fire-log.js';
50
+ import { main as latency } from './latency.js';
51
+ import { liveEffects, main as pushMarker } from './live-runs.js';
52
+ import { main as count, REGISTRY_ENV, STDIO } from './registry.js';
53
+ import { liveDeps, runHook } from './runtime.js';
54
+ /** The environment variable the hook reads the checks module from. */
55
+ export const CHECKS_ENV = 'KAVACH_RULE_CHECKS';
56
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
57
+ const isTexts = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
58
+ /** Does `value` have the shape of a `Check`? */
59
+ export function isCheck(value) {
60
+ return (isRecord(value) &&
61
+ typeof value['rule'] === 'string' &&
62
+ isTexts(value['events']) &&
63
+ (value['tools'] === undefined || isTexts(value['tools'])) &&
64
+ typeof value['cost'] === 'string' &&
65
+ typeof value['instead'] === 'string' &&
66
+ typeof value['run'] === 'function');
67
+ }
68
+ /** The checks a loaded module exports as `CHECKS`, else as its default export; throws naming what is wrong. */
69
+ export function checksOf(loaded, where) {
70
+ const exported = isRecord(loaded) ? (loaded['CHECKS'] ?? loaded['default']) : undefined;
71
+ if (!Array.isArray(exported)) {
72
+ throw new Error(`${where} exports no list of checks (as CHECKS or its default export)`);
73
+ }
74
+ const checks = [];
75
+ for (const [i, item] of exported.entries()) {
76
+ if (!isCheck(item)) {
77
+ throw new Error(`${where}: check ${i} is not a check (rule, events, cost, instead and run)`);
78
+ }
79
+ checks.push(item);
80
+ }
81
+ return checks;
82
+ }
83
+ /** Import the consumer's checks module at `path`. */
84
+ export async function loadChecks(path) {
85
+ const loaded = await import(__rewriteRelativeImportExtension(pathToFileURL(path).href));
86
+ return checksOf(loaded, path);
87
+ }
88
+ /** A flag's value, else its variable's; undefined when neither names one. */
89
+ function chosen(argv, flag, env, name) {
90
+ const value = option(argv, flag) ?? env[name];
91
+ return value === '' ? undefined : value;
92
+ }
93
+ /** The hook: the answer to one hook event on `stdin`. Never throws. */
94
+ export async function hook(argv, stdin, env) {
95
+ const base = env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
96
+ const at = (path) => (path === undefined ? undefined : resolve(base, path));
97
+ let registry;
98
+ let firesDir;
99
+ let checks;
100
+ try {
101
+ // The fire-log folder first, so that any failure after it is logged there.
102
+ firesDir = at(chosen(argv, '--fires-dir', env, FIRES_DIR_ENV));
103
+ registry = at(chosen(argv, '--registry', env, REGISTRY_ENV));
104
+ onlyKnown(argv, ['--checks', '--registry', '--fires-dir']);
105
+ const path = at(chosen(argv, '--checks', env, CHECKS_ENV));
106
+ if (path === undefined)
107
+ throw new Error(`no checks module: pass --checks or set ${CHECKS_ENV}`);
108
+ checks = await loadChecks(path);
109
+ }
110
+ catch (error) {
111
+ // fail-open: the runtime logs the failure as its own crash, under the event.
112
+ const failure = error instanceof Error ? error : new Error(String(error));
113
+ checks = () => {
114
+ throw failure;
115
+ };
116
+ }
117
+ return runHook(stdin, { ...liveDeps(env, { registry, firesDir }), checks });
118
+ }
119
+ /** The CLI; returns the exit status. */
120
+ export async function run(argv, io, stdin, env) {
121
+ const [command, ...rest] = argv;
122
+ if (command === 'count')
123
+ return count(rest, io, process.cwd(), env);
124
+ if (command === 'push-marker')
125
+ return pushMarker(rest, liveEffects, io.err);
126
+ if (command === 'latency') {
127
+ try {
128
+ await latency(rest, process.cwd(), io.out);
129
+ return 0;
130
+ }
131
+ catch (error) {
132
+ io.err(`latency: ${error instanceof Error ? error.message : String(error)}`);
133
+ return 2;
134
+ }
135
+ }
136
+ io.write(await hook(argv, stdin(), env));
137
+ return 0;
138
+ }
139
+ if (isCliEntry(process.argv[1], import.meta.url)) {
140
+ process.exitCode = await run(process.argv.slice(2), {
141
+ ...STDIO,
142
+ write: (text) => {
143
+ process.stdout.write(text);
144
+ },
145
+ }, () => readFileSync(0).toString(), process.env);
146
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The text a file-writing tool call proposes: what an edit-time check judges
3
+ * before the tool runs.
4
+ */
5
+ /**
6
+ * The file's text once the tool has run: a Write's content; an Edit applied to
7
+ * `current`. An Edit whose old text is not in the file fails anyway, so its
8
+ * new text is judged beside the current file.
9
+ */
10
+ export function afterEdit(tool, given, current) {
11
+ const text = (key) => {
12
+ const value = given[key];
13
+ return typeof value === 'string' ? value : '';
14
+ };
15
+ if (tool === 'Write')
16
+ return text('content');
17
+ const before = current ?? '';
18
+ const old = text('old_string');
19
+ const next = text('new_string');
20
+ if (old === '' || !before.includes(old))
21
+ return `${before}\n${next}`;
22
+ return given['replace_all'] === true ? before.split(old).join(next) : before.replace(old, () => next);
23
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The fire log: one JSON line per check that refused, warned, was passed with
3
+ * a stated reason, or crashed.
4
+ *
5
+ * Where: the folder the caller names (`firesDir`'s override: the runtime's
6
+ * `firesDir` option, the CLI's `--fires-dir` or `KAVACH_RULE_FIRES_DIR`), else
7
+ * `<primary checkout>/.tmp/rule-checks/fires.jsonl`, the checkout that owns
8
+ * the git common directory, so rows from every worktree land in one file and
9
+ * outlive the worktree.
10
+ *
11
+ * Shared state, written by hooks running in parallel across sessions:
12
+ * - Ordering: nothing claims or waits on the file; readers only read history.
13
+ * - Failure cleanup: a failed or short append leaves no row or one partial
14
+ * line, never a stale record another process trusts, and throws so the
15
+ * runtime reports the missed row. A short write is not retried: a second
16
+ * append could land after another writer's row and split this one. The next
17
+ * append starts on a fresh line, so only the partial line fails to parse,
18
+ * and a reader skips it.
19
+ * - Overlap: every append is one write of whole lines (O_APPEND);
20
+ * rows are short (the excerpt is capped), nothing truncates, rewrites or
21
+ * sweeps the file, so writers cannot erase each other's rows. Two writers
22
+ * that both find a partial line both start a fresh one: one empty line.
23
+ * - Input validity: rows are built here from typed values; nothing in this
24
+ * module reads the file back.
25
+ * Accepted limit: an append on a network filesystem may interleave; keep the
26
+ * folder on a local disk.
27
+ */
28
+ import { closeSync, fstatSync, mkdirSync, openSync, readSync, writeSync } from 'node:fs';
29
+ import { dirname, join } from 'node:path';
30
+ export const EXCERPT_MAX = 120;
31
+ export const FIRES_FILE = 'fires.jsonl';
32
+ /** The environment variable the CLI reads the fire-log folder from. */
33
+ export const FIRES_DIR_ENV = 'KAVACH_RULE_FIRES_DIR';
34
+ /** `value` on one line, at most `max` characters, an ellipsis marking a cut. */
35
+ export function clip(value, max = EXCERPT_MAX) {
36
+ const line = value.replace(/\s+/g, ' ').trim();
37
+ return line.length <= max ? line : `${line.slice(0, max - 1)}…`;
38
+ }
39
+ /**
40
+ * The folder the fire log lives in for a session whose checkout is `root`:
41
+ * `override` when it names one, else `.tmp/rule-checks` in the primary checkout.
42
+ */
43
+ export function firesDir(override, root, git) {
44
+ if (override !== undefined && override !== '')
45
+ return override;
46
+ const common = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
47
+ return join(dirname(common), '.tmp', 'rule-checks');
48
+ }
49
+ /** The branch checked out at `root`; `HEAD` when detached, `unknown` outside git. */
50
+ export function branchAt(root, git) {
51
+ try {
52
+ return git(root, ['branch', '--show-current']) || 'HEAD';
53
+ }
54
+ catch {
55
+ // fail-open: a row without its branch still records the fire.
56
+ return 'unknown';
57
+ }
58
+ }
59
+ /** A newline when the log ends in a partial line a failed append left. */
60
+ function freshLine(fd) {
61
+ const { size } = fstatSync(fd);
62
+ if (size === 0)
63
+ return '';
64
+ const last = Buffer.alloc(1);
65
+ readSync(fd, last, 0, 1, size - 1);
66
+ return last.toString() === '\n' ? '' : '\n';
67
+ }
68
+ export function appendFires(dir, rows, write = writeSync) {
69
+ mkdirSync(dir, { recursive: true });
70
+ const fd = openSync(join(dir, FIRES_FILE), 'a+');
71
+ try {
72
+ const lines = rows.map((row) => `${JSON.stringify(row)}\n`).join('');
73
+ const bytes = Buffer.from(freshLine(fd) + lines);
74
+ const wrote = write(fd, bytes);
75
+ if (wrote !== bytes.length)
76
+ throw new Error(`fire log: wrote ${wrote} of ${bytes.length} bytes`);
77
+ }
78
+ finally {
79
+ closeSync(fd);
80
+ }
81
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Hook latency on the Bash path: every PreToolUse hook the checkout's
3
+ * `.claude/settings.json` registers for Bash, fed a fixed set of everyday
4
+ * commands, timed alone and all together.
5
+ *
6
+ * Claude Code runs the hooks that match one call in parallel
7
+ * (https://code.claude.com/docs/en/hooks, "All matching hooks run in
8
+ * parallel"), so the "per call" line, every hook started at once and timed to
9
+ * the last exit, is what a Bash call waits; the per-hook lines say which hook
10
+ * sets it. A budget worth holding each check to is p95 ≤ 150 ms.
11
+ *
12
+ * Hooks only read the command they are handed, so replaying these commands
13
+ * runs none of them. The load average and the interpreters on PATH are printed
14
+ * with the numbers, because both move them.
15
+ *
16
+ * The timed hooks run with `KAVACH_RULE_FIRES_DIR` pointed at
17
+ * {@link LATENCY_FIRES}, so their refusals stay out of the real fire log; a
18
+ * hook registered with its own `--fires-dir` keeps writing there.
19
+ *
20
+ * `cli.js latency [--root <dir>] [--rounds N]` (default: the current folder,
21
+ * 5 rounds over the corpus).
22
+ */
23
+ import { spawn, spawnSync } from 'node:child_process';
24
+ import { readFileSync } from 'node:fs';
25
+ import { loadavg } from 'node:os';
26
+ import { join, resolve } from 'node:path';
27
+ import { FIRES_DIR_ENV } from './fire-log.js';
28
+ /** Commands an agent runs every hour, from cheap reads to pushes. */
29
+ export const CORPUS = [
30
+ 'git status --short',
31
+ 'ls src',
32
+ 'grep -n "export" src/index.ts',
33
+ 'node --version',
34
+ 'git log --oneline -5',
35
+ 'git diff --stat origin/main',
36
+ 'git add README.md',
37
+ 'git commit -m "docs: note"',
38
+ 'git push -u origin HEAD',
39
+ 'gh pr view --json number,state',
40
+ ];
41
+ /** The PreToolUse hook commands whose matcher selects the Bash tool. */
42
+ export function bashHooks(settings) {
43
+ return (settings.hooks?.['PreToolUse'] ?? [])
44
+ .filter((entry) => new RegExp(`^(?:${entry.matcher ?? '.*'})$`).test('Bash'))
45
+ .flatMap((entry) => entry.hooks.map((hook) => hook.command));
46
+ }
47
+ /** Nearest-rank percentile of ascending `sorted`; 0 for no samples. */
48
+ export function percentile(sorted, p) {
49
+ return sorted[Math.min(sorted.length, Math.ceil((p / 100) * sorted.length)) - 1] ?? 0;
50
+ }
51
+ export function hookInput(root, command) {
52
+ return JSON.stringify({
53
+ hook_event_name: 'PreToolUse',
54
+ tool_name: 'Bash',
55
+ cwd: root,
56
+ tool_input: { command, description: 'latency probe' },
57
+ });
58
+ }
59
+ /** A hook that has not exited by now is hung, not slow. */
60
+ export const HOOK_TIMEOUT_MS = 30_000;
61
+ /**
62
+ * `bash -c command` with `input` on stdin, in its own process group so a
63
+ * hook that hangs is killed with everything it started.
64
+ */
65
+ function runBash(command, input, env, timeoutMs) {
66
+ return new Promise((done) => {
67
+ const child = spawn('bash', ['-c', command], { env, stdio: ['pipe', 'ignore', 'ignore'], detached: true });
68
+ let settled = false;
69
+ const settle = (outcome) => {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ done(outcome);
75
+ };
76
+ const timer = setTimeout(() => {
77
+ try {
78
+ // Never `kill(-0)`: without a pid that would signal this process's own group.
79
+ if (child.pid !== undefined)
80
+ process.kill(-child.pid, 'SIGKILL');
81
+ }
82
+ catch {
83
+ // fail-open: a group that already ended has nothing left to kill.
84
+ }
85
+ settle('timeout');
86
+ }, timeoutMs);
87
+ child.on('error', () => settle('spawn-error'));
88
+ child.on('exit', () => settle('exit'));
89
+ // A hook may exit without reading its input; the broken pipe is no failure.
90
+ child.stdin.on('error', () => undefined);
91
+ child.stdin.end(input);
92
+ });
93
+ }
94
+ /**
95
+ * Milliseconds from starting `command` under bash with `input` on stdin to its
96
+ * exit, whatever its status: a refusal is as much an answer as a pass.
97
+ */
98
+ export async function timeHook(command, input, env, timeoutMs = HOOK_TIMEOUT_MS, clock = () => performance.now()) {
99
+ const start = clock();
100
+ const outcome = await runBash(command, input, env, timeoutMs);
101
+ if (outcome !== 'exit')
102
+ throw new Error(`${command}: ${outcome}`);
103
+ return clock() - start;
104
+ }
105
+ /** Where the timed hooks' fires go, under the measured checkout. */
106
+ export const LATENCY_FIRES = '.tmp/rule-checks/latency-fires';
107
+ /** Run `steps` one after another: timings taken side by side would load each other. */
108
+ function inTurn(steps) {
109
+ return steps.reduce((done, step) => done.then(step), Promise.resolve());
110
+ }
111
+ /** Every hook alone, then all at once, `rounds` times over `corpus`. */
112
+ export async function measure(hooks, corpus, rounds, root, clock = () => performance.now()) {
113
+ // The corpus's foreground push may be refused; timing runs must not write
114
+ // those refusals into the fire log the count and reviews read.
115
+ const env = {
116
+ ...process.env,
117
+ CLAUDE_PROJECT_DIR: root,
118
+ [FIRES_DIR_ENV]: join(root, LATENCY_FIRES),
119
+ };
120
+ const alone = hooks.map((name) => ({ name, ms: [] }));
121
+ const together = { name: 'per call (all in parallel)', ms: [] };
122
+ const calls = Array.from({ length: rounds }, () => corpus).flat();
123
+ await inTurn(calls.flatMap((command) => {
124
+ const input = hookInput(root, command);
125
+ return [
126
+ ...alone.map((series) => async () => {
127
+ series.ms.push(await timeHook(series.name, input, env));
128
+ }),
129
+ async () => {
130
+ const start = clock();
131
+ await Promise.all(hooks.map((hook) => timeHook(hook, input, env)));
132
+ together.ms.push(clock() - start);
133
+ },
134
+ ];
135
+ }));
136
+ return [...alone, together];
137
+ }
138
+ /** Where an interpreter resolves on PATH; `missing` when it does not. */
139
+ export function which(program) {
140
+ const found = spawnSync('bash', ['-c', `command -v ${program}`], {
141
+ encoding: 'utf8',
142
+ });
143
+ return found.stdout.trim() || 'missing';
144
+ }
145
+ export function render(series, load, tools) {
146
+ const lines = [
147
+ `load average ${load.map((value) => value.toFixed(2)).join(' ')}`,
148
+ ...Object.entries(tools).map(([name, path]) => `${name}: ${path}`),
149
+ '',
150
+ 'hook | samples | p50 ms | p95 ms | max ms',
151
+ ];
152
+ for (const { name, ms } of series) {
153
+ const sorted = [...ms].sort((a, b) => a - b);
154
+ lines.push([
155
+ name,
156
+ sorted.length,
157
+ percentile(sorted, 50).toFixed(0),
158
+ percentile(sorted, 95).toFixed(0),
159
+ percentile(sorted, 100).toFixed(0),
160
+ ].join(' | '));
161
+ }
162
+ return lines.join('\n');
163
+ }
164
+ export function rounds(argv) {
165
+ const at = argv.indexOf('--rounds');
166
+ if (at === -1)
167
+ return 5;
168
+ const value = Number(argv[at + 1]);
169
+ if (!Number.isSafeInteger(value) || value < 1) {
170
+ throw new Error('--rounds needs a positive whole number');
171
+ }
172
+ return value;
173
+ }
174
+ /** The checkout whose settings and hooks are timed: `--root`, else `fallback`. */
175
+ export function rootOf(argv, fallback) {
176
+ const at = argv.indexOf('--root');
177
+ if (at === -1)
178
+ return fallback;
179
+ const value = argv[at + 1];
180
+ if (value === undefined)
181
+ throw new Error('--root needs a folder');
182
+ return resolve(value);
183
+ }
184
+ export async function main(argv, fallbackRoot, out) {
185
+ const root = rootOf(argv, fallbackRoot);
186
+ const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf8'));
187
+ const series = await measure(bashHooks(settings), CORPUS, rounds(argv), root);
188
+ out(render(series, loadavg(), {
189
+ node: which('node'),
190
+ python3: which('python3'),
191
+ }));
192
+ }