abf2060328
Сессионный флаг standby-mode + управляющий UserPromptSubmit-хук рукопожатия + SessionStart-сброс. Страж if standbyActive в 12 блокирующих хуках; рельсы floor/snapshot/verify-gate не тронуты. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
7.5 KiB
JavaScript
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();
|