Files
gstack/hosts/claude/hooks/hook-log.ts
garrytan 8a6ffdaca1 W1: shared hook logging helper (hosts/claude/hooks/hook-log.ts)
One hook-errors.log writer: root from resolveStateRoot, 0600 on every
append, opt-in rate limit used only by memorable-user-prompt. The five
hooks route through it.
2026-09-30 06:38:50 +00:00

60 lines
2.6 KiB
TypeScript

/**
* hook-log — the one hook-errors.log writer for the Claude Code hooks in this
* directory. Root from resolveStateRoot() (lib/state-root.ts), mode 0600 on
* every append (an existing 0644 log is tightened), best-effort: logging never
* blocks a hook. Add a hook: `logHookError('my-hook', msg)`; pass
* `{ rateLimit: { nowMs, key } }` to drop repeats within LOG_RATE_LIMIT_MS
* (only memorable-user-prompt uses it). Moved from the five hooks' private
* copies; test/hook-log.test.ts pins the shared root and the mode.
*/
import * as fs from 'fs';
import * as path from 'path';
import { createHash } from 'crypto';
import { resolveStateRoot } from '../../../lib/state-root';
export const LOG_RATE_LIMIT_MS = 10 * 60 * 1000;
/** Distinct rate-limit keys remembered at once (the marker file is rewritten on every log line). */
const RATE_LIMIT_KEYS = 32;
export interface HookLogOptions {
/** Drop a repeat of `key` logged by this hook within LOG_RATE_LIMIT_MS of `nowMs`. */
rateLimit?: { nowMs: number; key: string };
}
export function hookErrorLogPath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateRoot(env), 'hook-errors.log');
}
/**
* Append one line to <state root>/hook-errors.log. With `rateLimit`, a
* per-hook marker (`hook-errors.<hook>.last`, up to RATE_LIMIT_KEYS live
* `digest:ts` lines) suppresses repeats, so hooks never contend on it.
*/
export function logHookError(hook: string, msg: string, opts: HookLogOptions = {}): void {
try {
const root = resolveStateRoot();
fs.mkdirSync(root, { recursive: true });
const nowMs = opts.rateLimit?.nowMs ?? Date.now();
if (opts.rateLimit) {
const marker = path.join(root, `hook-errors.${hook}.last`);
const digest = createHash('sha256').update(opts.rateLimit.key).digest('hex').slice(0, 16);
const live: string[] = [];
try {
for (const line of fs.readFileSync(marker, 'utf8').split('\n')) {
const [d, ts] = line.trim().split(':');
if (!d || !ts || nowMs - Number(ts) >= LOG_RATE_LIMIT_MS) continue;
if (d === digest) return;
live.push(line.trim());
}
} catch { /* no marker yet */ }
live.push(`${digest}:${nowMs}`);
fs.writeFileSync(marker, `${live.slice(-RATE_LIMIT_KEYS).join('\n')}\n`, { mode: 0o600 });
}
const log = path.join(root, 'hook-errors.log');
fs.appendFileSync(log, `${new Date(nowMs).toISOString()} ${hook}: ${msg}\n`, { mode: 0o600 });
if (process.platform !== 'win32') { try { fs.chmodSync(log, 0o600); } catch { /* not ours to tighten */ } }
} catch {
// best-effort; never block the session because logging failed
}
}