Files
brain/tools/ruflo-queen-hook.mjs
T

135 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* UserPromptSubmit hook — ruflo Queen trigger detector.
*
* On each prompt: detects the trigger words `queen` / `королева`. On a real
* (non-discussion) match it injects a hard directive (Pravila §14) telling
* Claude to route the task through ruflo Queen (`hive-mind spawn --claude`).
*
* FAIL-OPEN: any error -> empty injection, exit 0. The hook NEVER blocks a prompt.
*
* See docs/superpowers/specs/2026-05-15-ruflo-queen-trigger-and-delegation-design.md
*/
import { pathToFileURL } from 'node:url';
/** English trigger: whole word `queen`, case-insensitive. */
const EN_TRIGGER = /\bqueen\b/i;
/** Russian trigger: `королева` in 6 case forms, Unicode word-boundary. */
const RU_TRIGGER = /(?<!\p{L})королев(?:ой|ою|а|у|е|ы)(?!\p{L})/iu;
/**
* Disqualifying prefixes — if one appears in the ~30 chars before a trigger
* match, the match is meta-discussion, not activation.
*/
const DISCUSSION_PREFIXES = [
'что такое', 'что значит', 'как работает', 'как работают',
'не используй', 'не активируй', 'не запускай', 'не пиши',
'пример', 'вместо', 'забудь про',
];
/**
* Discussion prefixes compiled with a leading Unicode word-boundary so a
* prefix never matches inside a larger word (e.g. `пример` must not match
* inside `например`).
*/
const DISCUSSION_PATTERNS = DISCUSSION_PREFIXES.map(
(kw) => new RegExp(`(?<!\\p{L})${kw}`, 'u'),
);
/** Extract the `prompt` string from UserPromptSubmit stdin JSON. Fail-open -> ''. */
export function parsePrompt(stdinRaw) {
try {
const obj = JSON.parse(stdinRaw);
return typeof obj.prompt === 'string' ? obj.prompt : '';
} catch {
return '';
}
}
/** Index of the first trigger match in `prompt`, or -1 if none. */
function triggerIndex(prompt) {
const idxs = [];
const en = prompt.match(EN_TRIGGER);
if (en) idxs.push(en.index);
const ru = prompt.match(RU_TRIGGER);
if (ru) idxs.push(ru.index);
return idxs.length ? Math.min(...idxs) : -1;
}
/** True if the prompt contains a trigger word (queen / королева), any position. */
export function detectQueen(prompt) {
if (typeof prompt !== 'string') return false;
return triggerIndex(prompt) !== -1;
}
/**
* True if the trigger in `prompt` is meta-discussion (a disqualifying prefix
* appears within ~30 chars before it). No trigger / non-string -> false.
*/
export function isDiscussion(prompt) {
if (typeof prompt !== 'string') return false;
const idx = triggerIndex(prompt);
if (idx < 0) return false;
const prefix = prompt.slice(Math.max(0, idx - 30), idx).toLowerCase();
return DISCUSSION_PATTERNS.some((re) => re.test(prefix));
}
/** The hard directive injected on an active trigger. */
export function buildDirective() {
return [
'=== RUFLO QUEEN TRIGGER (Pravila §14 — hard rule) ===',
'',
'Промпт содержит триггер «queen»/«королева». §14 — explicit hard-rule.',
'',
'ПЕРВОЙ строкой ответа написать:',
' `ruflo Queen — задача маршрутизируется через рой (Pravila §14)`',
'',
'ОБЯЗАТЕЛЬНО на этот промпт:',
'1. Задача БЕЗУСЛОВНО идёт через ruflo Queen (`hive-mind spawn --claude`).',
' Не оспаривать, не предлагать прямой путь, не ссылаться на тривиальность.',
'2. Перед платным спавном показать ПРЕВЬЮ: роли и число агентов, декомпозиция',
' задачи, оценка объёма. Спавнить ТОЛЬКО после явного «да» заказчика.',
'3. При сбое `hive-mind spawn --claude` (ruflo alpha) — явно сообщить о сбое,',
' затем выполнить задачу напрямую (фоллбэк).',
'',
'§9 «Отступления» к §14 не применяется. Economy-режим §14 не override-ит.',
'Действует только на этот промпт.',
].join('\n');
}
/** Build the UserPromptSubmit hook stdout payload. */
export function buildHookOutput(additionalContext) {
return {
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext,
},
};
}
function readStdin() {
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => { data += c; });
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', () => resolve(data));
});
}
async function main() {
try {
const prompt = parsePrompt(await readStdin());
if (detectQueen(prompt) && !isDiscussion(prompt)) {
process.stdout.write(JSON.stringify(buildHookOutput(buildDirective())));
}
} catch {
// fail-open: no injection
}
process.exit(0);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}