From bb7633b318025c40a0b59e285c27242f3408be77 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: Mon, 22 Jun 2026 04:30:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(secretary):=20=D1=8F=D0=B4=D1=80=D0=BE=20?= =?UTF-8?q?=E2=80=94=20=D0=B4=D0=B5=D1=82=D0=B5=D0=BA=D1=82=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B,=20=D0=BF=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=BB=20(reconcile),=20=D0=BD=D0=B0=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=BA=D0=B0,=20=D0=BE=D0=B3=D0=BB=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/secretary-flag.mjs | 15 ++++++++++ tools/secretary-flag.test.mjs | 17 +++++++++++ tools/secretary-index.mjs | 10 +++++++ tools/secretary-index.test.mjs | 16 +++++++++++ tools/secretary-protocol.mjs | 47 +++++++++++++++++++++++++++++++ tools/secretary-protocol.test.mjs | 20 +++++++++++++ tools/secretary-slice.mjs | 14 +++++++++ tools/secretary-slice.test.mjs | 15 ++++++++++ 8 files changed, 154 insertions(+) create mode 100644 tools/secretary-flag.mjs create mode 100644 tools/secretary-flag.test.mjs create mode 100644 tools/secretary-index.mjs create mode 100644 tools/secretary-index.test.mjs create mode 100644 tools/secretary-protocol.mjs create mode 100644 tools/secretary-protocol.test.mjs create mode 100644 tools/secretary-slice.mjs create mode 100644 tools/secretary-slice.test.mjs diff --git a/tools/secretary-flag.mjs b/tools/secretary-flag.mjs new file mode 100644 index 0000000..5ee4551 --- /dev/null +++ b/tools/secretary-flag.mjs @@ -0,0 +1,15 @@ +// Детект команды секретаря в тексте промпта. Кавычки/код снимаются до сопоставления, +// чтобы цитирование не срабатывало (приём как в существующих детекторах). +function stripQuoted(text) { + return String(text || '') + .replace(/«[^»]*»/g, ' ') + .replace(/"[^"]*"/g, ' ') + .replace(/`[^`]*`/g, ' '); +} + +export function detectSecretaryCommand(promptText) { + const t = stripQuoted(promptText).toLowerCase(); + if (/выключи\s+секретар/.test(t)) return 'off'; + if (/включи\s+секретар/.test(t)) return 'on'; + return null; +} diff --git a/tools/secretary-flag.test.mjs b/tools/secretary-flag.test.mjs new file mode 100644 index 0000000..91b283f --- /dev/null +++ b/tools/secretary-flag.test.mjs @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { detectSecretaryCommand } from './secretary-flag.mjs'; + +describe('detectSecretaryCommand', () => { + it('распознаёт включение', () => { + expect(detectSecretaryCommand('включи секретаря пожалуйста')).toBe('on'); + }); + it('распознаёт выключение', () => { + expect(detectSecretaryCommand('всё, выключи секретаря')).toBe('off'); + }); + it('нет команды — null', () => { + expect(detectSecretaryCommand('давай продолжим работу')).toBeNull(); + }); + it('цитата в кавычках не срабатывает', () => { + expect(detectSecretaryCommand('фраза «включи секретаря» это команда')).toBeNull(); + }); +}); diff --git a/tools/secretary-index.mjs b/tools/secretary-index.mjs new file mode 100644 index 0000000..64d5ebd --- /dev/null +++ b/tools/secretary-index.mjs @@ -0,0 +1,10 @@ +// Апсерт строки дела в оглавление (§D8). Ключ — /protocol.md. +export function upsertIndexEntry(indexMd, { slug, title, goal, status, date }) { + const line = `- [${title}](${slug}/protocol.md) — ${goal} · ${status} · ${date}`; + const key = `(${slug}/protocol.md)`; + const lines = String(indexMd || '').split('\n').filter((l) => l.length > 0); + const idx = lines.findIndex((l) => l.includes(key)); + if (idx >= 0) lines[idx] = line; + else lines.push(line); + return lines.join('\n'); +} diff --git a/tools/secretary-index.test.mjs b/tools/secretary-index.test.mjs new file mode 100644 index 0000000..22c3ef2 --- /dev/null +++ b/tools/secretary-index.test.mjs @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { upsertIndexEntry } from './secretary-index.mjs'; + +describe('upsertIndexEntry', () => { + it('добавляет новое дело', () => { + const md = upsertIndexEntry('', { slug: 'sec', title: 'Секретарь', goal: 'память сути', status: 'открыто', date: '2026-06-21' }); + expect(md).toContain('[Секретарь](sec/protocol.md)'); + expect(md).toContain('открыто'); + }); + it('обновляет существующее дело без дубля', () => { + const first = upsertIndexEntry('', { slug: 'sec', title: 'Секретарь', goal: 'g', status: 'открыто', date: '2026-06-21' }); + const upd = upsertIndexEntry(first, { slug: 'sec', title: 'Секретарь', goal: 'g', status: 'закрыто', date: '2026-06-22' }); + expect(upd.match(/sec\/protocol\.md/g).length).toBe(1); + expect(upd).toContain('закрыто'); + }); +}); diff --git a/tools/secretary-protocol.mjs b/tools/secretary-protocol.mjs new file mode 100644 index 0000000..3e68c56 --- /dev/null +++ b/tools/secretary-protocol.mjs @@ -0,0 +1,47 @@ +// Структура и сверка короткого протокола (§D5/§D7). Отменённое зачёркивается, не удаляется. +export function EMPTY_PROTOCOL() { + return { decisions: [], will: [], open: [], doneNext: [], history: [] }; +} + +function prov(turns) { + return Array.isArray(turns) && turns.length ? ` [${turns.map((t) => `→${t}`).join(', ')}]` : ''; +} + +export function applyExtraction(protocol, extraction = {}) { + const p = { + decisions: [...protocol.decisions], will: [...protocol.will], open: [...protocol.open], + doneNext: [...protocol.doneNext], history: [...protocol.history], + }; + for (const d of extraction.decisions || []) { + p.decisions.push({ text: d.text, why: d.why || null, turns: d.turns || [], struck: false }); + } + for (const s of extraction.supersede || []) { + const old = p.decisions.find((d) => d.text === s.oldText && !d.struck); + if (old) old.struck = true; + p.decisions.push({ text: s.newText, why: s.why || null, turns: s.turns || [], struck: false }); + p.history.push({ oldText: s.oldText, newText: s.newText, turns: s.turns || [] }); + } + for (const w of extraction.will || []) p.will.push({ text: w.text, turns: w.turns || [] }); + for (const o of extraction.open || []) p.open.push({ text: o.text, turns: o.turns || [] }); + for (const s of extraction.doneNext || []) p.doneNext.push({ text: s.text, done: !!s.done, turns: s.turns || [] }); + return p; +} + +export function renderProtocol(protocol) { + const L = []; + L.push('## Решения'); + for (const d of protocol.decisions) { + const body = d.struck ? `~~${d.text}~~` : d.text; + const why = d.why ? ` — ${d.why}` : ''; + L.push(`- ${body}${why}${prov(d.turns)}`); + } + L.push('', '## Твоя воля / запреты'); + for (const w of protocol.will) L.push(`- ${w.text}${prov(w.turns)}`); + L.push('', '## Открытые вопросы'); + for (const o of protocol.open) L.push(`- ${o.text}${prov(o.turns)}`); + L.push('', '## Сделано / дальше'); + for (const s of protocol.doneNext) L.push(`- [${s.done ? 'x' : ' '}] ${s.text}${prov(s.turns)}`); + L.push('', '## История (заменено, не стёрто)'); + for (const h of protocol.history) L.push(`- ~~${h.oldText}~~ → ${h.newText}${prov(h.turns)}`); + return L.join('\n'); +} diff --git a/tools/secretary-protocol.test.mjs b/tools/secretary-protocol.test.mjs new file mode 100644 index 0000000..098e34e --- /dev/null +++ b/tools/secretary-protocol.test.mjs @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { applyExtraction, renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs'; + +describe('secretary-protocol', () => { + it('добавляет решение с провенансом', () => { + const p = applyExtraction(EMPTY_PROTOCOL(), { + decisions: [{ text: 'единица = дело', why: 'тянется через сессии', turns: [7] }], + }); + const md = renderProtocol(p); + expect(md).toContain('единица = дело'); + expect(md).toContain('[→7]'); + }); + it('сверка зачёркивает, не удаляет', () => { + let p = applyExtraction(EMPTY_PROTOCOL(), { decisions: [{ text: 'A', turns: [1] }] }); + p = applyExtraction(p, { supersede: [{ oldText: 'A', newText: 'B', turns: [2] }] }); + const md = renderProtocol(p); + expect(md).toContain('~~A~~'); + expect(md).toContain('B'); + }); +}); diff --git a/tools/secretary-slice.mjs b/tools/secretary-slice.mjs new file mode 100644 index 0000000..00f6131 --- /dev/null +++ b/tools/secretary-slice.mjs @@ -0,0 +1,14 @@ +// Нарезка сырого журнала на ходы по диапазону [from,to] (§D6). Идемпотентность по turn — +// забота писателя файлов (вызывающего хука). +export function sliceTurns(rawLog, from, to) { + const out = []; + const re = /=== ХОД turn=(\d+)[^\n]*===([\s\S]*?)=== КОНЕЦ ХОДА ===/g; + let m; + while ((m = re.exec(String(rawLog || ''))) !== null) { + const turn = Number(m[1]); + if (turn >= from && turn <= to) { + out.push({ turn, content: m[0].trim() }); + } + } + return out; +} diff --git a/tools/secretary-slice.test.mjs b/tools/secretary-slice.test.mjs new file mode 100644 index 0000000..cc1aecb --- /dev/null +++ b/tools/secretary-slice.test.mjs @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { sliceTurns } from './secretary-slice.mjs'; + +describe('sliceTurns', () => { + it('режет журнал на ходы по диапазону', () => { + const raw = [ + '=== ХОД turn=5 · t · session=a ===\nx\n=== КОНЕЦ ХОДА ===', + '=== ХОД turn=6 · t · session=a ===\ny\n=== КОНЕЦ ХОДА ===', + '=== ХОД turn=7 · t · session=a ===\nz\n=== КОНЕЦ ХОДА ===', + ].join('\n'); + const out = sliceTurns(raw, 6, 7); + expect(out.map((o) => o.turn)).toEqual([6, 7]); + expect(out[0].content).toContain('y'); + }); +});