2026-05-25 18:24:05 +03:00
#!/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 ,
2026-05-26 11:19:13 +03:00
readRationalizationFlags ,
2026-06-08 12:20:10 +03:00
exitDisciplineDecision ,
2026-05-25 18:24:05 +03:00
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' ,
'без церемоний' ,
'без скила сейчас' ,
2026-05-26 11:19:13 +03:00
// expanded vocabulary
'давай разок' ,
'только сейчас' ,
'один раз без правил' ,
'на этот раз без' ,
'я знаю что не надо но' ,
2026-05-25 18:24:05 +03:00
] ;
2026-05-29 18:32:57 +03:00
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 ;
}
2026-05-25 18:24:05 +03:00
export function findRationalizationPhrases ( text ) {
if ( typeof text !== 'string' ) return [ ] ;
2026-05-29 18:32:57 +03:00
const cleaned = stripQuotedContext ( text ) ;
const lo = cleaned . toLowerCase ( ) ;
2026-05-25 18:24:05 +03:00
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 ;
}
2026-05-26 11:19:13 +03:00
/**
* 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 } ;
}
2026-05-25 18:24:05 +03:00
async function main ( ) {
2026-06-08 12:20:10 +03:00
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' } ,
) ;
2026-05-25 18:24:05 +03:00
}
const isCli = process . argv [ 1 ] && process . argv [ 1 ] . replace ( /\\/g , '/' ) . endsWith ( '/enforce-rationalization-audit.mjs' ) ;
if ( isCli ) main ( ) ;