diff --git a/tools/ruflo-queen-hook.mjs b/tools/ruflo-queen-hook.mjs new file mode 100644 index 00000000..eb8d1d0e --- /dev/null +++ b/tools/ruflo-queen-hook.mjs @@ -0,0 +1,125 @@ +#!/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 = /(? ''. */ +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_PREFIXES.some((kw) => prefix.includes(kw)); +} + +/** 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(); +} diff --git a/tools/ruflo-queen-hook.test.mjs b/tools/ruflo-queen-hook.test.mjs new file mode 100644 index 00000000..de8b0d40 --- /dev/null +++ b/tools/ruflo-queen-hook.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { + parsePrompt, detectQueen, isDiscussion, buildDirective, buildHookOutput, +} from './ruflo-queen-hook.mjs'; + +const HOOK = fileURLToPath(new URL('./ruflo-queen-hook.mjs', import.meta.url)); + +/** Run the hook as a subprocess with the given stdin; return stdout. */ +function runHook(stdinJson) { + return execFileSync(process.execPath, [HOOK], { input: stdinJson, encoding: 'utf8' }); +} + +test('detectQueen: matches English "queen" as a whole word', () => { + assert.equal(detectQueen('queen, fix the bug'), true); +}); +test('detectQueen: matches Russian "королеву" (case form)', () => { + assert.equal(detectQueen('сделай это через королеву'), true); +}); +test('detectQueen: case-insensitive (КОРОЛЕВА)', () => { + assert.equal(detectQueen('КОРОЛЕВА, подними рой'), true); +}); +test('detectQueen: word-boundary — "queens" is NOT a match', () => { + assert.equal(detectQueen('several queens'), false); +}); +test('detectQueen: no trigger — "очередь" is not "королева"', () => { + assert.equal(detectQueen('почини очередь задач'), false); +}); +test('detectQueen: non-string input -> false', () => { + assert.equal(detectQueen(123), false); +}); + +test('isDiscussion: "что такое queen?" is meta-discussion', () => { + assert.equal(isDiscussion('что такое queen?'), true); +}); +test('isDiscussion: "как работает триггер queen" is meta-discussion', () => { + assert.equal(isDiscussion('как работает триггер queen'), true); +}); +test('isDiscussion: "не используй queen сейчас" is meta-discussion', () => { + assert.equal(isDiscussion('не используй queen сейчас'), true); +}); +test('isDiscussion: a real queen task is NOT discussion (even a question)', () => { + assert.equal(detectQueen('queen, почему падает тест?'), true); + assert.equal(isDiscussion('queen, почему падает тест?'), false); +}); +test('isDiscussion: no trigger -> false', () => { + assert.equal(isDiscussion('обычная задача без триггера'), false); +}); + +test('buildDirective: contains §14 reference and the spawn command', () => { + const d = buildDirective(); + assert.ok(d.includes('Pravila §14')); + assert.ok(d.includes('hive-mind spawn --claude')); +}); + +test('buildHookOutput: correct UserPromptSubmit payload shape', () => { + const o = buildHookOutput('some context'); + assert.equal(o.hookSpecificOutput.hookEventName, 'UserPromptSubmit'); + assert.equal(o.hookSpecificOutput.additionalContext, 'some context'); +}); + +test('parsePrompt: extracts the prompt field', () => { + assert.equal(parsePrompt('{"prompt":"hello"}'), 'hello'); +}); +test('parsePrompt: empty string on invalid JSON (fail-open)', () => { + assert.equal(parsePrompt('{битый json'), ''); +}); + +test('main: queen prompt -> stdout carries the directive', () => { + const out = runHook(JSON.stringify({ prompt: 'queen, do the thing' })); + assert.ok(out.includes('RUFLO QUEEN TRIGGER')); +}); +test('main: non-queen prompt -> empty stdout', () => { + assert.equal(runHook(JSON.stringify({ prompt: 'just a normal task' })).trim(), ''); +}); +test('main: broken stdin -> empty stdout, exit 0 (fail-open)', () => { + assert.equal(runHook('{not json').trim(), ''); +});