feat(secretary): ядро — детект команды, протокол (reconcile), нарезка, оглавление (TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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('закрыто');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user