0ea3b5d70d
Brain-retro #5 candidate C, hole 9: enforce-rationalization-audit.mjs only logged rationalization phrases (e.g., 'just this once', 'пока без') — never blocked. Also vocab was sparse. Changes: - Expanded vocabulary by 5 phrases: 'давай разок', 'только сейчас', 'один раз без правил', 'на этот раз без', 'я знаю что не надо но'. - Made decide() accept priorFlagCount; blocks on 3rd flag/session. - main() reads rationalization-flags-<session>.jsonl to compute count before calling decide().
136 lines
4.7 KiB
JavaScript
136 lines
4.7 KiB
JavaScript
#!/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,
|
|
exitDecision,
|
|
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 findRationalizationPhrases(text) {
|
|
if (typeof text !== 'string') return [];
|
|
const lo = text.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() {
|
|
try {
|
|
const raw = await readStdin();
|
|
const event = parseEventJson(raw);
|
|
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);
|
|
|
|
// Check if we should block based on rationalization phrases specifically
|
|
const text = lastAssistantText(transcript);
|
|
const decision = decide({ assistantText: text, sessionId: event.session_id, priorFlagCount });
|
|
exitDecision(decision.block ? { block: true, message: decision.message } : { block: false });
|
|
} catch {
|
|
exitDecision({ block: false });
|
|
}
|
|
}
|
|
|
|
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-rationalization-audit.mjs');
|
|
if (isCli) main();
|