From 8e19b52d913fa5ff28eddb0676689558bcfda992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 15 May 2026 14:15:14 +0300 Subject: [PATCH] =?UTF-8?q?docs(plan):=20ruflo=20memory=20H7=20fix=20+=20a?= =?UTF-8?q?dvisory=20hook=20=E2=80=94=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cspell-words.txt | 3 + ...5-ruflo-memory-h7-fix-and-advisory-hook.md | 868 ++++++++++++++++++ 2 files changed, 871 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-15-ruflo-memory-h7-fix-and-advisory-hook.md diff --git a/cspell-words.txt b/cspell-words.txt index 2d0fcab7..18e32d7f 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1204,3 +1204,6 @@ poincaré бинаря промпты свежеустановленный +персистентности +закоммичена +AUTHOK diff --git a/docs/superpowers/plans/2026-05-15-ruflo-memory-h7-fix-and-advisory-hook.md b/docs/superpowers/plans/2026-05-15-ruflo-memory-h7-fix-and-advisory-hook.md new file mode 100644 index 00000000..7d8d080f --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-ruflo-memory-h7-fix-and-advisory-hook.md @@ -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) | +| `/ruflo/node_modules/@claude-flow/cli/dist/src/memory/memory-initializer.js` | H7-патч (вне git, накладывается скриптом) | Patch (Task 5) | + +`` = `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`). Записать его как ``. + +- [ ] **Step 2: Smoke-spawn на тривиальной задаче** + +```bash +ruflo hive-mind spawn --claude -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) " +``` + +Expected: pre-commit зелёный (gitleaks/eslint-vue skip — `.mjs` в `tools/` не под +ESLint-конфигом приложения; gitleaks проходит). + +--- + +### Task 5: Наложить H7-патч и проверить cross-process персистентность + +**Files:** патчит (вне git) `/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) " +``` + +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) " +``` + +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).