#!/usr/bin/env node /** * PreToolUse hook — fact-check absolutes guard. * * Scans Edit/Write tool inputs on markdown/documentation paths for * absolute claims (`always`, `never`, `obviously`, `faster`, `better`, * `toujours`, `jamais`, `forcement`) without an accompanying source * reference. * * When an unsourced absolute is detected on a doc file, the hook exits * with decision=block and a clear reason, forcing the author to cite a * source (matching `_byan/knowledge/sources.md`, `RFC`, `CVE-`, a URL, * or a `[CLAIM L]` prefix) before writing. * * Non-blocking outside of Edit/Write tools or when the target is code * (not documentation). */ const fs = require('fs'); const path = require('path'); const ABSOLUTES = [ /\btoujours\b/i, /\bjamais\b/i, /\bforc[eé]ment\b/i, /\bobviously\b/i, /\balways\b/i, /\bnever\b/i, /\bclearly\b/i, /\bundoubtedly\b/i, /\bfaster than\b/i, /\bbetter than\b/i, /\bplus rapide que\b/i, /\bmeilleur que\b/i, ]; const SOURCE_MARKERS = [ /\bRFC\s*\d+/i, /\bCVE-\d{4}-\d+/i, /https?:\/\//, /\[CLAIM\s+L[1-5]\]/i, /\[FACT\s+USER-VERIFIED/i, /\bsource\s*:/i, /_byan\/knowledge\/sources\.md/, ]; const DOC_EXTS = ['.md', '.mdx', '.rst', '.txt']; // Paths exempted from scanning — these files DESCRIBE the rule or meta-docs // where absolutes appear as examples, not as claims. const EXEMPT_PATH_PATTERNS = [ /\.claude\/hooks\//, /\.claude\/agents\/bmad-compliance\.md$/, /_byan\/mcp\/byan-mcp-server\/lib\/(peer-review|fd-state)\.js$/, /_byan\/mcp\/byan-mcp-server\/test\//, /_byan\/knowledge\/(fact-check|mantras)/i, /\/fact-check-absolutes\.js$/, /\.claude\/skills\/byan-fact-check\//, /install\/__tests__\/.*fact-check/i, /__tests__\/.*fact-check/i, /\.claude\/skills\/byan-taste(?:-[\w-]+)?\//, /\.claude\/skills\/byan-brandkit\//, /\.claude\/skills\/impeccable\//, ]; function isExemptPath(filePath) { if (!filePath) return false; return EXEMPT_PATH_PATTERNS.some((re) => re.test(filePath)); } // Strip content that cannot be a claim : // - fenced code blocks ``` ... ``` // - inline backticks `...` // - block quotes (lines starting with >) // - regex / array syntax that contains the word as a token function stripNonClaimZones(text) { if (!text) return ''; return text // Fenced code blocks .replace(/```[\s\S]*?```/g, '') // Inline code .replace(/`[^`\n]+`/g, '') // Markdown block quotes .replace(/^> .*$/gm, '') // Lines that look like list of patterns (e.g. "- toujours") .replace(/^[\s-]*['"]?\b(toujours|jamais|forc[eé]ment|obviously|always|never|clearly|undoubtedly)\b['"]?/gim, ''); } function readStdin() { return new Promise((resolve) => { if (process.stdin.isTTY) return resolve(''); let data = ''; process.stdin.on('data', (c) => (data += c)); process.stdin.on('end', () => resolve(data)); process.stdin.on('error', () => resolve(data)); }); } function isDoc(filePath) { if (!filePath) return false; return DOC_EXTS.some((ext) => filePath.toLowerCase().endsWith(ext)); } function extractText(toolName, input) { if (!input) return ''; if (toolName === 'Write') return String(input.content || ''); if (toolName === 'Edit') { return [input.new_string, input.old_string].filter(Boolean).join('\n'); } return ''; } function findUnsourced(text) { if (!text) return null; for (const re of ABSOLUTES) { const match = text.match(re); if (!match) continue; const idx = match.index || 0; const windowStart = Math.max(0, idx - 240); const windowEnd = Math.min(text.length, idx + match[0].length + 240); const ctx = text.slice(windowStart, windowEnd); const hasSource = SOURCE_MARKERS.some((sm) => sm.test(ctx)); if (!hasSource) { return { absolute: match[0], context: text.slice(Math.max(0, idx - 80), idx + 80) }; } } return null; } (async () => { const raw = await readStdin(); let payload = {}; try { payload = raw ? JSON.parse(raw) : {}; } catch { payload = {}; } const toolName = payload.tool_name || payload.toolName || ''; const input = payload.tool_input || payload.toolInput || {}; const target = input.file_path || ''; if (!['Edit', 'Write'].includes(toolName) || !isDoc(target) || isExemptPath(target)) { process.stdout.write( JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', }, }) ); process.exit(0); } const rawText = extractText(toolName, input); const text = stripNonClaimZones(rawText); const hit = findUnsourced(text); if (!hit) { process.stdout.write( JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', }, }) ); process.exit(0); } const reason = [ `BYAN fact-check guard : unsourced absolute "${hit.absolute}" detected in ${path.basename(target)}.`, `Context : ...${hit.context}...`, `Add a source (RFC, CVE, URL, [CLAIM L], or entry in _byan/knowledge/sources.md) before writing this. `, `Alternative : reformulate with hedging ("often", "in my tests", "tends to") to drop the absolute claim.`, ].join('\n'); process.stdout.write( JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, }, }) ); process.exit(0); })();