2b6170313b
Единица разбора — спан: реальный промпт владельца + вся активность ассистента
до следующего реального промпта. Системные ходы (гейт-фидбек, загрузка навыка)
приклеиваются к спану, не считаются отдельными. Разбор отложенный: закрытые
спаны разбираются один раз (курсор в флажке сессии); reconcile и аудитор
получают ПОЛНЫЙ склеенный спан (промпт + все ответы + все действия).
- Слой 1: снят обрез вывода действий (полная картина), защита структурных меток.
- Граница спана — событие UserPromptSubmit (prompt-hook метит realPromptTurns),
фолбэк по sysLabel; выключение через mode:closing (финальный спан добивает Stop).
- Калибровка скрытых вопросов: страж-ноп (не мутировать при неизменном тексте) +
кап показа родословной (~~первая~~ → текущая, данные целы).
- Шаги — по спанам («Ход (промпт) N [вобрал ходы X-Y]»); «висит N промптов».
- Новый модуль secretary-span.mjs (computeSpans/spansToDistill/recordRealPrompt/
parseTurnBlock/assembleSpan).
Свод секретаря зелёный (138 тестов), живой прогон на реальной модели подтвердил:
Шаги по спанам, гейт-шум не плодит скрытые вопросы, находки выживают по одному раз.
Спека/план: docs/superpowers/{specs,plans}/2026-06-23-secretary-span-redesign*.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
142 lines
8.6 KiB
JavaScript
142 lines
8.6 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs';
|
||
|
||
describe('EMPTY_PROTOCOL', () => {
|
||
it('пустой протокол со всеми 9 разделами', () => {
|
||
expect(EMPTY_PROTOCOL()).toEqual({
|
||
subject: '', status: 'открыто',
|
||
decisions: [], alternatives: [], consequences: [],
|
||
will: [], open: [], doneNext: [], history: [], steps: [],
|
||
hidden: [], acceptance: [], tails: [], nextSvId: 1,
|
||
});
|
||
});
|
||
it('EMPTY_PROTOCOL содержит поля аудитора скрытых вопросов', () => {
|
||
const p = EMPTY_PROTOCOL();
|
||
expect(p.hidden).toEqual([]);
|
||
expect(p.acceptance).toEqual([]);
|
||
expect(p.tails).toEqual([]);
|
||
expect(p.nextSvId).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('renderProtocol — 9 категорий + шаги', () => {
|
||
const proto = {
|
||
subject: 'фоновый секретарь', status: 'открыто',
|
||
decisions: [{ text: 'D', why: 'w', turns: [7], session: '69992620-x' }],
|
||
alternatives: [{ text: 'ALT', turns: [8], session: '69992620-x' }],
|
||
consequences: [{ text: 'CONS', turns: [9], session: '69992620-x' }],
|
||
will: [{ text: 'W', turns: [10], session: '69992620-x' }],
|
||
open: [{ text: 'Q', turns: [11], session: '69992620-x' }],
|
||
doneNext: [{ text: 'N', done: false, turns: [12], session: '69992620-x' }],
|
||
history: [],
|
||
};
|
||
it('шапка «Дело» со статусом/хозяином/целью (по opts)', () => {
|
||
const md = renderProtocol(proto, { work: 'создание-секретаря', date: '2026-06-22 11:00' });
|
||
expect(md).toContain('**Дело:** создание-секретаря');
|
||
expect(md).toContain('**Статус:** открыто');
|
||
expect(md).toContain('**Хозяин:** владелец');
|
||
expect(md).toContain('**Цель:** фоновый секретарь');
|
||
});
|
||
it('разделы Альтернативы и Последствия / цена', () => {
|
||
const md = renderProtocol(proto);
|
||
expect(md).toContain('## Альтернативы');
|
||
expect(md).toContain('- ALT');
|
||
expect(md).toContain('## Последствия / цена');
|
||
expect(md).toContain('- CONS');
|
||
});
|
||
it('провенанс [→N] без метки файла @ (имя файла — только в Шагах)', () => {
|
||
const md = renderProtocol(proto);
|
||
expect(md).toContain('- D — w [→7]');
|
||
expect(md).not.toContain('@69992620');
|
||
expect(md).toContain('## Твоя воля / запреты');
|
||
expect(md).toContain('## Открытые вопросы');
|
||
});
|
||
it('история: тайм-линия toggle (внёс →, вынес ←, вернул →, снова вынес ←)', () => {
|
||
const md = renderProtocol({
|
||
subject: '', status: 'открыто', steps: [],
|
||
decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [],
|
||
history: [{ text: 'пункт X', events: [{ turn: 41, dir: 'in' }, { turn: 43, dir: 'out' }, { turn: 55, dir: 'in' }, { turn: 70, dir: 'out' }] }],
|
||
});
|
||
expect(md).toContain('~~пункт X~~ [→41] [←43] [→55] [←70]');
|
||
});
|
||
it('провенанс с несколькими ходами: [→33], [50]', () => {
|
||
const md = renderProtocol({
|
||
subject: '', status: 'открыто', steps: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [], history: [],
|
||
decisions: [{ text: 'Y', why: null, turns: [33, 50] }],
|
||
});
|
||
expect(md).toContain('- Y [→33], [50]');
|
||
});
|
||
it('зачёркивание во всех корзинах', () => {
|
||
const md = renderProtocol({
|
||
subject: '', status: 'открыто', history: [],
|
||
decisions: [{ text: 'D', struck: true }], alternatives: [{ text: 'A', struck: true }],
|
||
consequences: [{ text: 'C', struck: true }], will: [{ text: 'W', struck: true }],
|
||
open: [{ text: 'Q', struck: true }], doneNext: [{ text: 'N', struck: true, done: false }],
|
||
});
|
||
for (const t of ['~~D~~', '~~A~~', '~~C~~', '~~W~~', '~~Q~~', '~~N~~']) expect(md).toContain(t);
|
||
});
|
||
it('раздел Шаги: ссылка на отдельный файл хода (s.file) вместо общего лога', () => {
|
||
const md = renderProtocol({
|
||
subject: '', status: 'открыто', history: [],
|
||
decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [],
|
||
steps: [{ turn: 1, session: 'sess', file: 'ходы/turn-1.log', text: 'Ход 1 — я: x · ты: y · делал: —' }],
|
||
});
|
||
expect(md).toContain('Ход 1 — я: x · ты: y · делал: — · ходы/turn-1.log');
|
||
expect(md).not.toContain('· sess.log');
|
||
});
|
||
it('раздел Шаги (Слой 1): строка на ход + название файла полного хода в конце строки', () => {
|
||
const md = renderProtocol({
|
||
subject: '', status: 'открыто', history: [],
|
||
decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [],
|
||
steps: [{ turn: 1, session: '69992620-x', text: 'Ход 1 — я: про оглавление · ты: тема+время · делал: читал хук' }],
|
||
});
|
||
expect(md).toContain('## Шаги (Слой 1)');
|
||
expect(md).toContain('Ход 1 — я: про оглавление · ты: тема+время · делал: читал хук · 69992620-x.log');
|
||
});
|
||
it('рендерит горящие блоки и скрытые вопросы с мутацией', () => {
|
||
const p = { ...EMPTY_PROTOCOL(),
|
||
acceptance: [{ text: 'заявлено работает', born: 14, lastTouch: 14, done: false },
|
||
{ text: 'старое', born: 1, lastTouch: 1, done: true }],
|
||
tails: [{ text: 'не запушено', born: 23, lastTouch: 23, done: false }],
|
||
hidden: [{ id: 'СВ-2', lens: 'Л1', status: 'мутировал', text: 'новая', born: 1, lastTouch: 11,
|
||
lineage: [{ turn: 1, text: 'старая' }] }] };
|
||
const md = renderProtocol(p, { work: 'x', date: '2026-06-22 14:00' });
|
||
expect(md).toContain('⚠️ ЗАЯВЛЕНО ГОТОВО');
|
||
expect(md).toContain('заявлено работает');
|
||
expect(md).not.toContain('старое'); // done — не показываем
|
||
expect(md).toContain('🧹 ХВОСТЫ');
|
||
expect(md).toContain('Скрытые вопросы');
|
||
expect(md).toContain('~~старая~~ → новая'); // мутация зачёркиванием
|
||
});
|
||
it('кап родословной: ~~первая~~ → текущая (середина скрыта, данные в JSON целы)', () => {
|
||
const p = { ...EMPTY_PROTOCOL(),
|
||
hidden: [{ id: 'СВ-1', lens: 'Л3', status: 'мутировал', text: 'нынешняя', born: 3, lastTouch: 15,
|
||
lineage: [{ turn: 3, text: 'первая' }, { turn: 9, text: 'средняя-1' }, { turn: 12, text: 'средняя-2' }] }] };
|
||
const md = renderProtocol(p, { work: 'x', date: 'd' });
|
||
expect(md).toContain('~~первая~~ → нынешняя');
|
||
expect(md).not.toContain('средняя-1');
|
||
expect(md).not.toContain('средняя-2');
|
||
});
|
||
it('«висит N промптов» считает спаны, прошедшие с born (не сырые ходы)', () => {
|
||
const p = { ...EMPTY_PROTOCOL(),
|
||
acceptance: [{ text: 'заявлено готово', born: 3, lastTouch: 3, done: false }] };
|
||
// реальные промпты на ходах 3,12,15,22; текущий ход 31 → с born=3 прошло 3 промпта (12,15,22)
|
||
const md = renderProtocol(p, { work: 'x', date: 'd', turn: 31, realPromptTurns: [3, 12, 15, 22] });
|
||
expect(md).toContain('висит 3 промптов');
|
||
});
|
||
it('Шаги: разделитель «—— сессия X ——» при смене сессии (не перед первой)', () => {
|
||
const md = renderProtocol({
|
||
subject: '', status: 'открыто', history: [],
|
||
decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [],
|
||
steps: [
|
||
{ turn: 1, session: 'sА', text: 'Ход 1 — я: a · ты: b · делал: —' },
|
||
{ turn: 2, session: 'sА', text: 'Ход 2 — я: c · ты: d · делал: —' },
|
||
{ turn: 3, session: 'sБ', text: 'Ход 3 — я: e · ты: f · делал: —' },
|
||
],
|
||
});
|
||
expect(md).toContain('—— сессия sБ ——');
|
||
expect(md).not.toContain('—— сессия sА ——'); // перед первой сессией разделителя нет
|
||
expect(md.indexOf('—— сессия sБ ——')).toBeLessThan(md.indexOf('Ход 3'));
|
||
});
|
||
});
|