feat(secretary): ядро — детект команды, протокол (reconcile), нарезка, оглавление (TDD)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-22 04:30:38 +03:00
parent c963142c27
commit bb7633b318
8 changed files with 154 additions and 0 deletions
+15
View File
@@ -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;
}
+17
View File
@@ -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();
});
});
+10
View File
@@ -0,0 +1,10 @@
// Апсерт строки дела в оглавление (§D8). Ключ — <slug>/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');
}
+16
View File
@@ -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('закрыто');
});
});
+47
View File
@@ -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');
}
+20
View File
@@ -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');
});
});
+14
View File
@@ -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;
}
+15
View File
@@ -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');
});
});