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>
147 lines
8.7 KiB
JavaScript
147 lines
8.7 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import { applyAudit, parseAuditResponse, buildAuditPrompt, LENSES, preserveRegistry } from './secretary-audit.mjs';
|
||
|
||
// Task 2: новые скрытые вопросы получают номер от хука
|
||
describe('applyAudit — новые СВ', () => {
|
||
it('новый СВ получает номер от хука и статус открыт', () => {
|
||
const p = { hidden: [], acceptance: [], tails: [], nextSvId: 1 };
|
||
applyAudit(p, { new: [{ text: 'хватит ли перезапуска?', lens: 'Л1' }], ops: [] }, 4);
|
||
expect(p.hidden).toHaveLength(1);
|
||
expect(p.hidden[0]).toMatchObject({ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'хватит ли перезапуска?', born: 4, lastTouch: 4 });
|
||
expect(p.nextSvId).toBe(2);
|
||
});
|
||
});
|
||
|
||
// Task 3: мутация (зачёркивание + родословная)
|
||
describe('applyAudit — мутация', () => {
|
||
it('mutate сохраняет старый текст в родословную и меняет статус', () => {
|
||
const p = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'старая', born: 1, lastTouch: 1, lineage: [] }],
|
||
acceptance: [], tails: [], nextSvId: 2 };
|
||
applyAudit(p, { new: [], ops: [{ id: 'СВ-1', action: 'mutate', newText: 'новая' }] }, 11);
|
||
expect(p.hidden[0].text).toBe('новая');
|
||
expect(p.hidden[0].status).toBe('мутировал');
|
||
expect(p.hidden[0].lineage).toEqual([{ turn: 1, text: 'старая' }]);
|
||
expect(p.hidden[0].lastTouch).toBe(11);
|
||
});
|
||
});
|
||
|
||
describe('applyAudit — страж-ноп (не мутировать при неизменном тексте)', () => {
|
||
it('mutate с тем же текстом по норме НЕ растит родословную', () => {
|
||
const p = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'Вопрос про X', born: 1, lastTouch: 1, lineage: [] }],
|
||
acceptance: [], tails: [], nextSvId: 2 };
|
||
applyAudit(p, { new: [], ops: [{ id: 'СВ-1', action: 'mutate', newText: ' вопрос про x ' }] }, 5);
|
||
expect(p.hidden[0].lineage).toEqual([]); // не выросла
|
||
expect(p.hidden[0].text).toBe('Вопрос про X'); // текст не подменён мусором регистра
|
||
expect(p.hidden[0].lastTouch).toBe(5); // касание зафиксировано
|
||
expect(p.hidden[0].status).toBe('открыт'); // статус не дёрнут на «мутировал»
|
||
});
|
||
it('mutate с реально новым текстом — как раньше (родословная растёт)', () => {
|
||
const p = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'старая', born: 1, lastTouch: 1, lineage: [] }],
|
||
acceptance: [], tails: [], nextSvId: 2 };
|
||
applyAudit(p, { new: [], ops: [{ id: 'СВ-1', action: 'mutate', newText: 'реально другая' }] }, 7);
|
||
expect(p.hidden[0].text).toBe('реально другая');
|
||
expect(p.hidden[0].lineage).toEqual([{ turn: 1, text: 'старая' }]);
|
||
});
|
||
});
|
||
|
||
// Task 4: закрытие, тихое закрытие, partial
|
||
describe('applyAudit — close/partial', () => {
|
||
it('close/тихое/partial выставляют статус', () => {
|
||
const mk = (id) => ({ id, lens: 'Л1', status: 'открыт', text: 't', born: 1, lastTouch: 1, lineage: [] });
|
||
const p = { hidden: [mk('СВ-1'), mk('СВ-2'), mk('СВ-3')], acceptance: [], tails: [], nextSvId: 4 };
|
||
applyAudit(p, { new: [], ops: [
|
||
{ id: 'СВ-1', action: 'close', silent: false },
|
||
{ id: 'СВ-2', action: 'close', silent: true },
|
||
{ id: 'СВ-3', action: 'partial' },
|
||
] }, 18);
|
||
expect(p.hidden[0].status).toBe('закрыт');
|
||
expect(p.hidden[1].status).toBe('тихо-закрыт');
|
||
expect(p.hidden[2].status).toBe('открыт');
|
||
});
|
||
});
|
||
|
||
// Task 5: горящие блоки Л8/Л9
|
||
describe('applyAudit — Л8/Л9 горящие блоки', () => {
|
||
it('Л8→acceptance, Л9→tails с дедупом; resolved гасит', () => {
|
||
const p = { hidden: [], acceptance: [], tails: [], nextSvId: 1 };
|
||
applyAudit(p, { new: [
|
||
{ text: 'заявлено работает, проверки нет', lens: 'Л8' },
|
||
{ text: 'temp-скрипт не удалён', lens: 'Л9' },
|
||
{ text: 'заявлено работает, проверки нет', lens: 'Л8' }, // дубль
|
||
], ops: [] }, 5);
|
||
expect(p.acceptance).toHaveLength(1);
|
||
expect(p.tails).toHaveLength(1);
|
||
applyAudit(p, { new: [], ops: [], resolved: ['заявлено работает, проверки нет'] }, 6);
|
||
expect(p.acceptance[0].done).toBe(true);
|
||
});
|
||
});
|
||
|
||
// Task 6: parseAuditResponse
|
||
describe('parseAuditResponse', () => {
|
||
it('вытаскивает JSON из обёртки и переносит дефолты', () => {
|
||
const txt = 'Вот результат:\n```json\n{"new":[{"text":"q","lens":"Л1"}]}\n```\nготово';
|
||
const r = parseAuditResponse(txt);
|
||
expect(r.new).toEqual([{ text: 'q', lens: 'Л1' }]);
|
||
expect(r.ops).toEqual([]);
|
||
expect(r.resolved).toEqual([]);
|
||
});
|
||
it('возвращает пустой результат на мусоре', () => {
|
||
expect(parseAuditResponse('не json')).toEqual({ new: [], ops: [], resolved: [] });
|
||
});
|
||
});
|
||
|
||
// Task 7: buildAuditPrompt и LENSES
|
||
describe('buildAuditPrompt и LENSES', () => {
|
||
it('промпт содержит все 9 линз, текущие СВ и обмен', () => {
|
||
expect(LENSES).toHaveLength(9);
|
||
const proto = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'старый вопрос' }] };
|
||
const msgs = buildAuditPrompt(proto, { user: 'реплика', assistant: 'ответ' });
|
||
const joined = JSON.stringify(msgs);
|
||
expect(joined).toContain('Л9');
|
||
expect(joined).toContain('СВ-1');
|
||
expect(joined).toContain('старый вопрос');
|
||
expect(joined).toContain('реплика');
|
||
});
|
||
// ФОРМА для callAnthropicAPI: { system, user } обе строки (НЕ массив сообщений — иначе API 400).
|
||
it('возвращает { system, user } строками (форма для callAnthropicAPI)', () => {
|
||
const msgs = buildAuditPrompt({ hidden: [] }, { user: 'у', assistant: 'а' });
|
||
expect(Array.isArray(msgs)).toBe(false);
|
||
expect(typeof msgs.system).toBe('string');
|
||
expect(typeof msgs.user).toBe('string');
|
||
expect(msgs.system.length).toBeGreaterThan(0);
|
||
});
|
||
// Контекст «по максимуму»: решения и явные вопросы шлём (для Л5/анти-дублей), Шаги — нет.
|
||
it('кидает контекст дела — решения и явные вопросы', () => {
|
||
const proto = { hidden: [], decisions: [{ text: 'решили B' }], open: [{ text: 'явный вопрос X' }] };
|
||
const { user } = buildAuditPrompt(proto, { user: 'у', assistant: 'а' });
|
||
expect(user).toContain('РЕШЕНИЯ');
|
||
expect(user).toContain('решили B');
|
||
expect(user).toContain('явный вопрос X');
|
||
});
|
||
it('подаёт действия обмена с содержимым (линзы видят, что делал ассистент)', () => {
|
||
const ex = { user: 'у', assistant: 'а', actions: [{ tool: 'Edit', input: '{"file":"f"}', result: 'ok' }] };
|
||
const { user } = buildAuditPrompt({ hidden: [] }, ex);
|
||
expect(user).toContain('Edit');
|
||
expect(user).toContain('{"file":"f"}');
|
||
});
|
||
});
|
||
|
||
// Изоляция реестра от reconcile: версию reconcile игнорируем, берём снимок ДО reconcile
|
||
describe('preserveRegistry — реестр СВ изолирован от reconcile', () => {
|
||
it('возвращает реестр из снимка, игнорируя перенумерованную версию reconcile', () => {
|
||
const reconciled = { hidden: [{ id: 'СВ-99', lens: '1' }], acceptance: [{ text: 'мусор' }], tails: [], nextSvId: 50 };
|
||
const snap = { hidden: [{ id: 'СВ-1', lens: 'Л4', text: 'вопрос' }], acceptance: [], tails: [{ text: 'хвост' }], nextSvId: 2 };
|
||
preserveRegistry(reconciled, snap);
|
||
expect(reconciled.hidden).toEqual([{ id: 'СВ-1', lens: 'Л4', text: 'вопрос' }]);
|
||
expect(reconciled.acceptance).toEqual([]);
|
||
expect(reconciled.tails).toEqual([{ text: 'хвост' }]);
|
||
expect(reconciled.nextSvId).toBe(2);
|
||
});
|
||
it('пустой снимок даёт чистый реестр', () => {
|
||
const p = { hidden: [{ id: 'x' }], nextSvId: 9 };
|
||
preserveRegistry(p, undefined);
|
||
expect(p.hidden).toEqual([]);
|
||
expect(p.nextSvId).toBe(1);
|
||
});
|
||
});
|