feat(enforce): T9 — Rule #10 rationalization audit (PostToolUse)
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
#!/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,
|
||||
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',
|
||||
'без церемоний',
|
||||
'без скила сейчас',
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
const event = parseEventJson(raw);
|
||||
const transcript = readTranscript(event.transcript_path);
|
||||
const flags = audit(transcript);
|
||||
for (const f of flags) appendRationalizationFlag(event.session_id, f.kind, f.evidence);
|
||||
exitDecision({ block: false });
|
||||
} catch {
|
||||
exitDecision({ block: false });
|
||||
}
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-rationalization-audit.mjs');
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findRationalizationPhrases, detectProdEditWithoutTest, audit } from './enforce-rationalization-audit.mjs';
|
||||
|
||||
describe('findRationalizationPhrases', () => {
|
||||
it('detects "just this once" in mixed case', () => {
|
||||
expect(findRationalizationPhrases('Hmm, Just This Once we will skip')).toContain('just this once');
|
||||
});
|
||||
it('detects "пока без" Russian', () => {
|
||||
expect(findRationalizationPhrases('сделаем пока без тестов')).toContain('пока без');
|
||||
});
|
||||
it('detects multiple phrases in one text', () => {
|
||||
const hits = findRationalizationPhrases('временно делаем потом разберусь');
|
||||
expect(hits.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
it('returns empty array on clean text', () => {
|
||||
expect(findRationalizationPhrases('coverage: skill:tdd')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectProdEditWithoutTest', () => {
|
||||
it('flags prod edit without any test edit in turn', () => {
|
||||
const uses = [{ name: 'Edit', input: { file_path: 'tools/foo.mjs' } }];
|
||||
expect(detectProdEditWithoutTest(uses)).toEqual(['tools/foo.mjs']);
|
||||
});
|
||||
it('does NOT flag when test also edited', () => {
|
||||
const uses = [
|
||||
{ name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } },
|
||||
{ name: 'Edit', input: { file_path: 'tools/foo.mjs' } },
|
||||
];
|
||||
expect(detectProdEditWithoutTest(uses)).toEqual([]);
|
||||
});
|
||||
it('does NOT flag for non-prod paths', () => {
|
||||
expect(detectProdEditWithoutTest([{ name: 'Edit', input: { file_path: 'docs/x.md' } }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit', () => {
|
||||
it('flags rationalization phrases in assistant text', () => {
|
||||
const entries = [
|
||||
{ message: { role: 'user', content: 'go' } },
|
||||
{ message: { role: 'assistant', content: [{ type: 'text', text: 'just this once без скила' }] } },
|
||||
];
|
||||
const flags = audit(entries);
|
||||
expect(flags.find((f) => f.kind === 'rationalization-phrase')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags prod-edit-without-test', () => {
|
||||
const entries = [
|
||||
{ message: { role: 'user', content: 'go' } },
|
||||
{ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', id: 't1', name: 'Edit', input: { file_path: 'tools/foo.mjs' } },
|
||||
] } },
|
||||
];
|
||||
const flags = audit(entries);
|
||||
expect(flags.find((f) => f.kind === 'prod-edit-without-test')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags weak commit messages (<12 chars)', () => {
|
||||
const entries = [
|
||||
{ message: { role: 'user', content: 'go' } },
|
||||
{ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git commit -m "fix"' } },
|
||||
] } },
|
||||
];
|
||||
const flags = audit(entries);
|
||||
expect(flags.find((f) => f.kind === 'weak-commit-message')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns no flags for clean turn', () => {
|
||||
const entries = [
|
||||
{ message: { role: 'user', content: 'go' } },
|
||||
{ message: { role: 'assistant', content: [
|
||||
{ type: 'text', text: 'coverage: skill:tdd\nworking properly' },
|
||||
{ type: 'tool_use', id: 't1', name: 'Edit', input: { file_path: 'tools/foo.test.mjs' } },
|
||||
{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: 'tools/foo.mjs' } },
|
||||
] } },
|
||||
];
|
||||
expect(audit(entries)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user