diff --git a/tools/round-memory-store.mjs b/tools/round-memory-store.mjs new file mode 100644 index 0000000..db8ef52 --- /dev/null +++ b/tools/round-memory-store.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** round-memory-store — транзиентная память кругов переговоров по задаче (SP2a). + * Хранит версии артефакта, дословные замечания (по стороне mentor|judge), доводы контроллера + * (по дорожке M|J). Ключ (taskId, stage∈{spec,plan}). Fail-quiet: любая ошибка → no-op/пусто. + * Структура файла — зеркало verdict-surface-store (один JSON на задачу в runtime). */ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { diffLines } from './version-diff.mjs'; + +function baseOf(baseDir) { return baseDir || join(homedir(), '.claude', 'runtime'); } +function memPath(taskId, baseDir) { return join(baseOf(baseDir), `round-memory-${taskId || 'unknown'}.json`); } + +function readMem(taskId, baseDir) { + try { + const p = memPath(taskId, baseDir); + if (!existsSync(p)) return {}; + const v = JSON.parse(readFileSync(p, 'utf8')); + return (v && typeof v === 'object') ? v : {}; + } catch { return {}; } +} + +function writeMem(taskId, baseDir, obj) { + try { + mkdirSync(baseOf(baseDir), { recursive: true }); + writeFileSync(memPath(taskId, baseDir), JSON.stringify(obj)); + return true; + } catch { return false; } +} + +function pushInto(taskId, baseDir, section, key, value) { + const m = readMem(taskId, baseDir); + m[section] = m[section] || {}; + if (!Array.isArray(m[section][key])) m[section][key] = []; + m[section][key].push(value); + return writeMem(taskId, baseDir, m); +} + +function readArr(taskId, baseDir, section, key) { + const m = readMem(taskId, baseDir); + const arr = m[section] && m[section][key]; + return Array.isArray(arr) ? arr : []; +} + +const str = (t) => String(t == null ? '' : t); + +export function recordVersion(taskId, stage, text, baseDir) { + return pushInto(taskId, baseDir, 'versions', stage, str(text)); +} +export function getVersions(taskId, stage, baseDir) { + return readArr(taskId, baseDir, 'versions', stage); +} +export function diffVersions(taskId, stage, baseDir) { + const v = getVersions(taskId, stage, baseDir); + if (v.length < 2) return ''; + return diffLines(v[v.length - 2], v[v.length - 1]); +} + +export function recordObjection(taskId, stage, side, text, baseDir) { + return pushInto(taskId, baseDir, 'objections', `${stage}:${side}`, str(text)); +} +export function getObjections(taskId, stage, side, baseDir) { + return readArr(taskId, baseDir, 'objections', `${stage}:${side}`); +} + +export function recordArg(taskId, stage, track, text, baseDir) { + return pushInto(taskId, baseDir, 'args', `${stage}:${track}`, str(text)); +} +export function getArgs(taskId, stage, track, baseDir) { + return readArr(taskId, baseDir, 'args', `${stage}:${track}`); +} + +export function clearRoundMemory(taskId, baseDir) { + try { + const p = memPath(taskId, baseDir); + if (existsSync(p)) writeFileSync(p, '{}'); + return true; + } catch { return false; } +} diff --git a/tools/round-memory-store.test.mjs b/tools/round-memory-store.test.mjs new file mode 100644 index 0000000..b99614d --- /dev/null +++ b/tools/round-memory-store.test.mjs @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + recordVersion, getVersions, diffVersions, + recordObjection, getObjections, + recordArg, getArgs, clearRoundMemory, +} from './round-memory-store.mjs'; + +const dir = () => mkdtempSync(join(tmpdir(), 'rmem-')); + +describe('round-memory-store', () => { + it('версии: record → get round-trip', () => { + const d = dir(); + recordVersion('t1', 'spec', 'v1', d); + recordVersion('t1', 'spec', 'v2', d); + expect(getVersions('t1', 'spec', d)).toEqual(['v1', 'v2']); + }); + it('diffVersions двух последних', () => { + const d = dir(); + recordVersion('t1', 'plan', 'a\nb', d); + recordVersion('t1', 'plan', 'a\nb\nc', d); + expect(diffVersions('t1', 'plan', d)).toContain('+ c'); + }); + it('замечания по стороне дословно', () => { + const d = dir(); + recordObjection('t1', 'spec', 'mentor', 'правь X', d); + recordObjection('t1', 'spec', 'judge', 'правь Y', d); + expect(getObjections('t1', 'spec', 'mentor', d)).toEqual(['правь X']); + expect(getObjections('t1', 'spec', 'judge', d)).toEqual(['правь Y']); + }); + it('доводы по дорожке (M/J)', () => { + const d = dir(); + recordArg('t1', 'spec', 'M', 'довод наставнику', d); + recordArg('t1', 'spec', 'J', 'довод судье', d); + expect(getArgs('t1', 'spec', 'M', d)).toEqual(['довод наставнику']); + expect(getArgs('t1', 'spec', 'J', d)).toEqual(['довод судье']); + }); + it('изоляция по (taskId, stage)', () => { + const d = dir(); + recordVersion('t1', 'spec', 'x', d); + expect(getVersions('t1', 'plan', d)).toEqual([]); + expect(getVersions('t2', 'spec', d)).toEqual([]); + }); + it('clearRoundMemory стирает задачу', () => { + const d = dir(); + recordVersion('t1', 'spec', 'x', d); + recordObjection('t1', 'spec', 'mentor', 'z', d); + clearRoundMemory('t1', d); + expect(getVersions('t1', 'spec', d)).toEqual([]); + expect(getObjections('t1', 'spec', 'mentor', d)).toEqual([]); + }); + it('fail-quiet: битый baseDir не кидает', () => { + expect(() => getVersions('t1', 'spec', '\0bad')).not.toThrow(); + expect(getVersions('t1', 'spec', '\0bad')).toEqual([]); + }); +}); diff --git a/tools/version-diff.mjs b/tools/version-diff.mjs new file mode 100644 index 0000000..6886e92 --- /dev/null +++ b/tools/version-diff.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** version-diff — чистый построчный diff двух текстов (SP2a). Без зависимостей, fail-safe: + * нестроковый/пустой вход трактуется как пустой текст, никогда не кидает. */ + +/** Построчный diff old→new. Общий префикс и суффикс опускаются; середина выводится как + * удалённые строки с префиксом '- ' и добавленные с префиксом '+ '. Возвращает текстовый блок + * (пустая строка, если изменений нет). */ +export function diffLines(oldText, newText) { + const a = String(oldText == null ? '' : oldText).split('\n'); + const b = String(newText == null ? '' : newText).split('\n'); + let start = 0; + while (start < a.length && start < b.length && a[start] === b[start]) start++; + let endA = a.length - 1; + let endB = b.length - 1; + while (endA >= start && endB >= start && a[endA] === b[endB]) { endA--; endB--; } + const lines = []; + for (let i = start; i <= endA; i++) lines.push(`- ${a[i]}`); + for (let i = start; i <= endB; i++) lines.push(`+ ${b[i]}`); + return lines.join('\n'); +} diff --git a/tools/version-diff.test.mjs b/tools/version-diff.test.mjs new file mode 100644 index 0000000..9747f17 --- /dev/null +++ b/tools/version-diff.test.mjs @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { diffLines } from './version-diff.mjs'; + +describe('diffLines', () => { + it('добавленные строки помечает +', () => { + const d = diffLines('a\nb', 'a\nb\nc'); + expect(d).toContain('+ c'); + }); + it('удалённые строки помечает -', () => { + const d = diffLines('a\nb\nc', 'a\nc'); + expect(d).toContain('- b'); + }); + it('идентичные тексты → пустой diff', () => { + expect(diffLines('a\nb', 'a\nb').trim()).toBe(''); + }); + it('пустой/нестроковый вход не кидает', () => { + expect(() => diffLines('', '')).not.toThrow(); + expect(() => diffLines(null, undefined)).not.toThrow(); + }); +});