docs(plan): ruflo memory H7 fix + advisory hook — implementation plan

8-task plan for the approved design (spec 1d4429c):
- D1 (Tasks 1-3): install standalone claude CLI, verify spawn --claude
- D2 (Tasks 4-5): TDD ruflo-h7-patch.mjs, apply patch, verify round-trip
- D3 (Tasks 6-7): TDD ruflo-recall-hook.mjs, register UserPromptSubmit hook
- Task 8: memory update + push

cspell-words.txt +3 entries used by the plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 14:15:14 +03:00
parent 1d4429c52a
commit 8e19b52d91
2 changed files with 871 additions and 0 deletions
+3
View File
@@ -1204,3 +1204,6 @@ poincaré
бинаря
промпты
свежеустановленный
персистентности
закоммичена
AUTHOK
@@ -0,0 +1,868 @@
# Ruflo Memory H7 Fix + Advisory Hook — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> (recommended) or superpowers:executing-plans to implement this plan task-by-task.
> Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Установить standalone `claude` CLI, починить баг персистентности ruflo
memory (H7) и добавить `UserPromptSubmit` advisory-хук, инжектящий ruflo-память в
каждый промпт.
**Architecture:** Три деливерабла, исполняются строго по порядку D1 → D2 → D3. D1 —
операционная установка пакета. D2 — два Node-ESM-скрипта (`tools/`): идемпотентный
re-apply-патч для глобального `@claude-flow/cli` + его юнит-тесты. D3 — Node-ESM
скрипт хука с чистым ядром + юнит-тесты + регистрация в `.claude/settings.json`.
Скрипты `tools/*.mjs` тестируются встроенным раннером `node --test` (Node 24),
независимо от Vitest приложения.
**Tech Stack:** Node.js 24 (ESM, `node:test`, `node:assert`), ruflo CLI
v3.7.0-alpha.38, Claude Code hooks (`UserPromptSubmit`), Windows Server 2022 +
PowerShell 5.1 / Git Bash.
**Спека:** `docs/superpowers/specs/2026-05-15-ruflo-memory-h7-fix-and-advisory-hook-design.md`
(commit `a6649e4`).
**Ветка:** `main` (согласно норме проекта — спека закоммичена в `main` коммитом
`a6649e4`; деливераблы — мелкие infra-файлы). Push в `origin/main` — финальный шаг
Task 8 (разрешён settings rule `Bash(git push origin main:*)`).
---
## File Structure
| Файл | Ответственность | Действие |
|---|---|---|
| `tools/ruflo-h7-patch.mjs` | Re-apply-скрипт H7-патча: чистые функции `isPatched`/`applyPatch`/`revertPatch`/`resolveTargetPath` + CLI `main()` | Create (Task 4) |
| `tools/ruflo-h7-patch.test.mjs` | Юнит-тесты чистых функций патч-скрипта (`node --test`) | Create (Task 4) |
| `tools/ruflo-recall-hook.mjs` | `UserPromptSubmit`-хук: чистые функции `parsePrompt`/`extractJson`/`formatRecall`/`buildHookOutput`/`rufloBinCandidates` + `main()` | Create (Task 6) |
| `tools/ruflo-recall-hook.test.mjs` | Юнит-тесты чистых функций хука (`node --test`) | Create (Task 6) |
| `.claude/settings.json` | +блок `hooks.UserPromptSubmit` | Modify (Task 7) |
| `<npm-root-g>/ruflo/node_modules/@claude-flow/cli/dist/src/memory/memory-initializer.js` | H7-патч (вне git, накладывается скриптом) | Patch (Task 5) |
`<npm-root-g>` = `C:\Users\Administrator\AppData\Roaming\npm\node_modules` (verified
`npm root -g`).
---
## D1 — Standalone `claude` CLI
### Task 1: Установить standalone `claude` CLI
**Files:** нет (глобальная npm-установка, не репозиторий).
- [ ] **Step 1: Установить пакет**
```bash
npm i -g @anthropic-ai/claude-code
```
Expected: установка завершается без ошибок (выводит установленную версию).
- [ ] **Step 2: Проверить, что бинарь на PATH**
```bash
claude --version
```
Expected: выводится номер версии (например `2.x.x`). Если `command not found`
перезапустить shell (PATH глобальных npm-bin обновляется при новом shell) и повторить.
**Commit:** нет — Task 1 не меняет файлы репозитория.
---
### Task 2: Проверить `claude` CLI и статус auth
**Files:** нет.
- [ ] **Step 1: Проверить, что ruflo doctor видит CLI**
```bash
ruflo doctor 2>&1 | grep -i "claude code cli"
```
Expected: строка вида `✓ Claude Code CLI: ...` (НЕ `⚠ ... Not installed`).
Если всё ещё `Not installed``claude` не на PATH; вернуться к Task 1 Step 2.
- [ ] **Step 2: Проверить статус авторизации CLI**
```bash
claude --version && echo "---" && claude -p "Reply with exactly: AUTHOK" 2>&1 | head -20
```
Expected — одно из двух:
- Вывод содержит `AUTHOK` → CLI авторизован (подхватил credentials из `~/.claude/`),
D1 можно завершать.
- Вывод требует логина (`Please run claude login` / приглашение войти) → **остановиться
и сообщить пользователю**: нужен интерактивный `claude login` (это user-action,
не автоматизируется). Без авторизации `hive-mind spawn --claude` (Task 3) работать
не будет.
**Commit:** нет.
---
### Task 3: Smoke-проверка `hive-mind spawn --claude`
**Files:** нет.
- [ ] **Step 1: Уточнить флаг отключения auto-permissions**
```bash
ruflo hive-mind spawn --help 2>&1
```
Expected: в выводе есть флаг `--claude` и флаг по умолчанию
`--dangerously-skip-permissions [default: true]`. Найти точное имя флага, который
ОТКЛЮЧАЕТ авто-пропуск permission-промптов (ожидается `--no-auto-permissions`;
использовать ровно то имя, что в `--help`). Записать его как `<NO_AUTOPERM_FLAG>`.
- [ ] **Step 2: Smoke-spawn на тривиальной задаче**
```bash
ruflo hive-mind spawn --claude <NO_AUTOPERM_FLAG> -o "Reply with the single word READY and make no file changes."
```
Expected: spawned-инстанс `claude` Code запускается, отрабатывает и завершается без
ошибки запуска подпроцесса. (Эта операция потребляет ёмкость существующего
Claude-плана пользователя — задача намеренно минимальная.)
Если падает с «claude: not found» / ошибкой запуска подпроцесса — вернуться к Task 1.
Если падает на auth — вернуться к Task 2 Step 2.
**Commit:** нет — D1 завершён, репозиторий не изменён.
---
## D2 — Фикс H7: патч `getBridge()`
### Task 4: Создать `tools/ruflo-h7-patch.mjs` (TDD)
**Files:**
- Create: `tools/ruflo-h7-patch.mjs`
- Create: `tools/ruflo-h7-patch.test.mjs`
- [ ] **Step 1: Написать падающий тест**
Создать `tools/ruflo-h7-patch.test.mjs`:
```js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
isPatched, applyPatch, revertPatch, resolveTargetPath, PATCH_MARKER,
} from './ruflo-h7-patch.mjs';
// Фикстура — фактический getBridge() из memory-initializer.js:70-84.
const FIXTURE = `let _bridge;
async function getBridge() {
if (_bridge === null)
return null;
if (_bridge)
return _bridge;
try {
_bridge = await import('./memory-bridge.js');
return _bridge;
}
catch {
_bridge = null;
return null;
}
}
`;
test('isPatched: false on clean content', () => {
assert.equal(isPatched(FIXTURE), false);
});
test('applyPatch: inserts patch line right after the anchor', () => {
const { content, changed } = applyPatch(FIXTURE);
assert.equal(changed, true);
assert.ok(content.includes(PATCH_MARKER));
const lines = content.split('\n');
const anchorIdx = lines.findIndex((l) => l.includes('async function getBridge()'));
assert.ok(lines[anchorIdx + 1].includes(PATCH_MARKER));
});
test('applyPatch: idempotent — second apply is a no-op', () => {
const once = applyPatch(FIXTURE).content;
const twice = applyPatch(once);
assert.equal(twice.changed, false);
assert.equal(twice.content, once);
});
test('isPatched: true after applyPatch', () => {
assert.equal(isPatched(applyPatch(FIXTURE).content), true);
});
test('applyPatch: throws when the anchor is absent', () => {
assert.throws(() => applyPatch('function other() {}\n'), /anchor .* not found/);
});
test('revertPatch: removes the patch line and restores the original', () => {
const patched = applyPatch(FIXTURE).content;
const { content, changed } = revertPatch(patched);
assert.equal(changed, true);
assert.equal(isPatched(content), false);
assert.equal(content, FIXTURE);
});
test('revertPatch: no-op on unpatched content', () => {
assert.equal(revertPatch(FIXTURE).changed, false);
});
test('resolveTargetPath: null when no candidate file exists', () => {
assert.equal(resolveTargetPath('/nonexistent/npm/root/zzz'), null);
});
```
- [ ] **Step 2: Запустить тест — убедиться, что падает**
```bash
node --test tools/ruflo-h7-patch.test.mjs
```
Expected: FAIL — `Cannot find module ... ruflo-h7-patch.mjs` (скрипт ещё не создан).
- [ ] **Step 3: Реализовать `tools/ruflo-h7-patch.mjs`**
Создать `tools/ruflo-h7-patch.mjs`:
```js
#!/usr/bin/env node
/**
* ruflo H7 patch re-apply tool.
*
* H7: `ruflo memory store` routes through the AgentDB-v3 bridge
* (memory-bridge.js:bridgeStoreEntry) which inserts into an in-RAM sql.js DB
* and never flushes to disk — entries are lost between CLI invocations.
*
* Fix: force getBridge() in @claude-flow/cli's memory-initializer.js to
* return null, so memory ops fall through to the raw sql.js path that DOES
* persist (db.export() + writeFileRestricted).
*
* The patch lives in the GLOBAL npm install and is overwritten by
* `ruflo update` — re-run this script after any ruflo upgrade.
*
* Usage:
* node tools/ruflo-h7-patch.mjs apply the patch (idempotent)
* node tools/ruflo-h7-patch.mjs --check exit 0 if patched, 1 if not
* node tools/ruflo-h7-patch.mjs --revert remove the patch
*
* See docs/superpowers/specs/2026-05-15-ruflo-memory-h7-fix-and-advisory-hook-design.md §4
*/
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
export const PATCH_MARKER = 'LIDERRA-H7-PATCH';
export const PATCH_LINE =
' return null; /* LIDERRA-H7-PATCH: AgentDB bridge writes in-RAM sql.js, never flushes — force raw persisting path */';
const ANCHOR = 'async function getBridge() {';
/** True if the file content already contains the patch. */
export function isPatched(content) {
return content.includes(PATCH_MARKER);
}
/**
* Insert the patch line as the first statement of getBridge().
* Idempotent. Throws if the anchor is absent (ruflo upstream changed).
*/
export function applyPatch(content) {
if (isPatched(content)) return { content, changed: false };
const idx = content.indexOf(ANCHOR);
if (idx === -1) {
throw new Error(
`anchor "${ANCHOR}" not found — ruflo upstream changed getBridge(); revise this patch manually`,
);
}
const insertAt = idx + ANCHOR.length;
const patched = content.slice(0, insertAt) + '\n' + PATCH_LINE + content.slice(insertAt);
return { content: patched, changed: true };
}
/** Remove the patch line (the line carrying PATCH_MARKER). */
export function revertPatch(content) {
if (!isPatched(content)) return { content, changed: false };
const lines = content.split('\n');
const kept = lines.filter((l) => !l.includes(PATCH_MARKER));
return { content: kept.join('\n'), changed: true };
}
/**
* Resolve the path to @claude-flow/cli's memory-initializer.js inside the
* global npm tree. Handles both nested (under ruflo/) and hoisted layouts.
*/
export function resolveTargetPath(npmRoot) {
const tail = ['@claude-flow', 'cli', 'dist', 'src', 'memory', 'memory-initializer.js'];
const candidates = [
join(npmRoot, 'ruflo', 'node_modules', ...tail),
join(npmRoot, ...tail),
];
return candidates.find((c) => existsSync(c)) ?? null;
}
function main() {
const mode = process.argv[2];
let npmRoot;
try {
npmRoot = execSync('npm root -g', { encoding: 'utf8' }).trim();
} catch (e) {
console.error('[ruflo-h7-patch] failed to run `npm root -g`: ' + e.message);
process.exit(2);
}
const target = resolveTargetPath(npmRoot);
if (!target) {
console.error('[ruflo-h7-patch] memory-initializer.js not found under ' + npmRoot);
process.exit(2);
}
const content = readFileSync(target, 'utf8');
if (mode === '--check') {
const patched = isPatched(content);
console.log(`[ruflo-h7-patch] ${target}\n ${patched ? 'PATCHED' : 'UNPATCHED'}`);
process.exit(patched ? 0 : 1);
}
if (mode === '--revert') {
const { content: out, changed } = revertPatch(content);
if (changed) writeFileSync(target, out);
console.log(`[ruflo-h7-patch] revert: ${changed ? 'patch removed' : 'was not patched'}`);
process.exit(0);
}
try {
const { content: out, changed } = applyPatch(content);
if (changed) writeFileSync(target, out);
console.log(`[ruflo-h7-patch] apply: ${changed ? 'patched' : 'already patched'}`);
process.exit(0);
} catch (e) {
console.error('[ruflo-h7-patch] ' + e.message);
process.exit(3);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
```
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
```bash
node --test tools/ruflo-h7-patch.test.mjs
```
Expected: PASS — 8 тестов, `# pass 8`, `# fail 0`.
- [ ] **Step 5: Коммит**
```bash
git add tools/ruflo-h7-patch.mjs tools/ruflo-h7-patch.test.mjs
git commit -m "feat(ruflo): H7 patch re-apply script (getBridge -> null)
Idempotent script that forces @claude-flow/cli getBridge() to return null
so ruflo memory ops use the persisting raw sql.js path. Pure-function core
unit-tested via node --test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
Expected: pre-commit зелёный (gitleaks/eslint-vue skip — `.mjs` в `tools/` не под
ESLint-конфигом приложения; gitleaks проходит).
---
### Task 5: Наложить H7-патч и проверить cross-process персистентность
**Files:** патчит (вне git) `<npm-root-g>/ruflo/node_modules/@claude-flow/cli/dist/src/memory/memory-initializer.js`.
- [ ] **Step 1: Проверить, что патч ещё не наложен**
```bash
node tools/ruflo-h7-patch.mjs --check
```
Expected: вывод `UNPATCHED`, exit code 1.
- [ ] **Step 2: Наложить патч**
```bash
node tools/ruflo-h7-patch.mjs
```
Expected: `[ruflo-h7-patch] apply: patched`.
Если вывод `anchor ... not found` (exit 3) — **остановиться**: ruflo upstream
изменил `getBridge()`; патч пересмотреть вручную против актуального исходника.
- [ ] **Step 3: Подтвердить статус патча**
```bash
node tools/ruflo-h7-patch.mjs --check
```
Expected: `PATCHED`, exit code 0.
- [ ] **Step 4: Перезапустить daemon, чтобы он загрузил пропатченный модуль**
```bash
pm2 restart ruflo-daemon && pm2 save
```
Expected: `ruflo-daemon` перезапущен, статус `online`. (Уже запущенный daemon держал
старый модуль в памяти; перезапуск подхватывает патч.)
- [ ] **Step 5: Cross-process round-trip — доказать персистентность**
```bash
echo "=== mtime before ==="; stat -c '%Y' .swarm/memory.db
ruflo memory store -k h7-verify --value "ROUNDTRIP-OK liderra 2026-05-15"
echo "=== mtime after store ==="; stat -c '%Y' .swarm/memory.db
ruflo memory retrieve -k h7-verify
ruflo memory stats 2>&1 | grep -E "Total Entries"
```
Expected:
- mtime `.swarm/memory.db` ПОСЛЕ store **больше**, чем ДО (файл записан);
- `memory retrieve -k h7-verify` → запись найдена, value содержит `ROUNDTRIP-OK`;
- `memory stats``Total Entries` ≥ 1.
Если retrieve не находит запись или mtime не изменилась — патч не возымел эффекта;
**остановиться** и диагностировать через `superpowers:systematic-debugging` (проверить:
действительно ли пропатчен файл; не кэширует ли что-то модуль; не лежит ли реальная
БД по иному пути из `getMemoryRoot()`).
- [ ] **Step 6: Очистить verify-запись**
```bash
ruflo memory delete -k h7-verify
ruflo memory retrieve -k h7-verify 2>&1 | grep -i "not found"
```
Expected: `delete` сообщает об удалении; повторный `retrieve``Key not found`.
**Commit:** нет — патч в `node_modules` (вне git); скрипт уже закоммичен в Task 4.
---
## D3 — Advisory-хук
### Task 6: Создать `tools/ruflo-recall-hook.mjs` (TDD)
**Files:**
- Create: `tools/ruflo-recall-hook.mjs`
- Create: `tools/ruflo-recall-hook.test.mjs`
- [ ] **Step 1: Написать падающий тест**
Создать `tools/ruflo-recall-hook.test.mjs`:
```js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parsePrompt, extractJson, formatRecall, buildHookOutput, rufloBinCandidates,
} from './ruflo-recall-hook.mjs';
test('parsePrompt: extracts the prompt field', () => {
assert.equal(parsePrompt('{"prompt":"hello world"}'), 'hello world');
});
test('parsePrompt: empty string when field is missing', () => {
assert.equal(parsePrompt('{"other":1}'), '');
});
test('parsePrompt: empty string on invalid JSON (fail-open)', () => {
assert.equal(parsePrompt('not json at all'), '');
});
test('parsePrompt: empty string when prompt is not a string', () => {
assert.equal(parsePrompt('{"prompt":123}'), '');
});
test('extractJson: pulls the JSON object out of noisy ruflo stdout', () => {
const stdout =
'[INFO] Searching: "x" (semantic)\n\n' +
'✅ Using sql.js\n' +
'{\n "query": "x",\n "results": []\n}\n';
const obj = extractJson(stdout);
assert.equal(obj.query, 'x');
assert.deepEqual(obj.results, []);
});
test('extractJson: null when there is no JSON', () => {
assert.equal(extractJson('[INFO] only noise, no braces'), null);
});
test('extractJson: null on malformed JSON (fail-open)', () => {
assert.equal(extractJson('noise {not: valid'), null);
});
test('formatRecall: empty string when there are no results', () => {
assert.equal(formatRecall({ results: [] }), '');
assert.equal(formatRecall(null), '');
assert.equal(formatRecall({}), '');
});
test('formatRecall: formats at most 3 results and tags the block', () => {
const out = formatRecall({
results: [
{ key: 'k1', preview: 'p1' },
{ key: 'k2', preview: 'p2' },
{ key: 'k3', preview: 'p3' },
{ key: 'k4', preview: 'p4' },
],
});
assert.ok(out.includes('k1') && out.includes('k3'));
assert.ok(!out.includes('k4'));
assert.ok(out.includes('ruflo memory recall'));
});
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('rufloBinCandidates: RUFLO_RECALL_BIN override wins', () => {
assert.deepEqual(rufloBinCandidates({ RUFLO_RECALL_BIN: 'X:/r.js' }), ['X:/r.js']);
});
test('rufloBinCandidates: builds an APPDATA-based candidate', () => {
const c = rufloBinCandidates({ APPDATA: 'C:\\\\Users\\\\X\\\\AppData\\\\Roaming' });
assert.equal(c.length, 1);
assert.ok(c[0].includes('ruflo') && c[0].endsWith('ruflo.js'));
});
test('rufloBinCandidates: empty array when no env hints', () => {
assert.deepEqual(rufloBinCandidates({}), []);
});
```
- [ ] **Step 2: Запустить тест — убедиться, что падает**
```bash
node --test tools/ruflo-recall-hook.test.mjs
```
Expected: FAIL — `Cannot find module ... ruflo-recall-hook.mjs`.
- [ ] **Step 3: Реализовать `tools/ruflo-recall-hook.mjs`**
Создать `tools/ruflo-recall-hook.mjs`:
```js
#!/usr/bin/env node
/**
* UserPromptSubmit hook — ruflo memory advisory recall.
*
* On each prompt: runs `ruflo memory search` and injects the top matches as
* additionalContext so Claude sees relevant recalled memory.
*
* FAIL-OPEN: any error / timeout / no results -> empty injection, exit 0.
* The hook NEVER blocks a prompt.
*
* Requires the H7 patch (tools/ruflo-h7-patch.mjs) — otherwise ruflo memory
* does not persist and search always returns nothing.
*
* See docs/superpowers/specs/2026-05-15-ruflo-memory-h7-fix-and-advisory-hook-design.md §5
*/
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
const SEARCH_TIMEOUT_MS = 3500;
const MAX_RESULTS = 3;
const MAX_QUERY_CHARS = 400;
/** Extract the `prompt` string from the UserPromptSubmit hook stdin JSON. */
export function parsePrompt(stdinRaw) {
try {
const obj = JSON.parse(stdinRaw);
return typeof obj.prompt === 'string' ? obj.prompt : '';
} catch {
return '';
}
}
/**
* Extract the JSON object from `ruflo memory search --format json` stdout.
* stdout is noisy ([INFO]..., "Using sql.js"...) followed by a pretty-printed
* JSON object — take from the first '{' to the last '}'.
*/
export function extractJson(stdout) {
const start = stdout.indexOf('{');
const end = stdout.lastIndexOf('}');
if (start === -1 || end === -1 || end < start) return null;
try {
return JSON.parse(stdout.slice(start, end + 1));
} catch {
return null;
}
}
/**
* Format the search result object into an additionalContext block.
* Returns '' when there are no usable results.
*/
export function formatRecall(searchObj) {
if (!searchObj || !Array.isArray(searchObj.results) || searchObj.results.length === 0) {
return '';
}
const lines = searchObj.results.slice(0, MAX_RESULTS).map((r) => {
const key = typeof r.key === 'string' ? r.key : '(no-key)';
const preview = typeof r.preview === 'string' ? r.preview : '';
return `- [${key}] ${preview}`;
});
return ['[ruflo memory recall — релевантное из прошлых сессий]', ...lines].join('\n');
}
/** Build the UserPromptSubmit hook stdout payload. */
export function buildHookOutput(additionalContext) {
return {
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext,
},
};
}
/** Candidate paths for the ruflo bin JS. RUFLO_RECALL_BIN overrides all. */
export function rufloBinCandidates(env) {
if (env.RUFLO_RECALL_BIN) return [env.RUFLO_RECALL_BIN];
const out = [];
if (env.APPDATA) {
out.push(join(env.APPDATA, 'npm', 'node_modules', 'ruflo', 'bin', 'ruflo.js'));
}
return out;
}
function resolveRufloBin(env) {
return rufloBinCandidates(env).find((p) => existsSync(p)) ?? null;
}
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));
});
}
function runSearch(rufloBin, query) {
return new Promise((resolve) => {
execFile(
process.execPath,
[rufloBin, 'memory', 'search', '-q', query, '--format', 'json', '-l', String(MAX_RESULTS)],
{ timeout: SEARCH_TIMEOUT_MS, encoding: 'utf8' },
(_err, stdout) => resolve(typeof stdout === 'string' ? stdout : ''),
);
});
}
async function main() {
let additionalContext = '';
try {
const stdinRaw = await readStdin();
const prompt = parsePrompt(stdinRaw);
const rufloBin = resolveRufloBin(process.env);
if (prompt.trim().length > 0 && rufloBin) {
const stdout = await runSearch(rufloBin, prompt.slice(0, MAX_QUERY_CHARS));
additionalContext = formatRecall(extractJson(stdout));
}
} catch {
additionalContext = '';
}
if (additionalContext) {
process.stdout.write(JSON.stringify(buildHookOutput(additionalContext)));
}
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
```
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
```bash
node --test tools/ruflo-recall-hook.test.mjs
```
Expected: PASS — 13 тестов, `# pass 13`, `# fail 0`.
- [ ] **Step 5: Интеграционная проверка скрипта (fail-open при отсутствии ruflo)**
```bash
echo '{"prompt":"anything"}' | RUFLO_RECALL_BIN=/nonexistent/zzz.js node tools/ruflo-recall-hook.mjs; echo "exit=$?"
```
Expected: `exit=0`, вывода нет (ruflo-бинарь не найден → пустой инжект, fail-open).
- [ ] **Step 6: Коммит**
```bash
git add tools/ruflo-recall-hook.mjs tools/ruflo-recall-hook.test.mjs
git commit -m "feat(ruflo): UserPromptSubmit advisory recall hook
Hook script that runs ruflo memory search per prompt and injects top
matches as additionalContext. Fail-open (error/timeout -> empty inject,
exit 0, never blocks). Pure-function core unit-tested via node --test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
Expected: pre-commit зелёный.
---
### Task 7: Зарегистрировать хук в `.claude/settings.json` и проверить
**Files:**
- Modify: `.claude/settings.json` (добавить `hooks.UserPromptSubmit`)
- [ ] **Step 1: Добавить блок `UserPromptSubmit` в `hooks`**
В `.claude/settings.json` заменить:
```json
"hooks": {
"PreToolUse": [
```
на:
```json
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/tools/ruflo-recall-hook.mjs\""
}
]
}
],
"PreToolUse": [
```
- [ ] **Step 2: Проверить, что `settings.json` остался валидным JSON**
```bash
node -e "JSON.parse(require('fs').readFileSync('.claude/settings.json','utf8')); console.log('settings.json: valid JSON')"
```
Expected: `settings.json: valid JSON`.
- [ ] **Step 3: End-to-end проверка хука с реальной памятью**
```bash
ruflo memory store -k hook-e2e --value "Лидерра advisory hook end-to-end probe entry"
echo '{"prompt":"advisory hook probe"}' | node tools/ruflo-recall-hook.mjs; echo "exit=$?"
ruflo memory delete -k hook-e2e
```
Expected: вывод хука — JSON, содержащий `"hookEventName":"UserPromptSubmit"` и
`"additionalContext"` с упоминанием `hook-e2e` (запись припомнена); `exit=0`.
Если `additionalContext` пуст — проверить, что патч H7 наложен (`node
tools/ruflo-h7-patch.mjs --check``PATCHED`) и что store/search работают (Task 5).
- [ ] **Step 4: Измерить латентность хука**
В PowerShell:
```powershell
Measure-Command { echo '{"prompt":"latency probe"}' | node tools/ruflo-recall-hook.mjs }
```
Expected: `TotalSeconds` записать. Если > 3.5 c — хук упирается в таймаут
`SEARCH_TIMEOUT_MS`; это допустимо (fail-open), но если латентность стабильно высокая,
поднять `SEARCH_TIMEOUT_MS` в `tools/ruflo-recall-hook.mjs` (отдельным шагом + повторный
`node --test`) или принять как known cost. Зафиксировать измеренное значение в коммите.
- [ ] **Step 5: Коммит**
```bash
git add .claude/settings.json
git commit -m "feat(ruflo): register UserPromptSubmit advisory recall hook
Wire tools/ruflo-recall-hook.mjs into .claude/settings.json so ruflo
memory recall is injected per prompt. Project-scoped, fail-open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
Expected: pre-commit зелёный.
---
## Task 8: Closeout — память + push
**Files:** memory `project_ruflo_integration.md` (вне git), push коммитов.
- [ ] **Step 1: Обновить memory `project_ruflo_integration.md`**
В `C:\Users\Administrator\.claude\projects\c---------------------crm-------------\memory\project_ruflo_integration.md`:
- Исправить раздел про H7: баг **исправлен** патчем `getBridge()` (раздел «Runtime
state» / «Open questions» / квирк H7).
- Добавить operational-ноту: **после каждого `ruflo update` запускать `node
tools/ruflo-h7-patch.mjs`** (патч в global node_modules затирается обновлением).
- Зафиксировать: `claude` CLI установлен (D1), advisory-хук активен (D3).
- [ ] **Step 2: Финальная верификация перед push**
```bash
node --test tools/ruflo-h7-patch.test.mjs tools/ruflo-recall-hook.test.mjs
node tools/ruflo-h7-patch.mjs --check
git log --oneline -4
```
Expected: все тесты PASS (`# fail 0`); патч `PATCHED`; в логе — 3 новых коммита
(Task 4, 6, 7) поверх `a6649e4`.
- [ ] **Step 3: Push в origin/main**
```bash
git push origin main
```
Expected: pre-push gauntlet (gitleaks-full-history + lychee) зелёный; коммиты
запушены. Push в `main` разрешён settings rule `Bash(git push origin main:*)`.
---
## Verification (end-to-end)
После Task 8:
- **D1:** `claude --version` → версия; `ruflo doctor` без «Claude Code CLI: Not installed».
- **D2:** `node tools/ruflo-h7-patch.mjs --check` → `PATCHED`; cross-process
round-trip (store → новый процесс retrieve) находит запись; `node --test
tools/ruflo-h7-patch.test.mjs` → 8 pass.
- **D3:** `node --test tools/ruflo-recall-hook.test.mjs` → 14 pass; `echo
'{"prompt":"..."}' | node tools/ruflo-recall-hook.mjs` на непустой памяти выдаёт
`UserPromptSubmit`-payload; `.claude/settings.json` — валидный JSON с блоком
`UserPromptSubmit`.
- Регрессия приложения не затрагивается (D1-D3 меняют только ruflo-инфраструктуру,
`.claude/settings.json`, `tools/` — не `app/`, не schema, не тесты приложения).
## Rollback
- **D1:** `npm uninstall -g @anthropic-ai/claude-code`.
- **D2:** `node tools/ruflo-h7-patch.mjs --revert` (или `npm i -g ruflo` —
переустановка перетирает патч); удалить `tools/ruflo-h7-patch.*`.
- **D3:** удалить блок `UserPromptSubmit` из `.claude/settings.json`; удалить
`tools/ruflo-recall-hook.*`.
- Откат коммитов: `git revert` коммитов Task 4/6/7.
## Notes
- `tools/*.mjs` намеренно вне Vitest приложения (Vitest сконфигурирован на
`app/resources/js/**`) — тестируются `node --test`, без правок конфигов проекта.
- Косметический баг namespace `undefined` (`commands/memory.js:38`) — вне scope
(спека §1.2). На round-trip не влияет: и `storeEntry`, и `getEntry` дефолтят
namespace в `'default'` внутри себя.
- Авто-store хук, routing-классификация в хуке, provider-ключ Anthropic — не-цели
(спека §1.2).