/** * Shared helpers for the 10-rule enforcement hook layer. * * Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md * Plan: docs/superpowers/plans/2026-05-25-enforce-hard-rules.md * * Design contract (M7 Фаза 0, правило 1 — уточнение helpers:7): * - НАБЛЮДАТЕЛЬНЫЕ хуки (FAIL_QUIET_OBSERVATION_HOOKS) — fail-quiet (exit 0, {}). * - ДИСЦИПЛИНАРНЫЕ/ЗАЩИТНЫЕ хуки (FAIL_CLOSE_DISCIPLINE_HOOKS) — fail-CLOSE: любая * внутренняя ошибка → block (exit 2), НЕ тихий пропуск (иначе баг хука = тихий * обход дисциплины, SE2). Используют disciplineOutcome / exitDisciplineDecision. * Прецедент: enforce-normative-content-rules:201-205. * Only deliberate violations (or discipline-hook internal errors) exit 2. * * Security note: this file uses child_process.execFileSync with FIXED arguments * (no user input concatenation) — pattern is safe by construction. No injection * surface. See readGitBranch(). * * Security Guidance #40: pure parsing — no exec/execSync except readGitBranch which * is the documented use case (fixed args, no user input). */ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; import { execFileSync } from 'child_process'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** Read full stdin as utf-8 string. Returns '' on empty/error. */ export async function readStdin(stdinStream = process.stdin) { return new Promise((resolve) => { let data = ''; let timedOut = false; const timer = setTimeout(() => { timedOut = true; resolve(data); }, 4500); stdinStream.setEncoding('utf-8'); stdinStream.on('data', (chunk) => { data += chunk; }); stdinStream.on('end', () => { if (timedOut) return; clearTimeout(timer); resolve(data); }); stdinStream.on('error', () => { clearTimeout(timer); resolve(''); }); }); } export function parseEventJson(raw) { try { return JSON.parse(raw || '{}'); } catch { return {}; } } /** Runtime directory: ~/.claude/runtime/ */ export function runtimeDir() { const dir = join(homedir(), '.claude', 'runtime'); try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ } return dir; } export function sentinelPath(name, sessionId) { return join(runtimeDir(), `${name}-${sessionId || 'unknown'}.json`); } export function writeSentinel(name, sessionId, data) { try { const p = sentinelPath(name, sessionId); writeFileSync(p, JSON.stringify({ ...data, written_at: new Date().toISOString() }, null, 2)); return p; } catch { return null; } } import { unlinkSync } from 'node:fs'; /** Штатный режим активен для сессии: часовой существует и active===true; иначе/ошибка → false. */ export function standbyActive(sessionId) { const s = readSentinel('standby-mode', sessionId); return !!(s && s.active === true); } /** Снять часовой (best-effort): удаляет файл; ошибка/отсутствие → молча. */ export function removeSentinel(name, sessionId) { try { const p = sentinelPath(name, sessionId); if (existsSync(p)) unlinkSync(p); return true; } catch { return false; } } export function readSentinel(name, sessionId) { try { const p = sentinelPath(name, sessionId); if (!existsSync(p)) return null; return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; } } export function sentinelAgeSec(name, sessionId) { const s = readSentinel(name, sessionId); if (!s || !s.written_at) return null; const ms = Date.now() - new Date(s.written_at).getTime(); if (!Number.isFinite(ms)) return null; return Math.floor(ms / 1000); } export function readTranscript(transcriptPath) { if (!transcriptPath || typeof transcriptPath !== 'string') return []; if (!existsSync(transcriptPath)) return []; try { const raw = readFileSync(transcriptPath, 'utf-8'); const lines = raw.split('\n').filter(Boolean); const out = []; for (const l of lines) { try { out.push(JSON.parse(l)); } catch { /* skip */ } } return out; } catch { return []; } } export function lastTurnEntries(entries) { if (!Array.isArray(entries) || entries.length === 0) return []; for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i]; // Sibling-session find 2026-05-30: harness-injected skill bodies arrive as // role:'user' messages with isMeta:true AND a top-level sourceToolUseID // linking them back to the originating Skill tool_use. Treating them as // turn boundaries hides both the user's real prompt (breaks coverage // detection) and the Skill tool_use (breaks detectLegitSkillActive in // enforce-normative-content-rules). Skip ONLY this exact shape — other // isMeta:true messages (auto-resume "Continue from where you left off.", // Stop hook feedback, local-command-caveat wrappers) remain valid // boundaries. Discriminator field sourceToolUseID is harness-controlled // and not writable by controller from inside a tool call. if (e && e.isMeta === true && typeof e.sourceToolUseID === 'string') continue; if (e && e.message && e.message.role === 'user') { const c = e.message.content; if (typeof c === 'string' && c.trim().length > 0) return entries.slice(i); if (Array.isArray(c)) { const hasToolResult = c.some((b) => b && b.type === 'tool_result'); const hasText = c.some((b) => b && b.type === 'text'); if (hasText && !hasToolResult) return entries.slice(i); } } } return entries; } export function lastUserPromptText(entries) { const turn = lastTurnEntries(entries); if (!turn || turn.length === 0) return ''; const e = turn[0]; if (!e || !e.message) return ''; const c = e.message.content; if (typeof c === 'string') return c; if (Array.isArray(c)) { return c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('\n'); } return ''; } export function lastAssistantText(entries) { const turn = lastTurnEntries(entries); let out = ''; for (const e of turn) { if (e && e.message && e.message.role === 'assistant') { const c = e.message.content; if (Array.isArray(c)) { for (const b of c) { if (b && b.type === 'text' && typeof b.text === 'string') out += b.text + '\n'; } } } } return out; } export function parseCoverageLine(text) { if (typeof text !== 'string') return null; const m = text.match(/coverage:\s*(skill|node|chain|hook|agent|direct)\s*:\s*([^\s\n<>]+)/i); if (!m) return null; return { channel: m[1].toLowerCase(), id: m[2] }; } export function sessionToolUses(entries) { if (!Array.isArray(entries)) return []; const uses = []; for (const e of entries) { const c = e && e.message && e.message.content; if (!Array.isArray(c)) continue; for (const b of c) { if (b && b.type === 'tool_use') uses.push({ name: b.name, input: b.input || {} }); } } return uses; } export function turnToolUses(entries) { const turn = lastTurnEntries(entries); const uses = []; for (const e of turn) { const c = e && e.message && e.message.content; if (!Array.isArray(c)) continue; for (const b of c) { if (b && b.type === 'tool_use') uses.push({ name: b.name, input: b.input || {} }); } } return uses; } export function turnToolResults(entries) { const turn = lastTurnEntries(entries); const results = []; for (const e of turn) { const c = e && e.message && e.message.content; if (!Array.isArray(c)) continue; for (const b of c) { if (b && b.type === 'tool_result') { const txt = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map((p) => (p && p.text) || '').join('\n') : ''; results.push({ tool_use_id: b.tool_use_id, is_error: b.is_error === true, content: txt }); } } } return results; } // v4 stubs — universal vocab override surface removed per spec §4.2. // Keep symbols exported so callers in other hooks compile; runtime returns null/empty. export function loadOverrideVocab(_path) { return { phrases: [] }; } export function _resetVocabCache() { /* no-op, vocab disabled */ } export function findOverride(_userPrompt, _ruleKey, _vocab) { return null; } export function findOverrideAttempt(_userPrompt, _ruleKey, _vocab) { return null; } export function logHookOutcome(ruleKey, outcome, sessionId) { try { const f = join(runtimeDir(), 'hook-outcomes.jsonl'); appendFileSync(f, JSON.stringify({ ts: new Date().toISOString(), session_id: sessionId || null, rule: ruleKey, outcome, }) + '\n'); } catch { /* ignore */ } } export function logOverride(ruleKey, phraseObj, sessionId) { try { const f = join(runtimeDir(), 'override-usage.jsonl'); appendFileSync(f, JSON.stringify({ ts: new Date().toISOString(), session_id: sessionId || null, rule: ruleKey, phrase: phraseObj && phraseObj.phrase, }) + '\n'); } catch { /* ignore */ } } /** * Read current git branch via execFileSync with fixed args (no shell, no user * input concatenation — safe by construction). Returns empty string on error. */ export function readGitBranch(cwd) { try { return execFileSync('git', ['branch', '--show-current'], { cwd: cwd || process.cwd(), encoding: 'utf-8', timeout: 1000, stdio: ['ignore', 'pipe', 'ignore'], }).trim(); } catch { return ''; } } export function expectedBranchPath(sessionId) { return join(runtimeDir(), `expected-branch-${sessionId || 'unknown'}`); } export function getExpectedBranch(sessionId) { try { const p = expectedBranchPath(sessionId); if (!existsSync(p)) return ''; return readFileSync(p, 'utf-8').trim(); } catch { return ''; } } export function setExpectedBranch(sessionId, branch) { try { writeFileSync(expectedBranchPath(sessionId), String(branch || '').trim()); return true; } catch { return false; } } export function appendRationalizationFlag(sessionId, kind, evidence) { try { const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`); appendFileSync(f, JSON.stringify({ ts: new Date().toISOString(), kind, evidence: typeof evidence === 'string' ? evidence.slice(0, 240) : evidence, }) + '\n'); } catch { /* ignore */ } } export function readRationalizationFlags(sessionId) { try { const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`); if (!existsSync(f)) return []; return readFileSync(f, 'utf-8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } } export function logJudgeVerdict(sessionId, { tool, verdict }, opts = {}) { try { const dir = opts.baseDir || runtimeDir(); const fi = join(dir, `llm-judge-verdicts-${sessionId || 'unknown'}.jsonl`); appendFileSync(fi, JSON.stringify({ ts: new Date().toISOString(), tool: tool || 'unknown', verdict: verdict == null ? null : String(verdict), }) + '\n'); } catch { /* ignore */ } } export function readJudgeVerdicts(sessionId, opts = {}) { try { const dir = opts.baseDir || runtimeDir(); const fi = join(dir, `llm-judge-verdicts-${sessionId || 'unknown'}.jsonl`); if (!existsSync(fi)) return []; return readFileSync(fi, 'utf-8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } } export function logSafeBaselineAction(sessionId, { tool, action }, opts = {}) { try { const dir = opts.baseDir || runtimeDir(); const fi = join(dir, `safe-baseline-actions-${sessionId || 'unknown'}.jsonl`); appendFileSync(fi, JSON.stringify({ ts: new Date().toISOString(), tool: tool || 'unknown', action: String(action || 'allow'), }) + '\n'); } catch { /* ignore */ } } export function readSafeBaselineActions(sessionId, opts = {}) { try { const dir = opts.baseDir || runtimeDir(); const fi = join(dir, `safe-baseline-actions-${sessionId || 'unknown'}.jsonl`); if (!existsSync(fi)) return []; return readFileSync(fi, 'utf-8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } } export function readRouterState(sessionId) { try { const p = join(runtimeDir(), `router-state-${sessionId || 'unknown'}.json`); if (!existsSync(p)) return null; return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; } } export function exitDecision({ block, message } = {}) { if (block) { if (message) process.stderr.write(message + '\n'); process.exit(2); return; } try { process.stdout.write('{}'); } catch { /* ignore */ } process.exit(0); } /** * Дисциплинарный исход (pure, тестируемо): выполнить decideFn; ЛЮБАЯ внутренняя * ошибка → fail-CLOSE (block:true), НЕ тихий пропуск (M7 Фаза 0, правило 1). * Наблюдательные хуки это НЕ используют — остаются fail-quiet. */ export async function disciplineOutcome(decideFn, { label = 'discipline' } = {}) { let o; try { o = await decideFn(); } catch { return { block: true, message: `[${label}] внутренняя ошибка — fail-CLOSE` }; } // Малформ-возврат (undefined/null/не-объект/нет boolean block) → fail-CLOSE. // Зеркалит прецедент normative-content-rules: там undefined-результат кидал на .block → // catch → block. Только явный объект с boolean block трактуется как решение decideFn — // забытый return обязан блокировать ГРОМКО, не пропускать молча (анти-SE2, правило 1). if (!o || typeof o !== 'object' || typeof o.block !== 'boolean') { return { block: true, message: `[${label}] некорректный исход (нет block) — fail-CLOSE` }; } return { block: o.block, message: o.message }; } /** Thin-main: дисциплинарный exit. Ошибка decideFn → блок (fail-CLOSE). */ export async function exitDisciplineDecision(decideFn, opts = {}) { exitDecision(await disciplineOutcome(decideFn, opts)); } /** * P-7 (M7 Фаза 0): дисциплинарные/защитные хуки обязаны быть fail-CLOSE; наблюдательные — * fail-quiet. Манифест-тест (Фаза 6 / P-6) сверит, что каждый зарегистрированный * дисциплинарный хук числится здесь и использует fail-CLOSE-обёртку. * NB: enforce-skill-journaler добавлен в Фазе 3 (SE-K); поглощённая дисциплина §4.2 — в Фазе 4. */ export const FAIL_CLOSE_DISCIPLINE_HOOKS = Object.freeze([ 'enforce-floor', 'enforce-supreme-gate', 'enforce-judge-gate', 'enforce-snapshot', 'enforce-floor-escape-consume', 'enforce-read-path-deny', 'enforce-mcp-classification', 'enforce-normative-content-rules', 'enforce-skill-journaler', 'enforce-coverage-verify', 'enforce-todowrite-skill-verifier', 'enforce-rationalization-audit', 'enforce-self-debrief-detector', ]); export const FAIL_QUIET_OBSERVATION_HOOKS = Object.freeze([ 'observer-stop-hook', 'cost-stop-hook', ]); export function isProductionCodePath(p) { if (typeof p !== 'string') return false; const n = p.replace(/\\/g, '/'); if (/\.(test|spec)\.[a-z0-9]+$/i.test(n)) return false; if (/(?:^|\/)tests?\//.test(n) || /(?:^|\/)spec\//.test(n)) return false; if (/(?:^|\/)tools\/[^/]+\.mjs$/.test(n)) return true; if (/(?:^|\/)app\/app\/.+\.php$/.test(n)) return true; if (/(?:^|\/)resources\/js\/.+\.(vue|ts|tsx|js)$/.test(n)) return true; return false; } export function isMemoryPath(p) { if (typeof p !== 'string') return false; const n = p.replace(/\\/g, '/'); if (/\/memory\/[^/]+\.md$/i.test(n)) return true; if (/\/MEMORY\.md$/i.test(n)) return true; return false; } /** * Returns true if path is docs-only (ends with `.md`, case-insensitive). * * Used by verify-before-push to short-circuit regression-gating for docs / * memory / spec / plan / SKILL.md pushes — no executable code → no test value. * * Anything else (.json, .php, .ts, .yaml, .mjs, no-extension files) returns false * and remains under the normal regression gate. */ export function isDocsOnlyPath(p) { if (typeof p !== 'string' || p.length === 0) return false; return /\.md$/i.test(p); } /** * Returns true iff `paths` is a non-empty array where EVERY entry is docs-only. * Empty/non-array → false (unknown = conservative, fall through to normal gate). */ export function isDocsOnlyChange(paths) { if (!Array.isArray(paths) || paths.length === 0) return false; return paths.every(isDocsOnlyPath); } /** * List changed file paths for an in-progress git commit / push. * - commit: staged files (`git diff --cached --name-only`) * - push: commits ahead of upstream (`git diff --name-only @{u}..HEAD`) * * Returns [] on any git error (no upstream, detached HEAD, git missing, etc.) * or unrecognized `kind`. Callers MUST treat empty as "unknown" and NOT short- * circuit on it. * * Security: execFileSync with fixed args, no user-input concatenation. */ export function listChangedFiles(kind, cwd) { try { let args; if (kind === 'commit') { args = ['diff', '--cached', '--name-only']; } else if (kind === 'push') { args = ['diff', '--name-only', '@{u}..HEAD']; } else { return []; } const out = execFileSync('git', args, { cwd: cwd || process.cwd(), encoding: 'utf-8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], }); return out.split('\n').map((s) => s.trim()).filter(Boolean); } catch { return []; } } export function detectGitCommandKind(cmd) { if (typeof cmd !== 'string') return null; const c = cmd.trim(); if (/(^|\s|;|&&|\|\|)git\s+push\b/i.test(c)) return 'push'; if (/(^|\s|;|&&|\|\|)git\s+commit\b/i.test(c)) return 'commit'; if (/(^|\s|;|&&|\|\|)git\s+cherry-pick\b/i.test(c)) return 'cherry-pick'; if (/(^|\s|;|&&|\|\|)git\s+reset\s+--hard\b/i.test(c)) return 'reset-hard'; if (/(^|\s|;|&&|\|\|)git\s+rebase\b/i.test(c)) return 'rebase'; if (/(^|\s|;|&&|\|\|)git\s+branch\s+-[df]\b/i.test(c)) return 'branch-force'; return null; } export function detectFullTestRun(cmd) { if (typeof cmd !== 'string') return null; const c = cmd.toLowerCase(); // FIRST-REAL-COMMAND approach: split on shell separators, find first segment // after skipping cd / env-prefix. Only that command counts. Embedded args // (commit messages, echo strings) don't matter — they live inside the args // of the first command, not as independent shell segments. // // Caveat: naive `&&` split can match inside quoted strings. We accept this // because we use the FIRST segment only; later segments are ignored. As // long as user's first real command is git/echo/etc, the whole command is // classified as that. const segments = c.split(/\s*(?:&&|\|\||;|\|)\s*/); let firstReal = null; for (let seg of segments) { seg = seg.trim(); // Strip env-var prefixes (KEY=value) and skip `cd ` segments. seg = seg.replace(/^(?:[a-z_][a-z0-9_]*=\S+\s+)+/i, '').trim(); if (/^cd\b/i.test(seg)) continue; firstReal = seg; break; } if (!firstReal) return null; // Hard guard: first real command starts with a non-test shell-utility → // whole compound is not a test run, regardless of quoted args. if (/^(?:git|scp|ssh|curl|wget|cat|echo|grep|awk|sed|tar|gzip|bzip2|cp|mv|rm|mkdir|touch|chmod|chown|ls|cd|pwd|head|tail|find)\b/.test(firstReal)) { return null; } if (/^npx\s+vitest\s+run\b/.test(firstReal) || /^vitest\s+run\b/.test(firstReal)) { // narrow vitest (specific .test file) is NOT full if (/\btools\/[^\s]+\.test\.mjs\b/.test(firstReal)) return null; return 'vitest-full'; } if (/^npm\s+run\s+test\b/.test(firstReal)) return 'npm-test'; if (/^php\s+artisan\s+test\b/.test(firstReal) || /^composer\s+test\b/.test(firstReal)) return 'pest'; if (/^(?:\.\/)?(?:vendor\/bin\/)?pest\b/.test(firstReal)) return 'pest'; return null; } export function isVerificationFresh(sessionId, maxAgeSec = 1800) { const s = readSentinel('verify-pass', sessionId); if (!s || s.result !== 'pass') return false; const age = sentinelAgeSec('verify-pass', sessionId); return age !== null && age <= maxAgeSec; }