#!/usr/bin/env node /** * Rule #10 — Rationalization audit (PostToolUse). * * Reads the last assistant text + nearby tool history. Detects rationalization * phrases and weak-test signals. Appends each flag to a JSONL file consumed by * Rule #1 injection on next prompt. * * NEVER blocks — soft visibility. Failure modes: * - skipped writing-plans for a feature task * - prod-code edit without matching test in same turn (despite TDD-gate * letting it through via override) * - assistant text contains rationalization phrases * * Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md */ import { readStdin, parseEventJson, readTranscript, lastAssistantText, turnToolUses, appendRationalizationFlag, readRationalizationFlags, exitDisciplineDecision, isProductionCodePath, } from './enforce-hook-helpers.mjs'; const RATIONALIZATION_PHRASES = [ 'just this once', 'пока без', 'сейчас быстрее', 'потом разберусь', 'временно', 'просто рационализация', "i'll come back to", 'i will come back to', 'we can skip', 'rationalize', 'без церемоний', 'без скила сейчас', // expanded vocabulary 'давай разок', 'только сейчас', 'один раз без правил', 'на этот раз без', 'я знаю что не надо но', ]; export function stripQuotedContext(text) { if (typeof text !== 'string') return ''; let stripped = text; stripped = stripped.replace(/```[\s\S]*?```/g, ''); stripped = stripped.replace(/`[^`\n]*`/g, ''); stripped = stripped.replace(/«[^»\n]*»/g, ''); stripped = stripped.replace(/"[^"\n]{1,200}"/g, ''); return stripped; } export function findRationalizationPhrases(text) { if (typeof text !== 'string') return []; const cleaned = stripQuotedContext(text); const lo = cleaned.toLowerCase(); const hits = []; for (const p of RATIONALIZATION_PHRASES) { if (lo.includes(p)) hits.push(p); } return hits; } export function detectProdEditWithoutTest(toolUses) { // Look for Edit/Write on production code; check if any test edit accompanies it. const prodEdits = []; let hasTestEdit = false; for (const u of toolUses) { if (!['Edit', 'Write', 'MultiEdit'].includes(u.name)) continue; const p = (u.input && (u.input.file_path || u.input.notebook_path)) || ''; if (/\.(test|spec)\.[a-z0-9]+$/i.test(p) || /Test\.php$/.test(p)) { hasTestEdit = true; continue; } if (isProductionCodePath(p)) prodEdits.push(p); } return prodEdits.length > 0 && !hasTestEdit ? prodEdits : []; } export function audit(transcriptEntries) { const flags = []; const text = lastAssistantText(transcriptEntries); const phrases = findRationalizationPhrases(text); for (const p of phrases) flags.push({ kind: 'rationalization-phrase', evidence: p }); const toolUses = turnToolUses(transcriptEntries); const orphanProdEdits = detectProdEditWithoutTest(toolUses); for (const p of orphanProdEdits) flags.push({ kind: 'prod-edit-without-test', evidence: p }); // Weak commit-message: git commit with very short message for (const u of toolUses) { if (u.name !== 'Bash') continue; const cmd = (u.input && u.input.command) || ''; if (!/git\s+commit/.test(cmd)) continue; const m = cmd.match(/-m\s+["']([^"']+)["']/); if (m && m[1].length < 12) { flags.push({ kind: 'weak-commit-message', evidence: m[1] }); } } return flags; } /** * Pure decision seam — injectable priorFlagCount for testability. * Blocks on 3rd flag of the same session (priorFlagCount >= 2). */ export function decide({ assistantText, sessionId: _sessionId, override = false, priorFlagCount = 0 }) { const detected = findRationalizationPhrases(assistantText || ''); if (override) return { block: false, detected }; if (priorFlagCount >= 2 && detected.length > 0) { return { block: true, message: `Rationalization detected (phrase: "${detected[0]}"). This is the ${priorFlagCount + 1}th flag in this session — blocking to prevent pattern escalation.`, detected, }; } return { block: false, detected }; } async function main() { const raw = await readStdin(); const event = parseEventJson(raw); // М7 Фаза 4b: fail-CLOSE (любая внутренняя ошибка → блок, не тихий пропуск, анти-SE2). await exitDisciplineDecision( () => { const transcript = readTranscript(event.transcript_path); const flags = audit(transcript); // Count prior flags before appending new ones const priorFlagCount = readRationalizationFlags(event.session_id).length; for (const f of flags) appendRationalizationFlag(event.session_id, f.kind, f.evidence); // Halt-counter: язык-детектор мягкий (flags), блок только на 3-м флаге (decide). const text = lastAssistantText(transcript); const decision = decide({ assistantText: text, sessionId: event.session_id, priorFlagCount }); return { block: decision.block, message: decision.block ? decision.message : undefined }; }, { label: 'enforce-rationalization-audit' }, ); } const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-rationalization-audit.mjs'); if (isCli) main();