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>
130 lines
8.2 KiB
JavaScript
130 lines
8.2 KiB
JavaScript
/**
|
||
* secretary-audit.mjs — ядро аудитора скрытых вопросов (9 линз).
|
||
* Экспорты: LENSES, buildAuditPrompt, parseAuditResponse, applyAudit.
|
||
* Чистый модуль без I/O — всё тестируется юнит-тестами.
|
||
*/
|
||
|
||
// Линзы Л8/Л9 идут в горящие блоки, а не в hidden[]
|
||
const AGENT_BURN = { 'Л8': 'acceptance', 'Л9': 'tails' };
|
||
|
||
// ── Task 7: 9 линз ─────────────────────────────────────────────────────────
|
||
|
||
export const LENSES = [
|
||
'Л1 Допущения — что молча считаем верным без проверки',
|
||
'Л2 Развилки — выбрали одно, другую ветку бросили/не взвесили',
|
||
'Л3 Дрейф воли владельца — решение уехало от того, что хотел владелец (вкл. натяжку ассистента)',
|
||
'Л4 Доказательность — заявлено как факт без пруфа',
|
||
'Л5 Противоречие/дрейф — ход спорит с прежним решением из реестра',
|
||
'Л6 Хрупкость — что ненадёжно/сломается на будущем',
|
||
'Л7 Смысл слов — одно слово в двух значениях',
|
||
'Л8 Приёмка по факту — «готово» без проверки глазами-руками (горит наверху)',
|
||
'Л9 Хвосты и уборка — не убрано/не закоммичено/не запушено (горит наверху)',
|
||
];
|
||
|
||
// ── Task 7: buildAuditPrompt ────────────────────────────────────────────────
|
||
|
||
export function buildAuditPrompt(proto, ex) {
|
||
const reg = (proto.hidden || []).filter((h) => /открыт|мутировал/.test(h.status))
|
||
.map((h) => `${h.id} [${h.lens} · ${h.status}]: ${h.text}`).join('\n') || '(реестр пуст)';
|
||
const system = [
|
||
'Ты — фоновый МНОГОЛИНЗОВЫЙ АУДИТОР скрытых вопросов. Видишь ТОЛЬКО реестр и ОДИН обмен.',
|
||
'Прощупай обмен через 9 линз (помечай находку линзой). Не выдумывай; линза молчит — пропусти.',
|
||
...LENSES,
|
||
'Решения и явные вопросы дела даны для КОНТЕКСТА: лови противоречия (Л5), не дублируй явное в скрытое. Действуй ТОЛЬКО над своими СВ.',
|
||
'Номера новым НЕ присваивай (это хук). Для существующих — операция над номером.',
|
||
'ВЫХОД строго JSON: {"new":[{"text","lens"}],"ops":[{"id","action":"mutate|close|partial","silent","newText"}],"resolved":[]}',
|
||
].join('\n');
|
||
// Контекст дела «по максимуму, без журнала ходов»: Шаги/История намеренно НЕ шлём
|
||
// (бесконечно растут, аудитору бесполезны). Решения/явные вопросы нужны для Л5 и анти-дублей.
|
||
const fmt = (arr, f) => ((arr || []).map(f).join('\n') || '—');
|
||
const ctx = [
|
||
proto.subject ? `ЦЕЛЬ: ${proto.subject}` : '',
|
||
`РЕШЕНИЯ:\n${fmt(proto.decisions, (d) => `- ${d.struck ? '(снято) ' : ''}${d.text}`)}`,
|
||
`АЛЬТЕРНАТИВЫ:\n${fmt(proto.alternatives, (a) => `- ${a.text}`)}`,
|
||
`ПОСЛЕДСТВИЯ/ЦЕНА:\n${fmt(proto.consequences, (c) => `- ${c.text}`)}`,
|
||
`ВОЛЯ/ЗАПРЕТЫ:\n${fmt(proto.will, (w) => `- ${w.text}`)}`,
|
||
`ЯВНЫЕ ОТКРЫТЫЕ ВОПРОСЫ:\n${fmt(proto.open, (o) => `- ${o.text}`)}`,
|
||
].filter(Boolean).join('\n\n');
|
||
const acts = ((ex.actions || []).map((a) =>
|
||
` • ${a.tool} in=${a.input ?? ''}${a.result != null ? ` → ${String(a.result).slice(0, 4000)}` : ''}`).join('\n')) || '—';
|
||
const user = `СКРЫТЫЕ ВОПРОСЫ (твой реестр, действуй только над ними):\n${reg}\n\n`
|
||
+ `КОНТЕКСТ ДЕЛА (без журнала ходов):\n${ctx}\n\n`
|
||
+ `=== ОБМЕН ===\n[ЮЗЕР]: ${ex.user || ''}\n[АССИСТЕНТ]: ${ex.assistant || ''}\n[ДЕЙСТВИЯ]:\n${acts}`;
|
||
// callAnthropicAPI ждёт { system, user } (как reconcile), НЕ массив сообщений — иначе API 400.
|
||
return { system, user };
|
||
}
|
||
|
||
// ── Task 6: parseAuditResponse ──────────────────────────────────────────────
|
||
|
||
export function parseAuditResponse(text) {
|
||
const empty = { new: [], ops: [], resolved: [] };
|
||
const s = String(text || '');
|
||
const start = s.indexOf('{'); const end = s.lastIndexOf('}');
|
||
if (start < 0 || end <= start) return empty;
|
||
let obj;
|
||
try { obj = JSON.parse(s.slice(start, end + 1)); }
|
||
catch { try { obj = JSON.parse(s.slice(start, end + 1).replace(/,\s*([}\]])/g, '$1')); } catch { return empty; } }
|
||
return { new: Array.isArray(obj.new) ? obj.new : [],
|
||
ops: Array.isArray(obj.ops) ? obj.ops : [],
|
||
resolved: Array.isArray(obj.resolved) ? obj.resolved : [] };
|
||
}
|
||
|
||
// ── Tasks 2–5: applyAudit ──────────────────────────────────────────────────
|
||
|
||
export function applyAudit(proto, parsed, turn) {
|
||
proto.hidden ||= []; proto.acceptance ||= []; proto.tails ||= [];
|
||
proto.nextSvId ||= 1;
|
||
|
||
// Новые находки
|
||
for (const n of (parsed?.new || [])) {
|
||
// Л8/Л9 — горящие блоки (append с дедупом)
|
||
if (AGENT_BURN[n.lens]) {
|
||
const arr = proto[AGENT_BURN[n.lens]];
|
||
const norm = (s) => String(s).trim().toLowerCase();
|
||
if (!arr.some((e) => norm(e.text) === norm(n.text)))
|
||
arr.push({ text: n.text, born: turn, lastTouch: turn, done: false });
|
||
continue;
|
||
}
|
||
// Л1–Л7 — реестр скрытых вопросов
|
||
proto.hidden.push({ id: `СВ-${proto.nextSvId++}`, lens: n.lens, status: 'открыт',
|
||
text: n.text, born: turn, lastTouch: turn, lineage: [] });
|
||
}
|
||
|
||
// Операции над существующими СВ
|
||
const byId = Object.fromEntries(proto.hidden.map((h) => [h.id, h]));
|
||
for (const op of (parsed?.ops || [])) {
|
||
const h = byId[op.id];
|
||
if (!h) continue;
|
||
if (op.action === 'mutate' && op.newText) {
|
||
const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||
if (norm(op.newText) !== norm(h.text)) { // страж-ноп: пустую пере-формулировку не пишем
|
||
h.lineage.push({ turn: h.lastTouch, text: h.text });
|
||
h.text = op.newText; h.status = 'мутировал';
|
||
}
|
||
}
|
||
if (op.action === 'close') h.status = op.silent ? 'тихо-закрыт' : 'закрыт';
|
||
// partial: статус остаётся 'открыт' (или 'мутировал'), только lastTouch ниже
|
||
h.lastTouch = turn;
|
||
}
|
||
|
||
// Гашение по resolved (для acceptance и tails)
|
||
const norm = (s) => String(s).trim().toLowerCase();
|
||
for (const r of (parsed?.resolved || []))
|
||
for (const arr of [proto.acceptance, proto.tails])
|
||
for (const e of arr) if (norm(e.text) === norm(r)) { e.done = true; e.lastTouch = turn; }
|
||
|
||
return proto;
|
||
}
|
||
|
||
// Реестр СВ (hidden/acceptance/tails/nextSvId) — ВОТЧИНА АУДИТОРА. reconcile переписывает весь
|
||
// протокол и перенумеровывает/корёжит скрытые вопросы; эта функция возвращает реестр из снимка,
|
||
// снятого ДО reconcile, чтобы потом его менял ТОЛЬКО аудитор. Игнорирует версию reconcile.
|
||
export function preserveRegistry(proto, snapshot) {
|
||
const s = snapshot || {};
|
||
proto.hidden = s.hidden || [];
|
||
proto.acceptance = s.acceptance || [];
|
||
proto.tails = s.tails || [];
|
||
proto.nextSvId = s.nextSvId || 1;
|
||
return proto;
|
||
}
|