feat(ruflo): queen-trigger UserPromptSubmit hook (TDD)

This commit is contained in:
Дмитрий
2026-05-15 17:17:27 +03:00
parent d21b6556d2
commit dc6caea99f
2 changed files with 205 additions and 0 deletions
+125
View File
@@ -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 = /(?<!\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 = [
'что такое', 'что значит', 'как работает', 'как работают',
'не используй', 'не активируй', 'не запускай', 'не пиши',
'пример', 'вместо', 'забудь про',
];
/** 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_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();
}
+80
View File
@@ -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(), '');
});