397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
147 lines
5.3 KiB
JavaScript
147 lines
5.3 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,
|
||
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();
|