From c8281f6b66f5bac5e5333f8f8b3248f29e9cdc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Sun, 20 Sep 2026 19:18:42 -0300 Subject: [PATCH 1/6] feat: session store, hook log and shared rulebook resolution --- scripts/__tests__/check.spec.ts | 5 - .../__tests__/helpers/session-store-worker.ts | 11 + scripts/__tests__/hook-log.spec.ts | 122 +++++++++++ scripts/__tests__/session-store.spec.ts | 105 ++++++++++ scripts/check.ts | 174 ++++++---------- scripts/lib/compose.ts | 30 ++- scripts/lib/hook-log.ts | 162 +++++++++++++++ scripts/lib/project-files.ts | 75 +++++++ scripts/lib/project-rulebook.ts | 76 +++++++ scripts/lib/session-store.ts | 191 ++++++++++++++++++ 10 files changed, 836 insertions(+), 115 deletions(-) create mode 100644 scripts/__tests__/helpers/session-store-worker.ts create mode 100644 scripts/__tests__/hook-log.spec.ts create mode 100644 scripts/__tests__/session-store.spec.ts create mode 100644 scripts/lib/hook-log.ts create mode 100644 scripts/lib/project-files.ts create mode 100644 scripts/lib/project-rulebook.ts create mode 100644 scripts/lib/session-store.ts diff --git a/scripts/__tests__/check.spec.ts b/scripts/__tests__/check.spec.ts index 9d7609b..34e1aa3 100644 --- a/scripts/__tests__/check.spec.ts +++ b/scripts/__tests__/check.spec.ts @@ -297,11 +297,6 @@ describe('runCli', () => { expect(rule?.check?.kind === 'regex' && rule.check.flags.includes('i')).toBe(false); }); - it('treats --hook as a no-op in this version', async () => { - const { code, io } = await run(['--hook', 'pre-tool-use']); - expect(code).toBe(0); - expect(io.out.join('')).toBe(''); - }); it('project.example stamps match the shipped base rulebooks', async () => { const { report } = await runJson(['--project-rulebook', 'rulebooks/project.example.rulebook.yaml', '--files', 'examples/**/*.ts']); diff --git a/scripts/__tests__/helpers/session-store-worker.ts b/scripts/__tests__/helpers/session-store-worker.ts new file mode 100644 index 0000000..6bfc36e --- /dev/null +++ b/scripts/__tests__/helpers/session-store-worker.ts @@ -0,0 +1,11 @@ +// Spawned by session-store.spec.ts: increments the block counter of one agent N times. +import { createSessionStore } from '../../lib/session-store.ts'; + +const [dataDir, sessionId, agentId, count] = process.argv.slice(2); +if (dataDir === undefined || sessionId === undefined || agentId === undefined || count === undefined) { + throw new Error('usage: session-store-worker '); +} +const store = createSessionStore({ dataDir, random: () => 1 }); +for (let i = 0; i < Number(count); i += 1) { + store.update(sessionId, agentId, (session) => ({ ...session, blocks: session.blocks + 1 })); +} diff --git a/scripts/__tests__/hook-log.spec.ts b/scripts/__tests__/hook-log.spec.ts new file mode 100644 index 0000000..77f745f --- /dev/null +++ b/scripts/__tests__/hook-log.spec.ts @@ -0,0 +1,122 @@ +import './helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { runCli, type CliIo } from '../check.ts'; +import { appendHookLog, percentile, readHookLogs, summarizeHookLogs, type HookLogEntry } from '../lib/hook-log.ts'; + +const PLUGIN_ROOT = resolve(import.meta.dir, '../..'); + +function entry(overrides: Partial): HookLogEntry { + return { + ts: '2026-09-20T10:00:00.000Z', + hook: 'pre-tool-use', + event: 'PreToolUse', + agentType: 'nestjs-hexagonal:domain-agent', + tool: 'Write', + path: 'src/orders/domain/order.entity.ts', + ruleIds: [], + decision: 'silent', + latencyMs: 10, + binarySource: 'plugin-root', + version: '1.3.0-dev.0', + ...overrides, + }; +} + +function capture(): CliIo & { out: string[]; err: string[] } { + const out: string[] = []; + const err: string[] = []; + return { out, err, stdout: (text) => void out.push(text), stderr: (text) => void err.push(text) }; +} + +interface Summary { + entries: number; + hooks: Record }>; + decisions: Record; + binarySources: Record; + semantic: { requests: number; uncertain: number; uncalibrated: number; uncertainRate: number; uncalibratedRate: number }; +} + +function isSummary(value: unknown): value is Summary { + return typeof value === 'object' && value !== null && 'hooks' in value && 'semantic' in value; +} + +describe('hook log', () => { + it('appends one JSONL line per decision into a file named by day', () => { + const dataDir = mkdtempSync(join(tmpdir(), 'hex-log-')); + appendHookLog(dataDir, entry({})); + appendHookLog(dataDir, entry({ ts: '2026-09-21T01:00:00.000Z', decision: 'deny', ruleIds: ['hex/domain-no-nest-decorators'] })); + expect(readdirSync(join(dataDir, 'logs')).sort()).toEqual(['hooks-20260920.jsonl', 'hooks-20260921.jsonl']); + const lines = readFileSync(join(dataDir, 'logs', 'hooks-20260921.jsonl'), 'utf8').trim().split('\n'); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0] ?? '{}')).toMatchObject({ decision: 'deny', ruleIds: ['hex/domain-no-nest-decorators'] }); + }); + + it('reads only entries since the given date and skips malformed lines', () => { + const dataDir = mkdtempSync(join(tmpdir(), 'hex-log-')); + appendHookLog(dataDir, entry({ ts: '2026-09-19T23:00:00.000Z' })); + appendHookLog(dataDir, entry({ ts: '2026-09-20T09:00:00.000Z' })); + appendHookLog(dataDir, entry({ ts: '2026-09-20T11:00:00.000Z' })); + const file = join(dataDir, 'logs', 'hooks-20260920.jsonl'); + const broken = `${readFileSync(file, 'utf8')}not json\n{"ts":"2026-09-20T12:00:00.000Z"}\n`; + writeFileSync(file, broken); + const entries = readHookLogs(dataDir, new Date('2026-09-20T10:00:00.000Z')); + expect(entries.map((item) => item.ts)).toEqual(['2026-09-20T11:00:00.000Z']); + expect(readHookLogs(join(dataDir, 'missing'), new Date(0))).toEqual([]); + }); + + it('computes nearest-rank percentiles', () => { + expect(percentile([], 0.5)).toBe(0); + expect(percentile([5], 0.95)).toBe(5); + expect(percentile([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 0.5)).toBe(5); + expect(percentile([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 0.95)).toBe(10); + expect(percentile([1, 2, 3, 4], 0.95)).toBe(4); + }); + + it('aggregates latency per hook, decisions by kind and semantic rates', () => { + const entries = [ + entry({ latencyMs: 10, decision: 'silent' }), + entry({ latencyMs: 30, decision: 'deny', ruleIds: ['hex/domain-no-nest-decorators'] }), + entry({ latencyMs: 20, decision: 'context' }), + entry({ hook: 'subagent-stop', event: 'SubagentStop', tool: null, path: null, latencyMs: 900, decision: 'block', binarySource: 'node_modules', semantic: { requests: 2, answered: 4, findings: 2, uncertain: 1, uncalibrated: 0, undecided: 1 } }), + entry({ hook: 'subagent-stop', event: 'SubagentStop', tool: null, path: null, latencyMs: 400, decision: 'release', binarySource: null, semantic: { requests: 1, answered: 3, findings: 1, uncertain: 0, uncalibrated: 1, undecided: 0 } }), + ]; + const summary = summarizeHookLogs(entries, new Date('2026-09-20T00:00:00.000Z'), new Date('2026-09-21T00:00:00.000Z')); + expect(summary.entries).toBe(5); + expect(summary.hooks['pre-tool-use']).toEqual({ entries: 3, p50Ms: 20, p95Ms: 30, decisions: { silent: 1, deny: 1, context: 1 } }); + expect(summary.hooks['subagent-stop']).toEqual({ entries: 2, p50Ms: 400, p95Ms: 900, decisions: { block: 1, release: 1 } }); + expect(summary.decisions).toEqual({ silent: 1, deny: 1, context: 1, block: 1, release: 1 }); + expect(summary.binarySources).toEqual({ 'plugin-root': 3, node_modules: 1, unknown: 1 }); + expect(summary.semantic).toEqual({ requests: 3, answered: 7, findings: 3, uncertain: 1, uncalibrated: 1, undecided: 1, uncertainRate: 1 / 8, uncalibratedRate: 1 / 8 }); + }); + + it('export-logs writes the summary of a fixture log to --out', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'hex-log-')); + appendHookLog(dataDir, entry({ latencyMs: 12 })); + appendHookLog(dataDir, entry({ latencyMs: 48, decision: 'deny', ruleIds: ['hex/vo-immutable'] })); + const out = join(dataDir, 'weekly.json'); + const io = capture(); + const code = await runCli(['export-logs', '--since', '2026-09-20', '--out', out], io, { cwd: dataDir, env: { CLAUDE_PLUGIN_DATA: dataDir }, pluginRoot: PLUGIN_ROOT }); + expect(code).toBe(0); + expect(io.out).toEqual([]); + expect(io.err.join('')).toContain('wrote 2 entries'); + expect(existsSync(out)).toBe(true); + const parsed: unknown = JSON.parse(readFileSync(out, 'utf8')); + if (!isSummary(parsed)) { + throw new Error('summary shape'); + } + expect(parsed.entries).toBe(2); + expect(parsed.hooks['pre-tool-use']?.p95Ms).toBe(48); + expect(parsed.decisions).toEqual({ silent: 1, deny: 1 }); + + const stdoutIo = capture(); + expect(await runCli(['export-logs', '--since', '2026-09-20'], stdoutIo, { cwd: dataDir, env: { CLAUDE_PLUGIN_DATA: dataDir }, pluginRoot: PLUGIN_ROOT })).toBe(0); + expect(stdoutIo.out.join('')).toContain('"entries": 2'); + + const usage = capture(); + expect(await runCli(['export-logs'], usage, { cwd: dataDir, env: {}, pluginRoot: PLUGIN_ROOT })).toBe(2); + expect(usage.err.join('')).toContain('--since'); + }); +}); diff --git a/scripts/__tests__/session-store.spec.ts b/scripts/__tests__/session-store.spec.ts new file mode 100644 index 0000000..babd59d --- /dev/null +++ b/scripts/__tests__/session-store.spec.ts @@ -0,0 +1,105 @@ +import './helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createSessionStore, emptySession, resolveDataDir, sanitizeId, SESSION_TTL_MS } from '../lib/session-store.ts'; + +const WORKER = join(import.meta.dir, 'helpers', 'session-store-worker.ts'); + +function runWorker(dataDir: string, sessionId: string, agentId: string, count: number): Promise { + return new Promise((resolve, reject) => { + const child = spawn('bun', [WORKER, dataDir, sessionId, agentId, String(count)], { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(`worker exited ${code}: ${stderr}`)); + } else { + resolve(code); + } + }); + }); +} + +describe('session store', () => { + it('creates the session on first update and reads it back', () => { + const dataDir = mkdtempSync(join(tmpdir(), 'hex-store-')); + const store = createSessionStore({ dataDir, random: () => 1 }); + expect(store.read('s1', 'a1')).toBeNull(); + const written = store.update('s1', 'a1', () => ({ ...emptySession('nestjs-hexagonal:domain-agent', '2026-09-20T00:00:00Z', null), touchedPaths: ['src/a.ts'] })); + expect(written.touchedPaths).toEqual(['src/a.ts']); + expect(store.read('s1', 'a1')?.agentType).toBe('nestjs-hexagonal:domain-agent'); + expect(store.filePath('s1', 'a1')).toBe(join(dataDir, 'sessions', 's1', 'a1.json')); + expect(readdirSync(join(dataDir, 'sessions', 's1'))).toEqual(['a1.json']); + }); + + it('sanitizes ids so a session id cannot escape the sessions directory', () => { + expect(sanitizeId('../../etc')).toBe('______etc'); + expect(sanitizeId('agent-abc123')).toBe('agent-abc123'); + expect(sanitizeId('')).toBe('_'); + const dataDir = mkdtempSync(join(tmpdir(), 'hex-store-')); + const store = createSessionStore({ dataDir, random: () => 1 }); + store.update('../../escape', 'a/../b', (session) => session); + expect(existsSync(join(dataDir, 'sessions', '______escape', 'a____b.json'))).toBe(true); + }); + + it('does not lose writes when two processes increment the same counter', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'hex-store-')); + const store = createSessionStore({ dataDir, random: () => 1 }); + store.update('s', 'a', () => emptySession('nestjs-hexagonal:domain-agent', '2026-09-20T00:00:00Z', null)); + await Promise.all([runWorker(dataDir, 's', 'a', 40), runWorker(dataDir, 's', 'a', 40)]); + expect(store.read('s', 'a')?.blocks).toBe(80); + expect(readdirSync(join(dataDir, 'sessions', 's')).filter((name) => name.endsWith('.tmp') || name.endsWith('.lock'))).toEqual([]); + }); + + it('recovers from a stale lock left by a crashed process', () => { + const dataDir = mkdtempSync(join(tmpdir(), 'hex-store-')); + mkdirSync(join(dataDir, 'sessions', 's'), { recursive: true }); + const lock = join(dataDir, 'sessions', 's', 'a.json.lock'); + writeFileSync(lock, ''); + const old = new Date(Date.now() - 60_000); + utimesSync(lock, old, old); + const store = createSessionStore({ dataDir, random: () => 1 }); + const written = store.update('s', 'a', (session) => ({ ...session, blocks: 1 })); + expect(written.blocks).toBe(1); + expect(existsSync(lock)).toBe(false); + }); + + it('collects sessions older than the TTL only on the sampled writes or above the threshold', () => { + const dataDir = mkdtempSync(join(tmpdir(), 'hex-store-')); + let clock = Date.now(); + let sample = 1; + const store = createSessionStore({ dataDir, now: () => clock, random: () => sample, gcThreshold: 3 }); + store.update('old', 'a', (session) => session); + const oldDir = join(dataDir, 'sessions', 'old'); + const past = new Date(clock - SESSION_TTL_MS - 1000); + utimesSync(oldDir, past, past); + + store.update('fresh-1', 'a', (session) => session); + expect(existsSync(oldDir)).toBe(true); + + sample = 0; + store.update('fresh-2', 'a', (session) => session); + expect(existsSync(oldDir)).toBe(false); + + store.update('old-2', 'a', (session) => session); + utimesSync(join(dataDir, 'sessions', 'old-2'), past, past); + sample = 1; + store.update('fresh-3', 'a', (session) => session); + store.update('fresh-4', 'a', (session) => session); + expect(existsSync(join(dataDir, 'sessions', 'old-2'))).toBe(false); + clock += 1; + expect(store.collectGarbage()).toBe(0); + }); + + it('falls back to a temp directory when CLAUDE_PLUGIN_DATA is not set', () => { + expect(resolveDataDir({ CLAUDE_PLUGIN_DATA: '/data/x' })).toBe('/data/x'); + expect(resolveDataDir({})).toBe(join(tmpdir(), 'nestjs-hexagonal-data')); + expect(resolveDataDir({ CLAUDE_PLUGIN_DATA: '' })).toBe(join(tmpdir(), 'nestjs-hexagonal-data')); + }); +}); diff --git a/scripts/check.ts b/scripts/check.ts index 053f341..8ef726a 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -1,19 +1,15 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { - RulebookCompositionError, - composeRulebook, - createDirectoryResolver, - readRulebookFile, - rulebookPathForId, - type ComposedRulebook, -} from './lib/compose.ts'; +import { RulebookCompositionError, semanticRulebookVersion, type ComposedRulebook } from './lib/compose.ts'; import { FittedFileError, loadFitted, type FittedFile } from './lib/decide.ts'; -import { registerBuiltinExecutors } from './lib/executors/index.ts'; +import { readHookLogs, summarizeHookLogs } from './lib/hook-log.ts'; import { createJevClient, type FetchLike } from './lib/jev-client.ts'; +import { changedFilesSince, projectSources, readSources } from './lib/project-files.ts'; +import { RulebookNotFoundError, loadProjectRulebook } from './lib/project-rulebook.ts'; import { matchGlob, normalizePath } from './lib/scope.ts'; +import { resolveDataDir } from './lib/session-store.ts'; import { explainRequests, planSemanticRequests, runSemanticRules, type SemanticExplain, type SemanticFinding, type Undecided } from './lib/semantic-engine.ts'; import type { Hunk } from './lib/state-builder.ts'; import { runStaticRules, type Finding, type SourceFile } from './lib/static-engine.ts'; @@ -42,13 +38,13 @@ interface ParsedArgs { strict: boolean; failOnUncertain: boolean; explain: boolean; - hook?: string; help: boolean; } class UsageError extends Error {} const USAGE = `Usage: nestjs-hexagonal-check [options] + nestjs-hexagonal-check export-logs --since [--out ] --rulebook rulebook to run; an id resolves to /rulebooks/.rulebook.yaml --project-rulebook project rulebook (default: $NESTJS_HEXAGONAL_RULEBOOK or $CLAUDE_PROJECT_DIR/.claude/rulebook.yaml) @@ -129,9 +125,6 @@ export function parseArgs(argv: string[]): ParsedArgs { case '--explain': parsed.explain = true; break; - case '--hook': - parsed.hook = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : ''; - break; case '--help': case '-h': parsed.help = true; @@ -144,45 +137,6 @@ export function parseArgs(argv: string[]): ParsedArgs { return parsed; } -function resolveRootRulebook(args: ParsedArgs, options: CliOptions): string { - const rulebooksDir = join(options.pluginRoot, 'rulebooks'); - if (args.rulebook !== undefined) { - const asPath = resolve(options.cwd, args.rulebook); - if (/\.ya?ml$/.test(args.rulebook) || existsSync(asPath)) { - return asPath; - } - const byId = rulebookPathForId(rulebooksDir, args.rulebook); - if (existsSync(byId)) { - return byId; - } - throw new UsageError(`rulebook '${args.rulebook}' is neither a file nor an id under ${rulebooksDir}`); - } - - const candidates: Array<{ path: string; origin: string }> = []; - if (args.projectRulebook !== undefined) { - const path = resolve(options.cwd, args.projectRulebook); - if (!existsSync(path)) { - throw new UsageError(`--project-rulebook ${args.projectRulebook} does not exist (${path})`); - } - return path; - } - const fromEnv = options.env.NESTJS_HEXAGONAL_RULEBOOK; - if (fromEnv !== undefined && fromEnv !== '') { - candidates.push({ path: resolve(options.cwd, fromEnv), origin: 'NESTJS_HEXAGONAL_RULEBOOK' }); - } - const projectDir = options.env.CLAUDE_PROJECT_DIR ?? options.cwd; - candidates.push({ path: join(projectDir, '.claude', 'rulebook.yaml'), origin: '.claude/rulebook.yaml' }); - - for (const candidate of candidates) { - if (existsSync(candidate.path)) { - return candidate.path; - } - } - throw new UsageError( - `no rulebook found: pass --rulebook , --project-rulebook , set NESTJS_HEXAGONAL_RULEBOOK or create ${join(projectDir, '.claude', 'rulebook.yaml')}`, - ); -} - function walk(dir: string, base: string, out: string[]): void { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (IGNORED_DIRECTORIES.has(entry.name)) { @@ -232,15 +186,11 @@ function expandGlobs(globs: string[], cwd: string): string[] { } function changedFiles(base: string, cwd: string): string[] { - const diff = execFileSync('git', ['diff', '--name-only', '--relative', '--diff-filter=ACMR', base], { cwd, encoding: 'utf8' }); - const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd, encoding: 'utf8' }); - const paths = `${diff}\n${untracked}` - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .filter((line) => existsSync(resolve(cwd, line)) && statSync(resolve(cwd, line)).isFile()) - .map((line) => normalizePath(line)); - return [...new Set(paths)].sort(); + const files = changedFilesSince(base, cwd); + if (files === null) { + throw new UsageError(`--diff ${base}: git is unavailable or ${cwd} is not inside a repository`); + } + return files; } const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/; @@ -271,41 +221,6 @@ function changedHunks(base: string, cwd: string): Record { return parseUnifiedDiff(diff); } -const PROJECT_TREE_IGNORED = new Set(['node_modules', '.git', 'dist']); -const SOURCE_EXTENSIONS = ['.ts', '.tsx']; - -function projectRoot(cwd: string): string { - try { - return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; - } catch { - return cwd; - } -} - -function walkTree(dir: string, cwd: string, out: string[]): void { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (PROJECT_TREE_IGNORED.has(entry.name)) { - continue; - } - const full = join(dir, entry.name); - if (entry.isDirectory()) { - walkTree(full, cwd, out); - } else if (entry.isFile() && SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension))) { - out.push(normalizePath(relative(cwd, full))); - } - } -} - -function projectSources(cwd: string): SourceFile[] { - const paths: string[] = []; - walkTree(projectRoot(cwd), cwd, paths); - return readSources(paths, cwd); -} - -function readSources(paths: string[], cwd: string): SourceFile[] { - return paths.map((path) => ({ path, content: readFileSync(resolve(cwd, path), 'utf8') })); -} - type AnyFinding = Finding | SemanticFinding; interface SemanticSummary { @@ -429,7 +344,7 @@ async function runSemantic( io.stderr(`${reason}\n`); return { ...empty, summary: { requests: 0, cached: 0, inputTokens: 0, undecided: [], skippedReason: reason } }; } - const loaded = loadFitted(options.fittedDir ?? join(options.pluginRoot, 'calibration', 'fitted'), pin, composed.rulebook.version); + const loaded = loadFitted(options.fittedDir ?? join(options.pluginRoot, 'calibration', 'fitted'), pin, semanticRulebookVersion(composed)); const fitted: FittedFile | null = loaded.status === 'none' ? null : loaded.fitted; const fittedMismatch = loaded.status === 'mismatch' ? loaded.reason : null; const client = createJevClient({ @@ -525,7 +440,52 @@ function exitCode(report: Report, args: ParsedArgs): number { return args.failOnUncertain && (uncertain || unanswered) ? 3 : 0; } +const EXPORT_LOGS_USAGE = `Usage: nestjs-hexagonal-check export-logs --since [--out ] + + Aggregates the hook decisions logged under $CLAUDE_PLUGIN_DATA/logs/hooks-YYYYMMDD.jsonl + since (ISO 8601): entries, p50/p95 latency per hook, decisions by kind, + binary sources and semantic uncertain/uncalibrated rates. Writes JSON to --out or stdout. +`; + +function runExportLogs(argv: string[], io: CliIo, options: CliOptions): number { + let since: string | undefined; + let out: string | undefined; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const value = argv[i + 1]; + if (arg === '--since' && value !== undefined) { + since = value; + i += 1; + } else if (arg === '--out' && value !== undefined) { + out = value; + i += 1; + } else { + io.stderr(`unknown option '${arg}'\n${EXPORT_LOGS_USAGE}`); + return 2; + } + } + if (since === undefined || Number.isNaN(Date.parse(since))) { + io.stderr(`--since is required and must parse as a date\n${EXPORT_LOGS_USAGE}`); + return 2; + } + const sinceDate = new Date(since); + const until = new Date(); + const summary = summarizeHookLogs(readHookLogs(resolveDataDir(options.env), sinceDate), sinceDate, until); + const text = `${JSON.stringify(summary, null, 2)}\n`; + if (out === undefined) { + io.stdout(text); + } else { + const target = resolve(options.cwd, out); + writeFileSync(target, text); + io.stderr(`wrote ${summary.entries} entr${summary.entries === 1 ? 'y' : 'ies'} to ${target}\n`); + } + return 0; +} + export async function runCli(argv: string[], io: CliIo, options: CliOptions): Promise { + if (argv[0] === 'export-logs') { + return runExportLogs(argv.slice(1), io, options); + } let args: ParsedArgs; try { args = parseArgs(argv); @@ -544,16 +504,14 @@ export async function runCli(argv: string[], io: CliIo, options: CliOptions): Pr return 0; } - if (args.hook !== undefined) { - io.stderr(`hook '${args.hook}' is not implemented in this version\n`); - return 0; - } - try { - const rulebookPath = resolveRootRulebook(args, options); - const { rulebook } = readRulebookFile(rulebookPath); - registerBuiltinExecutors(); - const composed = composeRulebook(rulebook, createDirectoryResolver(join(options.pluginRoot, 'rulebooks'))); + const { path: rulebookPath, composed } = loadProjectRulebook({ + cwd: options.cwd, + env: options.env, + pluginRoot: options.pluginRoot, + ...(args.rulebook !== undefined ? { rulebook: args.rulebook } : {}), + ...(args.projectRulebook !== undefined ? { projectRulebook: args.projectRulebook } : {}), + }); if (args.files.length === 0 && args.diff === undefined) { throw new UsageError('pass --files or --diff '); @@ -572,7 +530,7 @@ export async function runCli(argv: string[], io: CliIo, options: CliOptions): Pr io.stdout(args.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : formatText(report)); return exitCode(report, args); } catch (error) { - if (error instanceof UsageError) { + if (error instanceof UsageError || error instanceof RulebookNotFoundError) { io.stderr(`${error.message}\n${USAGE}`); return 2; } diff --git a/scripts/lib/compose.ts b/scripts/lib/compose.ts index 2e2c598..49fb1e5 100644 --- a/scripts/lib/compose.ts +++ b/scripts/lib/compose.ts @@ -27,7 +27,10 @@ export type BaseResolver = (id: string) => ResolvedBase | null; export interface ComposedRulebook { rulebook: Rulebook; rules: Rule[]; + /** rule id -> id of the rulebook that declares it */ sources: Record; + /** rulebook id -> version of the copy that was composed */ + versions: Record; warnings: string[]; uncalibrated: boolean; } @@ -42,11 +45,13 @@ function collectRules( visiting: Set, collected: Map, warnings: string[], + versions: Record, ): { rules: Rule[]; sources: Record } { if (visiting.has(rulebook.id)) { throw new RulebookCompositionError(`extends cycle detected at rulebook '${rulebook.id}'`); } visiting.add(rulebook.id); + versions[rulebook.id] = rulebook.version; const rules: Rule[] = []; const sources: Record = {}; @@ -69,7 +74,7 @@ function collectRules( `rulebook-mismatch for '${entry.id}': expected ${entry.version}@${entry.sha256.slice(0, 8)}, found ${base.rulebook.version}@${base.sha256.slice(0, 8)} (${base.path}); decisions are uncalibrated`, ); } - const inherited = collectRules(base.rulebook, resolve, visiting, collected, warnings); + const inherited = collectRules(base.rulebook, resolve, visiting, collected, warnings, versions); for (const rule of inherited.rules) { if (sources[rule.id] !== undefined) { throw new RulebookCompositionError( @@ -143,7 +148,8 @@ function applyOverride(rule: Rule, override: Override): Rule | null { export function composeRulebook(rulebook: Rulebook, resolve: BaseResolver): ComposedRulebook { const warnings: string[] = []; - const collected = collectRules(rulebook, resolve, new Set(), new Map(), warnings); + const versions: Record = {}; + const collected = collectRules(rulebook, resolve, new Set(), new Map(), warnings, versions); const byId = new Map(collected.rules.map((rule) => [rule.id, rule])); for (const override of rulebook.overrides) { @@ -169,11 +175,31 @@ export function composeRulebook(rulebook: Rulebook, resolve: BaseResolver): Comp rulebook, rules, sources, + versions, warnings, uncalibrated: warnings.some((warning) => warning.startsWith('rulebook-mismatch')), }; } +/** + * Version the fitted thresholds must be compared against: the version of the + * rulebook that declares the semantic rules (a project rulebook that only + * extends a base keeps the base calibration). When semantic rules come from + * more than one rulebook, the project version is used and the fitted file is + * expected to be regenerated for that composition. + */ +export function semanticRulebookVersion(composed: ComposedRulebook): string { + const sources = new Set(composed.rules.filter((rule) => rule.class === 'semantic').map((rule) => composed.sources[rule.id])); + if (sources.size === 1) { + const [source] = sources; + const version = source === undefined ? undefined : composed.versions[source]; + if (version !== undefined) { + return version; + } + } + return composed.rulebook.version; +} + export function readRulebookFile(path: string): { rulebook: Rulebook; text: string } { const text = readFileSync(path, 'utf8'); const parsed = parseRulebook(parse(text)); diff --git a/scripts/lib/hook-log.ts b/scripts/lib/hook-log.ts new file mode 100644 index 0000000..b95d157 --- /dev/null +++ b/scripts/lib/hook-log.ts @@ -0,0 +1,162 @@ +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { z } from 'zod'; + +export const HOOK_DECISIONS = ['skip', 'silent', 'context', 'deny', 'block', 'release', 'error'] as const; + +export const HookLogEntrySchema = z.object({ + ts: z.string(), + hook: z.string(), + event: z.string(), + agentType: z.string().nullable(), + tool: z.string().nullable(), + path: z.string().nullable(), + ruleIds: z.array(z.string()), + decision: z.enum(HOOK_DECISIONS), + latencyMs: z.number().nonnegative(), + binarySource: z.string().nullable(), + version: z.string(), + semantic: z + .object({ + requests: z.number().int().nonnegative(), + answered: z.number().int().nonnegative(), + findings: z.number().int().nonnegative(), + uncertain: z.number().int().nonnegative(), + uncalibrated: z.number().int().nonnegative(), + undecided: z.number().int().nonnegative(), + }) + .optional(), +}); + +export type HookLogEntry = z.infer; +export type HookDecision = HookLogEntry['decision']; + +const LOG_FILE = /^hooks-(\d{8})\.jsonl$/; + +export function logsDir(dataDir: string): string { + return join(dataDir, 'logs'); +} + +function dayStamp(date: Date): string { + return date.toISOString().slice(0, 10).replace(/-/g, ''); +} + +export function appendHookLog(dataDir: string, entry: HookLogEntry): void { + const dir = logsDir(dataDir); + mkdirSync(dir, { recursive: true }); + appendFileSync(join(dir, `hooks-${dayStamp(new Date(entry.ts))}.jsonl`), `${JSON.stringify(entry)}\n`); +} + +export function readHookLogs(dataDir: string, since: Date): HookLogEntry[] { + const dir = logsDir(dataDir); + if (!existsSync(dir)) { + return []; + } + const sinceStamp = dayStamp(since); + const entries: HookLogEntry[] = []; + for (const name of readdirSync(dir).sort()) { + const match = LOG_FILE.exec(name); + if (!match || match[1] < sinceStamp) { + continue; + } + for (const line of readFileSync(join(dir, name), 'utf8').split('\n')) { + if (line.trim() === '') { + continue; + } + try { + const parsed = HookLogEntrySchema.safeParse(JSON.parse(line)); + if (parsed.success && new Date(parsed.data.ts) >= since) { + entries.push(parsed.data); + } + } catch { + continue; + } + } + } + return entries; +} + +export interface HookAggregate { + entries: number; + p50Ms: number; + p95Ms: number; + decisions: Record; +} + +export interface SemanticAggregate { + requests: number; + answered: number; + findings: number; + uncertain: number; + uncalibrated: number; + undecided: number; + uncertainRate: number; + uncalibratedRate: number; +} + +export interface HookLogSummary { + since: string; + until: string; + entries: number; + hooks: Record; + decisions: Record; + binarySources: Record; + semantic: SemanticAggregate; +} + +export function percentile(sorted: number[], fraction: number): number { + if (sorted.length === 0) { + return 0; + } + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * fraction) - 1)); + return sorted[index] ?? 0; +} + +function bump(counts: Record, key: string): void { + counts[key] = (counts[key] ?? 0) + 1; +} + +export function summarizeHookLogs(entries: HookLogEntry[], since: Date, until: Date): HookLogSummary { + const byHook = new Map(); + const decisions: Record = {}; + const binarySources: Record = {}; + const semantic = { requests: 0, answered: 0, findings: 0, uncertain: 0, uncalibrated: 0, undecided: 0 }; + for (const entry of entries) { + const group = byHook.get(entry.hook) ?? []; + group.push(entry); + byHook.set(entry.hook, group); + bump(decisions, entry.decision); + bump(binarySources, entry.binarySource ?? 'unknown'); + if (entry.semantic) { + semantic.requests += entry.semantic.requests; + semantic.answered += entry.semantic.answered; + semantic.findings += entry.semantic.findings; + semantic.uncertain += entry.semantic.uncertain; + semantic.uncalibrated += entry.semantic.uncalibrated; + semantic.undecided += entry.semantic.undecided; + } + } + const hooks: Record = {}; + for (const [hook, group] of [...byHook.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const latencies = group.map((entry) => entry.latencyMs).sort((a, b) => a - b); + const hookDecisions: Record = {}; + for (const entry of group) { + bump(hookDecisions, entry.decision); + } + hooks[hook] = { entries: group.length, p50Ms: percentile(latencies, 0.5), p95Ms: percentile(latencies, 0.95), decisions: hookDecisions }; + } + const answeredOrUndecided = semantic.answered + semantic.undecided; + return { + since: since.toISOString(), + until: until.toISOString(), + entries: entries.length, + hooks, + decisions, + binarySources, + semantic: { + ...semantic, + uncertainRate: answeredOrUndecided === 0 ? 0 : semantic.uncertain / answeredOrUndecided, + uncalibratedRate: answeredOrUndecided === 0 ? 0 : semantic.uncalibrated / answeredOrUndecided, + }, + }; +} diff --git a/scripts/lib/project-files.ts b/scripts/lib/project-files.ts new file mode 100644 index 0000000..1081237 --- /dev/null +++ b/scripts/lib/project-files.ts @@ -0,0 +1,75 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { normalizePath } from './scope.ts'; +import type { SourceFile } from './static-engine.ts'; + +const PROJECT_TREE_IGNORED = new Set(['node_modules', '.git', 'dist']); +const SOURCE_EXTENSIONS = ['.ts', '.tsx']; + +export function gitTopLevel(cwd: string): string | null { + try { + const out = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + return out === '' ? null : out; + } catch { + return null; + } +} + +export function gitHead(cwd: string): string | null { + try { + const out = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + return /^[0-9a-f]{40}$/.test(out) ? out : null; + } catch { + return null; + } +} + +function walkTree(dir: string, base: string, out: string[]): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (PROJECT_TREE_IGNORED.has(entry.name)) { + continue; + } + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walkTree(full, base, out); + } else if (entry.isFile() && SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension))) { + out.push(normalizePath(relative(base, full))); + } + } +} + +export function readSources(paths: string[], base: string): SourceFile[] { + return paths.map((path) => ({ path, content: readFileSync(resolve(base, path), 'utf8') })); +} + +/** Every TypeScript source of the project tree, with paths relative to `base`. */ +export function projectSources(base: string): SourceFile[] { + const root = gitTopLevel(base) ?? base; + const paths: string[] = []; + walkTree(root, base, paths); + return readSources(paths, base); +} + +function existingFiles(lines: string, base: string): string[] { + return lines + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .filter((line) => existsSync(resolve(base, line)) && statSync(resolve(base, line)).isFile()) + .map((line) => normalizePath(line)); +} + +/** + * Files changed since `ref` plus untracked files, relative to `base`. + * Returns null when git is unavailable or `base` is not inside a repository. + */ +export function changedFilesSince(ref: string, base: string): string[] | null { + try { + const diff = execFileSync('git', ['diff', '--name-only', '--relative', '--diff-filter=ACMR', ref], { cwd: base, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: base, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + return [...new Set(existingFiles(`${diff}\n${untracked}`, base))].sort(); + } catch { + return null; + } +} diff --git a/scripts/lib/project-rulebook.ts b/scripts/lib/project-rulebook.ts new file mode 100644 index 0000000..aca9366 --- /dev/null +++ b/scripts/lib/project-rulebook.ts @@ -0,0 +1,76 @@ +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { composeRulebook, createDirectoryResolver, readRulebookFile, rulebookPathForId, type ComposedRulebook } from './compose.ts'; +import { registerBuiltinExecutors } from './executors/index.ts'; + +export class RulebookNotFoundError extends Error {} + +export interface RulebookLookup { + cwd: string; + env: Record; + pluginRoot: string; + rulebook?: string; + projectRulebook?: string; +} + +export function projectDirOf(env: Record, cwd: string): string { + const fromEnv = env.CLAUDE_PROJECT_DIR; + return fromEnv !== undefined && fromEnv !== '' ? fromEnv : cwd; +} + +/** + * Resolves the rulebook a run should start from: an explicit `--rulebook` + * (path or plugin id), an explicit project rulebook, `NESTJS_HEXAGONAL_RULEBOOK` + * or `$CLAUDE_PROJECT_DIR/.claude/rulebook.yaml`. The CLI and the hooks share + * this so both read the same file for the same project. + */ +export function resolveRootRulebook(lookup: RulebookLookup): string { + const rulebooksDir = join(lookup.pluginRoot, 'rulebooks'); + if (lookup.rulebook !== undefined) { + const asPath = resolve(lookup.cwd, lookup.rulebook); + if (/\.ya?ml$/.test(lookup.rulebook) || existsSync(asPath)) { + return asPath; + } + const byId = rulebookPathForId(rulebooksDir, lookup.rulebook); + if (existsSync(byId)) { + return byId; + } + throw new RulebookNotFoundError(`rulebook '${lookup.rulebook}' is neither a file nor an id under ${rulebooksDir}`); + } + if (lookup.projectRulebook !== undefined) { + const path = resolve(lookup.cwd, lookup.projectRulebook); + if (!existsSync(path)) { + throw new RulebookNotFoundError(`--project-rulebook ${lookup.projectRulebook} does not exist (${path})`); + } + return path; + } + + const candidates: string[] = []; + const fromEnv = lookup.env.NESTJS_HEXAGONAL_RULEBOOK; + if (fromEnv !== undefined && fromEnv !== '') { + candidates.push(resolve(lookup.cwd, fromEnv)); + } + const projectDir = projectDirOf(lookup.env, lookup.cwd); + candidates.push(join(projectDir, '.claude', 'rulebook.yaml')); + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + throw new RulebookNotFoundError( + `no rulebook found: pass --rulebook , --project-rulebook , set NESTJS_HEXAGONAL_RULEBOOK or create ${join(projectDir, '.claude', 'rulebook.yaml')}`, + ); +} + +export interface LoadedRulebook { + path: string; + composed: ComposedRulebook; +} + +export function loadProjectRulebook(lookup: RulebookLookup): LoadedRulebook { + const path = resolveRootRulebook(lookup); + const { rulebook } = readRulebookFile(path); + registerBuiltinExecutors(); + const composed = composeRulebook(rulebook, createDirectoryResolver(join(lookup.pluginRoot, 'rulebooks'))); + return { path, composed }; +} diff --git a/scripts/lib/session-store.ts b/scripts/lib/session-store.ts new file mode 100644 index 0000000..275124b --- /dev/null +++ b/scripts/lib/session-store.ts @@ -0,0 +1,191 @@ +import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { z } from 'zod'; + +const UnresolvedFindingSchema = z.object({ + path: z.string(), + ruleId: z.string(), + severity: z.enum(['FAIL', 'WARN']), + line: z.number().int().positive().optional(), + evidence: z.string(), + fix: z.string(), +}); + +export const AgentSessionSchema = z.object({ + agentType: z.string(), + startedAt: z.string(), + headSha: z.string().nullable(), + touchedPaths: z.array(z.string()), + blocks: z.number().int().nonnegative(), + advisoryBytes: z.number().int().nonnegative(), + unresolved: z.array(UnresolvedFindingSchema), +}); + +export type AgentSession = z.infer; +export type UnresolvedFinding = z.infer; + +export const SESSION_TTL_MS = 24 * 60 * 60 * 1000; +const GC_PROBABILITY = 1 / 20; +const GC_THRESHOLD = 200; +const LOCK_STALE_MS = 5_000; +const LOCK_WAIT_MS = 2_000; +const LOCK_POLL_MS = 5; + +export interface SessionStoreOptions { + dataDir: string; + now?: () => number; + random?: () => number; + ttlMs?: number; + gcThreshold?: number; +} + +export interface SessionStore { + read(sessionId: string, agentId: string): AgentSession | null; + update(sessionId: string, agentId: string, mutate: (session: AgentSession) => AgentSession): AgentSession; + filePath(sessionId: string, agentId: string): string; + collectGarbage(): number; +} + +export function emptySession(agentType: string, startedAt: string, headSha: string | null): AgentSession { + return { agentType, startedAt, headSha, touchedPaths: [], blocks: 0, advisoryBytes: 0, unresolved: [] }; +} + +/** `$CLAUDE_PLUGIN_DATA`, or a per-user temp directory when the plugin runs in place without one. */ +export function resolveDataDir(env: Record): string { + const fromEnv = env.CLAUDE_PLUGIN_DATA; + if (fromEnv !== undefined && fromEnv !== '') { + return fromEnv; + } + return join(tmpdir(), 'nestjs-hexagonal-data'); +} + +export function sanitizeId(id: string): string { + const cleaned = id.replace(/[^A-Za-z0-9_-]/g, '_'); + return cleaned === '' ? '_' : cleaned.slice(0, 120); +} + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function acquireLock(lockPath: string, now: () => number): void { + const deadline = now() + LOCK_WAIT_MS; + for (;;) { + try { + closeSync(openSync(lockPath, 'wx')); + return; + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) { + throw error; + } + try { + if (now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) { + unlinkSync(lockPath); + continue; + } + } catch { + continue; + } + if (now() > deadline) { + throw new Error(`session store lock ${lockPath} is held for more than ${LOCK_WAIT_MS} ms`); + } + sleepSync(LOCK_POLL_MS); + } + } +} + +function releaseLock(lockPath: string): void { + try { + unlinkSync(lockPath); + } catch { + void 0; + } +} + +function readSessionFile(path: string): AgentSession | null { + if (!existsSync(path)) { + return null; + } + try { + const parsed = AgentSessionSchema.safeParse(JSON.parse(readFileSync(path, 'utf8'))); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +function writeAtomically(path: string, text: string): void { + const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`; + writeFileSync(tmp, text); + try { + renameSync(tmp, path); + } catch { + renameSync(tmp, path); + } +} + +export function createSessionStore(options: SessionStoreOptions): SessionStore { + const now = options.now ?? Date.now; + const random = options.random ?? Math.random; + const ttlMs = options.ttlMs ?? SESSION_TTL_MS; + const gcThreshold = options.gcThreshold ?? GC_THRESHOLD; + const sessionsDir = join(options.dataDir, 'sessions'); + + const filePath = (sessionId: string, agentId: string): string => join(sessionsDir, sanitizeId(sessionId), `${sanitizeId(agentId)}.json`); + + const collectGarbage = (): number => { + if (!existsSync(sessionsDir)) { + return 0; + } + let removed = 0; + for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const dir = join(sessionsDir, entry.name); + try { + if (now() - statSync(dir).mtimeMs > ttlMs) { + rmSync(dir, { recursive: true, force: true }); + removed += 1; + } + } catch { + void 0; + } + } + return removed; + }; + + const maybeCollect = (): void => { + let count = 0; + try { + count = readdirSync(sessionsDir).length; + } catch { + return; + } + if (random() < GC_PROBABILITY || count > gcThreshold) { + collectGarbage(); + } + }; + + return { + filePath, + collectGarbage, + read: (sessionId, agentId) => readSessionFile(filePath(sessionId, agentId)), + update: (sessionId, agentId, mutate) => { + const path = filePath(sessionId, agentId); + mkdirSync(join(sessionsDir, sanitizeId(sessionId)), { recursive: true }); + const lockPath = `${path}.lock`; + acquireLock(lockPath, now); + try { + const current = readSessionFile(path) ?? emptySession('unknown', new Date(now()).toISOString(), null); + const next = AgentSessionSchema.parse(mutate(current)); + writeAtomically(path, JSON.stringify(next)); + maybeCollect(); + return next; + } finally { + releaseLock(lockPath); + } + }, + }; +} From 8437d48c669a0b17b521ac99e4e972b95a9a6921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Sun, 20 Sep 2026 19:18:42 -0300 Subject: [PATCH 2/6] feat: advisory hooks with static deny and SubagentStop gate --- .claude-plugin/plugin.json | 10 + hooks/hooks.json | 71 ++++++ package.json | 2 + scripts/__tests__/hooks/helpers.ts | 109 +++++++++ scripts/__tests__/hooks/hook-io.spec.ts | 114 ++++++++++ scripts/__tests__/hooks/hooks-json.spec.ts | 58 +++++ scripts/__tests__/hooks/post-tool-use.spec.ts | 143 ++++++++++++ scripts/__tests__/hooks/pre-tool-use.spec.ts | 117 ++++++++++ .../__tests__/hooks/subagent-start.spec.ts | 75 +++++++ scripts/__tests__/hooks/subagent-stop.spec.ts | 180 +++++++++++++++ scripts/__tests__/run-sh.spec.ts | 64 +++++- scripts/hooks/agent-post-tool-use.ts | 42 ++++ scripts/hooks/lib/hook-common.ts | 212 ++++++++++++++++++ scripts/hooks/lib/hook-io.ts | 179 +++++++++++++++ scripts/hooks/lib/runner.ts | 104 +++++++++ scripts/hooks/post-tool-use.ts | 91 ++++++++ scripts/hooks/pre-tool-use.ts | 85 +++++++ scripts/hooks/subagent-start.ts | 82 +++++++ scripts/hooks/subagent-stop.ts | 100 +++++++++ scripts/run.sh | 23 +- 20 files changed, 1849 insertions(+), 12 deletions(-) create mode 100644 hooks/hooks.json create mode 100644 scripts/__tests__/hooks/helpers.ts create mode 100644 scripts/__tests__/hooks/hook-io.spec.ts create mode 100644 scripts/__tests__/hooks/hooks-json.spec.ts create mode 100644 scripts/__tests__/hooks/post-tool-use.spec.ts create mode 100644 scripts/__tests__/hooks/pre-tool-use.spec.ts create mode 100644 scripts/__tests__/hooks/subagent-start.spec.ts create mode 100644 scripts/__tests__/hooks/subagent-stop.spec.ts create mode 100644 scripts/hooks/agent-post-tool-use.ts create mode 100644 scripts/hooks/lib/hook-common.ts create mode 100644 scripts/hooks/lib/hook-io.ts create mode 100644 scripts/hooks/lib/runner.ts create mode 100644 scripts/hooks/post-tool-use.ts create mode 100644 scripts/hooks/pre-tool-use.ts create mode 100644 scripts/hooks/subagent-start.ts create mode 100644 scripts/hooks/subagent-stop.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 239a62c..c70c967 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -9,6 +9,16 @@ "homepage": "https://github.com/softtor/nestjs-hexagonal", "repository": "https://github.com/softtor/nestjs-hexagonal", "license": "MIT", + "defaultEnabled": false, + "userConfig": { + "TYPESAFE_API_KEY": { + "type": "string", + "title": "TypeSafe API key (optional)", + "description": "Enables the semantic (Jev) rules in the hooks and the CLI. Leave empty to keep the plugin static-only and offline; the key is read by the hooks as CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY and never written to any output or log.", + "sensitive": true, + "required": false + } + }, "keywords": [ "nestjs", "hexagonal-architecture", diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..de551fb --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,71 @@ +{ + "hooks": { + "SubagentStart": [ + { + "matcher": "^nestjs-hexagonal:.*", + "hooks": [ + { + "type": "command", + "command": "sh", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/run.sh", "--hook", "subagent-start"], + "timeout": 5, + "statusMessage": "nestjs-hexagonal: composing the rulebook slice" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "sh", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/run.sh", "--hook", "pre-tool-use"], + "timeout": 5, + "statusMessage": "nestjs-hexagonal: static rules on the pending write" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "sh", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/run.sh", "--hook", "post-tool-use"], + "timeout": 15, + "statusMessage": "nestjs-hexagonal: checking the written file" + } + ] + }, + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "sh", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/run.sh", "--hook", "agent-post-tool-use"], + "timeout": 5 + } + ] + } + ], + "SubagentStop": [ + { + "matcher": "^nestjs-hexagonal:(domain-agent|application-agent|infrastructure-agent|presentation-agent|broadcasting-agent|listener-agent)$", + "hooks": [ + { + "type": "command", + "command": "sh", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/run.sh", "--hook", "subagent-stop"], + "timeout": 20, + "statusMessage": "nestjs-hexagonal: checking the files this agent touched" + } + ] + } + ] + } +} diff --git a/package.json b/package.json index 1b2506c..bd9da51 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ ".claude-plugin", "agents", "calibration/fitted", + "hooks", "rulebooks", "scripts/check.ts", + "scripts/hooks", "scripts/lib", "scripts/run.sh", "shared", diff --git a/scripts/__tests__/hooks/helpers.ts b/scripts/__tests__/hooks/helpers.ts new file mode 100644 index 0000000..1da5aa3 --- /dev/null +++ b/scripts/__tests__/hooks/helpers.ts @@ -0,0 +1,109 @@ +import '../helpers/no-network.ts'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { sha256Of } from '../../lib/compose.ts'; +import type { FetchLike } from '../../lib/jev-client.ts'; +import { createSessionStore, type SessionStore } from '../../lib/session-store.ts'; +import type { HookContext, HookHandler } from '../../hooks/lib/hook-common.ts'; +import { executeHook } from '../../hooks/lib/runner.ts'; + +export const PLUGIN_ROOT = resolve(import.meta.dir, '../../..'); + +export interface Project { + dir: string; + dataDir: string; + store: SessionStore; +} + +export function makeProject(options: { rulebook?: boolean; git?: boolean } = {}): Project { + const dir = mkdtempSync(join(tmpdir(), 'hex-hook-')); + if (options.rulebook !== false) { + const base = readFileSync(join(PLUGIN_ROOT, 'rulebooks', 'hexagonal.rulebook.yaml'), 'utf8'); + mkdirSync(join(dir, '.claude')); + writeFileSync( + join(dir, '.claude', 'rulebook.yaml'), + `$schema: nestjs-hexagonal/rulebook@1\nid: p\nversion: 0.1.0\nextends:\n - { id: hexagonal, version: 1.3.0, sha256: ${sha256Of(base)} }\nmodel: { provider: typesafe, pin: jev-1.13.0 }\n`, + ); + } + if (options.git) { + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init'], { cwd: dir }); + } + const dataDir = mkdtempSync(join(tmpdir(), 'hex-hook-data-')); + return { dir, dataDir, store: createSessionStore({ dataDir, random: () => 1 }) }; +} + +export function writeProjectFile(project: Project, relativePath: string, content: string): string { + const absolute = join(project.dir, relativePath); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, content); + return absolute; +} + +export function context(project: Project, env: Record = {}, fetchImpl?: FetchLike): HookContext { + return { + env: { CLAUDE_PROJECT_DIR: project.dir, CLAUDE_PLUGIN_DATA: project.dataDir, ...env }, + cwd: project.dir, + pluginRoot: PLUGIN_ROOT, + store: project.store, + ...(fetchImpl ? { fetchImpl } : {}), + }; +} + +export interface HookRun { + code: number; + stdout: string; + stderr: string; + json: unknown; +} + +export async function runHook(name: string, handler: HookHandler, input: Record, ctx: HookContext): Promise { + const out: string[] = []; + const err: string[] = []; + const code = await executeHook(name, handler, JSON.stringify(input), ctx, { stdout: (text) => void out.push(text), stderr: (text) => void err.push(text) }); + const stdout = out.join(''); + return { code, stdout, stderr: err.join(''), json: stdout === '' ? null : JSON.parse(stdout) }; +} + +export function readLog(project: Project): Array> { + const dir = join(project.dataDir, 'logs'); + const lines: Array> = []; + if (!existsSync(dir)) { + return lines; + } + for (const name of readdirSync(dir)) { + for (const line of readFileSync(join(dir, name), 'utf8').split('\n')) { + if (line.trim() !== '') { + const parsed: unknown = JSON.parse(line); + if (typeof parsed === 'object' && parsed !== null) { + lines.push(Object.fromEntries(Object.entries(parsed))); + } + } + } + } + return lines; +} + +export const DOMAIN_AGENT = 'nestjs-hexagonal:domain-agent'; +export const APPLICATION_AGENT = 'nestjs-hexagonal:application-agent'; + +export const NEST_SERVICE = "import { Injectable } from '@nestjs/common';\n\n@Injectable()\nexport class OrderService {\n run(): void {}\n}\n"; +export const PLAIN_SERVICE = 'export class OrderService {\n run(): void {}\n}\n'; + +export function jevFetch(noul: number, model = 'jev-1.13.0'): { fetchImpl: FetchLike; calls: number[] } { + const calls: number[] = []; + const fetchImpl: FetchLike = async (_url, init) => { + calls.push(1); + const body = typeof init.body === 'string' ? init.body : ''; + const request: unknown = JSON.parse(body); + const questionIds = + typeof request === 'object' && request !== null && 'questions' in request && typeof request.questions === 'object' && request.questions !== null + ? Object.keys(request.questions) + : []; + const answers = Object.fromEntries(questionIds.map((id) => [id, { type: 'noul', noul }])); + return new Response(JSON.stringify({ model, answers, usage: { input_tokens: 10, output_tokens: 1 } }), { status: 200, headers: { 'content-type': 'application/json' } }); + }; + return { fetchImpl, calls }; +} diff --git a/scripts/__tests__/hooks/hook-io.spec.ts b/scripts/__tests__/hooks/hook-io.spec.ts new file mode 100644 index 0000000..460a6e0 --- /dev/null +++ b/scripts/__tests__/hooks/hook-io.spec.ts @@ -0,0 +1,114 @@ +import '../helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { BODY_LEAK_MIN_LENGTH, contextOutput, denyOutput, fileToolInput, findLeak, forbiddenOutput, parseHookInput, secretsFromEnv } from '../../hooks/lib/hook-io.ts'; +import { handler as preToolUse } from '../../hooks/pre-tool-use.ts'; +import { handler as subagentStart } from '../../hooks/subagent-start.ts'; +import { context, DOMAIN_AGENT, makeProject, NEST_SERVICE, readLog, runHook } from './helpers.ts'; +import { join } from 'node:path'; + +const SECRET = 'sk-typesafe-0123456789abcdef'; + +describe('hook input parsing', () => { + it('accepts the documented fields and keeps unknown ones', () => { + const parsed = parseHookInput(JSON.stringify({ hook_event_name: 'PreToolUse', session_id: 's', cwd: '/p', tool_name: 'Write', tool_input: { file_path: '/p/a.ts', content: 'x' }, tool_use_id: 't', agent_id: 'a', agent_type: 'Explore', stop_hook_active: false, last_assistant_message: 'done', permission_mode: 'default', effort: { level: 'high' } })); + if (!parsed.ok) { + throw new Error(parsed.error); + } + expect(parsed.input.tool_name).toBe('Write'); + expect(parsed.input.stop_hook_active).toBe(false); + expect(parsed.input.permission_mode).toBe('default'); + const tool = fileToolInput(parsed.input); + expect(tool?.tool).toBe('Write'); + }); + + it('rejects empty, non-JSON and contract-violating stdin', () => { + expect(parseHookInput('').ok).toBe(false); + expect(parseHookInput('not json').ok).toBe(false); + expect(parseHookInput('{"session_id":"s"}').ok).toBe(false); + expect(parseHookInput('{"hook_event_name":"X","stop_hook_active":"yes"}').ok).toBe(false); + }); + + it('parses Edit input with replace_all defaulting to false and ignores other tools', () => { + const edit = parseHookInput(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: '/p/a.ts', old_string: 'a', new_string: 'b' } })); + const tool = edit.ok ? fileToolInput(edit.input) : null; + expect(tool?.tool === 'Edit' && tool.input.replace_all).toBe(false); + const bash = parseHookInput(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'ls' } })); + expect(bash.ok ? fileToolInput(bash.input) : 'x').toBeNull(); + const malformed = parseHookInput(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Write', tool_input: { content: 'x' } })); + expect(malformed.ok ? fileToolInput(malformed.input) : 'x').toBeNull(); + }); +}); + +describe('output shapes', () => { + it('builds the documented JSON per event', () => { + expect(denyOutput('r')).toEqual({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'r' } }); + expect(contextOutput('PostToolUse', 'c')).toEqual({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'c' } }); + }); +}); + +describe('leak guard', () => { + it('collects the key from both environment variables and skips empty values', () => { + expect(secretsFromEnv({ TYPESAFE_API_KEY: 'a', CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY: 'b' })).toEqual(['b', 'a']); + expect(secretsFromEnv({ TYPESAFE_API_KEY: '' })).toEqual([]); + }); + + it('flags a secret anywhere in the text and a raw body only above the evidence cap', () => { + const forbidden = forbiddenOutput({ TYPESAFE_API_KEY: SECRET }, ['short body', 'x'.repeat(BODY_LEAK_MIN_LENGTH)]); + expect(forbidden.bodies).toHaveLength(1); + expect(findLeak(`reason ${SECRET}`, forbidden)).toBe('secret'); + expect(findLeak(`evidence: short body`, forbidden)).toBeNull(); + expect(findLeak(`code: ${'x'.repeat(BODY_LEAK_MIN_LENGTH)}`, forbidden)).toBe('body'); + expect(findLeak('clean', forbidden)).toBeNull(); + }); + + it('never lets the key placed in the environment reach stdout, stderr or the JSONL log', async () => { + const project = makeProject(); + const ctx = context(project, { TYPESAFE_API_KEY: SECRET, CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY: `${SECRET}-option` }); + const start = await runHook('subagent-start', subagentStart, { hook_event_name: 'SubagentStart', session_id: 's', agent_id: 'a', agent_type: DOMAIN_AGENT, cwd: project.dir }, ctx); + const deny = await runHook( + 'pre-tool-use', + preToolUse, + { hook_event_name: 'PreToolUse', session_id: 's', agent_id: 'a', agent_type: DOMAIN_AGENT, cwd: project.dir, tool_name: 'Write', tool_input: { file_path: join(project.dir, 'src/orders/domain/order.service.ts'), content: NEST_SERVICE } }, + ctx, + ); + const everything = [start.stdout, start.stderr, deny.stdout, deny.stderr, JSON.stringify(readLog(project))].join('\n'); + expect(everything).not.toContain(SECRET); + expect(deny.stdout).toContain('"permissionDecision":"deny"'); + }); + + it('suppresses an output that would carry the secret and logs the suppression', async () => { + const project = makeProject(); + const ctx = context(project, { TYPESAFE_API_KEY: SECRET }); + const leaking = await runHook('leaky', async () => ({ output: contextOutput('PostToolUse', `key is ${SECRET}`), decision: 'context' }), { hook_event_name: 'PostToolUse', session_id: 's' }, ctx); + expect(leaking.code).toBe(0); + expect(leaking.stdout).toBe(''); + expect(leaking.stderr).toContain('output suppressed'); + expect(leaking.stderr).not.toContain(SECRET); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['error']); + }); + + it('suppresses an output that would echo a raw file body', async () => { + const project = makeProject(); + const body = 'export const x = 1;\n'.repeat(20); + const leaking = await runHook('leaky', async () => ({ output: contextOutput('PostToolUse', body), decision: 'context', bodies: [body] }), { hook_event_name: 'PostToolUse', session_id: 's' }, context(project)); + expect(leaking.stdout).toBe(''); + expect(leaking.stderr).toContain('raw file body'); + }); + + it('fails open when the handler throws and when stdin is unusable', async () => { + const project = makeProject(); + const thrown = await runHook('broken', async () => { + throw new Error('boom'); + }, { hook_event_name: 'PostToolUse', session_id: 's' }, context(project)); + expect(thrown.code).toBe(0); + expect(thrown.stdout).toBe(''); + expect(thrown.stderr).toContain('boom'); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['error']); + + const out: string[] = []; + const { executeHook } = await import('../../hooks/lib/runner.ts'); + const code = await executeHook('broken', async () => ({ output: null, decision: 'skip' }), 'garbage', context(project), { stdout: (text) => void out.push(text), stderr: () => void 0 }); + expect(code).toBe(0); + expect(out).toEqual([]); + }); +}); diff --git a/scripts/__tests__/hooks/hooks-json.spec.ts b/scripts/__tests__/hooks/hooks-json.spec.ts new file mode 100644 index 0000000..2f52bf1 --- /dev/null +++ b/scripts/__tests__/hooks/hooks-json.spec.ts @@ -0,0 +1,58 @@ +import '../helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { z } from 'zod'; +import { PLUGIN_ROOT } from './helpers.ts'; + +const HandlerSchema = z.object({ + type: z.literal('command'), + command: z.literal('sh'), + args: z.tuple([z.literal('${CLAUDE_PLUGIN_ROOT}/scripts/run.sh'), z.literal('--hook'), z.enum(['subagent-start', 'pre-tool-use', 'post-tool-use', 'subagent-stop', 'agent-post-tool-use'])]), + timeout: z.number().int().positive().max(60), + statusMessage: z.string().optional(), +}); + +const HooksJsonSchema = z.object({ + hooks: z.record(z.enum(['SubagentStart', 'PreToolUse', 'PostToolUse', 'SubagentStop']), z.array(z.object({ matcher: z.string(), hooks: z.array(HandlerSchema).min(1) }))), +}); + +describe('hooks/hooks.json', () => { + const parsed = HooksJsonSchema.parse(JSON.parse(readFileSync(join(PLUGIN_ROOT, 'hooks', 'hooks.json'), 'utf8'))); + + it('routes every handler through run.sh with a known hook name and a timeout in seconds', () => { + const names = Object.values(parsed.hooks).flatMap((groups) => groups.flatMap((group) => group.hooks.map((handler) => handler.args[2]))); + expect(names.sort()).toEqual(['agent-post-tool-use', 'post-tool-use', 'pre-tool-use', 'subagent-start', 'subagent-stop']); + }); + + it('matches only the plugin agents on SubagentStart and the six pipeline agents on SubagentStop', () => { + const start = new RegExp(parsed.hooks.SubagentStart?.[0]?.matcher ?? ''); + expect(start.test('nestjs-hexagonal:domain-agent')).toBe(true); + expect(start.test('nestjs-hexagonal:architecture-reviewer')).toBe(true); + expect(start.test('Explore')).toBe(false); + const stop = new RegExp(parsed.hooks.SubagentStop?.[0]?.matcher ?? ''); + for (const agent of ['domain-agent', 'application-agent', 'infrastructure-agent', 'presentation-agent', 'broadcasting-agent', 'listener-agent']) { + expect(stop.test(`nestjs-hexagonal:${agent}`)).toBe(true); + } + expect(stop.test('nestjs-hexagonal:architecture-reviewer')).toBe(false); + expect(stop.test('other:nestjs-hexagonal:domain-agent')).toBe(false); + }); + + it('matches Write and Edit exactly on the tool events, plus Agent on PostToolUse', () => { + expect(parsed.hooks.PreToolUse?.map((group) => group.matcher)).toEqual(['Write|Edit']); + expect(parsed.hooks.PostToolUse?.map((group) => group.matcher)).toEqual(['Write|Edit', 'Agent']); + }); + + it('is shipped by the package', () => { + const pkg: unknown = JSON.parse(readFileSync(join(PLUGIN_ROOT, 'package.json'), 'utf8')); + const files = typeof pkg === 'object' && pkg !== null && 'files' in pkg && Array.isArray(pkg.files) ? pkg.files : []; + expect(files).toContain('hooks'); + expect(files).toContain('scripts/hooks'); + }); + + it('declares the key as optional sensitive user config and installs disabled', () => { + const plugin: unknown = JSON.parse(readFileSync(join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json'), 'utf8')); + const shape = z.object({ defaultEnabled: z.literal(false), userConfig: z.object({ TYPESAFE_API_KEY: z.object({ type: z.literal('string'), sensitive: z.literal(true), required: z.literal(false), title: z.string(), description: z.string() }) }) }); + expect(shape.safeParse(plugin).success).toBe(true); + }); +}); diff --git a/scripts/__tests__/hooks/post-tool-use.spec.ts b/scripts/__tests__/hooks/post-tool-use.spec.ts new file mode 100644 index 0000000..6b31912 --- /dev/null +++ b/scripts/__tests__/hooks/post-tool-use.spec.ts @@ -0,0 +1,143 @@ +import '../helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { ADVISORY_BYTE_CAP, handler, MAIN_THREAD_AGENT_ID } from '../../hooks/post-tool-use.ts'; +import { APPLICATION_AGENT, context, DOMAIN_AGENT, jevFetch, makeProject, NEST_SERVICE, PLAIN_SERVICE, readLog, runHook, writeProjectFile } from './helpers.ts'; + +interface ContextJson { + hookSpecificOutput: { hookEventName: string; additionalContext: string }; +} + +function isContextJson(value: unknown): value is ContextJson { + return typeof value === 'object' && value !== null && 'hookSpecificOutput' in value; +} + +function postInput(project: { dir: string }, relativePath: string, content: string, agent: { id: string; type: string } | null): Record { + return { + hook_event_name: 'PostToolUse', + session_id: 's', + cwd: project.dir, + ...(agent === null ? {} : { agent_id: agent.id, agent_type: agent.type }), + tool_name: 'Write', + tool_input: { file_path: join(project.dir, relativePath), content }, + tool_response: { filePath: join(project.dir, relativePath), type: 'create' }, + }; +} + +const HANDLER_PATH = 'src/orders/application/create-order.handler.ts'; +const HANDLER_SOURCE = 'export class CreateOrderHandler {\n async execute(): Promise {\n await Promise.resolve();\n }\n}\n'; + +describe('post-tool-use hook', () => { + it('records the touched path for any agent and stays silent on a clean file', async () => { + const project = makeProject(); + const path = 'src/orders/domain/order.service.ts'; + writeProjectFile(project, path, PLAIN_SERVICE); + const main = await runHook('post-tool-use', handler, postInput(project, path, PLAIN_SERVICE, null), context(project)); + expect(main.stdout).toBe(''); + expect(project.store.read('s', MAIN_THREAD_AGENT_ID)?.touchedPaths).toEqual([path]); + const other = await runHook('post-tool-use', handler, postInput(project, path, PLAIN_SERVICE, { id: 'x', type: 'Explore' }), context(project)); + expect(other.stdout).toBe(''); + expect(project.store.read('s', 'x')?.touchedPaths).toEqual([path]); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['silent', 'silent']); + }); + + it('skips paths outside the project and files without a scoped rule (still recording the path)', async () => { + const project = makeProject(); + const outside = await runHook('post-tool-use', handler, { ...postInput(project, 'x.ts', 'x', null), tool_input: { file_path: '/elsewhere/x.ts', content: 'x' } }, context(project)); + expect(outside.stdout).toBe(''); + writeProjectFile(project, 'docs/readme.md', 'hi'); + const unscoped = await runHook('post-tool-use', handler, postInput(project, 'docs/readme.md', 'hi', { id: 'a', type: DOMAIN_AGENT }), context(project)); + expect(unscoped.stdout).toBe(''); + expect(project.store.read('s', 'a')?.touchedPaths).toEqual(['docs/readme.md']); + }); + + it('returns static findings as additionalContext for any agent, without a semantic call when no key is set', async () => { + const project = makeProject(); + const path = 'src/orders/domain/order.service.ts'; + writeProjectFile(project, path, NEST_SERVICE); + const run = await runHook('post-tool-use', handler, postInput(project, path, NEST_SERVICE, { id: 'x', type: 'Explore' }), context(project)); + if (!isContextJson(run.json)) { + throw new Error(`unexpected output ${run.stdout}`); + } + expect(run.json.hookSpecificOutput.hookEventName).toBe('PostToolUse'); + expect(run.json.hookSpecificOutput.additionalContext).toContain('hex/domain-no-nest-decorators (FAIL)'); + expect(run.json.hookSpecificOutput.additionalContext).not.toContain('semantic'); + const [entry] = readLog(project); + expect(entry).toMatchObject({ decision: 'context', ruleIds: ['hex/domain-no-nest-decorators'] }); + expect(entry.semantic).toBeUndefined(); + }); + + it('asks Jev only for a plugin agent with a key and reports advise as advisory text', async () => { + const project = makeProject(); + writeProjectFile(project, HANDLER_PATH, HANDLER_SOURCE); + const jev = jevFetch(0.9); + const key = { CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY: 'sk-option-key' }; + const run = await runHook('post-tool-use', handler, postInput(project, HANDLER_PATH, HANDLER_SOURCE, { id: 'a', type: APPLICATION_AGENT }), context(project, key, jev.fetchImpl)); + if (!isContextJson(run.json)) { + throw new Error(`unexpected output ${run.stdout}`); + } + expect(jev.calls.length).toBeGreaterThan(0); + expect(run.json.hookSpecificOutput.additionalContext).toContain('semantic ask hex/handler-no-business-rules'); + expect(run.json.hookSpecificOutput.additionalContext).not.toContain('sk-option-key'); + const [entry] = readLog(project); + expect(entry.semantic).toMatchObject({ findings: 1 }); + + const noAgent = jevFetch(0.9); + const main = await runHook('post-tool-use', handler, postInput(project, HANDLER_PATH, HANDLER_SOURCE, null), context(project, key, noAgent.fetchImpl)); + expect(noAgent.calls).toEqual([]); + expect(main.stdout).toBe(''); + const foreign = jevFetch(0.9); + await runHook('post-tool-use', handler, postInput(project, HANDLER_PATH, HANDLER_SOURCE, { id: 'b', type: 'Explore' }), context(project, key, foreign.fetchImpl)); + expect(foreign.calls).toEqual([]); + }); + + it('reduces an uncertain or uncalibrated answer to one short line', async () => { + const project = makeProject(); + writeProjectFile(project, HANDLER_PATH, HANDLER_SOURCE); + const uncertain = await runHook('post-tool-use', handler, postInput(project, HANDLER_PATH, HANDLER_SOURCE, { id: 'a', type: APPLICATION_AGENT }), context(project, { TYPESAFE_API_KEY: 'sk-env' }, jevFetch(0.5).fetchImpl)); + if (!isContextJson(uncertain.json)) { + throw new Error(`unexpected output ${uncertain.stdout}`); + } + expect(uncertain.json.hookSpecificOutput.additionalContext).toContain('semantic abstained on 1 rule answer(s) (hex/handler-no-business-rules)'); + expect(readLog(project)[0]?.semantic).toMatchObject({ uncertain: 1 }); + const otherSource = HANDLER_SOURCE.replace('CreateOrderHandler', 'CancelOrderHandler'); + writeProjectFile(project, HANDLER_PATH, otherSource); + const stale = await runHook('post-tool-use', handler, postInput(project, HANDLER_PATH, otherSource, { id: 'b', type: APPLICATION_AGENT }), context(project, { TYPESAFE_API_KEY: 'sk-env' }, jevFetch(0.9, 'jev-9.0.0').fetchImpl)); + if (!isContextJson(stale.json)) { + throw new Error(`unexpected output ${stale.stdout}`); + } + expect(stale.json.hookSpecificOutput.additionalContext).toContain('semantic abstained'); + expect(readLog(project)[1]?.semantic).toMatchObject({ uncalibrated: 1 }); + }); + + it('fails open when Jev is unreachable', async () => { + const project = makeProject(); + writeProjectFile(project, HANDLER_PATH, HANDLER_SOURCE); + const failing = async (): Promise => { + throw new Error('connection refused'); + }; + const run = await runHook('post-tool-use', handler, postInput(project, HANDLER_PATH, HANDLER_SOURCE, { id: 'a', type: APPLICATION_AGENT }), context(project, { TYPESAFE_API_KEY: 'sk-env' }, failing)); + expect(run.code).toBe(0); + expect(run.stdout).toBe(''); + expect(readLog(project)[0]?.semantic).toMatchObject({ undecided: 2, findings: 0 }); + }); + + it('emits only a one-line notice once the advisory cap of the agent is reached', async () => { + const project = makeProject(); + const path = 'src/orders/domain/order.service.ts'; + writeProjectFile(project, path, NEST_SERVICE); + const agent = { id: 'a', type: DOMAIN_AGENT }; + const first = await runHook('post-tool-use', handler, postInput(project, path, NEST_SERVICE, agent), context(project)); + expect(first.stdout).toContain('hex/domain-no-nest-decorators'); + const bytes = project.store.read('s', 'a')?.advisoryBytes ?? 0; + expect(bytes).toBeGreaterThan(0); + project.store.update('s', 'a', (session) => ({ ...session, advisoryBytes: ADVISORY_BYTE_CAP })); + const capped = await runHook('post-tool-use', handler, postInput(project, path, NEST_SERVICE, agent), context(project)); + if (!isContextJson(capped.json)) { + throw new Error(`unexpected output ${capped.stdout}`); + } + expect(capped.json.hookSpecificOutput.additionalContext).toContain('advisory cap reached'); + expect(capped.json.hookSpecificOutput.additionalContext).not.toContain('(FAIL)'); + expect(project.store.read('s', 'a')?.advisoryBytes).toBe(ADVISORY_BYTE_CAP); + }); +}); diff --git a/scripts/__tests__/hooks/pre-tool-use.spec.ts b/scripts/__tests__/hooks/pre-tool-use.spec.ts new file mode 100644 index 0000000..6f0e800 --- /dev/null +++ b/scripts/__tests__/hooks/pre-tool-use.spec.ts @@ -0,0 +1,117 @@ +import '../helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { handler, resultingContent } from '../../hooks/pre-tool-use.ts'; +import { context, DOMAIN_AGENT, makeProject, NEST_SERVICE, PLAIN_SERVICE, readLog, runHook, writeProjectFile } from './helpers.ts'; + +interface PreToolUseJson { + hookSpecificOutput: { hookEventName: string; permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string }; +} + +function isPreToolUseJson(value: unknown): value is PreToolUseJson { + return typeof value === 'object' && value !== null && 'hookSpecificOutput' in value; +} + +function writeInput(project: { dir: string }, relativePath: string, content: string, agentType: string | null = DOMAIN_AGENT): Record { + return { + hook_event_name: 'PreToolUse', + session_id: 's', + cwd: project.dir, + ...(agentType === null ? {} : { agent_id: 'a', agent_type: agentType }), + tool_name: 'Write', + tool_input: { file_path: join(project.dir, relativePath), content }, + }; +} + +describe('pre-tool-use hook', () => { + it('computes the content an Edit would produce', () => { + const project = makeProject(); + const path = writeProjectFile(project, 'src/a.ts', 'a b a'); + expect(resultingContent({ tool: 'Edit', input: { file_path: path, old_string: 'a', new_string: 'c', replace_all: false } }, path)).toBe('c b a'); + expect(resultingContent({ tool: 'Edit', input: { file_path: path, old_string: 'a', new_string: '$&', replace_all: true } }, path)).toBe('$& b $&'); + expect(resultingContent({ tool: 'Edit', input: { file_path: path, old_string: 'zzz', new_string: 'c', replace_all: false } }, path)).toBeNull(); + expect(resultingContent({ tool: 'Edit', input: { file_path: join(project.dir, 'missing.ts'), old_string: 'a', new_string: 'c', replace_all: false } }, join(project.dir, 'missing.ts'))).toBeNull(); + }); + + it('stays silent for a non-plugin agent, the main thread, a path outside the project and a path outside every rule scope', async () => { + const project = makeProject(); + const other = await runHook('pre-tool-use', handler, writeInput(project, 'src/orders/domain/order.service.ts', NEST_SERVICE, 'Explore'), context(project)); + expect(other.stdout).toBe(''); + const main = await runHook('pre-tool-use', handler, writeInput(project, 'src/orders/domain/order.service.ts', NEST_SERVICE, null), context(project)); + expect(main.stdout).toBe(''); + const outside = await runHook('pre-tool-use', handler, { ...writeInput(project, 'x.ts', NEST_SERVICE), tool_input: { file_path: '/elsewhere/src/orders/domain/order.service.ts', content: NEST_SERVICE } }, context(project)); + expect(outside.stdout).toBe(''); + const unscoped = await runHook('pre-tool-use', handler, writeInput(project, 'src/orders/README.md', NEST_SERVICE), context(project)); + expect(unscoped.stdout).toBe(''); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['skip', 'skip', 'skip', 'skip']); + }); + + it('denies a Write that puts @Injectable into the domain, naming the rule and the fix', async () => { + const project = makeProject(); + const run = await runHook('pre-tool-use', handler, writeInput(project, 'src/orders/domain/order.service.ts', NEST_SERVICE), context(project, { TYPESAFE_API_KEY: 'sk-present' })); + expect(run.code).toBe(0); + if (!isPreToolUseJson(run.json)) { + throw new Error(`unexpected output ${run.stdout}`); + } + expect(run.json.hookSpecificOutput.hookEventName).toBe('PreToolUse'); + expect(run.json.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(run.json.hookSpecificOutput.permissionDecisionReason).toContain('hex/domain-no-nest-decorators (FAIL)'); + expect(run.json.hookSpecificOutput.permissionDecisionReason).toContain('fix:'); + expect(run.json.hookSpecificOutput.permissionDecisionReason).not.toContain('sk-present'); + expect(run.json.hookSpecificOutput.permissionDecisionReason).not.toContain('semantic'); + const [entry] = readLog(project); + expect(entry).toMatchObject({ decision: 'deny', ruleIds: ['hex/domain-no-nest-decorators'], path: 'src/orders/domain/order.service.ts', tool: 'Write' }); + }); + + it('lists at most three findings in the deny reason', async () => { + const project = makeProject(); + const content = ["import { Injectable } from '@nestjs/common';", "import { Inject } from '@nestjs/common';", "import { PrismaService } from '../infrastructure/prisma.service';", "import { Controller } from '@nestjs/common';", 'export class OrderService {}'].join('\n'); + const run = await runHook('pre-tool-use', handler, writeInput(project, 'src/orders/domain/order.service.ts', content), context(project)); + if (!isPreToolUseJson(run.json)) { + throw new Error(`unexpected output ${run.stdout}`); + } + const reason = run.json.hookSpecificOutput.permissionDecisionReason ?? ''; + expect(reason.split('\n').filter((line) => line.includes('(FAIL)'))).toHaveLength(3); + expect(reason).toContain('more FAIL finding(s)'); + }); + + it('does not deny an Edit whose replacement removes the violation, and adds no permission decision', async () => { + const project = makeProject(); + const path = writeProjectFile(project, 'src/orders/domain/order.service.ts', NEST_SERVICE); + const input = { + hook_event_name: 'PreToolUse', + session_id: 's', + cwd: project.dir, + agent_id: 'a', + agent_type: DOMAIN_AGENT, + tool_name: 'Edit', + tool_input: { file_path: path, old_string: "import { Injectable } from '@nestjs/common';\n\n@Injectable()\n", new_string: '' }, + }; + const run = await runHook('pre-tool-use', handler, input, context(project)); + expect(run.code).toBe(0); + expect(run.stdout).toBe(''); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['silent']); + + const stillBroken = await runHook('pre-tool-use', handler, { ...input, tool_input: { file_path: path, old_string: 'run(): void {}', new_string: 'run(): void { return; }' } }, context(project)); + expect(stillBroken.stdout).toContain('"permissionDecision":"deny"'); + }); + + it('returns a WARN as additionalContext without a permission decision', async () => { + const project = makeProject(); + const handlerSource = `export class CreateOrderHandler {\n async execute(): Promise {\n${' await Promise.resolve();\n'.repeat(40)} }\n}\n`; + const run = await runHook('pre-tool-use', handler, writeInput(project, 'src/orders/application/create-order.handler.ts', handlerSource), context(project)); + if (!isPreToolUseJson(run.json)) { + throw new Error(`unexpected output ${run.stdout}`); + } + expect(run.json.hookSpecificOutput.permissionDecision).toBeUndefined(); + expect(run.json.hookSpecificOutput.additionalContext).toContain('hex/handler-max-lines (WARN)'); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['context']); + }); + + it('is silent on a clean write', async () => { + const project = makeProject(); + const run = await runHook('pre-tool-use', handler, writeInput(project, 'src/orders/domain/order.service.ts', PLAIN_SERVICE), context(project)); + expect(run.stdout).toBe(''); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['silent']); + }); +}); diff --git a/scripts/__tests__/hooks/subagent-start.spec.ts b/scripts/__tests__/hooks/subagent-start.spec.ts new file mode 100644 index 0000000..aaa2f84 --- /dev/null +++ b/scripts/__tests__/hooks/subagent-start.spec.ts @@ -0,0 +1,75 @@ +import '../helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { loadComposedRulebook } from '../../lib/compose.ts'; +import { join } from 'node:path'; +import { composeSliceContext, CONTEXT_TOKEN_BUDGET, handler, layersForAgent } from '../../hooks/subagent-start.ts'; +import { context, DOMAIN_AGENT, makeProject, PLUGIN_ROOT, readLog, runHook } from './helpers.ts'; + +interface ContextJson { + hookSpecificOutput: { hookEventName: string; additionalContext: string }; +} + +function isContextJson(value: unknown): value is ContextJson { + return typeof value === 'object' && value !== null && 'hookSpecificOutput' in value; +} + +const composed = loadComposedRulebook(join(PLUGIN_ROOT, 'rulebooks', 'project.example.rulebook.yaml'), join(PLUGIN_ROOT, 'rulebooks')); + +describe('subagent-start hook', () => { + it('maps agents to the layers they write', () => { + expect(layersForAgent('nestjs-hexagonal:domain-agent')).toEqual(['domain']); + expect(layersForAgent('nestjs-hexagonal:application-agent')).toEqual(['application']); + expect(layersForAgent('nestjs-hexagonal:listener-agent')).toEqual(['infrastructure', 'presentation']); + expect(layersForAgent('nestjs-hexagonal:architecture-reviewer')).toEqual(['domain', 'application', 'infrastructure', 'presentation']); + }); + + it('composes the slice with ids, titles, severity and the fix of FAIL rules under the token budget', () => { + const slice = composeSliceContext('p', '0.1.0', DOMAIN_AGENT, composed.rules); + expect(slice.ruleIds).toContain('hex/domain-no-nest-decorators'); + expect(slice.ruleIds).toContain('softtor/no-emoji'); + expect(slice.ruleIds).not.toContain('hex/controller-thin'); + expect(slice.ruleIds).not.toContain('hex/tests-coverage'); + expect(slice.text).toContain('hex/domain-no-nest-decorators (static, FAIL)'); + expect(slice.text).toContain('fix: Remove the import and the decorator'); + expect(slice.text).toContain('If the SubagentStop hook blocks the stop'); + expect(Math.ceil(slice.text.length / 4)).toBeLessThanOrEqual(CONTEXT_TOKEN_BUDGET); + const warnLine = slice.text.split('\n').find((line) => line.includes('hex/entity-not-anemic')); + expect(warnLine).toBeDefined(); + expect(warnLine).not.toContain('fix:'); + }); + + it('truncates the list instead of exceeding the budget', () => { + const many = Array.from({ length: 80 }, (_, index) => ({ ...composed.rules[0], id: `hex/rule-${index}`, title: 'A long title '.repeat(10), fix: 'A long fix '.repeat(20) })); + const slice = composeSliceContext('p', '0.1.0', DOMAIN_AGENT, many); + expect(Math.ceil(slice.text.length / 4)).toBeLessThanOrEqual(CONTEXT_TOKEN_BUDGET); + expect(slice.text).toContain('more rule(s) omitted'); + expect(slice.ruleIds.length).toBeLessThan(80); + }); + + it('stays silent for agents outside the plugin and for projects without a rulebook', async () => { + const project = makeProject(); + const other = await runHook('subagent-start', handler, { hook_event_name: 'SubagentStart', session_id: 's', agent_id: 'a', agent_type: 'Explore', cwd: project.dir }, context(project)); + expect(other.stdout).toBe(''); + const bare = makeProject({ rulebook: false }); + const noRulebook = await runHook('subagent-start', handler, { hook_event_name: 'SubagentStart', session_id: 's', agent_id: 'a', agent_type: DOMAIN_AGENT, cwd: bare.dir }, context(bare)); + expect(noRulebook.stdout).toBe(''); + expect(noRulebook.code).toBe(0); + }); + + it('injects the slice as additionalContext, records the session start and logs the decision', async () => { + const project = makeProject({ git: true }); + const run = await runHook('subagent-start', handler, { hook_event_name: 'SubagentStart', session_id: 's', agent_id: 'agent-1', agent_type: DOMAIN_AGENT, cwd: project.dir }, context(project)); + expect(run.code).toBe(0); + if (!isContextJson(run.json)) { + throw new Error(`unexpected output ${run.stdout}`); + } + expect(run.json.hookSpecificOutput.hookEventName).toBe('SubagentStart'); + expect(run.json.hookSpecificOutput.additionalContext).toContain('hex/domain-no-nest-decorators'); + const session = project.store.read('s', 'agent-1'); + expect(session?.agentType).toBe(DOMAIN_AGENT); + expect(session?.headSha).toMatch(/^[0-9a-f]{40}$/); + expect(session?.blocks).toBe(0); + const [entry] = readLog(project); + expect(entry).toMatchObject({ hook: 'subagent-start', event: 'SubagentStart', agentType: DOMAIN_AGENT, decision: 'context' }); + }); +}); diff --git a/scripts/__tests__/hooks/subagent-stop.spec.ts b/scripts/__tests__/hooks/subagent-stop.spec.ts new file mode 100644 index 0000000..b72a0e5 --- /dev/null +++ b/scripts/__tests__/hooks/subagent-stop.spec.ts @@ -0,0 +1,180 @@ +import '../helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { emptySession } from '../../lib/session-store.ts'; +import { findAgentSession, handler as agentPostToolUse } from '../../hooks/agent-post-tool-use.ts'; +import { handler, MAX_BLOCKS, touchedFiles } from '../../hooks/subagent-stop.ts'; +import { APPLICATION_AGENT, context, DOMAIN_AGENT, jevFetch, makeProject, NEST_SERVICE, PLAIN_SERVICE, readLog, runHook, writeProjectFile, type Project } from './helpers.ts'; + +interface BlockJson { + decision: string; + reason: string; +} + +interface SystemMessageJson { + systemMessage: string; +} + +function isBlockJson(value: unknown): value is BlockJson { + return typeof value === 'object' && value !== null && 'decision' in value && 'reason' in value; +} + +function isSystemMessageJson(value: unknown): value is SystemMessageJson { + return typeof value === 'object' && value !== null && 'systemMessage' in value; +} + +function stopInput(project: Project, agentId = 'agent-1', agentType = DOMAIN_AGENT, stopHookActive = false): Record { + return { hook_event_name: 'SubagentStop', session_id: 's', cwd: project.dir, agent_id: agentId, agent_type: agentType, stop_hook_active: stopHookActive, last_assistant_message: 'done' }; +} + +function startSession(project: Project, agentId: string, agentType: string, headSha: string | null, touched: string[] = []): void { + project.store.update('s', agentId, () => ({ ...emptySession(agentType, new Date().toISOString(), headSha), touchedPaths: touched })); +} + +function headOf(project: Project): string { + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: project.dir, encoding: 'utf8' }).trim(); +} + +const BAD_PATH = 'src/orders/domain/order.service.ts'; + +describe('subagent-stop hook', () => { + it('enumerates touched files as the union of the store and git changes since the agent start', () => { + const project = makeProject({ git: true }); + writeProjectFile(project, 'src/before.ts', 'export const a = 1;\n'); + execFileSync('git', ['add', '.'], { cwd: project.dir }); + execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '-m', 'before'], { cwd: project.dir }); + const head = headOf(project); + writeProjectFile(project, 'src/before.ts', 'export const a = 2;\n'); + writeProjectFile(project, 'src/untracked.ts', 'export const b = 1;\n'); + writeProjectFile(project, 'src/from-store.ts', 'export const c = 1;\n'); + const session = { ...emptySession(DOMAIN_AGENT, 'now', head), touchedPaths: ['src/from-store.ts', 'src/deleted.ts'] }; + expect(touchedFiles(session, project.dir)).toEqual(['src/before.ts', 'src/from-store.ts', 'src/untracked.ts']); + const noGit = makeProject(); + writeProjectFile(noGit, 'src/only-store.ts', 'export const d = 1;\n'); + expect(touchedFiles({ ...emptySession(DOMAIN_AGENT, 'now', null), touchedPaths: ['src/only-store.ts'] }, noGit.dir)).toEqual(['src/only-store.ts']); + }); + + it('is silent for non-plugin agents, without a rulebook, and when the touched files pass', async () => { + const project = makeProject(); + expect((await runHook('subagent-stop', handler, stopInput(project, 'x', 'Explore'), context(project))).stdout).toBe(''); + const bare = makeProject({ rulebook: false }); + expect((await runHook('subagent-stop', handler, stopInput(bare), context(bare))).stdout).toBe(''); + writeProjectFile(project, BAD_PATH, PLAIN_SERVICE); + startSession(project, 'agent-1', DOMAIN_AGENT, null, [BAD_PATH]); + const clean = await runHook('subagent-stop', handler, stopInput(project), context(project)); + expect(clean.stdout).toBe(''); + expect(clean.code).toBe(0); + expect(project.store.read('s', 'agent-1')?.unresolved).toEqual([]); + }); + + it('blocks twice on a static FAIL, then releases with a systemMessage on the third stop', async () => { + const project = makeProject({ git: true }); + startSession(project, 'agent-1', DOMAIN_AGENT, headOf(project)); + writeProjectFile(project, BAD_PATH, NEST_SERVICE); + + const first = await runHook('subagent-stop', handler, stopInput(project), context(project)); + if (!isBlockJson(first.json)) { + throw new Error(`unexpected output ${first.stdout}`); + } + expect(first.json.decision).toBe('block'); + expect(first.json.reason).toContain(`${BAD_PATH}:1`); + expect(first.json.reason).toContain('hex/domain-no-nest-decorators (FAIL)'); + expect(first.json.reason).toContain('fix:'); + expect(project.store.read('s', 'agent-1')?.blocks).toBe(1); + expect(project.store.read('s', 'agent-1')?.unresolved.map((finding) => finding.ruleId)).toEqual(['hex/domain-no-nest-decorators']); + + const second = await runHook('subagent-stop', handler, stopInput(project, 'agent-1', DOMAIN_AGENT, true), context(project)); + expect(isBlockJson(second.json) && second.json.decision).toBe('block'); + expect(project.store.read('s', 'agent-1')?.blocks).toBe(MAX_BLOCKS); + + const third = await runHook('subagent-stop', handler, stopInput(project, 'agent-1', DOMAIN_AGENT, true), context(project)); + if (!isSystemMessageJson(third.json)) { + throw new Error(`unexpected output ${third.stdout}`); + } + expect(third.json.systemMessage).toBe(`nestjs-hexagonal: 2 blocks reached, releasing ${DOMAIN_AGENT} with unresolved FAILs: hex/domain-no-nest-decorators ${BAD_PATH}:1`); + expect(third.stdout).not.toContain('"decision"'); + expect(project.store.read('s', 'agent-1')?.blocks).toBe(MAX_BLOCKS); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['block', 'block', 'release']); + }); + + it('lists only files the agent touched, even when another file in the project also fails', async () => { + const project = makeProject(); + writeProjectFile(project, 'src/other/domain/other.service.ts', NEST_SERVICE); + writeProjectFile(project, BAD_PATH, NEST_SERVICE); + startSession(project, 'agent-1', DOMAIN_AGENT, null, [BAD_PATH]); + const run = await runHook('subagent-stop', handler, stopInput(project), context(project)); + if (!isBlockJson(run.json)) { + throw new Error(`unexpected output ${run.stdout}`); + } + expect(run.json.reason).toContain(BAD_PATH); + expect(run.json.reason).not.toContain('other.service.ts'); + }); + + it('appends semantic advisory lines to the reason when a key is present, never blocking on them', async () => { + const project = makeProject(); + const handlerPath = 'src/orders/application/create-order.handler.ts'; + writeProjectFile(project, handlerPath, 'export class CreateOrderHandler {\n async execute(): Promise {\n await Promise.resolve();\n }\n}\n'); + startSession(project, 'agent-2', APPLICATION_AGENT, null, [handlerPath]); + const advise = jevFetch(0.9); + const clean = await runHook('subagent-stop', handler, stopInput(project, 'agent-2', APPLICATION_AGENT), context(project, { TYPESAFE_API_KEY: 'sk-env' }, advise.fetchImpl)); + expect(clean.stdout).toBe(''); + expect(advise.calls).toEqual([]); + + writeProjectFile(project, BAD_PATH, NEST_SERVICE); + startSession(project, 'agent-2', APPLICATION_AGENT, null, [handlerPath, BAD_PATH]); + const blocking = await runHook('subagent-stop', handler, stopInput(project, 'agent-2', APPLICATION_AGENT), context(project, { TYPESAFE_API_KEY: 'sk-env' }, advise.fetchImpl)); + if (!isBlockJson(blocking.json)) { + throw new Error(`unexpected output ${blocking.stdout}`); + } + expect(advise.calls.length).toBeGreaterThan(0); + expect(blocking.json.reason).toContain('advisory (semantic, not blocking)'); + expect(blocking.json.reason).toContain('semantic ask hex/handler-no-business-rules'); + expect(blocking.json.reason).not.toContain('sk-env'); + expect(readLog(project).at(-1)?.semantic).toMatchObject({ findings: 1 }); + }); +}); + +describe('agent-post-tool-use hook', () => { + function agentInput(project: Project, response: Record): Record { + return { hook_event_name: 'PostToolUse', session_id: 's', cwd: project.dir, tool_name: 'Agent', tool_input: { prompt: 'p', description: 'd', subagent_type: DOMAIN_AGENT }, tool_response: response }; + } + + it('looks the session up by the bare id and by the agent- prefixed spelling', () => { + const project = makeProject(); + startSession(project, 'agent-1', DOMAIN_AGENT, null); + startSession(project, '2', DOMAIN_AGENT, null); + expect(findAgentSession(project.store, 's', '1')?.agentType).toBe(DOMAIN_AGENT); + expect(findAgentSession(project.store, 's', 'agent-1')?.agentType).toBe(DOMAIN_AGENT); + expect(findAgentSession(project.store, 's', 'agent-2')?.agentType).toBe(DOMAIN_AGENT); + expect(findAgentSession(project.store, 's', '3')).toBeNull(); + }); + + it('returns the unresolved FAIL list of a completed plugin agent and nothing otherwise', async () => { + const project = makeProject(); + writeProjectFile(project, BAD_PATH, NEST_SERVICE); + startSession(project, 'agent-1', DOMAIN_AGENT, null, [BAD_PATH]); + project.store.update('s', 'agent-1', (session) => ({ ...session, blocks: MAX_BLOCKS })); + await runHook('subagent-stop', handler, stopInput(project), context(project)); + + const completed = await runHook('agent-post-tool-use', agentPostToolUse, agentInput(project, { status: 'completed', agentId: 'agent-1', content: [{ type: 'text', text: 'done' }] }), context(project)); + expect(completed.stdout).toContain('"hookEventName":"PostToolUse"'); + expect(completed.stdout).toContain('1 unresolved static FAIL finding(s)'); + expect(completed.stdout).toContain('hex/domain-no-nest-decorators (FAIL)'); + + const launched = await runHook('agent-post-tool-use', agentPostToolUse, agentInput(project, { status: 'async_launched', agentId: 'agent-1' }), context(project)); + expect(launched.stdout).toBe(''); + const unknown = await runHook('agent-post-tool-use', agentPostToolUse, agentInput(project, { status: 'completed', agentId: 'nobody' }), context(project)); + expect(unknown.stdout).toBe(''); + + startSession(project, 'explore-1', 'Explore', null); + project.store.update('s', 'explore-1', (session) => ({ ...session, unresolved: [{ path: BAD_PATH, ruleId: 'hex/x', severity: 'FAIL', evidence: 'e', fix: 'f' }] })); + const foreign = await runHook('agent-post-tool-use', agentPostToolUse, agentInput(project, { status: 'completed', agentId: 'explore-1' }), context(project)); + expect(foreign.stdout).toBe(''); + + project.store.update('s', 'agent-1', (session) => ({ ...session, unresolved: [] })); + const resolved = await runHook('agent-post-tool-use', agentPostToolUse, agentInput(project, { status: 'completed', agentId: 'agent-1' }), context(project)); + expect(resolved.stdout).toBe(''); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['release', 'context', 'skip', 'skip', 'skip', 'silent']); + }); +}); diff --git a/scripts/__tests__/run-sh.spec.ts b/scripts/__tests__/run-sh.spec.ts index fa3616d..3022043 100644 --- a/scripts/__tests__/run-sh.spec.ts +++ b/scripts/__tests__/run-sh.spec.ts @@ -1,9 +1,10 @@ import './helpers/no-network.ts'; import { describe, expect, it } from 'bun:test'; import { execFileSync, spawnSync } from 'node:child_process'; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, symlinkSync, writeFileSync, chmodSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync, chmodSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { sha256Of } from '../lib/compose.ts'; const PLUGIN_ROOT = resolve(import.meta.dir, '../..'); const RUN_SH = join(PLUGIN_ROOT, 'scripts', 'run.sh'); @@ -29,13 +30,17 @@ function makeProject(withRulebook: boolean): string { const dir = mkdtempSync(join(tmpdir(), 'hex-run-')); if (withRulebook) { mkdirSync(join(dir, '.claude')); - writeFileSync(join(dir, '.claude', 'rulebook.yaml'), '$schema: nestjs-hexagonal/rulebook@1\nid: p\nversion: 0.1.0\nmodel: { provider: typesafe, pin: jev-1.13.0 }\n'); + const base = readFileSync(join(PLUGIN_ROOT, 'rulebooks', 'hexagonal.rulebook.yaml'), 'utf8'); + writeFileSync( + join(dir, '.claude', 'rulebook.yaml'), + `$schema: nestjs-hexagonal/rulebook@1\nid: p\nversion: 0.1.0\nextends:\n - { id: hexagonal, version: 1.3.0, sha256: ${sha256Of(base)} }\nmodel: { provider: typesafe, pin: jev-1.13.0 }\n`, + ); } return dir; } function hookInput(filePath: string): string { - return JSON.stringify({ tool_name: 'Write', tool_input: { file_path: filePath, content: 'x' } }); + return JSON.stringify({ hook_event_name: 'PreToolUse', session_id: 's', tool_name: 'Write', tool_input: { file_path: filePath, content: 'x' } }); } describe('run.sh hook gate', () => { @@ -75,12 +80,45 @@ describe('run.sh hook gate', () => { expect(traversal.stdout).toBe(''); }); - it('reaches check.ts for a file inside the project, even when it does not exist yet', () => { + it('reaches the hook script for a file inside the project, even when it does not exist yet', () => { const dir = makeProject(true); - const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir }, hookInput(join(dir, 'src', 'new', 'file.ts'))); + const dataDir = mkdtempSync(join(tmpdir(), 'hex-data-')); + const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir, CLAUDE_PLUGIN_DATA: dataDir }, hookInput(join(dir, 'src', 'new', 'file.ts'))); expect(result.status).toBe(0); expect(result.stdout).toBe(''); - expect(result.stderr).toContain('not implemented in this version'); + expect(result.stderr).toBe(''); + const log = readdirSync(join(dataDir, 'logs')); + expect(log).toHaveLength(1); + expect(readFileSync(join(dataDir, 'logs', log[0] ?? ''), 'utf8')).toContain('"binarySource":"plugin-root"'); + }); + + it('denies through run.sh when a plugin agent writes a NestJS decorator into the domain', () => { + const dir = makeProject(true); + const input = JSON.stringify({ + hook_event_name: 'PreToolUse', + session_id: 's', + agent_id: 'a', + agent_type: 'nestjs-hexagonal:domain-agent', + cwd: dir, + tool_name: 'Write', + tool_input: { file_path: join(dir, 'src', 'orders', 'domain', 'order.service.ts'), content: "import { Injectable } from '@nestjs/common';\n" }, + }); + const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir, CLAUDE_PLUGIN_DATA: mkdtempSync(join(tmpdir(), 'hex-data-')) }, input); + expect(result.status).toBe(0); + expect(result.stdout).toContain('"permissionDecision":"deny"'); + expect(result.stdout).toContain('hex/domain-no-nest-decorators'); + }); + + it('exits 0 silently for --hook subagent-stop without a rulebook and for an unknown hook name', () => { + const dir = makeProject(false); + const stop = runSh(RUN_SH, ['--hook', 'subagent-stop'], { CLAUDE_PROJECT_DIR: dir }, JSON.stringify({ hook_event_name: 'SubagentStop', session_id: 's', agent_id: 'a', agent_type: 'nestjs-hexagonal:domain-agent', cwd: dir })); + expect(stop.status).toBe(0); + expect(stop.stdout).toBe(''); + expect(stop.stderr).toBe(''); + const unknown = runSh(RUN_SH, ['--hook', 'nope'], { CLAUDE_PROJECT_DIR: makeProject(true) }, '{}'); + expect(unknown.status).toBe(0); + expect(unknown.stdout).toBe(''); + expect(unknown.stderr).toContain("unknown hook 'nope'"); }); it('prefers the project node_modules binary when it is not itself', () => { @@ -100,9 +138,10 @@ describe('run.sh hook gate', () => { const binDir = join(dir, 'node_modules', '.bin'); mkdirSync(binDir, { recursive: true }); symlinkSync(RUN_SH, join(binDir, 'nestjs-hexagonal-check')); - const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir }, hookInput(join(dir, 'a.ts'))); + const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir, CLAUDE_PLUGIN_DATA: mkdtempSync(join(tmpdir(), 'hex-data-')) }, hookInput(join(dir, 'a.ts'))); expect(result.status).toBe(0); - expect(result.stderr).toContain('not implemented in this version'); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); }); it('does not recurse when the project binary is a relative link to an installed copy of itself', () => { @@ -208,5 +247,14 @@ describe('run.sh installed as a hoisted dev dependency', () => { expect(result.stderr).not.toContain('dependencies missing'); expect(result.stdout).toContain('hex/domain-no-nest-decorators'); expect(result.status).toBe(1); + + mkdirSync(join(project, '.claude')); + writeFileSync(join(project, '.claude', 'rulebook.yaml'), '$schema: nestjs-hexagonal/rulebook@1\nid: p\nversion: 0.1.0\nmodel: { provider: typesafe, pin: jev-1.13.0 }\n'); + const dataDir = mkdtempSync(join(tmpdir(), 'hex-data-')); + const hook = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: project, CLAUDE_PLUGIN_DATA: dataDir }, hookInput(join(project, 'src', 'orders', 'domain', 'order.service.ts'))); + expect(hook.status).toBe(0); + expect(hook.stderr).toBe(''); + const log = readdirSync(join(dataDir, 'logs')); + expect(readFileSync(join(dataDir, 'logs', log[0] ?? ''), 'utf8')).toContain('"binarySource":"node_modules"'); }); }); diff --git a/scripts/hooks/agent-post-tool-use.ts b/scripts/hooks/agent-post-tool-use.ts new file mode 100644 index 0000000..f5f7b29 --- /dev/null +++ b/scripts/hooks/agent-post-tool-use.ts @@ -0,0 +1,42 @@ +import type { AgentSession, SessionStore } from '../lib/session-store.ts'; +import { PREFIX, formatStaticFinding, isPluginAgent, sessionStore, skip, uniqueRuleIds, type HookHandler } from './lib/hook-common.ts'; +import { agentToolResponse, contextOutput } from './lib/hook-io.ts'; +import { runHookMain } from './lib/runner.ts'; + +/** + * The Agent tool reports the run as `agentId`; SubagentStart/Stop receive + * `agent_id`. The docs show both bare and `agent-` prefixed ids, so the + * lookup tries the exact id and both spellings. + */ +export function findAgentSession(store: SessionStore, sessionId: string, agentId: string): AgentSession | null { + const candidates = agentId.startsWith('agent-') ? [agentId, agentId.slice('agent-'.length)] : [agentId, `agent-${agentId}`]; + for (const candidate of candidates) { + const session = store.read(sessionId, candidate); + if (session !== null) { + return session; + } + } + return null; +} + +export const handler: HookHandler = async (input, context) => { + const response = agentToolResponse(input); + if (response === null || response.agentId === undefined || response.status !== 'completed') { + return skip(); + } + const session = findAgentSession(sessionStore(context), input.session_id, response.agentId); + if (session === null || !isPluginAgent(session.agentType)) { + return skip(); + } + const fails = session.unresolved.filter((finding) => finding.severity === 'FAIL'); + if (fails.length === 0) { + return { output: null, decision: 'silent' }; + } + const text = [ + `${PREFIX} ${session.agentType} finished with ${fails.length} unresolved static FAIL finding(s); they need a fix before this work is complete:`, + ...fails.map(formatStaticFinding), + ].join('\n'); + return { output: contextOutput('PostToolUse', text), decision: 'context', ruleIds: uniqueRuleIds(fails) }; +}; + +await runHookMain('agent-post-tool-use', handler, import.meta.url); diff --git a/scripts/hooks/lib/hook-common.ts b/scripts/hooks/lib/hook-common.ts new file mode 100644 index 0000000..340aa2f --- /dev/null +++ b/scripts/hooks/lib/hook-common.ts @@ -0,0 +1,212 @@ +import { join } from 'node:path'; +import { RulebookCompositionError, semanticRulebookVersion, type ComposedRulebook } from '../../lib/compose.ts'; +import { loadFitted, type FittedFile } from '../../lib/decide.ts'; +import type { HookDecision, HookLogEntry } from '../../lib/hook-log.ts'; +import { createJevClient, type FetchLike } from '../../lib/jev-client.ts'; +import { RulebookNotFoundError, loadProjectRulebook, projectDirOf } from '../../lib/project-rulebook.ts'; +import type { Rule } from '../../lib/rulebook.schema.ts'; +import { isInScope, normalizePath } from '../../lib/scope.ts'; +import { runSemanticRules, type SemanticFinding } from '../../lib/semantic-engine.ts'; +import { createSessionStore, resolveDataDir, type SessionStore, type UnresolvedFinding } from '../../lib/session-store.ts'; +import type { Finding, SourceFile } from '../../lib/static-engine.ts'; +import type { HookInput, HookOutput } from './hook-io.ts'; + +export const PLUGIN_AGENT_PREFIX = 'nestjs-hexagonal:'; +export const PREFIX = '[nestjs-hexagonal]'; + +export interface HookContext { + env: Record; + cwd: string; + pluginRoot: string; + fetchImpl?: FetchLike; + now?: () => number; + store?: SessionStore; +} + +export interface SemanticStats { + requests: number; + answered: number; + findings: number; + uncertain: number; + uncalibrated: number; + undecided: number; +} + +export interface HookResult { + output: HookOutput | null; + decision: HookDecision; + ruleIds?: string[]; + path?: string | null; + semantic?: SemanticStats; + /** Raw bodies the output must never contain verbatim (file contents, replacements). */ + bodies?: string[]; + stderr?: string; +} + +export type HookHandler = (input: HookInput, context: HookContext) => Promise; + +export function skip(): HookResult { + return { output: null, decision: 'skip' }; +} + +export function isPluginAgent(agentType: string | undefined): agentType is string { + return agentType !== undefined && agentType.startsWith(PLUGIN_AGENT_PREFIX); +} + +export function projectDir(input: HookInput, context: HookContext): string { + return projectDirOf(context.env, input.cwd ?? context.cwd); +} + +/** Path relative to the project, or null when the absolute path is outside it. */ +export function relativeProjectPath(project: string, absolute: string): string | null { + const root = normalizePath(project).replace(/\/+$/, ''); + const target = normalizePath(absolute); + if (target === root) { + return null; + } + if (!target.startsWith(`${root}/`)) { + return null; + } + const relative = target.slice(root.length + 1); + return relative.split('/').includes('..') ? null : relative; +} + +export function sessionStore(context: HookContext): SessionStore { + return context.store ?? createSessionStore({ dataDir: resolveDataDir(context.env), ...(context.now ? { now: context.now } : {}) }); +} + +export type LoadedRulebookResult = { ok: true; composed: ComposedRulebook } | { ok: false; reason: string }; + +export function loadRulebook(input: HookInput, context: HookContext): LoadedRulebookResult { + try { + const { composed } = loadProjectRulebook({ cwd: projectDir(input, context), env: context.env, pluginRoot: context.pluginRoot }); + return { ok: true, composed }; + } catch (error) { + if (error instanceof RulebookNotFoundError || error instanceof RulebookCompositionError) { + return { ok: false, reason: error.message }; + } + throw error; + } +} + +export function staticRules(rules: Rule[], options: { external: boolean }): Rule[] { + return rules.filter((rule) => rule.class === 'static' && rule.check !== undefined && (options.external || rule.check.kind !== 'external')); +} + +export function rulesInScope(rules: Rule[], path: string): Rule[] { + return rules.filter((rule) => isInScope(rule.scope, path)); +} + +export function apiKey(env: Record): string | undefined { + const fromOption = env.CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY; + if (fromOption !== undefined && fromOption !== '') { + return fromOption; + } + const fromEnv = env.TYPESAFE_API_KEY; + return fromEnv !== undefined && fromEnv !== '' ? fromEnv : undefined; +} + +function location(finding: { path: string; line?: number }): string { + return finding.line === undefined ? finding.path : `${finding.path}:${finding.line}`; +} + +export function formatStaticFinding(finding: Finding | UnresolvedFinding): string { + return `${PREFIX} ${finding.ruleId} (${finding.severity}) ${location(finding)}: ${finding.evidence} - fix: ${finding.fix}`; +} + +export function formatSemanticFinding(finding: SemanticFinding): string { + const calibration = finding.calibrated ? '' : ', not calibrated'; + return `${PREFIX} semantic ${finding.decision} ${finding.ruleId} (${finding.severity}) ${location(finding)}: ${finding.evidence}${calibration} - fix: ${finding.fix}`; +} + +export function toUnresolved(finding: Finding): UnresolvedFinding { + const unresolved: UnresolvedFinding = { path: finding.path, ruleId: finding.ruleId, severity: finding.severity, evidence: finding.evidence, fix: finding.fix }; + if (finding.line !== undefined) { + unresolved.line = finding.line; + } + return unresolved; +} + +export function uniqueRuleIds(findings: Array<{ ruleId: string }>): string[] { + return [...new Set(findings.map((finding) => finding.ruleId))].sort(); +} + +export interface SemanticAdvisory { + lines: string[]; + stats: SemanticStats; + findings: SemanticFinding[]; +} + +export const SEMANTIC_TIMEOUT_MS = 6_000; + +/** + * Semantic rules as advisory text. Never denies or blocks in this version: + * advise/ask findings become lines, uncertain/uncalibrated ones a single + * short line, and any client error is swallowed into the stats. + */ +export async function semanticAdvisory( + composed: ComposedRulebook, + files: SourceFile[], + key: string, + context: HookContext, + concurrency: number, +): Promise { + const rules = composed.rules.filter((rule) => rule.class === 'semantic'); + const stats: SemanticStats = { requests: 0, answered: 0, findings: 0, uncertain: 0, uncalibrated: 0, undecided: 0 }; + if (rules.length === 0 || files.length === 0) { + return { lines: [], stats, findings: [] }; + } + const pin = composed.rulebook.model.pin; + const loaded = loadFitted(join(context.pluginRoot, 'calibration', 'fitted'), pin, semanticRulebookVersion(composed)); + const fitted: FittedFile | null = loaded.status === 'none' ? null : loaded.fitted; + const dataDir = resolveDataDir(context.env); + const client = createJevClient({ + apiKey: key, + pin, + rulebookVersion: composed.rulebook.version, + timeoutMs: SEMANTIC_TIMEOUT_MS, + cacheDir: join(dataDir, 'cache'), + logPath: join(dataDir, 'jev.jsonl'), + breakerPath: join(dataDir, 'breaker.json'), + ...(context.fetchImpl ? { fetchImpl: context.fetchImpl } : {}), + }); + const result = await runSemanticRules(rules, files, { client, fitted, uncalibrated: composed.uncalibrated || loaded.status === 'mismatch', concurrency }); + const applied = Object.values(result.applied).reduce((sum, ids) => sum + ids.length, 0); + const undecided = result.undecided.reduce((sum, entry) => sum + entry.ruleIds.length, 0); + stats.requests = result.requests; + stats.undecided = undecided; + stats.answered = Math.max(0, applied - undecided); + const lines: string[] = []; + const abstained: SemanticFinding[] = []; + for (const finding of result.findings) { + if (finding.decision === 'uncertain' || finding.decision === 'uncalibrated') { + abstained.push(finding); + if (finding.decision === 'uncertain') { + stats.uncertain += 1; + } else { + stats.uncalibrated += 1; + } + continue; + } + stats.findings += 1; + lines.push(formatSemanticFinding(finding)); + } + if (abstained.length > 0) { + lines.push(`${PREFIX} semantic abstained on ${abstained.length} rule answer(s) (${uniqueRuleIds(abstained).join(', ')}); no action needed`); + } + return { lines, stats, findings: result.findings }; +} + +export function logFields(input: HookInput, hook: string, decision: HookDecision, result: HookResult): Omit { + const semantic = result.semantic; + return { + hook, + event: input.hook_event_name, + agentType: input.agent_type ?? null, + tool: input.tool_name ?? null, + path: result.path ?? null, + ruleIds: result.ruleIds ?? [], + decision, + ...(semantic ? { semantic } : {}), + }; +} diff --git a/scripts/hooks/lib/hook-io.ts b/scripts/hooks/lib/hook-io.ts new file mode 100644 index 0000000..b9fd22d --- /dev/null +++ b/scripts/hooks/lib/hook-io.ts @@ -0,0 +1,179 @@ +import { z } from 'zod'; + +/** + * Stdin contract of the Claude Code hook events this plugin subscribes to + * (hooks.md, "Common input fields" plus the per-event tables). Unknown + * fields are kept so a newer Claude Code never breaks parsing. + */ +export const HookInputSchema = z + .object({ + hook_event_name: z.string(), + session_id: z.string().default('unknown'), + cwd: z.string().optional(), + transcript_path: z.string().optional(), + tool_name: z.string().optional(), + tool_input: z.json().optional(), + tool_response: z.json().optional(), + tool_use_id: z.string().optional(), + agent_id: z.string().optional(), + agent_type: z.string().optional(), + stop_hook_active: z.boolean().optional(), + last_assistant_message: z.string().optional(), + }) + .loose(); + +export type HookInput = z.infer; + +export type ParsedHookInput = { ok: true; input: HookInput } | { ok: false; error: string }; + +export function parseHookInput(raw: string): ParsedHookInput { + if (raw.trim() === '') { + return { ok: false, error: 'empty stdin' }; + } + let json: unknown; + try { + json = JSON.parse(raw); + } catch (error) { + return { ok: false, error: `stdin is not JSON: ${error instanceof Error ? error.message : String(error)}` }; + } + const parsed = HookInputSchema.safeParse(json); + if (!parsed.success) { + return { ok: false, error: `stdin does not match the hook input contract: ${parsed.error.issues.map((issue) => issue.message).join('; ')}` }; + } + return { ok: true, input: parsed.data }; +} + +const WriteInputSchema = z.object({ file_path: z.string().min(1), content: z.string() }).loose(); +const EditInputSchema = z + .object({ file_path: z.string().min(1), old_string: z.string(), new_string: z.string(), replace_all: z.boolean().default(false) }) + .loose(); +const AgentResponseSchema = z.object({ status: z.string().optional(), agentId: z.string().optional() }).loose(); + +export type WriteInput = z.infer; +export type EditInput = z.infer; +export type FileToolInput = { tool: 'Write'; input: WriteInput } | { tool: 'Edit'; input: EditInput }; + +export function fileToolInput(hookInput: HookInput): FileToolInput | null { + if (hookInput.tool_name === 'Write') { + const parsed = WriteInputSchema.safeParse(hookInput.tool_input); + return parsed.success ? { tool: 'Write', input: parsed.data } : null; + } + if (hookInput.tool_name === 'Edit') { + const parsed = EditInputSchema.safeParse(hookInput.tool_input); + return parsed.success ? { tool: 'Edit', input: parsed.data } : null; + } + return null; +} + +export function agentToolResponse(hookInput: HookInput): { status: string | undefined; agentId: string | undefined } | null { + if (hookInput.tool_name !== 'Agent') { + return null; + } + const parsed = AgentResponseSchema.safeParse(hookInput.tool_response); + return parsed.success ? { status: parsed.data.status, agentId: parsed.data.agentId } : null; +} + +export type PermissionDecision = 'allow' | 'deny' | 'ask'; + +export interface PreToolUseOutput { + hookSpecificOutput: { + hookEventName: 'PreToolUse'; + permissionDecision?: PermissionDecision; + permissionDecisionReason?: string; + additionalContext?: string; + }; +} + +export interface ContextOutput { + hookSpecificOutput: { hookEventName: 'PostToolUse' | 'SubagentStart' | 'SubagentStop'; additionalContext: string }; +} + +export interface BlockOutput { + decision: 'block'; + reason: string; +} + +export interface SystemMessageOutput { + systemMessage: string; +} + +export type HookOutput = PreToolUseOutput | ContextOutput | BlockOutput | SystemMessageOutput; + +export function denyOutput(reason: string): PreToolUseOutput { + return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }; +} + +/** Advisory PreToolUse output: no permission decision, so the normal permission flow still applies. */ +export function preToolUseContextOutput(additionalContext: string): PreToolUseOutput { + return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext } }; +} + +export function contextOutput(hookEventName: ContextOutput['hookSpecificOutput']['hookEventName'], additionalContext: string): ContextOutput { + return { hookSpecificOutput: { hookEventName, additionalContext } }; +} + +export function blockOutput(reason: string): BlockOutput { + return { decision: 'block', reason }; +} + +export function systemMessageOutput(systemMessage: string): SystemMessageOutput { + return { systemMessage }; +} + +/** + * Strings that must never reach stdout, stderr or the JSONL log: the API key + * values and the raw bodies handed to the hook (file contents, replacements). + * Bodies shorter than the evidence cap are not tracked because a finding's + * evidence line legitimately quotes up to 160 characters of the file. + */ +export interface ForbiddenOutput { + secrets: string[]; + bodies: string[]; +} + +export const BODY_LEAK_MIN_LENGTH = 200; + +export function secretsFromEnv(env: Record): string[] { + return [env.CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY, env.TYPESAFE_API_KEY].filter((value): value is string => value !== undefined && value !== ''); +} + +export function forbiddenOutput(env: Record, bodies: Array): ForbiddenOutput { + return { + secrets: secretsFromEnv(env), + bodies: bodies.filter((body): body is string => body !== undefined && body.length >= BODY_LEAK_MIN_LENGTH), + }; +} + +export type LeakKind = 'secret' | 'body'; + +function escapedForms(value: string): string[] { + const escaped = JSON.stringify(value).slice(1, -1); + return escaped === value ? [value] : [value, escaped]; +} + +/** Checks the raw value and its JSON-escaped form, because the output is serialized JSON. */ +export function findLeak(text: string, forbidden: ForbiddenOutput): LeakKind | null { + for (const secret of forbidden.secrets) { + if (escapedForms(secret).some((form) => text.includes(form))) { + return 'secret'; + } + } + for (const body of forbidden.bodies) { + if (escapedForms(body).some((form) => text.includes(form))) { + return 'body'; + } + } + return null; +} + +export function serializeOutput(output: HookOutput): string { + return `${JSON.stringify(output)}\n`; +} + +export async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} diff --git a/scripts/hooks/lib/runner.ts b/scripts/hooks/lib/runner.ts new file mode 100644 index 0000000..4b727e2 --- /dev/null +++ b/scripts/hooks/lib/runner.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { appendHookLog, type HookDecision, type HookLogEntry } from '../../lib/hook-log.ts'; +import { resolveDataDir } from '../../lib/session-store.ts'; +import { PREFIX, logFields, type HookContext, type HookHandler, type HookResult } from './hook-common.ts'; +import { findLeak, forbiddenOutput, parseHookInput, readStdin, serializeOutput } from './hook-io.ts'; + +export interface HookIo { + stdout: (text: string) => void; + stderr: (text: string) => void; +} + +function pluginVersion(pluginRoot: string): string { + try { + const parsed: unknown = JSON.parse(readFileSync(resolve(pluginRoot, 'package.json'), 'utf8')); + if (typeof parsed === 'object' && parsed !== null && 'version' in parsed && typeof parsed.version === 'string') { + return parsed.version; + } + } catch { + void 0; + } + return 'unknown'; +} + +function writeLog(context: HookContext, entry: HookLogEntry, secrets: string[], io: HookIo): void { + const line = JSON.stringify(entry); + if (secrets.some((secret) => line.includes(secret))) { + io.stderr(`${PREFIX} log line suppressed: it would contain a secret\n`); + return; + } + try { + appendHookLog(resolveDataDir(context.env), entry); + } catch (error) { + io.stderr(`${PREFIX} could not append the hook log: ${error instanceof Error ? error.message : String(error)}\n`); + } +} + +/** + * Runs one hook end to end: parse stdin, run the handler, guard the output + * against secrets and raw bodies, log the decision. Always exits 0: a hook + * that fails must never block the agent, only stay silent. + */ +export async function executeHook(hook: string, handler: HookHandler, raw: string, context: HookContext, io: HookIo): Promise { + const now = context.now ?? Date.now; + const started = now(); + const parsed = parseHookInput(raw); + if (!parsed.ok) { + io.stderr(`${PREFIX} ${hook}: ${parsed.error} (ignored)\n`); + return 0; + } + const input = parsed.input; + let result: HookResult; + try { + result = await handler(input, context); + } catch (error) { + io.stderr(`${PREFIX} ${hook}: ${error instanceof Error ? error.message : String(error)} (fail-open)\n`); + result = { output: null, decision: 'error' }; + } + if (result.stderr !== undefined && result.stderr !== '') { + io.stderr(`${result.stderr}\n`); + } + + const forbidden = forbiddenOutput(context.env, result.bodies ?? []); + let decision: HookDecision = result.decision; + if (result.output !== null) { + const text = serializeOutput(result.output); + const leak = findLeak(text, forbidden); + if (leak === null) { + io.stdout(text); + } else { + io.stderr(`${PREFIX} ${hook}: output suppressed because it would contain a ${leak === 'secret' ? 'secret' : 'raw file body'}\n`); + decision = 'error'; + } + } + + const entry: HookLogEntry = { + ts: new Date(now()).toISOString(), + ...logFields(input, hook, decision, result), + latencyMs: Math.max(0, now() - started), + binarySource: context.env.NESTJS_HEXAGONAL_BINARY_SOURCE ?? null, + version: pluginVersion(context.pluginRoot), + }; + writeLog(context, entry, forbidden.secrets, io); + return 0; +} + +export function isMainModule(moduleUrl: string): boolean { + const entry = process.argv[1]; + if (entry === undefined) { + return false; + } + return moduleUrl === pathToFileURL(isAbsolute(entry) ? entry : resolve(entry)).href; +} + +export async function runHookMain(hook: string, handler: HookHandler, moduleUrl: string): Promise { + if (!isMainModule(moduleUrl)) { + return; + } + const pluginRoot = dirname(dirname(dirname(fileURLToPath(moduleUrl)))); + const raw = await readStdin(); + const io: HookIo = { stdout: (text) => void process.stdout.write(text), stderr: (text) => void process.stderr.write(text) }; + process.exitCode = await executeHook(hook, handler, raw, { env: process.env, cwd: process.cwd(), pluginRoot }, io); +} diff --git a/scripts/hooks/post-tool-use.ts b/scripts/hooks/post-tool-use.ts new file mode 100644 index 0000000..d0534eb --- /dev/null +++ b/scripts/hooks/post-tool-use.ts @@ -0,0 +1,91 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { projectSources } from '../lib/project-files.ts'; +import { emptySession } from '../lib/session-store.ts'; +import { runStaticRules } from '../lib/static-engine.ts'; +import { + PREFIX, + apiKey, + formatStaticFinding, + isPluginAgent, + loadRulebook, + projectDir, + relativeProjectPath, + rulesInScope, + semanticAdvisory, + sessionStore, + skip, + staticRules, + uniqueRuleIds, + type HookHandler, + type HookResult, +} from './lib/hook-common.ts'; +import { contextOutput, fileToolInput } from './lib/hook-io.ts'; +import { runHookMain } from './lib/runner.ts'; + +/** Advisory bytes one agent receives per session before the hook falls back to a one-line notice. */ +export const ADVISORY_BYTE_CAP = 8 * 1024; +export const MAIN_THREAD_AGENT_ID = 'main'; +const SEMANTIC_CONCURRENCY = 4; + +export const handler: HookHandler = async (input, context) => { + const tool = fileToolInput(input); + if (tool === null) { + return skip(); + } + const project = projectDir(input, context); + const path = relativeProjectPath(project, tool.input.file_path); + if (path === null) { + return skip(); + } + const loaded = loadRulebook(input, context); + if (!loaded.ok) { + return { ...skip(), stderr: `${PREFIX} post-tool-use: ${loaded.reason}` }; + } + const store = sessionStore(context); + const agentId = input.agent_id ?? MAIN_THREAD_AGENT_ID; + const now = context.now ?? Date.now; + const session = store.update(input.session_id, agentId, (current) => { + const base = current.agentType === 'unknown' ? emptySession(input.agent_type ?? MAIN_THREAD_AGENT_ID, new Date(now()).toISOString(), null) : current; + return base.touchedPaths.includes(path) ? base : { ...base, touchedPaths: [...base.touchedPaths, path] }; + }); + + const composed = loaded.composed; + const inScopeStatic = rulesInScope(staticRules(composed.rules, { external: true }), path); + const semanticEligible = input.agent_id !== undefined && isPluginAgent(input.agent_type); + const key = semanticEligible ? apiKey(context.env) : undefined; + const inScopeSemantic = key === undefined ? [] : rulesInScope(composed.rules.filter((rule) => rule.class === 'semantic'), path); + if (inScopeStatic.length === 0 && inScopeSemantic.length === 0) { + return { output: null, decision: 'silent', path }; + } + if (!existsSync(tool.input.file_path)) { + return { output: null, decision: 'silent', path }; + } + const content = readFileSync(tool.input.file_path, 'utf8'); + const file = { path, content }; + const bodies = [content, tool.tool === 'Write' ? tool.input.content : tool.input.new_string]; + + const staticResult = inScopeStatic.length === 0 ? { findings: [] } : runStaticRules(inScopeStatic, [file], { projectFiles: () => projectSources(project) }); + const lines = staticResult.findings.map(formatStaticFinding); + const result: HookResult = { output: null, decision: 'silent', path, bodies, ruleIds: uniqueRuleIds(staticResult.findings) }; + if (key !== undefined && inScopeSemantic.length > 0) { + const advisory = await semanticAdvisory(composed, [file], key, context, SEMANTIC_CONCURRENCY); + lines.push(...advisory.lines); + result.semantic = advisory.stats; + result.ruleIds = uniqueRuleIds([...staticResult.findings, ...advisory.findings]); + } + if (lines.length === 0) { + return result; + } + if (session.advisoryBytes >= ADVISORY_BYTE_CAP) { + return { + ...result, + decision: 'context', + output: contextOutput('PostToolUse', `${PREFIX} advisory cap reached for this agent in this session; run nestjs-hexagonal-check --files ${path} for the findings`), + }; + } + const text = [`${PREFIX} ${lines.length} finding(s) in ${path}:`, ...lines].join('\n'); + store.update(input.session_id, agentId, (current) => ({ ...current, advisoryBytes: current.advisoryBytes + Buffer.byteLength(text) })); + return { ...result, decision: 'context', output: contextOutput('PostToolUse', text) }; +}; + +await runHookMain('post-tool-use', handler, import.meta.url); diff --git a/scripts/hooks/pre-tool-use.ts b/scripts/hooks/pre-tool-use.ts new file mode 100644 index 0000000..aa04469 --- /dev/null +++ b/scripts/hooks/pre-tool-use.ts @@ -0,0 +1,85 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { runStaticRules, type Finding } from '../lib/static-engine.ts'; +import { + PREFIX, + formatStaticFinding, + isPluginAgent, + loadRulebook, + projectDir, + relativeProjectPath, + rulesInScope, + skip, + staticRules, + uniqueRuleIds, + type HookHandler, +} from './lib/hook-common.ts'; +import { denyOutput, fileToolInput, preToolUseContextOutput, type FileToolInput } from './lib/hook-io.ts'; +import { runHookMain } from './lib/runner.ts'; + +export const DENY_REASON_MAX_FINDINGS = 3; + +/** The file content the tool call would produce, or null when it cannot be computed (the tool itself will fail). */ +export function resultingContent(tool: FileToolInput, absolutePath: string): string | null { + if (tool.tool === 'Write') { + return tool.input.content; + } + if (!existsSync(absolutePath)) { + return null; + } + const current = readFileSync(absolutePath, 'utf8'); + const { old_string: oldString, new_string: newString, replace_all: replaceAll } = tool.input; + if (oldString === '' || !current.includes(oldString)) { + return null; + } + if (replaceAll) { + return current.split(oldString).join(newString); + } + const index = current.indexOf(oldString); + return `${current.slice(0, index)}${newString}${current.slice(index + oldString.length)}`; +} + +export function denyReason(fails: Finding[]): string { + const shown = fails.slice(0, DENY_REASON_MAX_FINDINGS).map(formatStaticFinding); + const more = fails.length > DENY_REASON_MAX_FINDINGS ? [`${PREFIX} ${fails.length - DENY_REASON_MAX_FINDINGS} more FAIL finding(s) in the same file`] : []; + return [`${PREFIX} this write would introduce ${fails.length} static FAIL finding(s); the file was not written:`, ...shown, ...more].join('\n'); +} + +export const handler: HookHandler = async (input, context) => { + if (!isPluginAgent(input.agent_type)) { + return skip(); + } + const tool = fileToolInput(input); + if (tool === null) { + return skip(); + } + const project = projectDir(input, context); + const path = relativeProjectPath(project, tool.input.file_path); + if (path === null) { + return skip(); + } + const loaded = loadRulebook(input, context); + if (!loaded.ok) { + return { ...skip(), stderr: `${PREFIX} pre-tool-use: ${loaded.reason}` }; + } + const rules = rulesInScope(staticRules(loaded.composed.rules, { external: false }), path); + if (rules.length === 0) { + return { ...skip(), path }; + } + const content = resultingContent(tool, tool.input.file_path); + if (content === null) { + return { ...skip(), path }; + } + const bodies = [content, tool.tool === 'Write' ? tool.input.content : tool.input.new_string]; + const { findings } = runStaticRules(rules, [{ path, content }]); + const fails = findings.filter((finding) => finding.severity === 'FAIL'); + if (fails.length > 0) { + return { output: denyOutput(denyReason(fails)), decision: 'deny', ruleIds: uniqueRuleIds(fails), path, bodies }; + } + if (findings.length > 0) { + const text = [`${PREFIX} ${findings.length} advisory WARN finding(s) in ${path}:`, ...findings.map(formatStaticFinding)].join('\n'); + return { output: preToolUseContextOutput(text), decision: 'context', ruleIds: uniqueRuleIds(findings), path, bodies }; + } + return { output: null, decision: 'silent', path, bodies }; +}; + +await runHookMain('pre-tool-use', handler, import.meta.url); diff --git a/scripts/hooks/subagent-start.ts b/scripts/hooks/subagent-start.ts new file mode 100644 index 0000000..6760bc3 --- /dev/null +++ b/scripts/hooks/subagent-start.ts @@ -0,0 +1,82 @@ +import { gitHead } from '../lib/project-files.ts'; +import type { Rule } from '../lib/rulebook.schema.ts'; +import { emptySession } from '../lib/session-store.ts'; +import { PREFIX, isPluginAgent, loadRulebook, projectDir, sessionStore, skip, type HookHandler } from './lib/hook-common.ts'; +import { contextOutput } from './lib/hook-io.ts'; +import { runHookMain } from './lib/runner.ts'; + +export const CONTEXT_TOKEN_BUDGET = 1_500; +const ALL_LAYERS = ['domain', 'application', 'infrastructure', 'presentation']; + +/** Rulebook layers an agent works in; agents outside the create-subdomain pipeline get every layer. */ +export function layersForAgent(agentType: string): string[] { + const name = agentType.slice(agentType.indexOf(':') + 1); + switch (name) { + case 'domain-agent': + return ['domain']; + case 'application-agent': + return ['application']; + case 'infrastructure-agent': + case 'presentation-agent': + case 'broadcasting-agent': + case 'listener-agent': + return ['infrastructure', 'presentation']; + default: + return ALL_LAYERS; + } +} + +function estimateTextTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function ruleLine(rule: Rule): string { + const fix = rule.severity === 'FAIL' ? ` fix: ${rule.fix}` : ''; + return `- ${rule.id} (${rule.class}, ${rule.severity}): ${rule.title}.${fix}`; +} + +export function composeSliceContext(rulebookId: string, rulebookVersion: string, agentType: string, rules: Rule[]): { text: string; ruleIds: string[] } { + const layers = layersForAgent(agentType); + const selected = rules.filter((rule) => rule.class !== 'runtime' && (rule.layer === 'any' || layers.includes(rule.layer))); + const ordered = [...selected.filter((rule) => rule.severity === 'FAIL'), ...selected.filter((rule) => rule.severity === 'WARN')]; + const header = [ + `${PREFIX} This project opted in to the rulebook ${rulebookId} ${rulebookVersion}. The rules below apply to the ${layers.join(' and ')} layer(s) this agent writes.`, + 'A static FAIL denies the Write or Edit that introduces it and blocks the Stop of this agent until the listed files are fixed; WARN and semantic rules are advisory.', + ]; + const footer = 'If the SubagentStop hook blocks the stop, fix the files listed in its reason and finish again.'; + const lines: string[] = []; + let omitted = 0; + for (const rule of ordered) { + const candidate = [...header, ...lines, ruleLine(rule), footer].join('\n'); + if (estimateTextTokens(candidate) > CONTEXT_TOKEN_BUDGET) { + omitted += 1; + continue; + } + lines.push(ruleLine(rule)); + } + if (omitted > 0) { + lines.push(`- (${omitted} more rule(s) omitted for length; run nestjs-hexagonal-check --explain for the full list)`); + } + return { text: [...header, ...lines, footer].join('\n'), ruleIds: ordered.slice(0, lines.length - (omitted > 0 ? 1 : 0)).map((rule) => rule.id) }; +} + +export const handler: HookHandler = async (input, context) => { + if (!isPluginAgent(input.agent_type)) { + return skip(); + } + const loaded = loadRulebook(input, context); + if (!loaded.ok) { + return { ...skip(), stderr: `${PREFIX} subagent-start: ${loaded.reason}` }; + } + const agentType = input.agent_type; + if (input.agent_id !== undefined) { + const now = context.now ?? Date.now; + const project = projectDir(input, context); + const session = emptySession(agentType, new Date(now()).toISOString(), gitHead(project)); + sessionStore(context).update(input.session_id, input.agent_id, () => session); + } + const { text, ruleIds } = composeSliceContext(loaded.composed.rulebook.id, loaded.composed.rulebook.version, agentType, loaded.composed.rules); + return { output: contextOutput('SubagentStart', text), decision: 'context', ruleIds }; +}; + +await runHookMain('subagent-start', handler, import.meta.url); diff --git a/scripts/hooks/subagent-stop.ts b/scripts/hooks/subagent-stop.ts new file mode 100644 index 0000000..91c726d --- /dev/null +++ b/scripts/hooks/subagent-stop.ts @@ -0,0 +1,100 @@ +import { existsSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { changedFilesSince, projectSources, readSources } from '../lib/project-files.ts'; +import { isInScope } from '../lib/scope.ts'; +import { emptySession, type AgentSession } from '../lib/session-store.ts'; +import { runStaticRules, type Finding } from '../lib/static-engine.ts'; +import { + PREFIX, + apiKey, + formatStaticFinding, + isPluginAgent, + loadRulebook, + projectDir, + semanticAdvisory, + sessionStore, + skip, + staticRules, + toUnresolved, + uniqueRuleIds, + type HookHandler, +} from './lib/hook-common.ts'; +import { blockOutput, systemMessageOutput } from './lib/hook-io.ts'; +import { runHookMain } from './lib/runner.ts'; + +/** + * Claude Code ends a subagent after 8 consecutive blocks; this plugin releases + * earlier so an agent that cannot satisfy a rule never burns the whole budget, + * and the residual report reaches the parent through the Agent PostToolUse hook. + */ +export const MAX_BLOCKS = 2; +export const REASON_MAX_FINDINGS = 20; +const SEMANTIC_CONCURRENCY = 8; + +export function touchedFiles(session: AgentSession, project: string): string[] { + const fromGit = changedFilesSince(session.headSha ?? 'HEAD', project) ?? []; + const union = new Set([...session.touchedPaths, ...fromGit]); + return [...union].filter((path) => existsSync(resolve(project, path)) && statSync(resolve(project, path)).isFile()).sort(); +} + +export function blockReason(agentType: string, fails: Finding[], advisory: string[]): string { + const shown = fails.slice(0, REASON_MAX_FINDINGS).map(formatStaticFinding); + const more = fails.length > REASON_MAX_FINDINGS ? [`${PREFIX} ${fails.length - REASON_MAX_FINDINGS} more FAIL finding(s)`] : []; + return [ + `${PREFIX} ${fails.length} static FAIL finding(s) remain in files ${agentType} touched. Fix them, then finish again:`, + ...shown, + ...more, + ...(advisory.length > 0 ? [`${PREFIX} advisory (semantic, not blocking):`, ...advisory] : []), + ].join('\n'); +} + +export function releaseMessage(agentType: string, fails: Finding[]): string { + const summary = fails.map((finding) => `${finding.ruleId} ${finding.path}${finding.line === undefined ? '' : `:${finding.line}`}`).join(', '); + return `nestjs-hexagonal: ${MAX_BLOCKS} blocks reached, releasing ${agentType} with unresolved FAILs: ${summary}`; +} + +export const handler: HookHandler = async (input, context) => { + if (!isPluginAgent(input.agent_type) || input.agent_id === undefined) { + return skip(); + } + const loaded = loadRulebook(input, context); + if (!loaded.ok) { + return { ...skip(), stderr: `${PREFIX} subagent-stop: ${loaded.reason}` }; + } + const agentType = input.agent_type; + const agentId = input.agent_id; + const composed = loaded.composed; + const project = projectDir(input, context); + const store = sessionStore(context); + const now = context.now ?? Date.now; + const session = store.read(input.session_id, agentId) ?? emptySession(agentType, new Date(now()).toISOString(), null); + + const paths = touchedFiles(session, project).filter((path) => composed.rules.some((rule) => rule.class !== 'runtime' && isInScope(rule.scope, path))); + if (paths.length === 0) { + store.update(input.session_id, agentId, (current) => ({ ...current, unresolved: [] })); + return { output: null, decision: 'silent' }; + } + const files = readSources(paths, project); + const bodies = files.map((file) => file.content); + const staticResult = runStaticRules(staticRules(composed.rules, { external: true }), files, { projectFiles: () => projectSources(project) }); + const fails = staticResult.findings.filter((finding) => finding.severity === 'FAIL'); + if (fails.length === 0) { + store.update(input.session_id, agentId, (current) => ({ ...current, unresolved: [] })); + return { output: null, decision: 'silent', ruleIds: uniqueRuleIds(staticResult.findings), bodies }; + } + + const key = apiKey(context.env); + const advisory = key === undefined ? null : await semanticAdvisory(composed, files, key, context, SEMANTIC_CONCURRENCY); + const ruleIds = uniqueRuleIds([...fails, ...(advisory?.findings ?? [])]); + const semantic = advisory ? { semantic: advisory.stats } : {}; + const unresolved = fails.map(toUnresolved); + + if (session.blocks < MAX_BLOCKS) { + store.update(input.session_id, agentId, (current) => ({ ...current, blocks: current.blocks + 1, unresolved })); + return { output: blockOutput(blockReason(agentType, fails, advisory?.lines ?? [])), decision: 'block', ruleIds, bodies, ...semantic }; + } + store.update(input.session_id, agentId, (current) => ({ ...current, unresolved })); + return { output: systemMessageOutput(releaseMessage(agentType, fails)), decision: 'release', ruleIds, bodies, ...semantic }; +}; + +await runHookMain('subagent-stop', handler, import.meta.url); diff --git a/scripts/run.sh b/scripts/run.sh index 9578273..2dfd4be 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -2,8 +2,8 @@ # Entry point for the nestjs-hexagonal-check CLI and for the plugin hooks. # Pure shell until the gate decides that a runtime is needed. # -# run.sh --hook [args] hook mode: opt-in gate, path containment, -# then check.ts --hook with stdin forwarded +# run.sh --hook hook mode: opt-in gate, path containment, +# then scripts/hooks/.ts with stdin forwarded # run.sh [args] CLI mode: forwards to check.ts set -u @@ -34,8 +34,17 @@ self_script=$(real_path "$0") self_root=$(real_dir "$(dirname "$self_script")/..") hook_mode=0 +hook_name="" if [ "${1:-}" = "--hook" ]; then hook_mode=1 + hook_name=${2:-} + case $hook_name in + subagent-start|pre-tool-use|post-tool-use|subagent-stop|agent-post-tool-use) ;; + *) + echo "nestjs-hexagonal-check: unknown hook '$hook_name' (skipping)" >&2 + exit 0 + ;; + esac fi if [ "${NESTJS_HEXAGONAL_DISABLE:-}" = "1" ]; then @@ -92,8 +101,14 @@ if [ -x "$project_bin" ] && [ "$(real_path "$project_bin")" != "$self_script" ]; exec "$project_bin" "$@" fi -export NESTJS_HEXAGONAL_BINARY_SOURCE=plugin-root -check_script="$self_root/scripts/check.ts" +# An installed copy execs this script with the source already set by the caller. +export NESTJS_HEXAGONAL_BINARY_SOURCE="${NESTJS_HEXAGONAL_BINARY_SOURCE:-plugin-root}" +if [ "$hook_mode" -eq 1 ]; then + check_script="$self_root/scripts/hooks/$hook_name.ts" + set -- +else + check_script="$self_root/scripts/check.ts" +fi deps_found=0 probe_dir=$self_root From dd7ea05edafe1a7c23b244bc8daeb48e13a82e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Sun, 20 Sep 2026 19:18:42 -0300 Subject: [PATCH 3/6] docs: hooks, disclosure, local development and onboarding --- CLAUDE.md | 12 +++++++++++- README.md | 42 ++++++++++++++++++++++++++++++++++++++---- calibration/README.md | 9 +++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f9ab04..544f25e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,17 @@ Compatible with GSD workflow. ## Rulebook (machine-readable rules) -`rulebooks/hexagonal.rulebook.yaml` encodes the rules above; `scripts/check.ts` (entry `scripts/run.sh`, bin `nestjs-hexagonal-check`) runs the static ones offline and, with `--classes semantic` and `TYPESAFE_API_KEY`, asks Jev the semantic ones (`scripts/lib/{jev-client,state-builder,decide,semantic-engine}.ts`). Runtime rules are still inert. Projects opt in with `.claude/rulebook.yaml` (`extends` with sha256 stamps, own rules, overrides by id); `NESTJS_HEXAGONAL_DISABLE=1` turns everything off. +`rulebooks/hexagonal.rulebook.yaml` encodes the rules above; `scripts/check.ts` (entry `scripts/run.sh`, bin `nestjs-hexagonal-check`) runs the static ones offline and, with `--classes semantic` and `TYPESAFE_API_KEY`, asks Jev the semantic ones (`scripts/lib/{jev-client,state-builder,decide,semantic-engine}.ts`). Runtime rules are still inert. Projects opt in with `.claude/rulebook.yaml` (`extends` with sha256 stamps, own rules, overrides by id); `NESTJS_HEXAGONAL_DISABLE=1` turns everything off. The same rulebook drives the hooks in `hooks/hooks.json` (`scripts/hooks/*.ts`, shared code in `scripts/hooks/lib/`, state in `scripts/lib/session-store.ts`, log in `scripts/lib/hook-log.ts`). + +| Hook | Matcher | Gate | Effect (v1) | +|---|---|---|---| +| `SubagentStart` | `^nestjs-hexagonal:.*` | opt-in project, plugin agent | rulebook slice of the agent's layer as `additionalContext` (<= 1,500 tokens); records HEAD sha and start time | +| `PreToolUse` | `Write\|Edit` | plugin agent, path in project and in a static scope | static FAIL -> `permissionDecision: deny` (3 findings max); WARN -> `additionalContext`, no permission decision; no network, no `external` checks | +| `PostToolUse` | `Write\|Edit` | any agent (path recorded per `agent_id`) | static for all; semantic only for plugin agent with key; `additionalContext` only with findings, 8 KB cap per agent and session | +| `SubagentStop` | the six pipeline agents | plugin agent | files = store paths + `git diff`/untracked since start; static FAIL -> `decision: block` listing only touched files (semantic lines advisory); after 2 blocks -> release with `systemMessage` | +| `PostToolUse` | `Agent` | completed plugin subagent | unresolved FAILs of that `agentId` from the store as `additionalContext` | + +Every hook runs through `scripts/run.sh --hook `, exits 0 whatever happens, never prints the key or a raw file body, and appends one line to `$CLAUDE_PLUGIN_DATA/logs/hooks-YYYYMMDD.jsonl` (`check.ts export-logs --since ` aggregates it). The key comes from `CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY` (plugin `userConfig`) or `TYPESAFE_API_KEY`; the plugin ships `defaultEnabled: false`. Semantic decisions (`scripts/lib/decide.ts`), per rule and per answer: diff --git a/README.md b/README.md index 9ce99c9..d8f0dcf 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,12 @@ This plugin provides layer-specific skills, specialized agents, and workflow orc ### Local development ```bash +cd /path/to/nestjs-hexagonal && bun install # --plugin-dir does not get the automatic dependency install claude --plugin-dir /path/to/nestjs-hexagonal ``` +Claude Code installs the Node dependencies of a plugin it copies into its cache (marketplace install), but a plugin loaded in place with `--plugin-dir` or from a local-directory marketplace keeps its source directory as `CLAUDE_PLUGIN_ROOT` and gets no install. Without `node_modules` the hooks fail open (one actionable line on stderr, exit 0) and the CLI exits 1. To test the hooks the way a user gets them, bump `version`, run `/plugin update` and `/reload-plugins` so the copy in the cache is the one that runs. + ## Skills ### Layer Skills @@ -167,7 +170,7 @@ The `sha256` stamp pins the content of the base rulebook the project was calibra ### Opt-in gate and kill switch -`scripts/run.sh` is the single entry point for the CLI and for the plugin hooks (hooks ship in a later version). In hook mode (`--hook`) it decides in pure shell, before starting any runtime: +`scripts/run.sh` is the single entry point for the CLI and for the plugin hooks. In hook mode (`--hook `) it decides in pure shell, before starting any runtime: 1. no `.claude/rulebook.yaml` in `$CLAUDE_PROJECT_DIR` and no `NESTJS_HEXAGONAL_RULEBOOK` pointing at an existing file: exit 0 with no output (the plugin is inert for projects that did not opt in); 2. `NESTJS_HEXAGONAL_DISABLE=1`: exit 0 (kill switch, also honoured by the CLI); @@ -175,15 +178,46 @@ The `sha256` stamp pins the content of the base rulebook the project was calibra 4. the project's own `node_modules/.bin/nestjs-hexagonal-check` is preferred when present, so the version pinned in the project's lockfile is the one that runs; otherwise the plugin's `scripts/check.ts`; 5. missing `node_modules` (plugin loaded in place, or a failed install): an actionable message on stderr and exit 0 in hook mode, exit 1 in CLI mode. -The runtime is `bun`; when it is absent the script falls back to `node --experimental-strip-types`. +The runtime is `bun`; when it is absent the script falls back to `node --experimental-strip-types`. In hook mode the script then runs `scripts/hooks/.ts` with stdin forwarded; the JSONL log records which copy ran (`binarySource: node_modules | plugin-root`). + +### Hooks + +`hooks/hooks.json` subscribes to four events. Every handler goes through `run.sh --hook `, so the opt-in gate, the path containment, the single execution source and the fail-open above apply to all of them. **In this version only a static FAIL blocks anything**; semantic answers are advisory text, and nothing blocks on `uncertain` or `uncalibrated`. + +| Hook | Fires for | What it does | Output | Budget | +|---|---|---|---|---| +| `SubagentStart` `^nestjs-hexagonal:.*` | plugin subagents | records the session start (HEAD sha, timestamp) and injects the slice of the composed rulebook for the agent's layer: rule ids, titles, severity, the `fix` of FAIL rules | `additionalContext` (at most 1,500 tokens) | 300 ms, no network | +| `PreToolUse` `Write\|Edit` | plugin subagents, path inside the project and inside the scope of a static rule | computes the content the call would produce (Edit applies `old_string` to `new_string`, honouring `replace_all`) and runs the static rules that need only that file (the two project-wide `external` checks run later) | static FAIL: `permissionDecision: deny` with rule id, evidence and fix (3 findings at most); WARN: `additionalContext` with no permission decision; pass: nothing | p95 1.5 s, no network | +| `PostToolUse` `Write\|Edit` | any agent | records the path per `agent_id`; static rules for everyone; semantic rules only for a plugin subagent with a key; `additionalContext` only when there is a finding, capped at 8 KB per agent and session (then a one-line notice) | `additionalContext` | p95 2 s | +| `SubagentStop` (the six pipeline agents) | plugin subagents | touched files = paths recorded in the session store plus `git diff --name-only` and untracked files since the recorded HEAD; full static run on them; a static FAIL blocks with `decision: block` and a `reason` listing only files this agent touched (semantic advisory lines appended when a key is present, at most 8 concurrent requests); the block counter lives in the session store and after 2 blocks the agent is released with a `systemMessage` listing the unresolved FAILs | `{ decision: "block", reason }`, or silent | 10 s | +| `PostToolUse` `Agent` | parent of a completed plugin subagent | reads the residual report of that `agentId` from the session store and returns the unresolved FAILs to the orchestrator | `additionalContext` | 200 ms | + +Why release after 2 blocks: Claude Code ends a subagent after 8 consecutive stop-hook blocks, and the `reason` becomes the subagent's next instruction. An agent that cannot satisfy a rule would otherwise burn the whole budget; releasing earlier keeps the unresolved list visible to the parent through the `Agent` hook. `stop_hook_active` is ignored for the counter because it is already `true` on the first continuation. Note that as of Claude Code v2.1.198 subagents run in the background by default, in which case the `Agent` PostToolUse hook fires at launch (`status: async_launched`) and stays silent; the release message still reaches the user as a `systemMessage`. + +State lives under `$CLAUDE_PLUGIN_DATA` (`~/.claude/plugins/data//`, or a temp directory when the variable is absent): `sessions//.json` (touched paths, block counter, advisory bytes, unresolved findings; written atomically with a lock and garbage-collected after 24 h), `logs/hooks-YYYYMMDD.jsonl` (one line per decision: event, agent type, tool, path relative to the project, rule ids, decision, latency, `binarySource`, plugin version, semantic counters; never code nor the key) and the Jev cache, log and circuit breaker described below. `nestjs-hexagonal-check export-logs --since 2026-09-14 --out weekly.json` aggregates the log: entries, p50/p95 latency per hook, decisions by kind, binary sources, uncertain and uncalibrated rates. + +### Disclosure + +- **What is sent:** with a key present, one request per file and state slice containing the rule preamble, the file path, the layer, the slice name and the code of that slice plus the rulebook questions. The whole file is sent only when a rule declares `slice: file`. The key travels in the `Authorization` header and never appears in a hook output, a reason, the JSONL log or the cache; the hooks refuse to print any output that would contain the key or a raw file body. +- **When:** only if all three hold: the project opted in with `.claude/rulebook.yaml` (or `NESTJS_HEXAGONAL_RULEBOOK`), the hook fires inside a plugin subagent (`agent_type` prefixed `nestjs-hexagonal:`, with an `agent_id`), and a key is configured. `PreToolUse` and `SubagentStart` never use the network. Static rules run offline for every agent. +- **To whom:** `https://api.typesafe.ai/v1/systemone`. TypeSafe states it does not train on customer data; zero data retention is only available under an enterprise contract. Treat the code you check as shared with that provider. +- **How to disable:** `NESTJS_HEXAGONAL_DISABLE=1` (everything), remove the project rulebook (all hooks stay silent), or remove the key (static only). The plugin installs disabled (`defaultEnabled: false`); `claude plugin enable nestjs-hexagonal` turns it on. + +### Onboarding another project + +1. Create `.claude/rulebook.yaml` extending `hexagonal` (and `softtor-conventions` only if multi-tenant scoping, no emoji and English identifiers are conventions of that project), stamping each base with `sha256sum rulebooks/.rulebook.yaml` of the installed copy. +2. Set the key, if semantic rules are wanted: answer the `TYPESAFE_API_KEY` prompt when enabling the plugin (stored in the keychain, exported to the hooks as `CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY`) or export `TYPESAFE_API_KEY` in the shell. The option is read first. +3. Pin the CLI in the project so the hooks and the CI run the same version: `bun add -d github:Softtor/nestjs-hexagonal#vX.Y.Z`. The hooks prefer `node_modules/.bin/nestjs-hexagonal-check` when it exists. +4. Run `bunx nestjs-hexagonal-check --files 'src/**/*.ts' --strict` once to see the baseline, and add it to lint-staged or CI. +5. Optional: `NESTJS_HEXAGONAL_RULEBOOK` in `.claude/settings.json` `env` when the rulebook lives elsewhere. ### Semantic checks (Jev) Five rules of the `hexagonal` rulebook are `semantic`: `hex/handler-no-business-rules`, `hex/port-no-infra-leak`, `hex/entity-not-anemic`, `hex/controller-thin` and `hex/no-overengineering`. They are questions that a regex cannot answer, so the CLI asks Jev (`jev-1.13.0`, pinned in the rulebook) and turns the probability into a decision. -- **Enable:** export `TYPESAFE_API_KEY` and pass `--classes static,semantic`. Without the key the semantic rules are skipped with a one-line notice and the exit code is 0; the static rules keep working offline. +- **Enable:** export `TYPESAFE_API_KEY` (or answer the plugin's `TYPESAFE_API_KEY` prompt, which the CLI reads as `CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY`) and pass `--classes static,semantic`. Without the key the semantic rules are skipped with a one-line notice and the exit code is 0; the static rules keep working offline. - **What is sent:** one request per file and state slice, containing the rule preamble, the file path, the layer, the slice name and the code of that slice (the enclosing declaration of the change for handlers and controllers, the whole file for ports, entities and the over-engineering question) plus the rulebook questions. The whole file is sent only when the rule declares `slice: file`. The key travels in the `Authorization` header and never appears in the output, the JSONL log or the cache. -- **When:** only on an explicit `--classes semantic` run. The plugin hooks (a later version) will add the same gate: project opted in with a rulebook, plugin subagent, key present. +- **When:** on an explicit `--classes semantic` run, and in the `PostToolUse` and `SubagentStop` hooks under the gate described in [Disclosure](#disclosure): project opted in with a rulebook, plugin subagent, key present. - **To whom:** `https://api.typesafe.ai/v1/systemone`. TypeSafe states it does not train on customer data; zero data retention is only available under an enterprise contract (`privacy@typesafe.ai`). Treat the code you check as shared with that provider. - **Local state:** with `CLAUDE_PLUGIN_DATA` set, answers are cached under `$CLAUDE_PLUGIN_DATA/cache` (keyed by state, questions, model pin and rulebook version), one JSONL line per call is appended to `$CLAUDE_PLUGIN_DATA/jev.jsonl` (rule ids, answer values, model, latency, tokens, decision; never the code nor the key) and a circuit breaker in `breaker.json` opens for five minutes after three failures in two minutes. - **Decisions:** `deny`, `ask`, `advise`, `pass`, `uncertain` (noul probability inside the abstention band, or choice/score confidence below `minConfidence`) and `uncalibrated` (the response model differs from the pin, or a base rulebook sha256 stamp does not match). `deny` requires fitted thresholds in `calibration/fitted/.json`, produced by the calibration harness from at least 30 good and 30 bad golden cases with precision >= 0.95; without them a rule yields at most `ask` and every finding is marked `calibrated: false`. `--strict` fails only on static FAIL and semantic `deny`. diff --git a/calibration/README.md b/calibration/README.md index 4b85a1d..734f7db 100644 --- a/calibration/README.md +++ b/calibration/README.md @@ -54,6 +54,15 @@ bun test ./calibration/__tests__ # regression spec now runs agai The `calibration` job of `.github/workflows/ci.yml` runs only on push to `main` and on the weekly schedule, with `secrets.TYPESAFE_API_KEY`; it never runs on pull requests (forks do not receive the secret and must not be able to spend it). It uploads `calibration/results` and `calibration/report.md` as workflow artifacts and does not commit; promoting a fitted file is a human decision made in a pull request that includes the results and the report. +## Hook gate verification + +The blocking mechanics of the `SubagentStop` hook were verified against the Claude Code docs (`hooks.md`, "Stop decision control" and "SubagentStop"): `{ "decision": "block", "reason": "..." }` on exit 0 keeps the subagent running and delivers `reason` as its next instruction, `stop_hook_active` is `true` on every continuation, Claude Code ends the loop after 8 consecutive blocks, and context for the parent goes through `PostToolUse` on the `Agent` tool. The unit tests cover the JSON contract; the live behaviour is a manual spike the coordinator runs after the PR is merged into a cached copy of the plugin: + +1. Create a dummy project with `.claude/rulebook.yaml` extending `hexagonal` (stamp with `sha256sum rulebooks/hexagonal.rulebook.yaml`), `bun install` in the plugin, bump `version`, `/plugin update`, `/reload-plugins`. +2. Run `nestjs-hexagonal:create-subdomain` for a small aggregate and, in the `domain-agent` prompt, ask for an `@Injectable()` service under `domain/`. +3. Observe the `PreToolUse` deny (`[plugin:nestjs-hexagonal]` reason with `hex/domain-no-nest-decorators`), then force the file through `Bash` and observe the `SubagentStop` block, the second block and the release `systemMessage` on the third stop. +4. Export the evidence: `nestjs-hexagonal-check export-logs --since --out calibration/experiments/hooks-spike.json` and attach it to the pilot report. + ## Changing a question A question change invalidates the calibration of that rule: bump the rulebook `version`, refresh the sha256 stamp in `rulebooks/project.example.rulebook.yaml`, rerun `run.ts` for the rule and refit. The answer cache is keyed by rulebook version, so stale answers are never reused. From 634533861236eca0b0fa7b520a085b4b24ae0272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Sun, 20 Sep 2026 19:23:04 -0300 Subject: [PATCH 4/6] fix: hook data dir, resume merge, git-scoped stop diff, ls-files sources --- README.md | 2 +- calibration/README.md | 2 +- scripts/__tests__/compose.spec.ts | 32 ++++++++++++++++++- scripts/__tests__/hook-log.spec.ts | 4 +++ .../__tests__/hooks/subagent-start.spec.ts | 11 +++++++ scripts/__tests__/hooks/subagent-stop.spec.ts | 1 + scripts/__tests__/session-store.spec.ts | 14 +++++--- scripts/check.ts | 13 +++++--- scripts/hooks/subagent-start.ts | 5 +-- scripts/hooks/subagent-stop.ts | 7 +++- scripts/lib/project-files.ts | 29 +++++++++++++++-- scripts/lib/session-store.ts | 17 ++++++++-- 12 files changed, 117 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d8f0dcf..6af2258 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ The runtime is `bun`; when it is absent the script falls back to `node --experim Why release after 2 blocks: Claude Code ends a subagent after 8 consecutive stop-hook blocks, and the `reason` becomes the subagent's next instruction. An agent that cannot satisfy a rule would otherwise burn the whole budget; releasing earlier keeps the unresolved list visible to the parent through the `Agent` hook. `stop_hook_active` is ignored for the counter because it is already `true` on the first continuation. Note that as of Claude Code v2.1.198 subagents run in the background by default, in which case the `Agent` PostToolUse hook fires at launch (`status: async_launched`) and stays silent; the release message still reaches the user as a `systemMessage`. -State lives under `$CLAUDE_PLUGIN_DATA` (`~/.claude/plugins/data//`, or a temp directory when the variable is absent): `sessions//.json` (touched paths, block counter, advisory bytes, unresolved findings; written atomically with a lock and garbage-collected after 24 h), `logs/hooks-YYYYMMDD.jsonl` (one line per decision: event, agent type, tool, path relative to the project, rule ids, decision, latency, `binarySource`, plugin version, semantic counters; never code nor the key) and the Jev cache, log and circuit breaker described below. `nestjs-hexagonal-check export-logs --since 2026-09-14 --out weekly.json` aggregates the log: entries, p50/p95 latency per hook, decisions by kind, binary sources, uncertain and uncalibrated rates. +State lives under `$CLAUDE_PLUGIN_DATA` (`~/.claude/plugins/data//`; when the variable is absent the hooks use the marketplace install directory if it exists, else a temp directory): `sessions//.json` (touched paths, block counter, advisory bytes, unresolved findings; written atomically with a lock and garbage-collected after 24 h), `logs/hooks-YYYYMMDD.jsonl` (one line per decision: event, agent type, tool, path relative to the project, rule ids, decision, latency, `binarySource`, plugin version, semantic counters; never code nor the key) and the Jev cache, log and circuit breaker described below. `nestjs-hexagonal-check export-logs --since 2026-09-14 --out weekly.json` aggregates the log: entries, p50/p95 latency per hook, decisions by kind, binary sources, uncertain and uncalibrated rates. A terminal does not receive `CLAUDE_PLUGIN_DATA`, so the command defaults to `~/.claude/plugins/data/nestjs-hexagonal-softtor-nestjs-hexagonal/` (the marketplace install) and accepts `--data-dir` for any other location. ### Disclosure diff --git a/calibration/README.md b/calibration/README.md index 734f7db..4552c38 100644 --- a/calibration/README.md +++ b/calibration/README.md @@ -61,7 +61,7 @@ The blocking mechanics of the `SubagentStop` hook were verified against the Clau 1. Create a dummy project with `.claude/rulebook.yaml` extending `hexagonal` (stamp with `sha256sum rulebooks/hexagonal.rulebook.yaml`), `bun install` in the plugin, bump `version`, `/plugin update`, `/reload-plugins`. 2. Run `nestjs-hexagonal:create-subdomain` for a small aggregate and, in the `domain-agent` prompt, ask for an `@Injectable()` service under `domain/`. 3. Observe the `PreToolUse` deny (`[plugin:nestjs-hexagonal]` reason with `hex/domain-no-nest-decorators`), then force the file through `Bash` and observe the `SubagentStop` block, the second block and the release `systemMessage` on the third stop. -4. Export the evidence: `nestjs-hexagonal-check export-logs --since --out calibration/experiments/hooks-spike.json` and attach it to the pilot report. +4. Export the evidence: `nestjs-hexagonal-check export-logs --since --out calibration/experiments/hooks-spike.json` (add `--data-dir` when the plugin was not installed from the marketplace) and attach it to the pilot report; the p95 of `post-tool-use` is the first number to read, because the two project-wide static checks list the repository through `git ls-files` on every domain or application write. ## Changing a question diff --git a/scripts/__tests__/compose.spec.ts b/scripts/__tests__/compose.spec.ts index c8e2b0b..6f26ba7 100644 --- a/scripts/__tests__/compose.spec.ts +++ b/scripts/__tests__/compose.spec.ts @@ -5,7 +5,7 @@ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { stringify } from 'yaml'; -import { composeRulebook, loadComposedRulebook, RulebookCompositionError, type BaseResolver } from '../lib/compose.ts'; +import { composeRulebook, loadComposedRulebook, RulebookCompositionError, semanticRulebookVersion, type BaseResolver } from '../lib/compose.ts'; import { parseRulebook, type Rulebook } from '../lib/rulebook.schema.ts'; function sha256(text: string): string { @@ -215,6 +215,12 @@ describe('loadComposedRulebook', () => { expect(composed.rulebook.id).toBe('proj'); }); + it('exposes the version of every composed rulebook', () => { + const project = book('proj', [rule('proj/c')], { version: '0.1.0', extends: [{ id: 'base', version: '1.0.0', sha256: sha256(baseText) }] }); + const composed = composeRulebook(project, resolver({ base: { rulebook: base, text: baseText } })); + expect(composed.versions).toEqual({ proj: '0.1.0', base: '1.0.0' }); + }); + it('reports schema errors with the file path', () => { const dir = mkdtempSync(join(tmpdir(), 'rulebook-')); const path = join(dir, 'broken.yaml'); @@ -222,3 +228,27 @@ describe('loadComposedRulebook', () => { expect(() => loadComposedRulebook(path, dir)).toThrow(/broken\.yaml/); }); }); + +describe('semanticRulebookVersion', () => { + const semantic = (id: string) => + rule(id, { + class: 'semantic', + question: { type: 'noul', instructions: 'q' }, + state: { slice: 'file' }, + check: undefined, + }); + const semanticBase = book('base', [semantic('hex/s')]); + + it('is the base version when every semantic rule comes from that base', () => { + const project = book('proj', [rule('proj/c')], { version: '0.1.0', extends: [{ id: 'base', version: '1.0.0', sha256: sha256(baseText) }] }); + const composed = composeRulebook(project, resolver({ base: { rulebook: semanticBase, text: baseText } })); + expect(semanticRulebookVersion(composed)).toBe('1.0.0'); + }); + + it('is the project version when semantic rules come from more than one rulebook, or from none', () => { + const mixed = book('proj', [semantic('proj/s')], { version: '0.1.0', extends: [{ id: 'base', version: '1.0.0', sha256: sha256(baseText) }] }); + expect(semanticRulebookVersion(composeRulebook(mixed, resolver({ base: { rulebook: semanticBase, text: baseText } })))).toBe('0.1.0'); + const none = book('proj', [rule('proj/c')], { version: '0.2.0' }); + expect(semanticRulebookVersion(composeRulebook(none, resolver({})))).toBe('0.2.0'); + }); +}); diff --git a/scripts/__tests__/hook-log.spec.ts b/scripts/__tests__/hook-log.spec.ts index 77f745f..94093d0 100644 --- a/scripts/__tests__/hook-log.spec.ts +++ b/scripts/__tests__/hook-log.spec.ts @@ -115,6 +115,10 @@ describe('hook log', () => { expect(await runCli(['export-logs', '--since', '2026-09-20'], stdoutIo, { cwd: dataDir, env: { CLAUDE_PLUGIN_DATA: dataDir }, pluginRoot: PLUGIN_ROOT })).toBe(0); expect(stdoutIo.out.join('')).toContain('"entries": 2'); + const explicit = capture(); + expect(await runCli(['export-logs', '--since', '2026-09-20', '--data-dir', dataDir], explicit, { cwd: tmpdir(), env: { HOME: mkdtempSync(join(tmpdir(), 'hex-home-')) }, pluginRoot: PLUGIN_ROOT })).toBe(0); + expect(explicit.out.join('')).toContain('"entries": 2'); + const usage = capture(); expect(await runCli(['export-logs'], usage, { cwd: dataDir, env: {}, pluginRoot: PLUGIN_ROOT })).toBe(2); expect(usage.err.join('')).toContain('--since'); diff --git a/scripts/__tests__/hooks/subagent-start.spec.ts b/scripts/__tests__/hooks/subagent-start.spec.ts index aaa2f84..3a7e73c 100644 --- a/scripts/__tests__/hooks/subagent-start.spec.ts +++ b/scripts/__tests__/hooks/subagent-start.spec.ts @@ -72,4 +72,15 @@ describe('subagent-start hook', () => { const [entry] = readLog(project); expect(entry).toMatchObject({ hook: 'subagent-start', event: 'SubagentStart', agentType: DOMAIN_AGENT, decision: 'context' }); }); + + it('keeps the counters of a running agent when the event fires again on resume', async () => { + const project = makeProject(); + const input = { hook_event_name: 'SubagentStart', session_id: 's', agent_id: 'agent-1', agent_type: DOMAIN_AGENT, cwd: project.dir }; + await runHook('subagent-start', handler, input, context(project)); + project.store.update('s', 'agent-1', (session) => ({ ...session, blocks: 2, touchedPaths: ['src/a.ts'] })); + await runHook('subagent-start', handler, input, context(project)); + expect(project.store.read('s', 'agent-1')).toMatchObject({ blocks: 2, touchedPaths: ['src/a.ts'] }); + await runHook('subagent-start', handler, { ...input, agent_type: 'nestjs-hexagonal:application-agent' }, context(project)); + expect(project.store.read('s', 'agent-1')).toMatchObject({ blocks: 0, touchedPaths: [], agentType: 'nestjs-hexagonal:application-agent' }); + }); }); diff --git a/scripts/__tests__/hooks/subagent-stop.spec.ts b/scripts/__tests__/hooks/subagent-stop.spec.ts index b72a0e5..40504f9 100644 --- a/scripts/__tests__/hooks/subagent-stop.spec.ts +++ b/scripts/__tests__/hooks/subagent-stop.spec.ts @@ -50,6 +50,7 @@ describe('subagent-stop hook', () => { writeProjectFile(project, 'src/from-store.ts', 'export const c = 1;\n'); const session = { ...emptySession(DOMAIN_AGENT, 'now', head), touchedPaths: ['src/from-store.ts', 'src/deleted.ts'] }; expect(touchedFiles(session, project.dir)).toEqual(['src/before.ts', 'src/from-store.ts', 'src/untracked.ts']); + expect(touchedFiles({ ...emptySession(DOMAIN_AGENT, 'now', null), touchedPaths: ['src/from-store.ts'] }, project.dir)).toEqual(['src/from-store.ts']); const noGit = makeProject(); writeProjectFile(noGit, 'src/only-store.ts', 'export const d = 1;\n'); expect(touchedFiles({ ...emptySession(DOMAIN_AGENT, 'now', null), touchedPaths: ['src/only-store.ts'] }, noGit.dir)).toEqual(['src/only-store.ts']); diff --git a/scripts/__tests__/session-store.spec.ts b/scripts/__tests__/session-store.spec.ts index babd59d..79df649 100644 --- a/scripts/__tests__/session-store.spec.ts +++ b/scripts/__tests__/session-store.spec.ts @@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createSessionStore, emptySession, resolveDataDir, sanitizeId, SESSION_TTL_MS } from '../lib/session-store.ts'; +import { createSessionStore, emptySession, MARKETPLACE_DATA_ID, resolveDataDir, sanitizeId, SESSION_TTL_MS } from '../lib/session-store.ts'; const WORKER = join(import.meta.dir, 'helpers', 'session-store-worker.ts'); @@ -97,9 +97,13 @@ describe('session store', () => { expect(store.collectGarbage()).toBe(0); }); - it('falls back to a temp directory when CLAUDE_PLUGIN_DATA is not set', () => { - expect(resolveDataDir({ CLAUDE_PLUGIN_DATA: '/data/x' })).toBe('/data/x'); - expect(resolveDataDir({})).toBe(join(tmpdir(), 'nestjs-hexagonal-data')); - expect(resolveDataDir({ CLAUDE_PLUGIN_DATA: '' })).toBe(join(tmpdir(), 'nestjs-hexagonal-data')); + it('falls back to the marketplace data directory, then to a temp directory, when CLAUDE_PLUGIN_DATA is not set', () => { + const home = mkdtempSync(join(tmpdir(), 'hex-home-')); + expect(resolveDataDir({ CLAUDE_PLUGIN_DATA: '/data/x', HOME: home })).toBe('/data/x'); + expect(resolveDataDir({ HOME: home })).toBe(join(tmpdir(), 'nestjs-hexagonal-data')); + expect(resolveDataDir({ CLAUDE_PLUGIN_DATA: '', HOME: home })).toBe(join(tmpdir(), 'nestjs-hexagonal-data')); + const marketplace = join(home, '.claude', 'plugins', 'data', MARKETPLACE_DATA_ID); + mkdirSync(marketplace, { recursive: true }); + expect(resolveDataDir({ HOME: home })).toBe(marketplace); }); }); diff --git a/scripts/check.ts b/scripts/check.ts index 8ef726a..8abb271 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -9,7 +9,7 @@ import { createJevClient, type FetchLike } from './lib/jev-client.ts'; import { changedFilesSince, projectSources, readSources } from './lib/project-files.ts'; import { RulebookNotFoundError, loadProjectRulebook } from './lib/project-rulebook.ts'; import { matchGlob, normalizePath } from './lib/scope.ts'; -import { resolveDataDir } from './lib/session-store.ts'; +import { MARKETPLACE_DATA_ID, resolveDataDir } from './lib/session-store.ts'; import { explainRequests, planSemanticRequests, runSemanticRules, type SemanticExplain, type SemanticFinding, type Undecided } from './lib/semantic-engine.ts'; import type { Hunk } from './lib/state-builder.ts'; import { runStaticRules, type Finding, type SourceFile } from './lib/static-engine.ts'; @@ -440,16 +440,18 @@ function exitCode(report: Report, args: ParsedArgs): number { return args.failOnUncertain && (uncertain || unanswered) ? 3 : 0; } -const EXPORT_LOGS_USAGE = `Usage: nestjs-hexagonal-check export-logs --since [--out ] +const EXPORT_LOGS_USAGE = `Usage: nestjs-hexagonal-check export-logs --since [--out ] [--data-dir ] - Aggregates the hook decisions logged under $CLAUDE_PLUGIN_DATA/logs/hooks-YYYYMMDD.jsonl + Aggregates the hook decisions logged under /logs/hooks-YYYYMMDD.jsonl since (ISO 8601): entries, p50/p95 latency per hook, decisions by kind, binary sources and semantic uncertain/uncalibrated rates. Writes JSON to --out or stdout. + defaults to $CLAUDE_PLUGIN_DATA, then ~/.claude/plugins/data/${MARKETPLACE_DATA_ID}. `; function runExportLogs(argv: string[], io: CliIo, options: CliOptions): number { let since: string | undefined; let out: string | undefined; + let dataDir: string | undefined; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; const value = argv[i + 1]; @@ -459,6 +461,9 @@ function runExportLogs(argv: string[], io: CliIo, options: CliOptions): number { } else if (arg === '--out' && value !== undefined) { out = value; i += 1; + } else if (arg === '--data-dir' && value !== undefined) { + dataDir = resolve(options.cwd, value); + i += 1; } else { io.stderr(`unknown option '${arg}'\n${EXPORT_LOGS_USAGE}`); return 2; @@ -470,7 +475,7 @@ function runExportLogs(argv: string[], io: CliIo, options: CliOptions): number { } const sinceDate = new Date(since); const until = new Date(); - const summary = summarizeHookLogs(readHookLogs(resolveDataDir(options.env), sinceDate), sinceDate, until); + const summary = summarizeHookLogs(readHookLogs(dataDir ?? resolveDataDir(options.env), sinceDate), sinceDate, until); const text = `${JSON.stringify(summary, null, 2)}\n`; if (out === undefined) { io.stdout(text); diff --git a/scripts/hooks/subagent-start.ts b/scripts/hooks/subagent-start.ts index 6760bc3..e4dedb1 100644 --- a/scripts/hooks/subagent-start.ts +++ b/scripts/hooks/subagent-start.ts @@ -72,8 +72,9 @@ export const handler: HookHandler = async (input, context) => { if (input.agent_id !== undefined) { const now = context.now ?? Date.now; const project = projectDir(input, context); - const session = emptySession(agentType, new Date(now()).toISOString(), gitHead(project)); - sessionStore(context).update(input.session_id, input.agent_id, () => session); + const fresh = emptySession(agentType, new Date(now()).toISOString(), gitHead(project)); + // The event also fires on resume: keep the counters and paths of a running agent. + sessionStore(context).update(input.session_id, input.agent_id, (current) => (current.agentType === agentType ? { ...current, headSha: current.headSha ?? fresh.headSha } : fresh)); } const { text, ruleIds } = composeSliceContext(loaded.composed.rulebook.id, loaded.composed.rulebook.version, agentType, loaded.composed.rules); return { output: contextOutput('SubagentStart', text), decision: 'context', ruleIds }; diff --git a/scripts/hooks/subagent-stop.ts b/scripts/hooks/subagent-stop.ts index 91c726d..b731fed 100644 --- a/scripts/hooks/subagent-stop.ts +++ b/scripts/hooks/subagent-stop.ts @@ -31,8 +31,13 @@ export const MAX_BLOCKS = 2; export const REASON_MAX_FINDINGS = 20; const SEMANTIC_CONCURRENCY = 8; +/** + * Paths recorded by PostToolUse plus, when SubagentStart recorded the HEAD, + * what git sees changed since then. Without a recorded HEAD the diff would + * attribute every dirty file of the repository to this agent, so it is skipped. + */ export function touchedFiles(session: AgentSession, project: string): string[] { - const fromGit = changedFilesSince(session.headSha ?? 'HEAD', project) ?? []; + const fromGit = session.headSha === null ? [] : (changedFilesSince(session.headSha, project) ?? []); const union = new Set([...session.touchedPaths, ...fromGit]); return [...union].filter((path) => existsSync(resolve(project, path)) && statSync(resolve(project, path)).isFile()).sort(); } diff --git a/scripts/lib/project-files.ts b/scripts/lib/project-files.ts index 1081237..897a3ba 100644 --- a/scripts/lib/project-files.ts +++ b/scripts/lib/project-files.ts @@ -43,11 +43,34 @@ export function readSources(paths: string[], base: string): SourceFile[] { return paths.map((path) => ({ path, content: readFileSync(resolve(base, path), 'utf8') })); } -/** Every TypeScript source of the project tree, with paths relative to `base`. */ +function gitSources(root: string, base: string): string[] | null { + try { + const out = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 }); + return out + .split('\0') + .filter((entry) => entry !== '' && SOURCE_EXTENSIONS.some((extension) => entry.endsWith(extension))) + .map((entry) => resolve(root, entry)) + .filter((full) => existsSync(full) && statSync(full).isFile()) + .map((full) => normalizePath(relative(base, full))); + } catch { + return null; + } +} + +/** + * Every TypeScript source of the project tree, with paths relative to `base`. + * Inside a repository the list comes from `git ls-files` (tracked plus + * untracked, honouring .gitignore, so worktrees and build output stay out); + * elsewhere the tree is walked. + */ export function projectSources(base: string): SourceFile[] { - const root = gitTopLevel(base) ?? base; + const root = gitTopLevel(base); + const fromGit = root === null ? null : gitSources(root, base); + if (fromGit !== null) { + return readSources(fromGit, base); + } const paths: string[] = []; - walkTree(root, base, paths); + walkTree(root ?? base, base, paths); return readSources(paths, base); } diff --git a/scripts/lib/session-store.ts b/scripts/lib/session-store.ts index 275124b..cc0cc6f 100644 --- a/scripts/lib/session-store.ts +++ b/scripts/lib/session-store.ts @@ -1,5 +1,5 @@ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { z } from 'zod'; @@ -51,12 +51,25 @@ export function emptySession(agentType: string, startedAt: string, headSha: stri return { agentType, startedAt, headSha, touchedPaths: [], blocks: 0, advisoryBytes: 0, unresolved: [] }; } -/** `$CLAUDE_PLUGIN_DATA`, or a per-user temp directory when the plugin runs in place without one. */ +/** Data directory id of a marketplace install: `@` with `@` replaced by `-` (plugins-reference.md, "Persistent data directory"). */ +export const MARKETPLACE_DATA_ID = 'nestjs-hexagonal-softtor-nestjs-hexagonal'; + +/** + * `$CLAUDE_PLUGIN_DATA` (exported to hook processes), else the marketplace + * install's data directory when it exists (a terminal running `export-logs` + * does not receive the variable), else a per-user temp directory (plugin + * loaded in place). + */ export function resolveDataDir(env: Record): string { const fromEnv = env.CLAUDE_PLUGIN_DATA; if (fromEnv !== undefined && fromEnv !== '') { return fromEnv; } + const home = env.HOME !== undefined && env.HOME !== '' ? env.HOME : homedir(); + const marketplace = join(home, '.claude', 'plugins', 'data', MARKETPLACE_DATA_ID); + if (existsSync(marketplace)) { + return marketplace; + } return join(tmpdir(), 'nestjs-hexagonal-data'); } From 5fd1705335abdb1507eea132b507cdfe0030b911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Sun, 20 Sep 2026 19:41:14 -0300 Subject: [PATCH 5/6] fix: baseline-scoped stop diff, no Jev on block path, regression-only deny --- CLAUDE.md | 6 +- README.md | 8 +-- scripts/__tests__/check.spec.ts | 10 +++ ...s-json.spec.ts => plugin-manifest.spec.ts} | 17 ++++- scripts/__tests__/hooks/post-tool-use.spec.ts | 16 +++++ scripts/__tests__/hooks/pre-tool-use.spec.ts | 26 ++++++- .../__tests__/hooks/subagent-start.spec.ts | 6 +- scripts/__tests__/hooks/subagent-stop.spec.ts | 67 ++++++++++++++++--- scripts/check.ts | 5 +- scripts/hooks/agent-post-tool-use.ts | 15 +++-- scripts/hooks/lib/hook-common.ts | 57 ++++++++++++---- scripts/hooks/post-tool-use.ts | 5 +- scripts/hooks/pre-tool-use.ts | 20 +++++- scripts/hooks/subagent-start.ts | 17 +++-- scripts/hooks/subagent-stop.ts | 48 +++++++------ scripts/lib/api-key.ts | 9 +++ scripts/lib/session-store.ts | 6 +- 17 files changed, 261 insertions(+), 77 deletions(-) rename scripts/__tests__/hooks/{hooks-json.spec.ts => plugin-manifest.spec.ts} (75%) create mode 100644 scripts/lib/api-key.ts diff --git a/CLAUDE.md b/CLAUDE.md index 544f25e..e7b578e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,10 +25,10 @@ Compatible with GSD workflow. | Hook | Matcher | Gate | Effect (v1) | |---|---|---|---| | `SubagentStart` | `^nestjs-hexagonal:.*` | opt-in project, plugin agent | rulebook slice of the agent's layer as `additionalContext` (<= 1,500 tokens); records HEAD sha and start time | -| `PreToolUse` | `Write\|Edit` | plugin agent, path in project and in a static scope | static FAIL -> `permissionDecision: deny` (3 findings max); WARN -> `additionalContext`, no permission decision; no network, no `external` checks | +| `PreToolUse` | `Write\|Edit` | plugin agent, path in project and in a static scope | only findings the current file does not already have: new static FAIL -> `permissionDecision: deny` (3 findings max); new WARN -> `additionalContext`, no permission decision; no network, no `external` checks | | `PostToolUse` | `Write\|Edit` | any agent (path recorded per `agent_id`) | static for all; semantic only for plugin agent with key; `additionalContext` only with findings, 8 KB cap per agent and session | -| `SubagentStop` | the six pipeline agents | plugin agent | files = store paths + `git diff`/untracked since start; static FAIL -> `decision: block` listing only touched files (semantic lines advisory); after 2 blocks -> release with `systemMessage` | -| `PostToolUse` | `Agent` | completed plugin subagent | unresolved FAILs of that `agentId` from the store as `additionalContext` | +| `SubagentStop` | the six pipeline agents | plugin agent | files = store paths + (`git diff`/untracked since start minus the baseline recorded at start); static FAIL -> `decision: block` at once, no Jev; clean stop -> semantic advisory under a 17 s deadline stored for the Agent hook; after 2 blocks -> release with `systemMessage` | +| `PostToolUse` | `Agent` | completed plugin subagent | unresolved FAILs and the last semantic advisory of that `agentId` from the store as `additionalContext` | Every hook runs through `scripts/run.sh --hook `, exits 0 whatever happens, never prints the key or a raw file body, and appends one line to `$CLAUDE_PLUGIN_DATA/logs/hooks-YYYYMMDD.jsonl` (`check.ts export-logs --since ` aggregates it). The key comes from `CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY` (plugin `userConfig`) or `TYPESAFE_API_KEY`; the plugin ships `defaultEnabled: false`. diff --git a/README.md b/README.md index 6af2258..626e154 100644 --- a/README.md +++ b/README.md @@ -187,10 +187,10 @@ The runtime is `bun`; when it is absent the script falls back to `node --experim | Hook | Fires for | What it does | Output | Budget | |---|---|---|---|---| | `SubagentStart` `^nestjs-hexagonal:.*` | plugin subagents | records the session start (HEAD sha, timestamp) and injects the slice of the composed rulebook for the agent's layer: rule ids, titles, severity, the `fix` of FAIL rules | `additionalContext` (at most 1,500 tokens) | 300 ms, no network | -| `PreToolUse` `Write\|Edit` | plugin subagents, path inside the project and inside the scope of a static rule | computes the content the call would produce (Edit applies `old_string` to `new_string`, honouring `replace_all`) and runs the static rules that need only that file (the two project-wide `external` checks run later) | static FAIL: `permissionDecision: deny` with rule id, evidence and fix (3 findings at most); WARN: `additionalContext` with no permission decision; pass: nothing | p95 1.5 s, no network | -| `PostToolUse` `Write\|Edit` | any agent | records the path per `agent_id`; static rules for everyone; semantic rules only for a plugin subagent with a key; `additionalContext` only when there is a finding, capped at 8 KB per agent and session (then a one-line notice) | `additionalContext` | p95 2 s | -| `SubagentStop` (the six pipeline agents) | plugin subagents | touched files = paths recorded in the session store plus `git diff --name-only` and untracked files since the recorded HEAD; full static run on them; a static FAIL blocks with `decision: block` and a `reason` listing only files this agent touched (semantic advisory lines appended when a key is present, at most 8 concurrent requests); the block counter lives in the session store and after 2 blocks the agent is released with a `systemMessage` listing the unresolved FAILs | `{ decision: "block", reason }`, or silent | 10 s | -| `PostToolUse` `Agent` | parent of a completed plugin subagent | reads the residual report of that `agentId` from the session store and returns the unresolved FAILs to the orchestrator | `additionalContext` | 200 ms | +| `PreToolUse` `Write\|Edit` | plugin subagents, path inside the project and inside the scope of a static rule | computes the content the call would produce (Edit applies `old_string` to `new_string`, honouring `replace_all`), runs the static rules that need only that file (the two project-wide `external` checks run later) and keeps only the findings the current file does not already have | new static FAIL: `permissionDecision: deny` with rule id, evidence and fix (3 findings at most); new WARN: `additionalContext` with no permission decision; nothing new: silent | p95 1.5 s, no network | +| `PostToolUse` `Write\|Edit` | any agent | records the path per `agent_id`; static rules for everyone; semantic rules only for a plugin subagent with a key, under a 13 s deadline; `additionalContext` only when there is a finding, capped at 8 KB per agent and session (then a one-line notice) | `additionalContext` | p95 2 s, hook timeout 15 s | +| `SubagentStop` (the six pipeline agents) | plugin subagents | touched files = paths recorded in the session store plus `git diff --name-only` and untracked files since the recorded HEAD, minus the files that were already dirty when the agent started (baseline recorded at SubagentStart); full static run on them; a static FAIL blocks at once with `decision: block` and a `reason` listing only files this agent touched, and Jev is never called on that path; on a clean stop the semantic advisory runs (key present, at most 8 concurrent requests, deadline 17 s) and is stored for the parent; the block counter lives in the session store and after 2 blocks the agent is released with a `systemMessage` listing the unresolved FAILs | `{ decision: "block", reason }`, or silent | 20 s (hook timeout); the blocking path needs no network | +| `PostToolUse` `Agent` | parent of a completed plugin subagent | reads the residual report of that `agentId` from the session store and returns the unresolved FAILs and the semantic advisory of the last clean stop to the orchestrator | `additionalContext` | 200 ms | Why release after 2 blocks: Claude Code ends a subagent after 8 consecutive stop-hook blocks, and the `reason` becomes the subagent's next instruction. An agent that cannot satisfy a rule would otherwise burn the whole budget; releasing earlier keeps the unresolved list visible to the parent through the `Agent` hook. `stop_hook_active` is ignored for the counter because it is already `true` on the first continuation. Note that as of Claude Code v2.1.198 subagents run in the background by default, in which case the `Agent` PostToolUse hook fires at launch (`status: async_launched`) and stays silent; the release message still reaches the user as a `systemMessage`. diff --git a/scripts/__tests__/check.spec.ts b/scripts/__tests__/check.spec.ts index 34e1aa3..6dbca3b 100644 --- a/scripts/__tests__/check.spec.ts +++ b/scripts/__tests__/check.spec.ts @@ -356,6 +356,16 @@ describe('runCli --classes semantic', () => { assertNetworkForbidden(); }); + it('reads the plugin option before the environment key and treats an empty key as absent', async () => { + const calls: string[] = []; + const { code, io } = await runJson(['--rulebook', 'hexagonal', '--files', handler, '--classes', 'semantic'], { CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY: 'sk-from-option', TYPESAFE_API_KEY: '' }, PLUGIN_ROOT, cannedFetch(noulOrNone(0.1), calls)); + expect(code).toBe(0); + expect(io.err.join('')).not.toContain('TYPESAFE_API_KEY is not set'); + expect(calls.length).toBeGreaterThan(0); + const empty = await runJson(['--rulebook', 'hexagonal', '--files', handler, '--classes', 'semantic'], { CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY: '', TYPESAFE_API_KEY: '' }); + expect(empty.io.err.join('')).toContain('TYPESAFE_API_KEY is not set'); + }); + it('does nothing when NESTJS_HEXAGONAL_DISABLE=1', async () => { const { code, io } = await run(['--rulebook', 'hexagonal', '--files', handler, '--classes', 'static,semantic'], { NESTJS_HEXAGONAL_DISABLE: '1', TYPESAFE_API_KEY: SEMANTIC_KEY }); expect(code).toBe(0); diff --git a/scripts/__tests__/hooks/hooks-json.spec.ts b/scripts/__tests__/hooks/plugin-manifest.spec.ts similarity index 75% rename from scripts/__tests__/hooks/hooks-json.spec.ts rename to scripts/__tests__/hooks/plugin-manifest.spec.ts index 2f52bf1..25b0f68 100644 --- a/scripts/__tests__/hooks/hooks-json.spec.ts +++ b/scripts/__tests__/hooks/plugin-manifest.spec.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { z } from 'zod'; +import { POST_TOOL_USE_TIMEOUT_S } from '../../hooks/post-tool-use.ts'; +import { SUBAGENT_STOP_TIMEOUT_S } from '../../hooks/subagent-stop.ts'; import { PLUGIN_ROOT } from './helpers.ts'; const HandlerSchema = z.object({ @@ -17,7 +19,7 @@ const HooksJsonSchema = z.object({ hooks: z.record(z.enum(['SubagentStart', 'PreToolUse', 'PostToolUse', 'SubagentStop']), z.array(z.object({ matcher: z.string(), hooks: z.array(HandlerSchema).min(1) }))), }); -describe('hooks/hooks.json', () => { +describe('plugin manifest and hooks.json', () => { const parsed = HooksJsonSchema.parse(JSON.parse(readFileSync(join(PLUGIN_ROOT, 'hooks', 'hooks.json'), 'utf8'))); it('routes every handler through run.sh with a known hook name and a timeout in seconds', () => { @@ -43,6 +45,11 @@ describe('hooks/hooks.json', () => { expect(parsed.hooks.PostToolUse?.map((group) => group.matcher)).toEqual(['Write|Edit', 'Agent']); }); + it('declares the timeouts the semantic deadlines are derived from', () => { + expect(parsed.hooks.PostToolUse?.[0]?.hooks[0]?.timeout).toBe(POST_TOOL_USE_TIMEOUT_S); + expect(parsed.hooks.SubagentStop?.[0]?.hooks[0]?.timeout).toBe(SUBAGENT_STOP_TIMEOUT_S); + }); + it('is shipped by the package', () => { const pkg: unknown = JSON.parse(readFileSync(join(PLUGIN_ROOT, 'package.json'), 'utf8')); const files = typeof pkg === 'object' && pkg !== null && 'files' in pkg && Array.isArray(pkg.files) ? pkg.files : []; @@ -50,9 +57,13 @@ describe('hooks/hooks.json', () => { expect(files).toContain('scripts/hooks'); }); - it('declares the key as optional sensitive user config and installs disabled', () => { + it('declares the key as optional sensitive user config', () => { const plugin: unknown = JSON.parse(readFileSync(join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json'), 'utf8')); - const shape = z.object({ defaultEnabled: z.literal(false), userConfig: z.object({ TYPESAFE_API_KEY: z.object({ type: z.literal('string'), sensitive: z.literal(true), required: z.literal(false), title: z.string(), description: z.string() }) }) }); + const shape = z.object({ userConfig: z.object({ TYPESAFE_API_KEY: z.object({ type: z.literal('string'), sensitive: z.literal(true), required: z.literal(false), title: z.string(), description: z.string() }) }) }); expect(shape.safeParse(plugin).success).toBe(true); }); + + it('subscribes only to the four events the README documents', () => { + expect(Object.keys(parsed.hooks).sort()).toEqual(['PostToolUse', 'PreToolUse', 'SubagentStart', 'SubagentStop']); + }); }); diff --git a/scripts/__tests__/hooks/post-tool-use.spec.ts b/scripts/__tests__/hooks/post-tool-use.spec.ts index 6b31912..8c047cc 100644 --- a/scripts/__tests__/hooks/post-tool-use.spec.ts +++ b/scripts/__tests__/hooks/post-tool-use.spec.ts @@ -1,6 +1,7 @@ import '../helpers/no-network.ts'; import { describe, expect, it } from 'bun:test'; import { join } from 'node:path'; +import type { FetchLike } from '../../lib/jev-client.ts'; import { ADVISORY_BYTE_CAP, handler, MAIN_THREAD_AGENT_ID } from '../../hooks/post-tool-use.ts'; import { APPLICATION_AGENT, context, DOMAIN_AGENT, jevFetch, makeProject, NEST_SERVICE, PLAIN_SERVICE, readLog, runHook, writeProjectFile } from './helpers.ts'; @@ -122,6 +123,21 @@ describe('post-tool-use hook', () => { expect(readLog(project)[0]?.semantic).toMatchObject({ undecided: 2, findings: 0 }); }); + it('gives up on Jev at the deadline and stays silent', async () => { + const project = makeProject(); + writeProjectFile(project, HANDLER_PATH, HANDLER_SOURCE); + const hanging: FetchLike = (_url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }); + const started = Date.now(); + const run = await runHook('post-tool-use', handler, postInput(project, HANDLER_PATH, HANDLER_SOURCE, { id: 'a', type: APPLICATION_AGENT }), { ...context(project, { TYPESAFE_API_KEY: 'sk-env' }, hanging), semanticDeadlineMs: 300 }); + expect(Date.now() - started).toBeLessThan(3_000); + expect(run.code).toBe(0); + expect(run.stdout).toBe(''); + expect(readLog(project)[0]?.semantic).toMatchObject({ findings: 0 }); + }); + it('emits only a one-line notice once the advisory cap of the agent is reached', async () => { const project = makeProject(); const path = 'src/orders/domain/order.service.ts'; diff --git a/scripts/__tests__/hooks/pre-tool-use.spec.ts b/scripts/__tests__/hooks/pre-tool-use.spec.ts index 6f0e800..88fa744 100644 --- a/scripts/__tests__/hooks/pre-tool-use.spec.ts +++ b/scripts/__tests__/hooks/pre-tool-use.spec.ts @@ -93,7 +93,31 @@ describe('pre-tool-use hook', () => { expect(readLog(project).map((entry) => entry.decision)).toEqual(['silent']); const stillBroken = await runHook('pre-tool-use', handler, { ...input, tool_input: { file_path: path, old_string: 'run(): void {}', new_string: 'run(): void { return; }' } }, context(project)); - expect(stillBroken.stdout).toContain('"permissionDecision":"deny"'); + expect(stillBroken.stdout).toBe(''); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['silent', 'silent']); + }); + + it('denies only the FAILs a write introduces, not the ones the file already has', async () => { + const project = makeProject(); + const path = writeProjectFile(project, 'src/orders/domain/order.service.ts', NEST_SERVICE); + const input = { + hook_event_name: 'PreToolUse', + session_id: 's', + cwd: project.dir, + agent_id: 'a', + agent_type: DOMAIN_AGENT, + tool_name: 'Edit', + tool_input: { file_path: path, old_string: 'export class OrderService {', new_string: "import { PrismaService } from '../infrastructure/prisma.service';\nexport class OrderService {" }, + }; + const run = await runHook('pre-tool-use', handler, input, context(project)); + if (!isPreToolUseJson(run.json)) { + throw new Error(`unexpected output ${run.stdout}`); + } + expect(run.json.hookSpecificOutput.permissionDecision).toBe('deny'); + const reason = run.json.hookSpecificOutput.permissionDecisionReason ?? ''; + expect(reason).toContain('introduce 1 static FAIL'); + expect(reason).toContain('prisma.service'); + expect(reason).not.toContain("'@nestjs/common'"); }); it('returns a WARN as additionalContext without a permission decision', async () => { diff --git a/scripts/__tests__/hooks/subagent-start.spec.ts b/scripts/__tests__/hooks/subagent-start.spec.ts index 3a7e73c..e146f9a 100644 --- a/scripts/__tests__/hooks/subagent-start.spec.ts +++ b/scripts/__tests__/hooks/subagent-start.spec.ts @@ -44,6 +44,8 @@ describe('subagent-start hook', () => { expect(Math.ceil(slice.text.length / 4)).toBeLessThanOrEqual(CONTEXT_TOKEN_BUDGET); expect(slice.text).toContain('more rule(s) omitted'); expect(slice.ruleIds.length).toBeLessThan(80); + expect(slice.ruleIds.every((id) => slice.text.includes(`- ${id} (`))).toBe(true); + expect(slice.ruleIds).toEqual(many.filter((entry) => slice.text.includes(`- ${entry.id} (`)).map((entry) => entry.id)); }); it('stays silent for agents outside the plugin and for projects without a rulebook', async () => { @@ -73,13 +75,13 @@ describe('subagent-start hook', () => { expect(entry).toMatchObject({ hook: 'subagent-start', event: 'SubagentStart', agentType: DOMAIN_AGENT, decision: 'context' }); }); - it('keeps the counters of a running agent when the event fires again on resume', async () => { + it('keeps the touched paths but resets the block counter when the event fires again on resume', async () => { const project = makeProject(); const input = { hook_event_name: 'SubagentStart', session_id: 's', agent_id: 'agent-1', agent_type: DOMAIN_AGENT, cwd: project.dir }; await runHook('subagent-start', handler, input, context(project)); project.store.update('s', 'agent-1', (session) => ({ ...session, blocks: 2, touchedPaths: ['src/a.ts'] })); await runHook('subagent-start', handler, input, context(project)); - expect(project.store.read('s', 'agent-1')).toMatchObject({ blocks: 2, touchedPaths: ['src/a.ts'] }); + expect(project.store.read('s', 'agent-1')).toMatchObject({ blocks: 0, touchedPaths: ['src/a.ts'] }); await runHook('subagent-start', handler, { ...input, agent_type: 'nestjs-hexagonal:application-agent' }, context(project)); expect(project.store.read('s', 'agent-1')).toMatchObject({ blocks: 0, touchedPaths: [], agentType: 'nestjs-hexagonal:application-agent' }); }); diff --git a/scripts/__tests__/hooks/subagent-stop.spec.ts b/scripts/__tests__/hooks/subagent-stop.spec.ts index 40504f9..657467c 100644 --- a/scripts/__tests__/hooks/subagent-stop.spec.ts +++ b/scripts/__tests__/hooks/subagent-stop.spec.ts @@ -5,6 +5,8 @@ import { join } from 'node:path'; import { emptySession } from '../../lib/session-store.ts'; import { findAgentSession, handler as agentPostToolUse } from '../../hooks/agent-post-tool-use.ts'; import { handler, MAX_BLOCKS, touchedFiles } from '../../hooks/subagent-stop.ts'; +import { handler as subagentStart } from '../../hooks/subagent-start.ts'; +import type { FetchLike } from '../../lib/jev-client.ts'; import { APPLICATION_AGENT, context, DOMAIN_AGENT, jevFetch, makeProject, NEST_SERVICE, PLAIN_SERVICE, readLog, runHook, writeProjectFile, type Project } from './helpers.ts'; interface BlockJson { @@ -50,6 +52,7 @@ describe('subagent-stop hook', () => { writeProjectFile(project, 'src/from-store.ts', 'export const c = 1;\n'); const session = { ...emptySession(DOMAIN_AGENT, 'now', head), touchedPaths: ['src/from-store.ts', 'src/deleted.ts'] }; expect(touchedFiles(session, project.dir)).toEqual(['src/before.ts', 'src/from-store.ts', 'src/untracked.ts']); + expect(touchedFiles({ ...session, baseline: ['src/before.ts', 'src/untracked.ts'] }, project.dir)).toEqual(['src/from-store.ts']); expect(touchedFiles({ ...emptySession(DOMAIN_AGENT, 'now', null), touchedPaths: ['src/from-store.ts'] }, project.dir)).toEqual(['src/from-store.ts']); const noGit = makeProject(); writeProjectFile(noGit, 'src/only-store.ts', 'export const d = 1;\n'); @@ -99,6 +102,24 @@ describe('subagent-stop hook', () => { expect(readLog(project).map((entry) => entry.decision)).toEqual(['block', 'block', 'release']); }); + it('does not block on a file that was already dirty before the agent started', async () => { + const project = makeProject({ git: true }); + writeProjectFile(project, 'src/other/domain/other.service.ts', NEST_SERVICE); + const start = { hook_event_name: 'SubagentStart', session_id: 's', agent_id: 'agent-1', agent_type: DOMAIN_AGENT, cwd: project.dir }; + await runHook('subagent-start', subagentStart, start, context(project)); + expect(project.store.read('s', 'agent-1')?.baseline).toContain('src/other/domain/other.service.ts'); + writeProjectFile(project, BAD_PATH, PLAIN_SERVICE); + project.store.update('s', 'agent-1', (session) => ({ ...session, touchedPaths: [BAD_PATH] })); + const run = await runHook('subagent-stop', handler, stopInput(project), context(project)); + expect(run.stdout).toBe(''); + expect(project.store.read('s', 'agent-1')?.blocks).toBe(0); + + writeProjectFile(project, 'src/other/domain/other.service.ts', `${NEST_SERVICE}// touched by the agent\n`); + project.store.update('s', 'agent-1', (session) => ({ ...session, touchedPaths: [BAD_PATH, 'src/other/domain/other.service.ts'] })); + const touched = await runHook('subagent-stop', handler, stopInput(project), context(project)); + expect(touched.stdout).toContain('"decision":"block"'); + }); + it('lists only files the agent touched, even when another file in the project also fails', async () => { const project = makeProject(); writeProjectFile(project, 'src/other/domain/other.service.ts', NEST_SERVICE); @@ -112,28 +133,48 @@ describe('subagent-stop hook', () => { expect(run.json.reason).not.toContain('other.service.ts'); }); - it('appends semantic advisory lines to the reason when a key is present, never blocking on them', async () => { + it('blocks on a static FAIL without calling Jev, and keeps the semantic advisory for clean stops only', async () => { const project = makeProject(); const handlerPath = 'src/orders/application/create-order.handler.ts'; writeProjectFile(project, handlerPath, 'export class CreateOrderHandler {\n async execute(): Promise {\n await Promise.resolve();\n }\n}\n'); - startSession(project, 'agent-2', APPLICATION_AGENT, null, [handlerPath]); - const advise = jevFetch(0.9); - const clean = await runHook('subagent-stop', handler, stopInput(project, 'agent-2', APPLICATION_AGENT), context(project, { TYPESAFE_API_KEY: 'sk-env' }, advise.fetchImpl)); - expect(clean.stdout).toBe(''); - expect(advise.calls).toEqual([]); - writeProjectFile(project, BAD_PATH, NEST_SERVICE); startSession(project, 'agent-2', APPLICATION_AGENT, null, [handlerPath, BAD_PATH]); + const advise = jevFetch(0.9); const blocking = await runHook('subagent-stop', handler, stopInput(project, 'agent-2', APPLICATION_AGENT), context(project, { TYPESAFE_API_KEY: 'sk-env' }, advise.fetchImpl)); if (!isBlockJson(blocking.json)) { throw new Error(`unexpected output ${blocking.stdout}`); } + expect(advise.calls).toEqual([]); + expect(blocking.json.reason).not.toContain('semantic'); + expect(readLog(project).at(-1)?.semantic).toBeUndefined(); + + writeProjectFile(project, BAD_PATH, PLAIN_SERVICE); + const clean = await runHook('subagent-stop', handler, stopInput(project, 'agent-2', APPLICATION_AGENT), context(project, { TYPESAFE_API_KEY: 'sk-env' }, advise.fetchImpl)); + expect(clean.stdout).toBe(''); expect(advise.calls.length).toBeGreaterThan(0); - expect(blocking.json.reason).toContain('advisory (semantic, not blocking)'); - expect(blocking.json.reason).toContain('semantic ask hex/handler-no-business-rules'); - expect(blocking.json.reason).not.toContain('sk-env'); + const session = project.store.read('s', 'agent-2'); + expect(session?.unresolved).toEqual([]); + expect(session?.advisory.join('\n')).toContain('semantic ask hex/handler-no-business-rules'); + expect(session?.advisory.join('\n')).not.toContain('sk-env'); expect(readLog(project).at(-1)?.semantic).toMatchObject({ findings: 1 }); }); + + it('gives up on the semantic advisory at the deadline instead of outliving the hook timeout', async () => { + const project = makeProject(); + const handlerPath = 'src/orders/application/create-order.handler.ts'; + writeProjectFile(project, handlerPath, 'export class CreateOrderHandler {\n async execute(): Promise {\n await Promise.resolve();\n }\n}\n'); + startSession(project, 'agent-2', APPLICATION_AGENT, null, [handlerPath]); + const hanging: FetchLike = (_url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }); + const started = Date.now(); + const run = await runHook('subagent-stop', handler, stopInput(project, 'agent-2', APPLICATION_AGENT), { ...context(project, { TYPESAFE_API_KEY: 'sk-env' }, hanging), semanticDeadlineMs: 300 }); + expect(Date.now() - started).toBeLessThan(3_000); + expect(run.stdout).toBe(''); + expect(readLog(project).at(-1)?.semantic).toMatchObject({ findings: 0 }); + expect(project.store.read('s', 'agent-2')?.advisory).toEqual([]); + }); }); describe('agent-post-tool-use hook', () => { @@ -162,6 +203,10 @@ describe('agent-post-tool-use hook', () => { expect(completed.stdout).toContain('"hookEventName":"PostToolUse"'); expect(completed.stdout).toContain('1 unresolved static FAIL finding(s)'); expect(completed.stdout).toContain('hex/domain-no-nest-decorators (FAIL)'); + project.store.update('s', 'agent-1', (session) => ({ ...session, advisory: ['[nestjs-hexagonal] semantic ask hex/x src/x.ts:1: e - fix: f'] })); + const withAdvisory = await runHook('agent-post-tool-use', agentPostToolUse, agentInput(project, { status: 'completed', agentId: 'agent-1', content: [] }), context(project)); + expect(withAdvisory.stdout).toContain('semantic ask hex/x'); + project.store.update('s', 'agent-1', (session) => ({ ...session, advisory: [] })); const launched = await runHook('agent-post-tool-use', agentPostToolUse, agentInput(project, { status: 'async_launched', agentId: 'agent-1' }), context(project)); expect(launched.stdout).toBe(''); @@ -176,6 +221,6 @@ describe('agent-post-tool-use hook', () => { project.store.update('s', 'agent-1', (session) => ({ ...session, unresolved: [] })); const resolved = await runHook('agent-post-tool-use', agentPostToolUse, agentInput(project, { status: 'completed', agentId: 'agent-1' }), context(project)); expect(resolved.stdout).toBe(''); - expect(readLog(project).map((entry) => entry.decision)).toEqual(['release', 'context', 'skip', 'skip', 'skip', 'silent']); + expect(readLog(project).map((entry) => entry.decision)).toEqual(['release', 'context', 'context', 'skip', 'skip', 'skip', 'silent']); }); }); diff --git a/scripts/check.ts b/scripts/check.ts index 8abb271..067be8f 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { apiKey as resolveApiKey } from './lib/api-key.ts'; import { RulebookCompositionError, semanticRulebookVersion, type ComposedRulebook } from './lib/compose.ts'; import { FittedFileError, loadFitted, type FittedFile } from './lib/decide.ts'; import { readHookLogs, summarizeHookLogs } from './lib/hook-log.ts'; @@ -338,8 +339,8 @@ async function runSemantic( if (rules.length === 0) { return { ...empty, summary: { requests: 0, cached: 0, inputTokens: 0, undecided: [] } }; } - const apiKey = options.env.TYPESAFE_API_KEY ?? options.env.CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY; - if (apiKey === undefined || apiKey === '') { + const apiKey = resolveApiKey(options.env); + if (apiKey === undefined) { const reason = `TYPESAFE_API_KEY is not set; skipped ${rules.length} semantic rule(s)`; io.stderr(`${reason}\n`); return { ...empty, summary: { requests: 0, cached: 0, inputTokens: 0, undecided: [], skippedReason: reason } }; diff --git a/scripts/hooks/agent-post-tool-use.ts b/scripts/hooks/agent-post-tool-use.ts index f5f7b29..0c0f469 100644 --- a/scripts/hooks/agent-post-tool-use.ts +++ b/scripts/hooks/agent-post-tool-use.ts @@ -29,14 +29,17 @@ export const handler: HookHandler = async (input, context) => { return skip(); } const fails = session.unresolved.filter((finding) => finding.severity === 'FAIL'); - if (fails.length === 0) { + if (fails.length === 0 && session.advisory.length === 0) { return { output: null, decision: 'silent' }; } - const text = [ - `${PREFIX} ${session.agentType} finished with ${fails.length} unresolved static FAIL finding(s); they need a fix before this work is complete:`, - ...fails.map(formatStaticFinding), - ].join('\n'); - return { output: contextOutput('PostToolUse', text), decision: 'context', ruleIds: uniqueRuleIds(fails) }; + const lines: string[] = []; + if (fails.length > 0) { + lines.push(`${PREFIX} ${session.agentType} finished with ${fails.length} unresolved static FAIL finding(s); they need a fix before this work is complete:`, ...fails.map(formatStaticFinding)); + } + if (session.advisory.length > 0) { + lines.push(`${PREFIX} semantic advisory on the files ${session.agentType} touched (not blocking):`, ...session.advisory); + } + return { output: contextOutput('PostToolUse', lines.join('\n')), decision: 'context', ruleIds: uniqueRuleIds(fails) }; }; await runHookMain('agent-post-tool-use', handler, import.meta.url); diff --git a/scripts/hooks/lib/hook-common.ts b/scripts/hooks/lib/hook-common.ts index 340aa2f..aa34df5 100644 --- a/scripts/hooks/lib/hook-common.ts +++ b/scripts/hooks/lib/hook-common.ts @@ -1,4 +1,5 @@ import { join } from 'node:path'; +import { apiKey } from '../../lib/api-key.ts'; import { RulebookCompositionError, semanticRulebookVersion, type ComposedRulebook } from '../../lib/compose.ts'; import { loadFitted, type FittedFile } from '../../lib/decide.ts'; import type { HookDecision, HookLogEntry } from '../../lib/hook-log.ts'; @@ -21,8 +22,12 @@ export interface HookContext { fetchImpl?: FetchLike; now?: () => number; store?: SessionStore; + /** Overrides the semantic deadline derived from the hook timeout (tests). */ + semanticDeadlineMs?: number; } +export { apiKey }; + export interface SemanticStats { requests: number; answered: number; @@ -97,15 +102,6 @@ export function rulesInScope(rules: Rule[], path: string): Rule[] { return rules.filter((rule) => isInScope(rule.scope, path)); } -export function apiKey(env: Record): string | undefined { - const fromOption = env.CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY; - if (fromOption !== undefined && fromOption !== '') { - return fromOption; - } - const fromEnv = env.TYPESAFE_API_KEY; - return fromEnv !== undefined && fromEnv !== '' ? fromEnv : undefined; -} - function location(finding: { path: string; line?: number }): string { return finding.line === undefined ? finding.path : `${finding.path}:${finding.line}`; } @@ -138,39 +134,72 @@ export interface SemanticAdvisory { } export const SEMANTIC_TIMEOUT_MS = 6_000; +const DEADLINE_GRACE_MS = 500; + +export interface SemanticAdvisoryOptions { + concurrency: number; + /** Total wall-clock budget; every in-flight request is aborted when it elapses. */ + deadlineMs: number; +} + +/** Wraps fetch so one shared controller can abort every request at the deadline. */ +function fetchWithDeadline(base: FetchLike, controller: AbortController): FetchLike { + return (url, init) => { + const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal; + return base(url, { ...init, signal }); + }; +} + +function undecidedAdvisory(rules: Rule[], files: SourceFile[], stats: SemanticStats): SemanticAdvisory { + const undecided = files.reduce((sum, file) => sum + rulesInScope(rules, file.path).length, 0); + return { lines: [], stats: { ...stats, undecided }, findings: [] }; +} /** * Semantic rules as advisory text. Never denies or blocks in this version: * advise/ask findings become lines, uncertain/uncalibrated ones a single - * short line, and any client error is swallowed into the stats. + * short line, and any client error or the deadline is swallowed into the stats. */ export async function semanticAdvisory( composed: ComposedRulebook, files: SourceFile[], key: string, context: HookContext, - concurrency: number, + options: SemanticAdvisoryOptions, ): Promise { const rules = composed.rules.filter((rule) => rule.class === 'semantic'); const stats: SemanticStats = { requests: 0, answered: 0, findings: 0, uncertain: 0, uncalibrated: 0, undecided: 0 }; if (rules.length === 0 || files.length === 0) { return { lines: [], stats, findings: [] }; } + const deadlineMs = context.semanticDeadlineMs ?? options.deadlineMs; const pin = composed.rulebook.model.pin; const loaded = loadFitted(join(context.pluginRoot, 'calibration', 'fitted'), pin, semanticRulebookVersion(composed)); const fitted: FittedFile | null = loaded.status === 'none' ? null : loaded.fitted; const dataDir = resolveDataDir(context.env); + const controller = new AbortController(); const client = createJevClient({ apiKey: key, pin, rulebookVersion: composed.rulebook.version, - timeoutMs: SEMANTIC_TIMEOUT_MS, + timeoutMs: Math.min(SEMANTIC_TIMEOUT_MS, deadlineMs), cacheDir: join(dataDir, 'cache'), logPath: join(dataDir, 'jev.jsonl'), breakerPath: join(dataDir, 'breaker.json'), - ...(context.fetchImpl ? { fetchImpl: context.fetchImpl } : {}), + fetchImpl: fetchWithDeadline(context.fetchImpl ?? ((url, init) => globalThis.fetch(url, init)), controller), + }); + const abortTimer = setTimeout(() => controller.abort(), deadlineMs); + let graceTimer: ReturnType | undefined; + const gaveUp = new Promise((resolve) => { + graceTimer = setTimeout(() => resolve(null), deadlineMs + DEADLINE_GRACE_MS); }); - const result = await runSemanticRules(rules, files, { client, fitted, uncalibrated: composed.uncalibrated || loaded.status === 'mismatch', concurrency }); + const run = runSemanticRules(rules, files, { client, fitted, uncalibrated: composed.uncalibrated || loaded.status === 'mismatch', concurrency: options.concurrency }); + const result = await Promise.race([run, gaveUp]); + clearTimeout(abortTimer); + clearTimeout(graceTimer); + if (result === null) { + return undecidedAdvisory(rules, files, stats); + } const applied = Object.values(result.applied).reduce((sum, ids) => sum + ids.length, 0); const undecided = result.undecided.reduce((sum, entry) => sum + entry.ruleIds.length, 0); stats.requests = result.requests; diff --git a/scripts/hooks/post-tool-use.ts b/scripts/hooks/post-tool-use.ts index d0534eb..a9ba985 100644 --- a/scripts/hooks/post-tool-use.ts +++ b/scripts/hooks/post-tool-use.ts @@ -25,6 +25,9 @@ import { runHookMain } from './lib/runner.ts'; /** Advisory bytes one agent receives per session before the hook falls back to a one-line notice. */ export const ADVISORY_BYTE_CAP = 8 * 1024; export const MAIN_THREAD_AGENT_ID = 'main'; +/** Must match hooks/hooks.json; the semantic deadline leaves 2 s for the static run and the output. */ +export const POST_TOOL_USE_TIMEOUT_S = 15; +const SEMANTIC_DEADLINE_MS = (POST_TOOL_USE_TIMEOUT_S - 2) * 1000; const SEMANTIC_CONCURRENCY = 4; export const handler: HookHandler = async (input, context) => { @@ -68,7 +71,7 @@ export const handler: HookHandler = async (input, context) => { const lines = staticResult.findings.map(formatStaticFinding); const result: HookResult = { output: null, decision: 'silent', path, bodies, ruleIds: uniqueRuleIds(staticResult.findings) }; if (key !== undefined && inScopeSemantic.length > 0) { - const advisory = await semanticAdvisory(composed, [file], key, context, SEMANTIC_CONCURRENCY); + const advisory = await semanticAdvisory(composed, [file], key, context, { concurrency: SEMANTIC_CONCURRENCY, deadlineMs: SEMANTIC_DEADLINE_MS }); lines.push(...advisory.lines); result.semantic = advisory.stats; result.ruleIds = uniqueRuleIds([...staticResult.findings, ...advisory.findings]); diff --git a/scripts/hooks/pre-tool-use.ts b/scripts/hooks/pre-tool-use.ts index aa04469..d75f74e 100644 --- a/scripts/hooks/pre-tool-use.ts +++ b/scripts/hooks/pre-tool-use.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from 'node:fs'; +import type { Rule } from '../lib/rulebook.schema.ts'; import { runStaticRules, type Finding } from '../lib/static-engine.ts'; import { PREFIX, @@ -38,6 +39,20 @@ export function resultingContent(tool: FileToolInput, absolutePath: string): str return `${current.slice(0, index)}${newString}${current.slice(index + oldString.length)}`; } +function findingKey(finding: Finding): string { + return `${finding.ruleId}\u0000${finding.evidence}`; +} + +/** Findings of the resulting content that the current file does not already have. */ +export function regressions(rules: Rule[], path: string, current: string | null, next: string): Finding[] { + const after = runStaticRules(rules, [{ path, content: next }]).findings; + if (current === null) { + return after; + } + const before = new Set(runStaticRules(rules, [{ path, content: current }]).findings.map(findingKey)); + return after.filter((finding) => !before.has(findingKey(finding))); +} + export function denyReason(fails: Finding[]): string { const shown = fails.slice(0, DENY_REASON_MAX_FINDINGS).map(formatStaticFinding); const more = fails.length > DENY_REASON_MAX_FINDINGS ? [`${PREFIX} ${fails.length - DENY_REASON_MAX_FINDINGS} more FAIL finding(s) in the same file`] : []; @@ -69,8 +84,9 @@ export const handler: HookHandler = async (input, context) => { if (content === null) { return { ...skip(), path }; } - const bodies = [content, tool.tool === 'Write' ? tool.input.content : tool.input.new_string]; - const { findings } = runStaticRules(rules, [{ path, content }]); + const current = existsSync(tool.input.file_path) ? readFileSync(tool.input.file_path, 'utf8') : null; + const bodies = [content, ...(current === null ? [] : [current]), tool.tool === 'Write' ? tool.input.content : tool.input.new_string]; + const findings = regressions(rules, path, current, content); const fails = findings.filter((finding) => finding.severity === 'FAIL'); if (fails.length > 0) { return { output: denyOutput(denyReason(fails)), decision: 'deny', ruleIds: uniqueRuleIds(fails), path, bodies }; diff --git a/scripts/hooks/subagent-start.ts b/scripts/hooks/subagent-start.ts index e4dedb1..9180ae7 100644 --- a/scripts/hooks/subagent-start.ts +++ b/scripts/hooks/subagent-start.ts @@ -1,6 +1,6 @@ -import { gitHead } from '../lib/project-files.ts'; +import { changedFilesSince, gitHead } from '../lib/project-files.ts'; import type { Rule } from '../lib/rulebook.schema.ts'; -import { emptySession } from '../lib/session-store.ts'; +import { emptySession, type AgentSession } from '../lib/session-store.ts'; import { PREFIX, isPluginAgent, loadRulebook, projectDir, sessionStore, skip, type HookHandler } from './lib/hook-common.ts'; import { contextOutput } from './lib/hook-io.ts'; import { runHookMain } from './lib/runner.ts'; @@ -45,6 +45,7 @@ export function composeSliceContext(rulebookId: string, rulebookVersion: string, ]; const footer = 'If the SubagentStop hook blocks the stop, fix the files listed in its reason and finish again.'; const lines: string[] = []; + const ruleIds: string[] = []; let omitted = 0; for (const rule of ordered) { const candidate = [...header, ...lines, ruleLine(rule), footer].join('\n'); @@ -53,11 +54,12 @@ export function composeSliceContext(rulebookId: string, rulebookVersion: string, continue; } lines.push(ruleLine(rule)); + ruleIds.push(rule.id); } if (omitted > 0) { lines.push(`- (${omitted} more rule(s) omitted for length; run nestjs-hexagonal-check --explain for the full list)`); } - return { text: [...header, ...lines, footer].join('\n'), ruleIds: ordered.slice(0, lines.length - (omitted > 0 ? 1 : 0)).map((rule) => rule.id) }; + return { text: [...header, ...lines, footer].join('\n'), ruleIds }; } export const handler: HookHandler = async (input, context) => { @@ -72,9 +74,12 @@ export const handler: HookHandler = async (input, context) => { if (input.agent_id !== undefined) { const now = context.now ?? Date.now; const project = projectDir(input, context); - const fresh = emptySession(agentType, new Date(now()).toISOString(), gitHead(project)); - // The event also fires on resume: keep the counters and paths of a running agent. - sessionStore(context).update(input.session_id, input.agent_id, (current) => (current.agentType === agentType ? { ...current, headSha: current.headSha ?? fresh.headSha } : fresh)); + const headSha = gitHead(project); + const fresh: AgentSession = { ...emptySession(agentType, new Date(now()).toISOString(), headSha), baseline: headSha === null ? [] : (changedFilesSince(headSha, project) ?? []) }; + // The event also fires on resume: keep the paths and the baseline of a running agent, reset its block counter. + sessionStore(context).update(input.session_id, input.agent_id, (current) => + current.agentType === agentType ? { ...current, blocks: 0, headSha: current.headSha ?? fresh.headSha, baseline: current.headSha === null ? fresh.baseline : current.baseline } : fresh, + ); } const { text, ruleIds } = composeSliceContext(loaded.composed.rulebook.id, loaded.composed.rulebook.version, agentType, loaded.composed.rules); return { output: contextOutput('SubagentStart', text), decision: 'context', ruleIds }; diff --git a/scripts/hooks/subagent-stop.ts b/scripts/hooks/subagent-stop.ts index b731fed..9cc4fb9 100644 --- a/scripts/hooks/subagent-stop.ts +++ b/scripts/hooks/subagent-stop.ts @@ -29,28 +29,28 @@ import { runHookMain } from './lib/runner.ts'; */ export const MAX_BLOCKS = 2; export const REASON_MAX_FINDINGS = 20; +/** Must match hooks/hooks.json; the semantic deadline leaves 3 s for the static run and the output. */ +export const SUBAGENT_STOP_TIMEOUT_S = 20; +const SEMANTIC_DEADLINE_MS = (SUBAGENT_STOP_TIMEOUT_S - 3) * 1000; const SEMANTIC_CONCURRENCY = 8; /** * Paths recorded by PostToolUse plus, when SubagentStart recorded the HEAD, - * what git sees changed since then. Without a recorded HEAD the diff would - * attribute every dirty file of the repository to this agent, so it is skipped. + * what git sees changed since then minus the files that were already dirty + * at that moment (the developer's or a sibling agent's work). Without a + * recorded HEAD the diff is skipped because it could not be attributed. */ export function touchedFiles(session: AgentSession, project: string): string[] { - const fromGit = session.headSha === null ? [] : (changedFilesSince(session.headSha, project) ?? []); + const baseline = new Set(session.baseline); + const fromGit = session.headSha === null ? [] : (changedFilesSince(session.headSha, project) ?? []).filter((path) => !baseline.has(path)); const union = new Set([...session.touchedPaths, ...fromGit]); return [...union].filter((path) => existsSync(resolve(project, path)) && statSync(resolve(project, path)).isFile()).sort(); } -export function blockReason(agentType: string, fails: Finding[], advisory: string[]): string { +export function blockReason(agentType: string, fails: Finding[]): string { const shown = fails.slice(0, REASON_MAX_FINDINGS).map(formatStaticFinding); const more = fails.length > REASON_MAX_FINDINGS ? [`${PREFIX} ${fails.length - REASON_MAX_FINDINGS} more FAIL finding(s)`] : []; - return [ - `${PREFIX} ${fails.length} static FAIL finding(s) remain in files ${agentType} touched. Fix them, then finish again:`, - ...shown, - ...more, - ...(advisory.length > 0 ? [`${PREFIX} advisory (semantic, not blocking):`, ...advisory] : []), - ].join('\n'); + return [`${PREFIX} ${fails.length} static FAIL finding(s) remain in files ${agentType} touched. Fix them, then finish again:`, ...shown, ...more].join('\n'); } export function releaseMessage(agentType: string, fails: Finding[]): string { @@ -84,22 +84,28 @@ export const handler: HookHandler = async (input, context) => { const staticResult = runStaticRules(staticRules(composed.rules, { external: true }), files, { projectFiles: () => projectSources(project) }); const fails = staticResult.findings.filter((finding) => finding.severity === 'FAIL'); if (fails.length === 0) { - store.update(input.session_id, agentId, (current) => ({ ...current, unresolved: [] })); - return { output: null, decision: 'silent', ruleIds: uniqueRuleIds(staticResult.findings), bodies }; + // Clean stop: the semantic advisory runs under a deadline and is kept for the parent (Agent hook), never shown to the stopping agent. + const key = apiKey(context.env); + const advisory = key === undefined ? null : await semanticAdvisory(composed, files, key, context, { concurrency: SEMANTIC_CONCURRENCY, deadlineMs: SEMANTIC_DEADLINE_MS }); + store.update(input.session_id, agentId, (current) => ({ ...current, unresolved: [], advisory: advisory?.lines ?? [] })); + return { + output: null, + decision: 'silent', + ruleIds: uniqueRuleIds([...staticResult.findings, ...(advisory?.findings ?? [])]), + bodies, + ...(advisory ? { semantic: advisory.stats } : {}), + }; } - const key = apiKey(context.env); - const advisory = key === undefined ? null : await semanticAdvisory(composed, files, key, context, SEMANTIC_CONCURRENCY); - const ruleIds = uniqueRuleIds([...fails, ...(advisory?.findings ?? [])]); - const semantic = advisory ? { semantic: advisory.stats } : {}; + // A static FAIL blocks at once: no network on the blocking path, so the hook timeout can never discard the decision. + const ruleIds = uniqueRuleIds(fails); const unresolved = fails.map(toUnresolved); - if (session.blocks < MAX_BLOCKS) { - store.update(input.session_id, agentId, (current) => ({ ...current, blocks: current.blocks + 1, unresolved })); - return { output: blockOutput(blockReason(agentType, fails, advisory?.lines ?? [])), decision: 'block', ruleIds, bodies, ...semantic }; + store.update(input.session_id, agentId, (current) => ({ ...current, blocks: current.blocks + 1, unresolved, advisory: [] })); + return { output: blockOutput(blockReason(agentType, fails)), decision: 'block', ruleIds, bodies }; } - store.update(input.session_id, agentId, (current) => ({ ...current, unresolved })); - return { output: systemMessageOutput(releaseMessage(agentType, fails)), decision: 'release', ruleIds, bodies, ...semantic }; + store.update(input.session_id, agentId, (current) => ({ ...current, unresolved, advisory: [] })); + return { output: systemMessageOutput(releaseMessage(agentType, fails)), decision: 'release', ruleIds, bodies }; }; await runHookMain('subagent-stop', handler, import.meta.url); diff --git a/scripts/lib/api-key.ts b/scripts/lib/api-key.ts new file mode 100644 index 0000000..39e4409 --- /dev/null +++ b/scripts/lib/api-key.ts @@ -0,0 +1,9 @@ +/** Plugin option first (`userConfig`), then the shell variable; an empty string counts as absent. */ +export function apiKey(env: Record): string | undefined { + for (const value of [env.CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY, env.TYPESAFE_API_KEY]) { + if (value !== undefined && value !== '') { + return value; + } + } + return undefined; +} diff --git a/scripts/lib/session-store.ts b/scripts/lib/session-store.ts index cc0cc6f..3deff3e 100644 --- a/scripts/lib/session-store.ts +++ b/scripts/lib/session-store.ts @@ -16,10 +16,14 @@ export const AgentSessionSchema = z.object({ agentType: z.string(), startedAt: z.string(), headSha: z.string().nullable(), + /** Files already modified or untracked when the agent started; never attributed to it. */ + baseline: z.array(z.string()).default([]), touchedPaths: z.array(z.string()), blocks: z.number().int().nonnegative(), advisoryBytes: z.number().int().nonnegative(), unresolved: z.array(UnresolvedFindingSchema), + /** Semantic advisory lines of the last clean stop, handed to the parent by the Agent hook. */ + advisory: z.array(z.string()).default([]), }); export type AgentSession = z.infer; @@ -48,7 +52,7 @@ export interface SessionStore { } export function emptySession(agentType: string, startedAt: string, headSha: string | null): AgentSession { - return { agentType, startedAt, headSha, touchedPaths: [], blocks: 0, advisoryBytes: 0, unresolved: [] }; + return { agentType, startedAt, headSha, baseline: [], touchedPaths: [], blocks: 0, advisoryBytes: 0, unresolved: [], advisory: [] }; } /** Data directory id of a marketplace install: `@` with `@` replaced by `-` (plugins-reference.md, "Persistent data directory"). */ From 4e1f1f74cb83a28bcaef0eb4d32c456387bdf8b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor?= Date: Sun, 20 Sep 2026 19:41:18 -0300 Subject: [PATCH 6/6] fix: drop defaultEnabled so hooks and agents register --- .claude-plugin/plugin.json | 1 - CLAUDE.md | 2 +- README.md | 5 +++-- scripts/__tests__/hooks/plugin-manifest.spec.ts | 5 +++++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c70c967..2df967f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -9,7 +9,6 @@ "homepage": "https://github.com/softtor/nestjs-hexagonal", "repository": "https://github.com/softtor/nestjs-hexagonal", "license": "MIT", - "defaultEnabled": false, "userConfig": { "TYPESAFE_API_KEY": { "type": "string", diff --git a/CLAUDE.md b/CLAUDE.md index e7b578e..204db8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Compatible with GSD workflow. | `SubagentStop` | the six pipeline agents | plugin agent | files = store paths + (`git diff`/untracked since start minus the baseline recorded at start); static FAIL -> `decision: block` at once, no Jev; clean stop -> semantic advisory under a 17 s deadline stored for the Agent hook; after 2 blocks -> release with `systemMessage` | | `PostToolUse` | `Agent` | completed plugin subagent | unresolved FAILs and the last semantic advisory of that `agentId` from the store as `additionalContext` | -Every hook runs through `scripts/run.sh --hook `, exits 0 whatever happens, never prints the key or a raw file body, and appends one line to `$CLAUDE_PLUGIN_DATA/logs/hooks-YYYYMMDD.jsonl` (`check.ts export-logs --since ` aggregates it). The key comes from `CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY` (plugin `userConfig`) or `TYPESAFE_API_KEY`; the plugin ships `defaultEnabled: false`. +Every hook runs through `scripts/run.sh --hook `, exits 0 whatever happens, never prints the key or a raw file body, and appends one line to `$CLAUDE_PLUGIN_DATA/logs/hooks-YYYYMMDD.jsonl` (`check.ts export-logs --since ` aggregates it). The key comes from `CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY` (plugin `userConfig`) or `TYPESAFE_API_KEY`. Opt-in is exclusively the `run.sh` gate (project `.claude/rulebook.yaml` or `NESTJS_HEXAGONAL_RULEBOOK`): never add `defaultEnabled: false` to `plugin.json`, because Claude Code 2.1.278 then reads `hooks.json` but registers neither hooks nor agents (`scripts/__tests__/hooks/plugin-manifest.spec.ts` guards this). Semantic decisions (`scripts/lib/decide.ts`), per rule and per answer: diff --git a/README.md b/README.md index 626e154..c21a96b 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ The runtime is `bun`; when it is absent the script falls back to `node --experim ### Hooks -`hooks/hooks.json` subscribes to four events. Every handler goes through `run.sh --hook `, so the opt-in gate, the path containment, the single execution source and the fail-open above apply to all of them. **In this version only a static FAIL blocks anything**; semantic answers are advisory text, and nothing blocks on `uncertain` or `uncalibrated`. +`hooks/hooks.json` subscribes to four events. Every handler goes through `run.sh --hook `, so the opt-in gate, the path containment, the single execution source and the fail-open above apply to all of them; that gate is the whole opt-in mechanism (see [Disclosure](#disclosure) for why the manifest does not use `defaultEnabled`). **In this version only a static FAIL blocks anything**; semantic answers are advisory text, and nothing blocks on `uncertain` or `uncalibrated`. | Hook | Fires for | What it does | Output | Budget | |---|---|---|---|---| @@ -201,7 +201,8 @@ State lives under `$CLAUDE_PLUGIN_DATA` (`~/.claude/plugins/data//`; when th - **What is sent:** with a key present, one request per file and state slice containing the rule preamble, the file path, the layer, the slice name and the code of that slice plus the rulebook questions. The whole file is sent only when a rule declares `slice: file`. The key travels in the `Authorization` header and never appears in a hook output, a reason, the JSONL log or the cache; the hooks refuse to print any output that would contain the key or a raw file body. - **When:** only if all three hold: the project opted in with `.claude/rulebook.yaml` (or `NESTJS_HEXAGONAL_RULEBOOK`), the hook fires inside a plugin subagent (`agent_type` prefixed `nestjs-hexagonal:`, with an `agent_id`), and a key is configured. `PreToolUse` and `SubagentStart` never use the network. Static rules run offline for every agent. - **To whom:** `https://api.typesafe.ai/v1/systemone`. TypeSafe states it does not train on customer data; zero data retention is only available under an enterprise contract. Treat the code you check as shared with that provider. -- **How to disable:** `NESTJS_HEXAGONAL_DISABLE=1` (everything), remove the project rulebook (all hooks stay silent), or remove the key (static only). The plugin installs disabled (`defaultEnabled: false`); `claude plugin enable nestjs-hexagonal` turns it on. +- **How to disable:** `NESTJS_HEXAGONAL_DISABLE=1` (everything), remove the project rulebook (all hooks stay silent), or remove the key (static only). +- **Opt-in is per project, not per install.** The only gate is the one in `run.sh`: a project without `.claude/rulebook.yaml` (or `NESTJS_HEXAGONAL_RULEBOOK`) never runs a hook, whatever the plugin state. The manifest deliberately does not set `defaultEnabled: false`: with that field Claude Code 2.1.278 reads `hooks/hooks.json` but registers neither the hooks nor the plugin agents (`create-subdomain` fails with "Agent type 'nestjs-hexagonal:domain-agent' not found"), so the field would disable the plugin instead of deferring its activation. ### Onboarding another project diff --git a/scripts/__tests__/hooks/plugin-manifest.spec.ts b/scripts/__tests__/hooks/plugin-manifest.spec.ts index 25b0f68..8713338 100644 --- a/scripts/__tests__/hooks/plugin-manifest.spec.ts +++ b/scripts/__tests__/hooks/plugin-manifest.spec.ts @@ -63,6 +63,11 @@ describe('plugin manifest and hooks.json', () => { expect(shape.safeParse(plugin).success).toBe(true); }); + it('does not declare defaultEnabled: Claude Code 2.1.278 reads hooks.json but registers neither hooks nor agents with it', () => { + const plugin: unknown = JSON.parse(readFileSync(join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json'), 'utf8')); + expect(typeof plugin === 'object' && plugin !== null && 'defaultEnabled' in plugin).toBe(false); + }); + it('subscribes only to the four events the README documents', () => { expect(Object.keys(parsed.hooks).sort()).toEqual(['PostToolUse', 'PreToolUse', 'SubagentStart', 'SubagentStop']); });