Files
brain/tools/enforce-rationalization-audit.mjs
T

147 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();