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:
@@ -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).
|
||||
Reference in New Issue
Block a user