#!/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 (!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();