Files
brain/tools/secretary-audit.mjs
T
Дмитрий 2b6170313b feat(secretary): нарезка по спанам (реальный промпт владельца) + полное сырьё
Единица разбора — спан: реальный промпт владельца + вся активность ассистента
до следующего реального промпта. Системные ходы (гейт-фидбек, загрузка навыка)
приклеиваются к спану, не считаются отдельными. Разбор отложенный: закрытые
спаны разбираются один раз (курсор в флажке сессии); 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>
2026-06-23 14:45:31 +03:00

130 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 25: 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;
}