Files
brain/tools/askuser-cosmetic-detector.mjs
T
Дмитрий abf2060328 feat standby: штатный режим - флаг, управляющий хук, сброс, страж в 12 хуков
Сессионный флаг standby-mode + управляющий UserPromptSubmit-хук рукопожатия + SessionStart-сброс. Страж if standbyActive в 12 блокирующих хуках; рельсы floor/snapshot/verify-gate не тронуты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:07:04 +03:00

186 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
/**
* PreToolUse(AskUserQuestion) -- cosmetic-AskUser hard-block detector (router-gate v4.1).
*
* Catches the pattern: simple A/B AskUser used as a substitute for structured
* ideation (brainstorming/writing-plans). Per-turn -> soft flag; >2/session
* without brainstorming skill -> hard-block.
*
* Spec: docs/superpowers/specs/2026-05-29-router-gate-v4-1-max-closure.md §4.5
*
* decide() is pure. main() wires session/turn state from sentinels + transcript.
*/
import {
readStdin,
parseEventJson,
readTranscript,
sessionToolUses,
turnToolUses,
runtimeDir,
appendRationalizationFlag,
exitDecision,
} from './enforce-hook-helpers.mjs';
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
/** True if the AskUser is a "simple A/B" (2 short options, no skill mention). */
export function isSimpleAB(questions) {
if (!Array.isArray(questions) || questions.length === 0) return false;
return questions.every((q) =>
q && Array.isArray(q.options) &&
q.options.length === 2 &&
q.options.every((o) => o && typeof o.label === 'string' && o.label.length < 30) &&
!q.options.some((o) => o && typeof o.label === 'string' && o.label.toLowerCase().includes('skill')),
);
}
// Calibration 5 (2026-05-31) — git-operation APPROVAL prompts are the sanctioned
// git-approval channel (enforce-askuser-answer-parser turns the chosen answer
// into an approve_git_operation record), never a substitute for structured
// ideation. They must NOT be treated as cosmetic A/B. Identified structurally:
// an option label is a literal git command. (SCOPE fix, not a discipline drop —
// see decide(): design A/B questions with non-git labels are unaffected.)
const GIT_CMD_RE = /\bgit\s+(?:commit|push|add|pull|merge|rebase|reset|checkout|switch|branch|stash|cherry-pick|revert|clean|restore|fetch|tag)\b/i;
/** True if this AskUser is a git-operation approval prompt (an option label is a git command). */
export function isGitApprovalQuestion(questions) {
if (!Array.isArray(questions)) return false;
return questions.some((q) =>
q && Array.isArray(q.options) &&
q.options.some((o) => o && typeof o.label === 'string' && GIT_CMD_RE.test(o.label)));
}
/**
* Pure cosmetic-AskUser decision (v4.1 §4.5).
* Caller passes PRIOR counts; decide computes prospective new counts.
* Hard-block (session >2 simple w/o brainstorming) takes precedence over per-turn soft_flag.
*
* @returns {{action:'allow'|'soft_flag'|'hard_block', block:boolean, reason:string|null, isSimpleAB:boolean, newSessionCount:number, newTurnCount:number}}
*/
export function decide({ questions, simpleCountSession = 0, simpleCountTurn = 0, skillMatchedThisTurn = false, brainstormingInvoked = false }) {
// Calibration 5: git-operation approval prompts are exempt — the sanctioned
// git-approval channel, never cosmetic ideation. Allow, do not count, never
// block. (Cannot be abused to dodge ideation discipline: a git-command label
// makes the answer a real approve_git_operation, not a cosmetic clarification.)
if (isGitApprovalQuestion(questions)) {
return { action: 'allow', block: false, reason: null, isSimpleAB: false, newSessionCount: simpleCountSession, newTurnCount: simpleCountTurn };
}
const simple = isSimpleAB(questions);
const newSessionCount = simpleCountSession + (simple ? 1 : 0);
const newTurnCount = simpleCountTurn + (simple ? 1 : 0);
if (!simple) {
return { action: 'allow', block: false, reason: null, isSimpleAB: false, newSessionCount, newTurnCount };
}
// Per-session hard-block first (precedence).
if (newSessionCount > 2 && !brainstormingInvoked) {
return {
action: 'hard_block',
block: true,
reason: 'v4.1 cosmetic AskUser hard-block: >2 simple AskUser in session without brainstorming skill. ' +
'This is a cosmetic clarification pattern instead of structured ideation. Invoke superpowers:brainstorming now.',
isSimpleAB: true,
newSessionCount,
newTurnCount,
};
}
// Per-turn soft flag.
if (newTurnCount >= 1 && !skillMatchedThisTurn) {
return {
action: 'soft_flag',
block: false,
reason: 'v4.1 cosmetic AskUser: simple A/B without active Skill match in turn. ' +
'If clarification -- continue; if this replaces brainstorming/writing-plans skill -- invoke Skill now.',
isSimpleAB: true,
newSessionCount,
newTurnCount,
};
}
return { action: 'allow', block: false, reason: null, isSimpleAB: true, newSessionCount, newTurnCount };
}
/** Count prior simple-AB AskUser entries from the persisted flags array. */
export function countSimpleSession(flags) {
if (!Array.isArray(flags)) return 0;
return flags.filter((f) => f && f.isSimpleAB === true).length;
}
/** True if superpowers:brainstorming was invoked anywhere this session. */
export function brainstormingInvokedSession(entries) {
return sessionToolUses(entries).some((u) =>
u.name === 'Skill' && typeof u.input?.skill === 'string' && u.input.skill.includes('brainstorming'));
}
/** True if any Skill tool was invoked in the current turn. */
export function skillMatchedThisTurn(entries) {
return turnToolUses(entries).some((u) => u.name === 'Skill');
}
function flagsPath(sessionId) {
return join(runtimeDir(), `ask-user-cosmetic-flags-${sessionId || 'unknown'}.jsonl`);
}
function readFlags(sessionId) {
try {
const p = flagsPath(sessionId);
if (!existsSync(p)) return [];
return readFileSync(p, 'utf-8').split('\n').filter(Boolean).map((l) => {
try { return JSON.parse(l); } catch { return null; }
}).filter(Boolean);
} catch { return []; }
}
export async function main() {
try {
const raw = await readStdin();
const event = parseEventJson(raw);
if ((await import('./enforce-hook-helpers.mjs')).standbyActive((event && event.session_id) || 'unknown')) return exitDecision({ block: false });
if (!event || event.tool_name !== 'AskUserQuestion') return exitDecision({ block: false });
const questions = event.tool_input?.questions || [];
const sessionId = event.session_id || 'unknown';
const transcript = readTranscript(event.transcript_path);
const priorFlags = readFlags(sessionId);
const simpleCountSession = countSimpleSession(priorFlags);
const brainstormingInvoked = brainstormingInvokedSession(transcript);
const skillThisTurn = skillMatchedThisTurn(transcript);
const result = decide({
questions,
simpleCountSession,
simpleCountTurn: 0,
skillMatchedThisTurn: skillThisTurn,
brainstormingInvoked,
});
try {
appendFileSync(flagsPath(sessionId), JSON.stringify({
ts: new Date().toISOString(),
session_id: sessionId,
isSimpleAB: result.isSimpleAB,
action: result.action,
askuser_structure: result.isSimpleAB ? 'simple_ab' : 'multi_option',
}) + '\n');
} catch { /* ignore persistence errors */ }
if (result.action === 'soft_flag') {
appendRationalizationFlag(sessionId, 'cosmetic_askuser_soft', result.reason);
return exitDecision({ block: false });
}
if (result.action === 'hard_block') {
appendRationalizationFlag(sessionId, 'cosmetic_askuser_hard', result.reason);
return exitDecision({ block: true, message: '[askuser-cosmetic-detector] ' + result.reason });
}
return exitDecision({ block: false });
} catch {
return exitDecision({ block: false }); // fail-open
}
}
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/askuser-cosmetic-detector.mjs');
if (isCli) main();