2026-06-15 08:06:08 +03:00
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
/**
|
|
|
|
|
|
* AskUserQuestion answer parsing library (router-gate v4, Stream E).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Pure functions only — no I/O, no exit. Consumed by gate hooks that wire
|
|
|
|
|
|
* approval-records / stop-detection. Stub-injectable LLM fallback (Stream D).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Spec: docs/superpowers/specs/2026-05-29-router-gate-v4-design.md §4.5 / §4.7
|
|
|
|
|
|
* (S27 stop-keywords, E33 invisible Unicode, E34 whitespace approval,
|
|
|
|
|
|
* multiSelect, annotations, Other social-eng detector).
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
// E33 — invisible / zero-width / direction-override / BOM / soft-hyphen.
|
|
|
|
|
|
// Code points: U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+202A-U+202E direction,
|
|
|
|
|
|
// U+2066-U+2069 isolation, U+FEFF BOM, U+00AD soft-hyphen.
|
|
|
|
|
|
const INVISIBLE_RE = /[]/g;
|
|
|
|
|
|
|
|
|
|
|
|
/** Strip invisible Unicode (E33). Non-string → ''. */
|
|
|
|
|
|
export function stripInvisible(s) {
|
|
|
|
|
|
if (typeof s !== 'string') return '';
|
|
|
|
|
|
return s.replace(INVISIBLE_RE, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Normalize a free-form answer: lowercase + strip invisible + collapse ws + trim. */
|
|
|
|
|
|
export function normalizeAnswer(s) {
|
|
|
|
|
|
if (typeof s !== 'string') return '';
|
|
|
|
|
|
return stripInvisible(s).toLowerCase().split(/\s+/).filter(Boolean).join(' ').trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 17:43:58 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* True если ответ дословно (по нормализации) совпал с ярлыком одной из опций вопроса —
|
|
|
|
|
|
* т.е. это ВЫБОР КНОПКИ. Ярлыки сочиняет контроллер (tool_input.questions[].options[].label),
|
|
|
|
|
|
* а среда может вернуть выбранную кнопку как «ответ» без действия владельца. Поэтому
|
|
|
|
|
|
* доверенную запись (floor_escape / approve_git_operation) из такого ответа НЕ порождаем —
|
|
|
|
|
|
* согласие только из свободного ввода владельца. Прецедент грани — observer-choice-detector
|
|
|
|
|
|
* (exact label match = выбор из опций, иначе свободный Other = владелец рулит сам).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function answerMatchesOption(answer, optionLabels) {
|
|
|
|
|
|
if (typeof answer !== 'string' || !Array.isArray(optionLabels)) return false;
|
|
|
|
|
|
const a = normalizeAnswer(answer);
|
|
|
|
|
|
if (!a) return false;
|
|
|
|
|
|
return optionLabels.some((l) => typeof l === 'string' && normalizeAnswer(l) === a);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 08:06:08 +03:00
|
|
|
|
/** Normalize a shell command for approval comparison (E34): collapse ws, keep case. */
|
|
|
|
|
|
export function normalizeCommand(cmd) {
|
|
|
|
|
|
if (typeof cmd !== 'string') return '';
|
|
|
|
|
|
return cmd.split(/\s+/).filter(Boolean).join(' ').trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// S27 — stop / abort / cancel keywords (Russian + English). After normalizeAnswer.
|
|
|
|
|
|
export const STOP_KEYWORDS = [
|
|
|
|
|
|
'стоп', 'стопа', 'стоит', 'стопаем', 'отмена', 'отменяю', 'отменить', 'отменяем',
|
|
|
|
|
|
'отмени', 'отменено', 'прекращаем', 'прекрати', 'прекратить', 'прекращай',
|
|
|
|
|
|
'хватит', 'довольно', 'закончили', 'закончил', 'закончить', 'останавливаемся',
|
|
|
|
|
|
'остановка', 'остановись', 'остановите', 'пас', 'пропуск', 'не надо', 'не делай',
|
|
|
|
|
|
'не делайте', 'не делать', 'ничего', 'нет', 'тормози', 'тормозим', 'глуши',
|
|
|
|
|
|
'глушим', 'забей', 'забили', 'забываем', 'шабаш', 'всё, поехали назад',
|
|
|
|
|
|
'закругляемся', 'снимем с повестки', 'выходим из этого', 'на этом всё',
|
|
|
|
|
|
'достаточно', 'cancel', 'abort', 'stop', 'halt', 'quit',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// Pre-split for matching: phrases (contain space) matched by substring;
|
|
|
|
|
|
// single tokens matched by token-membership (no Cyrillic \b reliability).
|
|
|
|
|
|
const STOP_PHRASES = STOP_KEYWORDS.filter((k) => k.includes(' '));
|
|
|
|
|
|
const STOP_TOKENS = new Set(STOP_KEYWORDS.filter((k) => !k.includes(' ')));
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* True if a free-form answer is a stop/abort/cancel intent (S27).
|
|
|
|
|
|
* Keyword-based; normalizes (E33 invisible strip + ws-collapse + lowercase) first.
|
|
|
|
|
|
* Punctuation attached to tokens (e.g. "нет,") is stripped before matching.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function isStopAnswer(text) {
|
|
|
|
|
|
const norm = normalizeAnswer(text);
|
|
|
|
|
|
if (!norm) return false;
|
|
|
|
|
|
const depunct = (s) => s.replace(/[.,;:!?…«»"'()\[\]{}]+/g, ' ').split(/\s+/).filter(Boolean).join(' ');
|
|
|
|
|
|
const cleaned = depunct(norm);
|
|
|
|
|
|
for (const phrase of STOP_PHRASES) {
|
|
|
|
|
|
if (cleaned.includes(depunct(normalizeAnswer(phrase)))) return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
const tokens = cleaned.split(' ');
|
|
|
|
|
|
for (const t of tokens) {
|
|
|
|
|
|
if (STOP_TOKENS.has(t)) return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Stop detection with LLM ambiguous fallback (§4.5).
|
|
|
|
|
|
* @param {string} text
|
|
|
|
|
|
* @param {{llmJudge?: (text:string)=>Promise<boolean>}} opts
|
|
|
|
|
|
* llmJudge default-stub returns false (never escalates). Stream D wires real judge.
|
|
|
|
|
|
* The injected llmJudge receives whitespace-collapsed lowercase text (post-normalizeAnswer), not the raw input.
|
|
|
|
|
|
* @returns {Promise<boolean>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function detectStopWithFallback(text, { llmJudge } = {}) {
|
|
|
|
|
|
if (isStopAnswer(text)) return true;
|
|
|
|
|
|
const judge = typeof llmJudge === 'function' ? llmJudge : async () => false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return (await judge(normalizeAnswer(text))) === true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false; // fail closed-safe: ambiguous + judge error → not a stop
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// E29 + v4.0 — controller suggesting concrete text for the "Other" field.
|
|
|
|
|
|
const OTHER_SOCIAL_ENG_PATTERNS = [
|
|
|
|
|
|
/напиши\s+в\s+other/iu,
|
|
|
|
|
|
/type\s+in\s+other/iu,
|
|
|
|
|
|
/в\s+поле\s+other/iu,
|
|
|
|
|
|
/копируй\s+в\s+other/iu,
|
|
|
|
|
|
/впиши\s+в\s+["«]?другое["»]?/iu,
|
|
|
|
|
|
/в\s+поле\s+["«]?другое["»]?/iu,
|
|
|
|
|
|
/нажми\s+["«]?другое["»]?\s+и\s+впиши/iu,
|
|
|
|
|
|
/укажи\s+в\s+графе\s+["«]?другое["»]?/iu,
|
2026-06-18 19:54:25 +03:00
|
|
|
|
// C (анти-диктовка): императивная диктовка метки FLOOR-ESCAPE (глагол рядом с меткой).
|
|
|
|
|
|
// НЕ ловит простое упоминание концепта (нет императива перед floor-escape).
|
|
|
|
|
|
/(?:набери|впиши|вставь|скопируй|введи|напечатай)[^.\n]{0,40}floor-escape/iu,
|
|
|
|
|
|
/(?:type|paste|enter|copy)[^.\n]{0,40}floor-escape/iu,
|
2026-06-15 08:06:08 +03:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Parse an AskUserQuestion tool result into selections + flattened text + stop flag.
|
|
|
|
|
|
* Handles single-string answers, multiSelect arrays, and annotations.notes (S15).
|
|
|
|
|
|
* @param {object} toolResult — { answers: {q: string|string[]}, annotations?: {q:{notes,preview}} }
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function parseAskUserResult(toolResult) {
|
|
|
|
|
|
const out = { selections: [], allText: [], stop: false };
|
|
|
|
|
|
if (!toolResult || typeof toolResult !== 'object') return out;
|
|
|
|
|
|
|
|
|
|
|
|
const answers = toolResult.answers && typeof toolResult.answers === 'object' ? toolResult.answers : {};
|
|
|
|
|
|
for (const v of Object.values(answers)) {
|
|
|
|
|
|
if (Array.isArray(v)) {
|
|
|
|
|
|
for (const item of v) if (typeof item === 'string') { out.selections.push(item); out.allText.push(item); }
|
|
|
|
|
|
} else if (typeof v === 'string') {
|
|
|
|
|
|
out.selections.push(v);
|
|
|
|
|
|
out.allText.push(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ann = toolResult.annotations && typeof toolResult.annotations === 'object' ? toolResult.annotations : {};
|
|
|
|
|
|
for (const meta of Object.values(ann)) {
|
|
|
|
|
|
if (meta && typeof meta.notes === 'string') out.allText.push(meta.notes);
|
|
|
|
|
|
if (meta && typeof meta.preview === 'string') out.allText.push(meta.preview);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out.stop = out.allText.some((t) => isStopAnswer(t));
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Whitespace-normalized command equality (E34) for approval-record matching. */
|
|
|
|
|
|
export function matchesApproval(approvedPattern, currentCommand) {
|
|
|
|
|
|
return normalizeCommand(approvedPattern) === normalizeCommand(currentCommand) &&
|
|
|
|
|
|
normalizeCommand(approvedPattern) !== '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Detect controller social-engineering of the AskUser "Other" field (E29 + v4.0 RU).
|
|
|
|
|
|
* @param {string} controllerText — controller response text in recent turns.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function detectOtherSocialEng(controllerText) {
|
|
|
|
|
|
if (typeof controllerText !== 'string') return { flagged: false, matched: null };
|
|
|
|
|
|
for (const re of OTHER_SOCIAL_ENG_PATTERNS) {
|
|
|
|
|
|
if (re.test(controllerText)) return { flagged: true, matched: re.toString() };
|
|
|
|
|
|
}
|
|
|
|
|
|
return { flagged: false, matched: null };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Build a pure approval record (consumer hook persists it to askuser-decisions-<sess>.jsonl).
|
|
|
|
|
|
* Pattern is whitespace-normalized (E34) so later matchesApproval is stable.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function buildApprovalRecord({ kind, pattern, sessionId, nowMs }) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
kind: String(kind ?? 'approve_generic'),
|
|
|
|
|
|
approved_action_pattern: normalizeCommand(pattern),
|
|
|
|
|
|
session_id: sessionId || 'unknown',
|
|
|
|
|
|
approved_at_ms: typeof nowMs === 'number' ? nowMs : Date.now(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Translate a free-form AskUserQuestion answer into a Stream B-compatible
|
|
|
|
|
|
* approve_git_operation record, or null if no git pattern detected.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Stream H Task 6 (schema sync): Stream E buildApprovalRecord returns the
|
|
|
|
|
|
* native parser schema {kind, approved_action_pattern, session_id, approved_at_ms};
|
|
|
|
|
|
* Stream B loadApprovedGitOps in shell-content-rules.mjs reads the wire format
|
|
|
|
|
|
* {type:'approve_git_operation', command, ts}. toApprovalRecord is the bridge.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Returns null for: non-string, empty, stop/abort/cancel intents, no git verb.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {string} answer - user's free-form answer text
|
|
|
|
|
|
* @param {object} [opts]
|
|
|
|
|
|
* @param {string} [opts.question] - the question that was asked (reserved for future use)
|
|
|
|
|
|
* @param {number} [opts.nowMs] - override timestamp for test determinism
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function toApprovalRecord(answer, { question, nowMs = Date.now() } = {}) {
|
|
|
|
|
|
if (typeof answer !== 'string') return null;
|
|
|
|
|
|
const norm = normalizeAnswer(answer);
|
|
|
|
|
|
if (!norm) return null;
|
|
|
|
|
|
if (isStopAnswer(answer)) return null;
|
|
|
|
|
|
// Detect a git verb after optional approval prefix; match verbs recognized
|
|
|
|
|
|
// by shell-content-rules GIT_CONDITIONAL_SUB + GIT_READONLY_SUB.
|
|
|
|
|
|
const gitMatch = /\b(git\s+(?:add|commit|push|pull|merge|rebase|reset|checkout|switch|branch|stash|cherry-pick|revert|clean|fetch|ls-remote|tag|status|log|show|diff|blame|format-patch|rev-parse|merge-base|remote)\b[^\n]*)/i.exec(answer);
|
|
|
|
|
|
if (!gitMatch) return null;
|
|
|
|
|
|
const command = normalizeCommand(gitMatch[1]);
|
|
|
|
|
|
return { type: 'approve_git_operation', command, ts: nowMs };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const FLOOR_ESCAPE_RE = /FLOOR-ESCAPE:\s*([^\n]+?)\s*$/;
|
|
|
|
|
|
|
|
|
|
|
|
/** Перевести ответ AskUser в floor_escape-запись, либо null. Stop-намерения → null. */
|
|
|
|
|
|
export function toFloorEscapeRecord(answer, { nowMs = Date.now() } = {}) {
|
|
|
|
|
|
if (typeof answer !== 'string') return null;
|
|
|
|
|
|
if (isStopAnswer(answer)) return null;
|
|
|
|
|
|
const m = FLOOR_ESCAPE_RE.exec(answer);
|
|
|
|
|
|
if (!m) return null;
|
|
|
|
|
|
let action = m[1].trim();
|
|
|
|
|
|
if (!action) return null;
|
|
|
|
|
|
// ✅O13: skill-escape канон — нижний регистр (зеркало canonicalAction Skill-ветки),
|
|
|
|
|
|
// иначе грант от иного регистра не совпадёт. write:-каноны не трогаем (как сейчас).
|
|
|
|
|
|
if (/^skill:/i.test(action)) action = action.toLowerCase();
|
|
|
|
|
|
return { type: 'floor_escape', action, ts: nowMs };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Подписать расписку (P10-c, router-mentor Машина 1). Возвращает копию record
|
|
|
|
|
|
* с полем sig (HMAC над record без sig). Без ключа — sig:null (downstream verify → false).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function signApprovalRecord(record, key) {
|
|
|
|
|
|
return { ...record, sig: signPayload(record, key, RECEIPT_DOMAINS.APPROVAL) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Проверить подпись расписки. Неподписанная/подделанная/без ключа → false. */
|
|
|
|
|
|
export function verifyApprovalRecord(record, key) {
|
|
|
|
|
|
return verifyReceipt(record, key, RECEIPT_DOMAINS.APPROVAL);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Подписать floor_escape-пропуск (M6 FIX-5, домен FLOOR_ESCAPE — зеркало signApprovalRecord).
|
|
|
|
|
|
* Без ключа — sig:null (downstream key-gated verify → принять как сегодня / отбросить при ключе). */
|
|
|
|
|
|
export function signFloorEscapeRecord(record, key) {
|
|
|
|
|
|
return { ...record, sig: signPayload(record, key, RECEIPT_DOMAINS.FLOOR_ESCAPE) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Проверить подпись floor_escape-пропуска. Неподписанная/подделанная/без ключа/чужой домен → false. */
|
|
|
|
|
|
export function verifyFloorEscapeRecord(record, key) {
|
|
|
|
|
|
return verifyReceipt(record, key, RECEIPT_DOMAINS.FLOOR_ESCAPE);
|
|
|
|
|
|
}
|