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>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
# Спека: секретарь — нарезка по реальным промптам владельца (спаны)
|
||||
|
||||
**Дата:** 2026-06-23 · **Репозиторий:** `claude-brain` · **Ветка:** `main`
|
||||
**Источник задачи:** `docs/secretary/_HANDOFF-span-redesign.md`
|
||||
**Режим:** штатный (стен нет; пол + проверка-перед-пушем остаются). Процесс: эта спека → `writing-plans` → TDD.
|
||||
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Секретарь сейчас разбирает разговор **по каждому Stop-ходу**. Системные впрыски
|
||||
(гейт-фидбек, загрузка навыка, ожидания) считаются отдельными ходами — их текст летит в LLM
|
||||
как «слова владельца». От этого reconcile деградирует, а аудитор по 6–10 раз пере-мутирует один
|
||||
вопрос («Скрытые вопросы» превращаются в простыни наслоений — видно в текущей
|
||||
`docs/secretary/протокол/protocol.md`).
|
||||
|
||||
**Новая единица разбора — спан:** один реальный промпт владельца + ВСЯ активность ассистента до
|
||||
следующего реального промпта. Системные ходы не отдельные единицы — они приклеиваются к спану.
|
||||
Разбор спана **отложенный**: копим до прихода следующего реального промпта, затем разбираем
|
||||
завершённый спан целиком. Модели (и reconcile, и аудитору) отдаётся **весь склеенный спан**
|
||||
(промпт + все ответы + все действия с полным содержимым) — без склейки агент не поймёт работу.
|
||||
|
||||
---
|
||||
|
||||
## Что НЕ трогаем (границы задачи)
|
||||
|
||||
- **Дедуп протокола** (`collapseProtocol`/`canonicalClauses`) — готов, не трогаем.
|
||||
- **Механизм `step`-формулировки** (поле `step{user,assistant}` в reconcile) — используем, переносим
|
||||
на спан.
|
||||
- **Изоляция реестра СВ от reconcile** (`preserveRegistry`, снимок ДО reconcile) — оставляем.
|
||||
- **Существующие живые тетради** (по-ходовые) — оставляем как есть, ретро-конвертации НЕТ.
|
||||
- **Пофайловая нарезка сырья** на `<дело>/ходы/turn-N.log` при выключении — остаётся **по ходу**
|
||||
(страховка), меняется только витрина «Шаги».
|
||||
|
||||
---
|
||||
|
||||
## Принцип нарезки (детально)
|
||||
|
||||
### Что считается «реальным промптом» (граница спана)
|
||||
|
||||
**Первичный сигнал — событие `UserPromptSubmit`.** Хук `secretary-prompt-hook.mjs` срабатывает
|
||||
ТОЛЬКО когда владелец реально набрал текст. Служебные впрыски (гейт-фидбек, загрузка навыка,
|
||||
авто-продолжения) это событие НЕ вызывают. Это и есть честная граница спана — не угадывание по
|
||||
тексту.
|
||||
|
||||
- При обычном промпте (не команда «включи/выключи»), пока секретарь включён, `prompt-hook`
|
||||
дописывает номер хода-начала спана в авторитетный список границ.
|
||||
- Номер хода-начала = `turnCount(rawFile) + 1` (та же формула, что в stop-хуке: при
|
||||
`UserPromptSubmit` сырьё этого хода ещё не записано, его запишет ближайший Stop как turn N+1).
|
||||
|
||||
**Запасной сигнал (fallback) — классификация по содержанию.** Если список границ недоступен/неполон
|
||||
(секретарь включили в середине сессии; файл-флажок потерян), stop-хук вычисляет границы из сырья
|
||||
сам: реальный промпт = ход, чей `[ЮЗЕР]` НЕ совпал с шаблонами `sysLabel`
|
||||
(`^Stop hook feedback`, `^Base directory for this skill`). Это страховка, не основной путь.
|
||||
|
||||
### Хранение границ и курсора
|
||||
|
||||
Границы спанов и курсор «докуда разобрано» — **сессионные** (нумерация ходов в сыром логе
|
||||
`raw/<session>.log` начинается с 1 в каждой сессии; провенанс `[→N]` и так сессионный, с
|
||||
разделителем «—— сессия X ——»). Храним в файле-флажке сессии
|
||||
`~/.claude/runtime/secretary-mode-<session>.json` (он уже существует и читается/пишется хуками):
|
||||
|
||||
- `realPromptTurns: number[]` — авторитетный список ходов-начал спанов (пишет `prompt-hook`).
|
||||
- `spanCursor: number` — индекс последнего разобранного спана (пишет `stop-hook`).
|
||||
|
||||
### Спан: открытый и закрытый
|
||||
|
||||
- Спаны = отрезки `[realPromptTurns[i] .. realPromptTurns[i+1]-1]`.
|
||||
- Все спаны, кроме последнего, — **закрытые** (пришёл следующий реальный промпт).
|
||||
- Последний спан (от последней границы до текущего хода) — **открытый**.
|
||||
- Цена: тетрадь обновляется на один реальный промпт позади. Это ОК (проговорено с владельцем).
|
||||
|
||||
---
|
||||
|
||||
## Отложенный разбор (stop-hook — главная переделка)
|
||||
|
||||
На каждом Stop при включённом секретаре:
|
||||
|
||||
1. **Слой 1 (сырьё) — пишем ВСЕГДА**, по каждому ходу, как сейчас (но с полным содержимым
|
||||
действий — см. ниже «Слой 1 — полная картина»).
|
||||
2. Вычисляем спаны (из `realPromptTurns` + fallback). Находим **закрытые** спаны с индексом
|
||||
`> spanCursor`.
|
||||
3. Для каждого нового закрытого спана по порядку:
|
||||
- **Собираем спан** из сырья (полное содержимое): `user` = текст реального промпта (ход-начало);
|
||||
`assistant` = склейка всех ответов ассистента по ходам спана; `actions` = все действия по
|
||||
ходам спана (с полными `input`/`result`).
|
||||
- **reconcile** по собранному спану (`reconcileTurn` на склеенном обмене; `buildReconcilePrompt`
|
||||
теперь подаёт действия с содержимым, не только имена — см. §2.4 хендоффа).
|
||||
- **одна строка «Шаги»** на спан (суть из `step` reconcile или фолбэк).
|
||||
- **аудит** по собранному спану (`buildAuditPrompt` теперь подаёт действия) + страж-ноп
|
||||
(см. «Калибровка СВ»).
|
||||
- `collapseProtocol`, продвигаем `spanCursor`.
|
||||
4. Пишем `protocol.json`/`protocol.md` + индекс. Если новых закрытых спанов нет — пишем только
|
||||
сырьё, тетрадь не трогаем (отставание на один промпт).
|
||||
|
||||
**Нумерация спана = номер его хода-начала** (совпадает с форматом `[→N]` в превью и с текущим
|
||||
провенансом). Отдельный сквозной индекс спана не вводим.
|
||||
|
||||
---
|
||||
|
||||
## Что отдаём модели (полный спан)
|
||||
|
||||
Источник содержимого — **Слой 1 (сырьё), теперь полный**. Собранный обмен спана:
|
||||
|
||||
- `user` — текст реального промпта (ход-начало спана);
|
||||
- `assistant` — склейка ВСЕХ ответов ассистента за спан;
|
||||
- `actions` — ВСЕ действия за спан, с полными `input` и `result`.
|
||||
|
||||
`buildReconcilePrompt` и `buildAuditPrompt` обновляются: оба подают действия с содержимым
|
||||
(сейчас reconcile шлёт только имена инструментов, аудит — не шлёт действий вовсе). Это сердце
|
||||
задачи: секретарь обязан видеть всё, что делал ассистент, чтобы линзами ловить ошибки/пропуски.
|
||||
|
||||
**Без обрезки.** Размер склейки не ограничиваем (решение владельца: «нужна полная картина»).
|
||||
Предохранитель на гигантский вывод — отдельной задачей, если понадобится.
|
||||
|
||||
---
|
||||
|
||||
## Слой 1 — полная картина (правка с разрешения владельца)
|
||||
|
||||
Сейчас `parseLastExchange` обрезает каждый вывод действия до `MAX_RESULT_CHARS = 1200`. Это прячет
|
||||
часть работы и от аудитора, и из черновика.
|
||||
|
||||
- **Снимаем обрез** (`MAX_RESULT_CHARS`): сырьё хранит полные выводы действий. Владелец явно
|
||||
разрешил задеть Слой 1: «нужна полная картина».
|
||||
- Существующий тест `secretary-transcript.test.mjs` «длинный результат усечён … оканчивается
|
||||
маркером …» — честно переписывается (полное содержимое, без обрезки).
|
||||
- Запись сырья (`buildRawRecord`, append по ходу) и пофайловая нарезка `ходы/turn-N.log` — формат
|
||||
не меняем, меняется только полнота содержимого.
|
||||
- Цена: файлы-черновики крупнее (полные выводы). Для текстовых логов терпимо.
|
||||
|
||||
---
|
||||
|
||||
## Шаги (витрина) — одна строка на спан
|
||||
|
||||
Раздел «Шаги (Слой 1)» — одна строка на **реальный промпт (спан)**:
|
||||
|
||||
```
|
||||
- Ход (промпт) N [вобрал ходы X-Y] — я: <суть промпта> · ты: <суть всего спана> · делал: <все инструменты спана> · <ссылка на сырьё>
|
||||
```
|
||||
|
||||
- `N` — ход-начало спана. `[вобрал ходы X-Y]` — только когда спан охватил больше одного хода.
|
||||
- Суть — модельная (`step`) по всему спану; фолбэк — детерминированная из первого/склейки.
|
||||
- `делал` — объединённый список инструментов всех ходов спана.
|
||||
- Сырьё (`<дело>/ходы/turn-N.log`) остаётся **по ходу**; ссылка ведёт на ход-начало спана.
|
||||
- При «выключи секретаря» пересборка «Шагов» — **по спанам** (`buildStepsFromRaw`/
|
||||
`mergeStepsPreservingText` становятся спан-осведомлёнными), нарезка файлов — по ходу.
|
||||
|
||||
---
|
||||
|
||||
## Провенанс и счётчики — по спанам
|
||||
|
||||
- `born`/`lastTouch`/`turns` в корзинах и реестре СВ — номера ходов-начал спанов (как сейчас, но
|
||||
спаны вместо сырых ходов).
|
||||
- Счётчик «висит N ходов» в горящих блоках (Л8/Л9) → **«висит N промптов»**: считаем число
|
||||
реальных промптов (спанов), прошедших с `born`, а не сырые ходы (иначе гейт-дёрганья врут цифру).
|
||||
Источник счёта — `realPromptTurns`.
|
||||
|
||||
---
|
||||
|
||||
## Калибровка «Скрытых вопросов» (надзор НЕ оскоплять)
|
||||
|
||||
Группировка по спанам уже резко снижает спам (один разбор на промпт вместо разбора каждой
|
||||
гейт-нагалки). Правило: **каждую НАСТОЯЩУЮ находку — сохранить ОДИН раз; резать только
|
||||
дубли-наслоения и гейт-шум, НЕ под ноль.** Две точечные правки:
|
||||
|
||||
1. **Страж-ноп в `applyAudit`** (`secretary-audit.mjs`): при `action === 'mutate'` НЕ мутировать,
|
||||
если `newText` по норме (`trim().toLowerCase().replace(/\s+/g,' ')`) равен текущему `h.text`.
|
||||
Тогда `lineage` не растёт пустыми пере-формулировками; `lastTouch` можно обновить (касание было).
|
||||
2. **Кап родословной в показе** (`renderProtocol`, рендер `hidden`): показывать
|
||||
`~~первая~~ → текущая` (первое звено `lineage` + текущий текст), середину прятать. **Данные
|
||||
(полный `lineage`) в `protocol.json` НЕ трогаем** — режем только показ.
|
||||
|
||||
**Контроль приёмки (что обязано выжить на живом прогоне):** в ручной модели мы случайно срезали
|
||||
~4 живых вопроса (Л7 «память кругов» = функция vs данные; Л3 дрейф — просил реальное, дал
|
||||
реконструкцию; Л4 «чистая функция» без пруфа; уход от coverage после gate-3). На живом прогоне
|
||||
эти находки обязаны выживать (по одному разу).
|
||||
|
||||
---
|
||||
|
||||
## Хвост последнего спана
|
||||
|
||||
- При «выключи секретаря» (`prompt-hook off`): разобрать финальный **открытый** спан ДО нарезки
|
||||
файлов и пересборки «Шагов».
|
||||
- Аварийный вылет окна (нет «выключи»): последний открытый спан остаётся в сырье дословно
|
||||
(данные целы), без выжимки. Триггера «закрытие сессии» в коде нет — лишний механизм ради редкого
|
||||
случая не вводим (решение владельца: «попроще»).
|
||||
|
||||
---
|
||||
|
||||
## Карта файлов и изменений
|
||||
|
||||
| Файл | Что меняется |
|
||||
|---|---|
|
||||
| `tools/secretary-transcript.mjs` | Снять обрез `MAX_RESULT_CHARS` (полное содержимое в сырьё и в спан). |
|
||||
| `tools/secretary-flag.mjs` | Хелперы записи/чтения `realPromptTurns` и `spanCursor` (если выносим из хуков). |
|
||||
| `tools/secretary-prompt-hook.mjs` | На обычном промпте (вкл-секретарь) дописывать границу спана; на «off» — разобрать финальный спан; пересборка «Шагов» по спанам. |
|
||||
| `tools/secretary-stop-hook.mjs` | Отложенная нарезка: вычислить спаны, разобрать закрытые (reconcile+аудит на склеенном спане), продвинуть курсор. |
|
||||
| `tools/secretary-layer1.mjs` | Сборка спана из сырья (полная); `buildStepLine`/`buildStepsFromRaw`/`mergeStepsPreservingText` — спан-осведомлённые («Ход (промпт) N [вобрал ходы X-Y]»). |
|
||||
| `tools/secretary-reconcile.mjs` | `buildReconcilePrompt` подаёт действия с содержимым; провенанс по спанам. |
|
||||
| `tools/secretary-audit.mjs` | `buildAuditPrompt` подаёт действия; страж-ноп в `applyAudit`. |
|
||||
| `tools/secretary-protocol.mjs` | Кап родословной в показе; «висит N промптов»; «Ход (промпт) N» в Шагах. |
|
||||
| `tools/secretary-*.test.mjs` | TDD: тесты на сборку спана, нарезку, страж-ноп, кап показа, счётчик промптов, полное сырьё. |
|
||||
|
||||
---
|
||||
|
||||
## Контракты ключевых функций (для TDD)
|
||||
|
||||
- `assembleSpan(rawText, spanStartTurn, spanEndTurn, session) → { user, assistant, actions }` —
|
||||
склейка обмена спана из сырья (полное содержимое; `user` из хода-начала; `assistant`/`actions`
|
||||
объединены по ходам отрезка). Новая функция в `secretary-layer1.mjs`.
|
||||
- `computeSpans(realPromptTurns, lastTurn) → [{ start, end, open }]` — отрезки спанов; последний
|
||||
`open:true`. Чистая функция (где разместить — решит план).
|
||||
- `recordRealPrompt(flag, turn) → flag'` — добавить границу в `realPromptTurns` (идемпотентно).
|
||||
- `buildStepLine` / `buildStepsFromRaw` / `mergeStepsPreservingText` — теперь на спан: метка
|
||||
«Ход (промпт) N [вобрал ходы X-Y]».
|
||||
- `applyAudit` — `mutate` с равным по норме `newText` НЕ растит `lineage` (страж-ноп).
|
||||
- `renderProtocol` (hidden) — кап показа `~~первая~~ → текущая`.
|
||||
- `parseLastExchange` — без обрезки результата.
|
||||
|
||||
---
|
||||
|
||||
## Edge-cases
|
||||
|
||||
- **Несколько закрытых спанов за один Stop** (редко, но возможно при быстрой череде промптов) —
|
||||
цикл по всем закрытым `> spanCursor`, по порядку.
|
||||
- **Секретарь включён в середине сессии** — границы пишутся с момента «включи»; ранние ходы не
|
||||
разбираются; первый спан = первый реальный промпт после «включи» (fallback по `sysLabel`
|
||||
страхует, если список пуст).
|
||||
- **Спан из одного хода** (промпт без гейт-петли) — `[вобрал ходы …]` не показываем.
|
||||
- **Параллельные сессии** — `realPromptTurns`/`spanCursor` сессионные (файл-флажок по сессии),
|
||||
не топчут друг друга.
|
||||
- **Срыв reconcile на спане** — как сейчас: категории заморожены, но «Шаги» и сырьё целы; курсор
|
||||
всё равно продвигается (спан не разберём дважды; срыв виден в `_reconcile.log`).
|
||||
|
||||
---
|
||||
|
||||
## Приёмка
|
||||
|
||||
- Полный свод секретаря — зелёный:
|
||||
```
|
||||
npx vitest run tools/secretary-reconcile.test.mjs tools/secretary-layer1.test.mjs tools/secretary-protocol.test.mjs tools/secretary-index.test.mjs tools/secretary-audit.test.mjs tools/secretary-hookutil.test.mjs tools/secretary-transcript.test.mjs tools/secretary-flag.test.mjs tools/secretary-prompt-hook.test.mjs
|
||||
```
|
||||
- **ЖИВОЙ прогон** с реальным `SECRETARY_LLM_KEY` на новом деле в несколько реальных промптов с
|
||||
гейт-петлёй внутри: (а) «Шаги» — по одному на реальный промпт; (б) гейт-шум не плодит скрытые
|
||||
вопросы; (в) настоящие находки аудитора выживают по одному разу (надзор не оскоплён); (г) Слой 1
|
||||
хранит все ходы дословно и теперь с полным содержимым действий.
|
||||
- Ничего из работы/диалога не теряется — сверить со старой тетрадью.
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы §4 хендоффа — решения
|
||||
|
||||
1. **Буфер спана** — отдельного файла НЕТ. Источник правды — Слой 1 (сырьё, теперь полное);
|
||||
границы — `realPromptTurns` в файле-флажке сессии; курсор — `spanCursor` там же. Переживает
|
||||
перезапуск (файлы на диске).
|
||||
2. **Закрытие последнего спана** — при «выключи секретаря». SessionEnd-триггера в коде нет; не
|
||||
вводим.
|
||||
3. **Большой спан** — без обрезки (решение владельца). Предохранитель — отдельно при нужде.
|
||||
4. **Счётчики** — «висит N промптов» по `realPromptTurns`, не по сырым ходам.
|
||||
5. **Существующие тетради** — оставляем как есть.
|
||||
6. **Калибровка lineage** — в этой же спеке (страж-ноп + кап показа).
|
||||
@@ -45,9 +45,11 @@ export function buildAuditPrompt(proto, ex) {
|
||||
`ВОЛЯ/ЗАПРЕТЫ:\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[ЮЗЕР]: ${ex.user || ''}\n[АССИСТЕНТ]: ${ex.assistant || ''}\n[ДЕЙСТВИЯ]:\n${acts}`;
|
||||
// callAnthropicAPI ждёт { system, user } (как reconcile), НЕ массив сообщений — иначе API 400.
|
||||
return { system, user };
|
||||
}
|
||||
@@ -94,8 +96,11 @@ export function applyAudit(proto, parsed, turn) {
|
||||
const h = byId[op.id];
|
||||
if (!h) continue;
|
||||
if (op.action === 'mutate' && op.newText) {
|
||||
h.lineage.push({ turn: h.lastTouch, text: h.text });
|
||||
h.text = op.newText; h.status = 'мутировал';
|
||||
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 ниже
|
||||
|
||||
@@ -25,6 +25,25 @@ describe('applyAudit — мутация', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 выставляют статус', () => {
|
||||
@@ -99,6 +118,12 @@ describe('buildAuditPrompt и LENSES', () => {
|
||||
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
|
||||
|
||||
+43
-16
@@ -1,10 +1,16 @@
|
||||
import { computeSpans } from './secretary-span.mjs';
|
||||
|
||||
// Обезвреживание маркеров внутри полезного текста: если в реплике/действии встретились те же
|
||||
// строки-разделители (цитата хода, тест-фикстура), ломаем их, чтобы счётчик ходов и нарезка
|
||||
// не считали их за настоящие границы (самозагрязнение лога при чтении/цитировании самого лога).
|
||||
function neutralizeMarkers(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/=== ХОД turn=/g, '=≡ ХОД turn=')
|
||||
.replace(/=== КОНЕЦ ХОДА ===/g, '=≡ КОНЕЦ ХОДА ≡=');
|
||||
.replace(/=== КОНЕЦ ХОДА ===/g, '=≡ КОНЕЦ ХОДА ≡=')
|
||||
// структурные метки блока: ломаем только в начале строки (реальные ставит buildRawRecord),
|
||||
// чтобы полный вывод действия не подделал границы при обратном разборе сырья.
|
||||
// Пробел перед «]» → «[ЮЗЕР ]» уже не совпадёт с разбором «^[ЮЗЕР]».
|
||||
.replace(/^\[(ЮЗЕР|АССИСТЕНТ|ДЕЙСТВИЕ|ВЫДАЧА)\]/gm, '[$1 ]');
|
||||
}
|
||||
|
||||
// Чистый билдер сырой записи Слоя 1 (§L1). PII вырезается вызывающим хуком до записи;
|
||||
@@ -46,30 +52,50 @@ export function prepareTurnFiles(rawText, protocol = {}) {
|
||||
return { files, steps };
|
||||
}
|
||||
|
||||
// Пересборка Шагов из общего сырья: по строке на КАЖДЫЙ ход (хук пишет Шаг только во вкл-ходы,
|
||||
// поэтому на остановке собираем все ходы из Слоя 1 — чтобы в Шагах не было пропусков).
|
||||
export function buildStepsFromRaw(rawText, session) {
|
||||
return splitRawIntoTurns(rawText).map(({ turn, block }) => {
|
||||
// Реальные границы по фолбэку: ход реальный, если его [ЮЗЕР] не совпал с sysLabel-шаблонами.
|
||||
// Экспортируется: stop-хук берёт её как запасной детект, если flag.realPromptTurns пуст.
|
||||
export function realBoundariesFromRaw(rawText) {
|
||||
return splitRawIntoTurns(rawText).filter(({ block }) => {
|
||||
const um = block.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]/);
|
||||
const am = block.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\]|\n=== КОНЕЦ ХОДА ===|$)/);
|
||||
const actions = [...block.matchAll(/\[ДЕЙСТВИЕ\]\s+(\S+)/g)].map((x) => x[1]);
|
||||
return { turn, session,
|
||||
text: buildStepLine({ turn, user: um ? um[1] : '', assistant: am ? am[1] : '', actions }) };
|
||||
const u = (um ? um[1] : '').trim();
|
||||
return !/^Stop hook feedback/i.test(u) && !/^Base directory for this skill/i.test(u);
|
||||
}).map((p) => p.turn);
|
||||
}
|
||||
|
||||
// Пересборка Шагов из сырья ПО СПАНАМ: одна строка на реальный промпт (склейка ходов спана).
|
||||
// realPromptTurns — авторитетные границы; null/пусто → фолбэк по sysLabel.
|
||||
export function buildStepsFromRaw(rawText, session, realPromptTurns = null) {
|
||||
const parts = splitRawIntoTurns(rawText);
|
||||
if (!parts.length) return [];
|
||||
const lastTurn = parts[parts.length - 1].turn;
|
||||
const bounds = (Array.isArray(realPromptTurns) && realPromptTurns.length)
|
||||
? realPromptTurns : realBoundariesFromRaw(rawText);
|
||||
const spans = computeSpans(bounds, lastTurn);
|
||||
const byTurn = new Map(parts.map((p) => [p.turn, p.block]));
|
||||
return spans.map(({ start, end }) => {
|
||||
const blocks = [];
|
||||
for (let t = start; t <= end; t++) if (byTurn.has(t)) blocks.push(byTurn.get(t));
|
||||
const startBlock = byTurn.get(start) || blocks[0] || '';
|
||||
const um = startBlock.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]/);
|
||||
const aAll = blocks.map((b) => (b.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\]|\n=== КОНЕЦ ХОДА ===|$)/) || [, ''])[1])
|
||||
.filter(Boolean).join(' ');
|
||||
const actions = blocks.flatMap((b) => [...b.matchAll(/\[ДЕЙСТВИЕ\]\s+(\S+)/g)].map((x) => x[1]));
|
||||
return { turn: start, session,
|
||||
text: buildStepLine({ turn: start, endTurn: end, user: um ? um[1] : '', assistant: aAll, actions }) };
|
||||
});
|
||||
}
|
||||
|
||||
// Слияние «Шагов» при выключении: на КАЖДЫЙ ход из сырья берём существующий шаг (модельная
|
||||
// формулировка) если он есть, иначе достраиваем детерминированно из сырья. Порядок — по сырью
|
||||
// (хронология); модельный текст переживает выключение/нарезку.
|
||||
export function mergeStepsPreservingText(existingSteps, rawText, session) {
|
||||
// Слияние «Шагов» при выключении: на КАЖДЫЙ спан берём существующий (модельный) шаг по turn-начала,
|
||||
// иначе достраиваем детерминированно. Порядок — по сырью.
|
||||
export function mergeStepsPreservingText(existingSteps, rawText, session, realPromptTurns = null) {
|
||||
const have = new Map((Array.isArray(existingSteps) ? existingSteps : []).map((s) => [s.turn, s]));
|
||||
return buildStepsFromRaw(rawText, session).map((r) => (have.has(r.turn) ? have.get(r.turn) : r));
|
||||
return buildStepsFromRaw(rawText, session, realPromptTurns).map((r) => (have.has(r.turn) ? have.get(r.turn) : r));
|
||||
}
|
||||
|
||||
// Человекочитаемая строка шага для раздела «Шаги (Слой 1)»: «Ход N — я: … · ты: … · делал: …».
|
||||
// Суть — первая фраза реплики; служебные строки (экономия/coverage/вердикт) отброшены;
|
||||
// «делал» — имена инструментов из действий хода. Название файла полного хода добавляет рендер.
|
||||
export function buildStepLine({ turn, user, assistant, actions = [], essence = null } = {}) {
|
||||
export function buildStepLine({ turn, endTurn = null, user, assistant, actions = [], essence = null } = {}) {
|
||||
// Содержательная фраза: убираем ведущую нумерацию списка («1.»/«2)»), копим до ≥25 симв.,
|
||||
// чтобы не выдать обрывок «Стоп.»; длинное усекаем.
|
||||
const firstSentence = (s) => {
|
||||
@@ -96,7 +122,8 @@ export function buildStepLine({ turn, user, assistant, actions = [], essence = n
|
||||
const u = eU || sysLabel(user) || firstSentence(user) || '(без вопроса)';
|
||||
const a = eA || firstSentence(cleanA) || '(без ответа)';
|
||||
const did = [...new Set((actions || []).map((t) => String(t).trim()).filter(Boolean))].join(', ') || '—';
|
||||
return `Ход ${turn} — я: ${u} · ты: ${a} · делал: ${did}`;
|
||||
const span = (endTurn != null && endTurn > turn) ? ` [вобрал ходы ${turn}-${endTurn}]` : '';
|
||||
return `Ход (промпт) ${turn}${span} — я: ${u} · ты: ${a} · делал: ${did}`;
|
||||
}
|
||||
|
||||
import { writeFileSync as _writeFileSync, renameSync as _renameSync } from 'node:fs';
|
||||
|
||||
@@ -12,20 +12,37 @@ describe('обезвреживание маркеров на записи (от
|
||||
expect((rec.match(/=== ХОД turn=/g) || []).length).toBe(1); // только реальный заголовок
|
||||
expect((rec.match(/=== КОНЕЦ ХОДА ===/g) || []).length).toBe(1); // только реальный конец
|
||||
});
|
||||
it('структурные метки внутри содержимого обезврежены (полный вывод не ломает разбор)', () => {
|
||||
const rec = buildRawRecord({
|
||||
turn: 1, time: 't', session: 's',
|
||||
user: 'u', assistant: 'a',
|
||||
actions: [{ tool: 'Read', input: 'x', result: '[ДЕЙСТВИЕ] Edit\n[ВЫДАЧА] Edit\n[ЮЗЕР]\n[АССИСТЕНТ]' }],
|
||||
});
|
||||
// в записи остаётся ровно один реальный набор маркеров действия (из buildRawRecord),
|
||||
// подделки из result не считаются за структурные.
|
||||
expect((rec.match(/^\[ДЕЙСТВИЕ\] /gm) || []).length).toBe(1);
|
||||
expect((rec.match(/^\[ВЫДАЧА\] /gm) || []).length).toBe(1);
|
||||
expect(rec).not.toMatch(/^\[ЮЗЕР\]\n\[АССИСТЕНТ\]$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStepsFromRaw — Шаг на КАЖДЫЙ ход (пересборка на остановке)', () => {
|
||||
describe('buildStepsFromRaw — Шаг на КАЖДЫЙ спан (пересборка на остановке)', () => {
|
||||
const raw = [
|
||||
'=== ХОД turn=1 · t · session=s ===', '[ЮЗЕР]', 'привет', '[АССИСТЕНТ]', 'ответ раз два три', '[ДЕЙСТВИЕ] Read in=x', '[ВЫДАЧА] Read', '', '=== КОНЕЦ ХОДА ===', '',
|
||||
'=== ХОД turn=2 · t · session=s ===', '[ЮЗЕР]', 'второй вопрос достаточно длинный', '[АССИСТЕНТ]', 'второй ответ', '=== КОНЕЦ ХОДА ===', '',
|
||||
'=== ХОД turn=3 · t · session=s ===', '[ЮЗЕР]', 'настоящий вопрос достаточно длинный', '[АССИСТЕНТ]', 'ответ раз', '[ДЕЙСТВИЕ] Read in=x', '[ВЫДАЧА] Read', 'r', '=== КОНЕЦ ХОДА ===', '',
|
||||
'=== ХОД turn=4 · t · session=s ===', '[ЮЗЕР]', 'Stop hook feedback: y', '[АССИСТЕНТ]', 'ответ два', '[ДЕЙСТВИЕ] Grep in=z', '[ВЫДАЧА] Grep', 'r2', '=== КОНЕЦ ХОДА ===', '',
|
||||
'=== ХОД turn=5 · t · session=s ===', '[ЮЗЕР]', 'второй настоящий вопрос длинный', '[АССИСТЕНТ]', 'ответ три', '=== КОНЕЦ ХОДА ===', '',
|
||||
].join('\n');
|
||||
it('по шагу на каждый ход, с сессией и инструментами', () => {
|
||||
const steps = buildStepsFromRaw(raw, 's');
|
||||
expect(steps.map((s) => s.turn)).toEqual([1, 2]);
|
||||
it('границы [3,5] → два спана: 3 (вобрал 3-4) и 5', () => {
|
||||
const steps = buildStepsFromRaw(raw, 's', [3, 5]);
|
||||
expect(steps.map((x) => x.turn)).toEqual([3, 5]);
|
||||
expect(steps[0].text).toContain('Ход (промпт) 3 [вобрал ходы 3-4] — я: настоящий вопрос');
|
||||
expect(steps[0].text).toContain('делал: Read, Grep'); // действия обоих ходов
|
||||
expect(steps[1].text).toContain('Ход (промпт) 5');
|
||||
expect(steps[0].session).toBe('s');
|
||||
expect(steps[0].text).toContain('Ход 1 — я: привет');
|
||||
expect(steps[0].text).toContain('делал: Read');
|
||||
expect(steps[1].text).toContain('Ход 2 — я: второй вопрос');
|
||||
});
|
||||
it('без границ — фолбэк по sysLabel (реальный = не служебный)', () => {
|
||||
const steps = buildStepsFromRaw(raw, 's', null);
|
||||
expect(steps.map((x) => x.turn)).toEqual([3, 5]); // ход 4 (гейт) приклеен к 3
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,13 +95,22 @@ describe('buildRawRecord', () => {
|
||||
});
|
||||
|
||||
describe('buildStepLine', () => {
|
||||
it('формат «Ход N — я: … · ты: … · делал: <инструменты>», без служебных строк', () => {
|
||||
it('формат «Ход (промпт) N — …», без служебных строк', () => {
|
||||
const s = buildStepLine({ turn: 5, user: 'сделай флажок.', assistant: 'экономия: 100%\nГотово.', actions: ['Edit', 'PowerShell', 'Edit'] });
|
||||
expect(s).toContain('Ход 5 — я: сделай флажок.');
|
||||
expect(s).toContain('Ход (промпт) 5 — я: сделай флажок.');
|
||||
expect(s).toContain('· ты: Готово.');
|
||||
expect(s).toContain('· делал: Edit, PowerShell');
|
||||
expect(s).not.toContain('экономия');
|
||||
});
|
||||
it('многоходовый спан показывает «[вобрал ходы X-Y]»', () => {
|
||||
const s = buildStepLine({ turn: 12, endTurn: 14, user: 'вопрос длинный достаточно', assistant: 'ответ' });
|
||||
expect(s).toContain('Ход (промпт) 12 [вобрал ходы 12-14] — я: вопрос длинный');
|
||||
});
|
||||
it('спан из одного хода — без «вобрал»', () => {
|
||||
const s = buildStepLine({ turn: 7, endTurn: 7, user: 'короткий вопрос достаточно длинный', assistant: 'ок' });
|
||||
expect(s).not.toContain('вобрал');
|
||||
expect(s).toContain('Ход (промпт) 7 —');
|
||||
});
|
||||
it('пустой вопрос → (без вопроса); без действий → —', () => {
|
||||
const s = buildStepLine({ turn: 2, user: '', assistant: 'a.' });
|
||||
expect(s).toContain('я: (без вопроса)');
|
||||
@@ -99,30 +125,29 @@ describe('buildStepLine', () => {
|
||||
expect(buildStepLine({ turn: 1, user: 'Stop hook feedback: coverage missing', assistant: '' })).toContain('я: (гейт проверки)');
|
||||
expect(buildStepLine({ turn: 2, user: 'Base directory for this skill: C:\\x\\skills\\writing-plans\\SKILL.md', assistant: 'x.' })).toContain('я: (навык: writing-plans)');
|
||||
});
|
||||
it('essence: берёт модельную суть дословно + детерминированный «делал»', () => {
|
||||
const s = buildStepLine({ turn: 12, user: 'длинная вода без точек '.repeat(10),
|
||||
assistant: 'вода', actions: ['Read', 'Read', 'Grep'],
|
||||
it('essence: модельную суть дословно + детерминированный «делал»', () => {
|
||||
const s = buildStepLine({ turn: 12, endTurn: 14, user: 'вода '.repeat(10), assistant: 'вода', actions: ['Read', 'Read', 'Grep'],
|
||||
essence: { user: 'промпт не логируется?', assistant: 'достать можно: поймать или пересобрать' } });
|
||||
expect(s).toBe('Ход 12 — я: промпт не логируется? · ты: достать можно: поймать или пересобрать · делал: Read, Grep');
|
||||
expect(s).toBe('Ход (промпт) 12 [вобрал ходы 12-14] — я: промпт не логируется? · ты: достать можно: поймать или пересобрать · делал: Read, Grep');
|
||||
});
|
||||
it('без essence — прежний фолбэк (firstSentence)', () => {
|
||||
it('без essence — фолбэк firstSentence', () => {
|
||||
const s = buildStepLine({ turn: 2, user: 'сделай флажок.', assistant: 'Готово.', essence: null });
|
||||
expect(s).toContain('я: сделай флажок');
|
||||
expect(s).toContain('ты: Готово');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeStepsPreservingText — выключение не затирает модельный текст', () => {
|
||||
describe('mergeStepsPreservingText — выключение не затирает модельный текст (по спанам)', () => {
|
||||
const raw = [
|
||||
'=== ХОД turn=1 · t · session=s ===', '[ЮЗЕР]', 'привет', '[АССИСТЕНТ]', 'хай', '=== КОНЕЦ ХОДА ===',
|
||||
'=== ХОД turn=2 · t · session=s ===', '[ЮЗЕР]', 'вопрос', '[АССИСТЕНТ]', 'ответ', '=== КОНЕЦ ХОДА ===', '',
|
||||
'=== ХОД turn=3 · t · session=s ===', '[ЮЗЕР]', 'привет достаточно длинный вопрос', '[АССИСТЕНТ]', 'хай', '=== КОНЕЦ ХОДА ===',
|
||||
'=== ХОД turn=4 · t · session=s ===', '[ЮЗЕР]', 'второй вопрос достаточно длинный', '[АССИСТЕНТ]', 'ответ', '=== КОНЕЦ ХОДА ===', '',
|
||||
].join('\n');
|
||||
it('существующий шаг сохраняется, пропущенный достраивается из сырья', () => {
|
||||
const existing = [{ turn: 2, session: 's', text: 'Ход 2 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —' }];
|
||||
const out = mergeStepsPreservingText(existing, raw, 's');
|
||||
expect(out.map((s) => s.turn)).toEqual([1, 2]);
|
||||
expect(out.find((s) => s.turn === 2).text).toBe('Ход 2 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —');
|
||||
expect(out.find((s) => s.turn === 1).text).toContain('Ход 1 — я: привет');
|
||||
it('существующий шаг спана сохраняется, пропущенный достраивается', () => {
|
||||
const existing = [{ turn: 4, session: 's', text: 'Ход (промпт) 4 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —' }];
|
||||
const out = mergeStepsPreservingText(existing, raw, 's', [3, 4]);
|
||||
expect(out.map((s) => s.turn)).toEqual([3, 4]);
|
||||
expect(out.find((s) => s.turn === 4).text).toBe('Ход (промпт) 4 — я: МОДЕЛЬНЫЙ · ты: ТЕКСТ · делал: —');
|
||||
expect(out.find((s) => s.turn === 3).text).toContain('Ход (промпт) 3 — я: привет');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
// UserPromptSubmit-переходник секретаря: ловит «включи/выключи секретаря».
|
||||
// Тонкий shell над чистым detectSecretaryCommand. Нарезка steps/ убрана: навигация идёт
|
||||
// прямо в raw/<session>.log по провенансу с сессией (метка @<session> рядом с [→N]).
|
||||
// UserPromptSubmit-переходник секретаря: ловит «включи/выключи секретаря» И метит границы спанов.
|
||||
// Реальный промпт владельца = срабатывание этого хука (служебные впрыски его не вызывают), поэтому
|
||||
// здесь — авторитетный детект границы спана. Тяжёлый разбор/нарезка — в Stop-хуке (таймаут 15 мин).
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs';
|
||||
import { prepareTurnFiles, buildStepsFromRaw, mergeStepsPreservingText } from './secretary-layer1.mjs';
|
||||
import { renderProtocol } from './secretary-protocol.mjs';
|
||||
import { recordRealPrompt } from './secretary-span.mjs';
|
||||
|
||||
function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } }
|
||||
function turnCount(rawFile) {
|
||||
@@ -37,6 +36,20 @@ export function planActivation({ requested, existing = [], startedAtTurn = 0, se
|
||||
return { confirm: false, flag: { mode: 'on', startedAtTurn, work: res.work, session } };
|
||||
}
|
||||
|
||||
// Решение хука на обычный промпт / выключение по отношению к границам спанов.
|
||||
// cmd: 'on'|'off'|null; flag — текущий флажок; turnCount — число ходов в сырье.
|
||||
// Возврат { flag: <новый флажок для записи> | null }.
|
||||
export function planPromptTurn({ cmd, flag, turnCount: tc }) {
|
||||
if (cmd === 'off') {
|
||||
// НЕ гасим сразу: финальный открытый спан разберёт ближайший Stop (у него таймаут 15 мин).
|
||||
return { flag: { ...(flag || {}), mode: 'closing' } };
|
||||
}
|
||||
if (cmd == null && flag && flag.mode === 'on') {
|
||||
return { flag: recordRealPrompt(flag, tc + 1) };
|
||||
}
|
||||
return { flag: null };
|
||||
}
|
||||
|
||||
function main() {
|
||||
let ev = {};
|
||||
try { ev = JSON.parse(readStdin() || '{}'); } catch { ev = {}; }
|
||||
@@ -44,12 +57,20 @@ function main() {
|
||||
const session = ev.session_id || ev.sessionId || 'unknown';
|
||||
const FLAG = join(homedir(), '.claude', 'runtime', secretaryModeFileName(session));
|
||||
const cmd = detectSecretaryCommand(prompt);
|
||||
if (!cmd) { process.exit(0); }
|
||||
|
||||
const secdir = join(process.cwd(), 'docs', 'secretary');
|
||||
const rawFile = join(secdir, 'raw', `${session}.log`);
|
||||
try { mkdirSync(dirname(FLAG), { recursive: true }); } catch { /* ignore */ }
|
||||
|
||||
const readFlag = () => { try { return JSON.parse(readFileSync(FLAG, 'utf-8')); } catch { return {}; } };
|
||||
|
||||
if (!cmd) {
|
||||
// Обычный промпт: при включённом секретаре метим границу спана (реальный промпт владельца).
|
||||
const r = planPromptTurn({ cmd: null, flag: readFlag(), turnCount: turnCount(rawFile) });
|
||||
if (r.flag) { try { writeFileSync(FLAG, JSON.stringify(r.flag)); } catch { /* ignore */ } }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cmd === 'on') {
|
||||
const m = prompt.match(/секретар[а-я]*\s+(?:для\s+|по\s+)?([a-zA-Zа-яёА-ЯЁ0-9-]{2,})/);
|
||||
const requested = (m && m[1]) || 'general';
|
||||
@@ -64,30 +85,9 @@ function main() {
|
||||
}
|
||||
try { writeFileSync(FLAG, JSON.stringify(plan.flag)); } catch { /* ignore */ }
|
||||
} else if (cmd === 'off') {
|
||||
// Остановка: режем общий сырой лог на отдельные файлы ходов в «<дело>/ходы/» и проставляем
|
||||
// в каждый Шаг ссылку «ходы/turn-N.log» (поднять один ход = открыть один маленький файл).
|
||||
try {
|
||||
let prevFlag = {};
|
||||
try { prevFlag = JSON.parse(readFileSync(FLAG, 'utf-8')); } catch { prevFlag = {}; }
|
||||
const work = prevFlag.work || 'general';
|
||||
const workDir = join(secdir, work);
|
||||
const protoJson = join(workDir, 'protocol.json');
|
||||
if (existsSync(rawFile) && existsSync(protoJson)) {
|
||||
const raw = readFileSync(rawFile, 'utf-8');
|
||||
const proto = JSON.parse(readFileSync(protoJson, 'utf-8'));
|
||||
// Шаги — на КАЖДЫЙ ход из Слоя 1 (не только вкл-ходы), затем нарезка + ссылки.
|
||||
proto.steps = mergeStepsPreservingText(proto.steps, raw, session);
|
||||
const { files, steps } = prepareTurnFiles(raw, proto);
|
||||
const hodyDir = join(workDir, 'ходы');
|
||||
mkdirSync(hodyDir, { recursive: true });
|
||||
for (const f of files) writeFileSync(join(hodyDir, f.name), f.content, 'utf-8');
|
||||
proto.steps = steps;
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||||
writeFileSync(protoJson, JSON.stringify(proto, null, 2), 'utf-8');
|
||||
writeFileSync(join(workDir, 'protocol.md'), renderProtocol(proto, { work, date: stamp }), 'utf-8');
|
||||
}
|
||||
} catch { /* fail-quiet: флажок всё равно гасим ниже */ }
|
||||
try { writeFileSync(FLAG, JSON.stringify({ mode: 'off' })); } catch { /* ignore */ }
|
||||
// Только метим mode:closing. Финальный спан разберёт + нарежет сырьё + погасит флажок Stop-хук.
|
||||
const r = planPromptTurn({ cmd: 'off', flag: readFlag(), turnCount: turnCount(rawFile) });
|
||||
try { writeFileSync(FLAG, JSON.stringify(r.flag)); } catch { /* ignore */ }
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { planActivation } from './secretary-prompt-hook.mjs';
|
||||
import { planActivation, planPromptTurn } from './secretary-prompt-hook.mjs';
|
||||
|
||||
describe('planPromptTurn — обычный промпт при включённом секретаре метит границу спана', () => {
|
||||
it('cmd=null, секретарь on → дописать границу (turnCount+1)', () => {
|
||||
const r = planPromptTurn({ cmd: null, flag: { mode: 'on', work: 'x', realPromptTurns: [3] }, turnCount: 11 });
|
||||
expect(r.flag.realPromptTurns).toEqual([3, 12]);
|
||||
});
|
||||
it('cmd=null, секретарь off → ничего', () => {
|
||||
const r = planPromptTurn({ cmd: null, flag: { mode: 'off' }, turnCount: 5 });
|
||||
expect(r.flag).toBeNull();
|
||||
});
|
||||
it('cmd=off → флажок mode:closing с сохранением полей', () => {
|
||||
const r = planPromptTurn({ cmd: 'off', flag: { mode: 'on', work: 'дело', realPromptTurns: [3, 12], spanCursor: 0, session: 's' }, turnCount: 20 });
|
||||
expect(r.flag.mode).toBe('closing');
|
||||
expect(r.flag.work).toBe('дело');
|
||||
expect(r.flag.realPromptTurns).toEqual([3, 12]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planActivation — решение хука: активировать или переспросить', () => {
|
||||
it('новое имя (нет похожих) — флажок on с work', () => {
|
||||
|
||||
@@ -45,12 +45,19 @@ export function renderProtocol(protocol, opts = {}) {
|
||||
L.push(`**Дело:** ${opts.work} · **Статус:** ${protocol.status || 'открыто'} · `
|
||||
+ `**Дата:** ${opts.date || ''} · **Хозяин:** владелец · **Цель:** ${protocol.subject || ''}`, '');
|
||||
}
|
||||
// «висит N» — число реальных промптов (спанов), прошедших с born, а не сырых ходов.
|
||||
const spanDist = (born) => {
|
||||
const rp = Array.isArray(opts.realPromptTurns) ? opts.realPromptTurns : null;
|
||||
if (rp && opts.turn) return rp.filter((t) => t > born && t <= opts.turn).length;
|
||||
return opts.turn && born != null ? opts.turn - born : 0; // фолбэк (сырые ходы)
|
||||
};
|
||||
const burn = (title, arr) => {
|
||||
const live = (arr || []).filter((e) => !e.done);
|
||||
if (!live.length) return;
|
||||
L.push(title);
|
||||
for (const e of live) {
|
||||
const stale = e.lastTouch != null && opts.turn && opts.turn > e.lastTouch ? ` · висит ${opts.turn - e.born} ходов` : '';
|
||||
const n = e.lastTouch != null && opts.turn && opts.turn > e.lastTouch ? spanDist(e.born) : 0;
|
||||
const stale = n > 0 ? ` · висит ${n} промптов` : '';
|
||||
L.push(`- ${e.text}${e.born ? ` [→${e.born}]` : ''}${stale}`);
|
||||
}
|
||||
L.push('');
|
||||
@@ -89,7 +96,7 @@ export function renderProtocol(protocol, opts = {}) {
|
||||
L.push('', '## Скрытые вопросы (фон)');
|
||||
for (const h of (protocol.hidden || [])) {
|
||||
const head = h.lineage && h.lineage.length
|
||||
? h.lineage.map((x) => `~~${x.text}~~`).join(' → ') + ' → ' + h.text
|
||||
? `~~${h.lineage[0].text}~~ → ${h.text}` // кап показа: только первая → текущая (данные в JSON целы)
|
||||
: h.text;
|
||||
const prov2 = ` [→${h.born}]` + (h.lastTouch && h.lastTouch !== h.born ? ` [${h.lastTouch}]` : '');
|
||||
L.push(`- ${h.id} [${h.lens} · ${h.status}]: ${head}${prov2}`);
|
||||
|
||||
@@ -108,6 +108,22 @@ describe('renderProtocol — 9 категорий + шаги', () => {
|
||||
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: [],
|
||||
|
||||
@@ -22,7 +22,8 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark
|
||||
].join('\n');
|
||||
const sec = (name, arr) => `${name}:\n` + ((arr || []).map((e) =>
|
||||
` - ${e.struck ? '[зачёркнуто] ' : ''}${e.text}${e.why ? ' — ' + e.why : ''}`).join('\n') || ' (пусто)');
|
||||
const acts = (lastExchange.actions || []).map((a) => a.tool).join(', ') || '—';
|
||||
const acts = ((lastExchange.actions || []).map((a) =>
|
||||
` • ${a.tool} in=${a.input ?? ''}${a.result != null ? `\n → ${String(a.result).replace(/\n/g, '\n ')}` : ''}`).join('\n')) || '—';
|
||||
const user = [
|
||||
`Тема дела: ${protocol.subject || '(нет)'}`,
|
||||
sec('Решения', protocol.decisions), sec('Альтернативы', protocol.alternatives),
|
||||
@@ -31,7 +32,7 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark
|
||||
'', 'Последний обмен:',
|
||||
`[ЮЗЕР]: ${lastExchange.user || ''}`,
|
||||
`[АССИСТЕНТ]: ${lastExchange.assistant || ''}`,
|
||||
`Действия: ${acts}`,
|
||||
`Действия (с содержимым):\n${acts}`,
|
||||
remark ? `\nЗАМЕЧАНИЕ (исправь и верни весь протокол):\n${remark}` : '',
|
||||
'', 'Верни ВЕСЬ обновлённый протокол как JSON.',
|
||||
].join('\n');
|
||||
|
||||
@@ -117,6 +117,13 @@ describe('buildReconcilePrompt', () => {
|
||||
expect(system.toLowerCase()).toContain('step');
|
||||
expect(system.toLowerCase()).toContain('суть');
|
||||
});
|
||||
it('подаёт действия с содержимым (input/result), а не только имена', () => {
|
||||
const ex = { user: 'u', assistant: 'a', actions: [{ tool: 'Read', input: '{"f":"x"}', result: 'СОДЕРЖИМОЕ' }] };
|
||||
const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: ex });
|
||||
expect(user).toContain('Read');
|
||||
expect(user).toContain('{"f":"x"}');
|
||||
expect(user).toContain('СОДЕРЖИМОЕ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcile — 9 категорий + стабильная тема', () => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Чистая спан-логика секретаря: границы спанов (реальные промпты владельца) → отрезки ходов.
|
||||
// Без I/O. Нумерация — номера ходов сырья (raw/<session>.log).
|
||||
import { splitRawIntoTurns } from './secretary-layer1.mjs';
|
||||
|
||||
/** Нормализованный, отсортированный список уникальных границ. */
|
||||
function norm(realPromptTurns) {
|
||||
return [...new Set((realPromptTurns || []).map(Number).filter((n) => Number.isFinite(n)))]
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** Отрезки спанов: [b_i .. b_{i+1}-1]; последний — до lastTurn, open:true. */
|
||||
export function computeSpans(realPromptTurns, lastTurn) {
|
||||
const b = norm(realPromptTurns);
|
||||
const out = [];
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
const start = b[i];
|
||||
const isLast = i === b.length - 1;
|
||||
const end = isLast ? lastTurn : b[i + 1] - 1;
|
||||
out.push({ start, end, open: isLast });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Закрытые спаны с индексом строго больше курсора — их надо разобрать сейчас.
|
||||
* Курсор «ничего не разобрано» = -1. */
|
||||
export function spansToDistill(realPromptTurns, lastTurn, spanCursor) {
|
||||
const cur = Number.isFinite(spanCursor) ? spanCursor : -1;
|
||||
return computeSpans(realPromptTurns, lastTurn)
|
||||
.map((s, index) => ({ ...s, index }))
|
||||
.filter((s) => !s.open && s.index > cur)
|
||||
.map(({ start, end, index }) => ({ start, end, index }));
|
||||
}
|
||||
|
||||
/** Добавить границу спана в флажок (идемпотентно, сортировка). Вход не мутируется. */
|
||||
export function recordRealPrompt(flag, turn) {
|
||||
const prev = Array.isArray(flag && flag.realPromptTurns) ? flag.realPromptTurns : [];
|
||||
const set = new Set(prev);
|
||||
set.add(Number(turn));
|
||||
return { ...flag, realPromptTurns: [...set].sort((a, b) => a - b) };
|
||||
}
|
||||
|
||||
/** Разбор одного блока хода сырья → {turn,user,assistant,actions}. Полное содержимое.
|
||||
* Формат (buildRawRecord): [ЮЗЕР]\n…\n[АССИСТЕНТ]\n…\n([ДЕЙСТВИЕ] tool in=…\n[ВЫДАЧА] tool\n…)* */
|
||||
export function parseTurnBlock(block) {
|
||||
const s = String(block || '');
|
||||
const turn = Number((s.match(/=== ХОД turn=(\d+)/) || [])[1]) || 0;
|
||||
const um = s.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]\n/);
|
||||
const am = s.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\] |\n=== КОНЕЦ ХОДА ===)/);
|
||||
const actions = [];
|
||||
const re = /\[ДЕЙСТВИЕ\] (\S+) in=([\s\S]*?)\n\[ВЫДАЧА\] \S+\n([\s\S]*?)(?=\n\[ДЕЙСТВИЕ\] |\n=== КОНЕЦ ХОДА ===)/g;
|
||||
let m;
|
||||
while ((m = re.exec(s)) !== null) actions.push({ tool: m[1], input: m[2], result: m[3] });
|
||||
return { turn, user: um ? um[1] : '', assistant: am ? am[1] : '', actions };
|
||||
}
|
||||
|
||||
/** Склейка обмена спана из сырья: user из хода-начала; assistant и actions — со всех ходов [start..end]. */
|
||||
export function assembleSpan(rawText, { start, end }) {
|
||||
const blocks = splitRawIntoTurns(rawText).filter((p) => p.turn >= start && p.turn <= end);
|
||||
const parsed = blocks.map((p) => parseTurnBlock(p.block));
|
||||
const startTurn = parsed.find((p) => p.turn === start) || parsed[0] || {};
|
||||
const assistant = parsed.map((p) => p.assistant).filter(Boolean).join('\n');
|
||||
const actions = parsed.flatMap((p) => p.actions);
|
||||
return { user: startTurn.user || '', assistant, actions };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeSpans, spansToDistill, recordRealPrompt, parseTurnBlock, assembleSpan } from './secretary-span.mjs';
|
||||
import { buildRawRecord } from './secretary-layer1.mjs';
|
||||
|
||||
describe('computeSpans', () => {
|
||||
it('границы → отрезки; последний открыт', () => {
|
||||
expect(computeSpans([3, 12, 15], 17)).toEqual([
|
||||
{ start: 3, end: 11, open: false },
|
||||
{ start: 12, end: 14, open: false },
|
||||
{ start: 15, end: 17, open: true },
|
||||
]);
|
||||
});
|
||||
it('одна граница → один открытый спан', () => {
|
||||
expect(computeSpans([3], 5)).toEqual([{ start: 3, end: 5, open: true }]);
|
||||
});
|
||||
it('пустой список → нет спанов', () => {
|
||||
expect(computeSpans([], 5)).toEqual([]);
|
||||
});
|
||||
it('неотсортированные/дубли нормализуются', () => {
|
||||
expect(computeSpans([12, 3, 3], 13)).toEqual([
|
||||
{ start: 3, end: 11, open: false },
|
||||
{ start: 12, end: 13, open: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spansToDistill', () => {
|
||||
it('закрытые спаны с индексом > курсора', () => {
|
||||
expect(spansToDistill([3, 12, 15], 17, -1)).toEqual([
|
||||
{ start: 3, end: 11, index: 0 },
|
||||
{ start: 12, end: 14, index: 1 },
|
||||
]);
|
||||
});
|
||||
it('курсор уже прошёл первый закрытый — отдаём только второй', () => {
|
||||
expect(spansToDistill([3, 12, 15], 17, 0)).toEqual([{ start: 12, end: 14, index: 1 }]);
|
||||
expect(spansToDistill([3, 12, 15], 17, 1)).toEqual([]);
|
||||
});
|
||||
it('открытый спан не отдаётся', () => {
|
||||
expect(spansToDistill([3, 12], 14, -1)).toEqual([{ start: 3, end: 11, index: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordRealPrompt', () => {
|
||||
it('добавляет границу, не дублирует, держит сортировку', () => {
|
||||
let f = { mode: 'on', work: 'x' };
|
||||
f = recordRealPrompt(f, 3);
|
||||
expect(f.realPromptTurns).toEqual([3]);
|
||||
f = recordRealPrompt(f, 12);
|
||||
expect(f.realPromptTurns).toEqual([3, 12]);
|
||||
f = recordRealPrompt(f, 12); // дубль игнор
|
||||
expect(f.realPromptTurns).toEqual([3, 12]);
|
||||
expect(f.mode).toBe('on'); // прочие поля целы
|
||||
});
|
||||
it('не мутирует вход', () => {
|
||||
const f = { mode: 'on' };
|
||||
const out = recordRealPrompt(f, 1);
|
||||
expect(f.realPromptTurns).toBeUndefined();
|
||||
expect(out.realPromptTurns).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTurnBlock', () => {
|
||||
it('тащит turn, user, assistant, действия с input/result', () => {
|
||||
const block = buildRawRecord({
|
||||
turn: 4, time: 't', session: 's', user: 'вопрос', assistant: 'ответ',
|
||||
actions: [{ tool: 'Read', input: '{"f":"a"}', result: 'СОДЕРЖИМОЕ\nдве строки' }],
|
||||
});
|
||||
const pt = parseTurnBlock(block);
|
||||
expect(pt.turn).toBe(4);
|
||||
expect(pt.user).toBe('вопрос');
|
||||
expect(pt.assistant).toBe('ответ');
|
||||
expect(pt.actions).toEqual([{ tool: 'Read', input: '{"f":"a"}', result: 'СОДЕРЖИМОЕ\nдве строки' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleSpan', () => {
|
||||
const raw = [
|
||||
buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящий промпт', assistant: 'первый ответ',
|
||||
actions: [{ tool: 'Read', input: 'a', result: 'r1' }] }),
|
||||
buildRawRecord({ turn: 4, time: 't', session: 's', user: 'Stop hook feedback: x', assistant: 'второй ответ',
|
||||
actions: [{ tool: 'Grep', input: 'b', result: 'r2' }] }),
|
||||
].join('');
|
||||
it('склеивает обмен спана: user из start, assistant и actions со всех ходов', () => {
|
||||
const ex = assembleSpan(raw, { start: 3, end: 4 });
|
||||
expect(ex.user).toBe('настоящий промпт');
|
||||
expect(ex.assistant).toContain('первый ответ');
|
||||
expect(ex.assistant).toContain('второй ответ');
|
||||
expect(ex.actions).toEqual([
|
||||
{ tool: 'Read', input: 'a', result: 'r1' },
|
||||
{ tool: 'Grep', input: 'b', result: 'r2' },
|
||||
]);
|
||||
});
|
||||
it('спан из одного хода', () => {
|
||||
const ex = assembleSpan(raw, { start: 3, end: 3 });
|
||||
expect(ex.user).toBe('настоящий промпт');
|
||||
expect(ex.actions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
// Stop-переходник секретаря: ВСЕГДА пишет сырьё (Слой 1); если секретарь включён —
|
||||
// онлайн-выжимка в протокол дела через НОВЫЙ мотор (SECRETARY_LLM_KEY).
|
||||
// Тонкий shell над parseLastExchange / buildRawRecord / reconcileTurn (модель-редактор) /
|
||||
// mergeTurnIntoProtocol (шаг пишется всегда) / writeFileAtomic / renderProtocol / upsertIndexEntry.
|
||||
import { existsSync, readFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
||||
// Stop-переходник секретаря: ВСЕГДА пишет сырьё (Слой 1); если секретарь включён — отложенный
|
||||
// разбор ПО СПАНАМ (реальный промпт + вся активность до следующего реального промпта).
|
||||
// Закрытые спаны (не последний) разбираются один раз; курсор в флажке сессии. При mode:'closing'
|
||||
// (после «выключи секретаря») добивается последний открытый спан + нарезка сырья + гашение флажка.
|
||||
import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { parseLastExchange } from './secretary-transcript.mjs';
|
||||
import { secretaryModeFileName } from './secretary-flag.mjs';
|
||||
import { buildRawRecord, buildStepLine, writeFileAtomic } from './secretary-layer1.mjs';
|
||||
import { buildRawRecord, buildStepLine, writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles } from './secretary-layer1.mjs';
|
||||
import { reconcileTurn, mergeTurnIntoProtocol, formatReconcileLogLine, collapseProtocol } from './secretary-reconcile.mjs';
|
||||
import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs';
|
||||
import { upsertIndexEntry } from './secretary-index.mjs';
|
||||
import { sanitize } from './observer-pii-filter.mjs';
|
||||
import { callAnthropicAPI } from './router-classifier.mjs';
|
||||
import { buildAuditPrompt, parseAuditResponse, applyAudit, preserveRegistry } from './secretary-audit.mjs';
|
||||
import { computeSpans, spansToDistill, assembleSpan } from './secretary-span.mjs';
|
||||
|
||||
function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } }
|
||||
function flagPath(session) { return join(homedir(), '.claude', 'runtime', secretaryModeFileName(session)); }
|
||||
function readFlag(session) {
|
||||
const f = join(homedir(), '.claude', 'runtime', secretaryModeFileName(session));
|
||||
try { return JSON.parse(readFileSync(f, 'utf-8')); } catch { return { mode: 'off' }; }
|
||||
try { return JSON.parse(readFileSync(flagPath(session), 'utf-8')); } catch { return { mode: 'off' }; }
|
||||
}
|
||||
function writeFlag(session, flag) {
|
||||
try { writeFileSync(flagPath(session), JSON.stringify(flag)); } catch { /* ignore */ }
|
||||
}
|
||||
function turnCount(rawFile) {
|
||||
if (!existsSync(rawFile)) return 0;
|
||||
@@ -49,9 +53,10 @@ async function main() {
|
||||
appendFileSync(rawFile, rec + '\n', 'utf-8');
|
||||
} catch { /* fail-quiet */ }
|
||||
|
||||
// Тетрадь (Слой 2) — только если секретарь включён.
|
||||
// Тетрадь (Слой 2) — только если секретарь включён или закрывается.
|
||||
const flag = readFlag(session);
|
||||
if (flag.mode !== 'on') { process.exit(0); }
|
||||
if (flag.mode !== 'on' && flag.mode !== 'closing') { process.exit(0); }
|
||||
const closing = flag.mode === 'closing';
|
||||
|
||||
const work = flag.work || 'general';
|
||||
const apiKey = process.env.SECRETARY_LLM_KEY;
|
||||
@@ -61,86 +66,119 @@ async function main() {
|
||||
let proto = EMPTY_PROTOCOL();
|
||||
try { if (existsSync(protoJson)) proto = JSON.parse(readFileSync(protoJson, 'utf-8')); } catch { proto = EMPTY_PROTOCOL(); }
|
||||
|
||||
// Снимок реестра СВ ДО reconcile: reconcile переписывает весь протокол и корёжит скрытые
|
||||
// вопросы (перенумеровывает). Реестром владеет ТОЛЬКО аудитор — вернём снимок после merge.
|
||||
const svSnapshot = JSON.parse(JSON.stringify({
|
||||
hidden: proto.hidden || [], acceptance: proto.acceptance || [],
|
||||
tails: proto.tails || [], nextSvId: proto.nextSvId || 1,
|
||||
}));
|
||||
// Сырьё целиком (только что дописали текущий ход) — источник для сборки спанов и фолбэк-границ.
|
||||
let rawText = '';
|
||||
try { rawText = readFileSync(rawFile, 'utf-8'); } catch { rawText = ''; }
|
||||
|
||||
// Видимый сигнал срыва reconcile — в лог дела (раньше тихий fail-quiet прятал причину).
|
||||
// Границы спанов: авторитетные из флажка (пишет prompt-hook), иначе фолбэк по sysLabel из сырья.
|
||||
const bounds = (Array.isArray(flag.realPromptTurns) && flag.realPromptTurns.length)
|
||||
? flag.realPromptTurns : realBoundariesFromRaw(rawText);
|
||||
const cursor = Number.isFinite(flag.spanCursor) ? flag.spanCursor : -1;
|
||||
|
||||
// Закрытые спаны к разбору; при закрытии добиваем и последний открытый (force-close).
|
||||
const list = spansToDistill(bounds, turn, cursor);
|
||||
if (closing) {
|
||||
const all = computeSpans(bounds, turn).map((s, index) => ({ ...s, index }));
|
||||
const lastOpen = all[all.length - 1];
|
||||
if (lastOpen && lastOpen.open && lastOpen.index > cursor)
|
||||
list.push({ start: lastOpen.start, end: lastOpen.end, index: lastOpen.index });
|
||||
}
|
||||
|
||||
// Обычный ход без новых закрытых спанов — тетрадь не трогаем (отставание на один промпт).
|
||||
if (!list.length && !closing) { process.exit(0); }
|
||||
|
||||
// Видимый сигнал срыва reconcile — в лог дела.
|
||||
const reLog = join(workDir, '_reconcile.log');
|
||||
const logReason = (info) => {
|
||||
try {
|
||||
mkdirSync(workDir, { recursive: true });
|
||||
const t = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
appendFileSync(reLog, formatReconcileLogLine({ turn, time: t, ...info }) + '\n', 'utf-8');
|
||||
appendFileSync(reLog, formatReconcileLogLine({ time: t, ...info }) + '\n', 'utf-8');
|
||||
} catch { /* лог вторичен */ }
|
||||
};
|
||||
|
||||
// Модель-редактор правит ВЕСЬ протокол; страж возвращает потерянные строки (reconcile не зависит
|
||||
// от точности модели). Нет ключа — reconcile пропускаем (логируем no-key), но шаг хода ниже
|
||||
// пишется ВСЁ РАВНО (целостность «Шагов»).
|
||||
const callModel = apiKey
|
||||
? (msgs) => callAnthropicAPI(msgs, {
|
||||
apiKey,
|
||||
baseUrl: process.env.SECRETARY_LLM_BASE_URL || undefined,
|
||||
model: process.env.SECRETARY_LLM_MODEL || undefined,
|
||||
perAttemptTimeoutMs: 300_000, // 5 минут на один ответ модели (секретарь пишет длинный протокол)
|
||||
maxRetries: 0, // одна попытка, без ×5 повторов
|
||||
perAttemptTimeoutMs: 300_000,
|
||||
maxRetries: 0,
|
||||
})
|
||||
: null;
|
||||
|
||||
let updated = null;
|
||||
if (apiKey) {
|
||||
updated = await reconcileTurn({ proto, ex, turn, session, callModel, diag: logReason });
|
||||
} else {
|
||||
logReason({ reason: 'no-key' });
|
||||
// Разбор каждого завершённого спана по порядку: reconcile + аудит на ПОЛНОМ склеенном спане.
|
||||
let lastIndex = cursor;
|
||||
for (const span of list) {
|
||||
const spanEx = assembleSpan(rawText, span);
|
||||
|
||||
// Снимок реестра СВ ДО reconcile (reconcile перенумеровывает hidden) — вернём после merge.
|
||||
const svSnapshot = JSON.parse(JSON.stringify({
|
||||
hidden: proto.hidden || [], acceptance: proto.acceptance || [],
|
||||
tails: proto.tails || [], nextSvId: proto.nextSvId || 1,
|
||||
}));
|
||||
|
||||
let updated = null;
|
||||
if (apiKey) {
|
||||
updated = await reconcileTurn({ proto, ex: spanEx, turn: span.start, session, callModel, diag: (i) => logReason({ turn: span.start, ...i }) });
|
||||
} else {
|
||||
logReason({ turn: span.start, reason: 'no-key' });
|
||||
}
|
||||
|
||||
const modelStep = (updated && updated.step) || null;
|
||||
if (updated && 'step' in updated) delete updated.step;
|
||||
const step = { turn: span.start, session,
|
||||
text: buildStepLine({ turn: span.start, endTurn: span.end, user: spanEx.user, assistant: spanEx.assistant,
|
||||
actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep }) };
|
||||
const toWrite = mergeTurnIntoProtocol({ proto, updated, step });
|
||||
|
||||
// Реестр СВ — вотчина аудитора: вернуть из снимка ДО reconcile.
|
||||
preserveRegistry(toWrite, svSnapshot);
|
||||
|
||||
// Аудитор скрытых вопросов (9 линз) на ПОЛНОМ спане.
|
||||
if (apiKey) {
|
||||
try {
|
||||
const auditMsgs = buildAuditPrompt(toWrite, spanEx);
|
||||
const raw = await callModel(auditMsgs);
|
||||
applyAudit(toWrite, parseAuditResponse(typeof raw === 'string' ? raw : (raw?.text || '')), span.start);
|
||||
} catch (e) { logReason({ turn: span.start, reason: 'audit-fail', error: e && e.message }); }
|
||||
}
|
||||
|
||||
proto = collapseProtocol(toWrite);
|
||||
lastIndex = span.index;
|
||||
}
|
||||
|
||||
// Шаг хода (Слой 1) ведёт хук детерминированно — пишется ВСЕГДА; протокол к записи через merge
|
||||
// (при срыве reconcile категории заморожены, но перечень ходов не получает дыр).
|
||||
// Модельная суть хода (если reconcile её вернул) — иначе фолбэк firstSentence в buildStepLine.
|
||||
const modelStep = (updated && updated.step) || null;
|
||||
if (updated && 'step' in updated) delete updated.step; // транзитное — в protocol.json не сохраняем
|
||||
const step = { turn, session,
|
||||
text: buildStepLine({ turn, user: ex.user, assistant: ex.assistant,
|
||||
actions: (ex.actions || []).map((a) => a.tool), essence: modelStep }) };
|
||||
const toWrite = mergeTurnIntoProtocol({ proto, updated, step });
|
||||
|
||||
// Вернуть реестр СВ из снимка (reconcile его НЕ владеет) — иначе он перенумеровывает СВ.
|
||||
preserveRegistry(toWrite, svSnapshot);
|
||||
|
||||
// Второй проход — аудитор скрытых вопросов (9 линз). Не зависит от reconcile.
|
||||
if (apiKey) {
|
||||
try {
|
||||
const auditMsgs = buildAuditPrompt(toWrite, ex);
|
||||
const raw = await callModel(auditMsgs);
|
||||
applyAudit(toWrite, parseAuditResponse(typeof raw === 'string' ? raw : (raw?.text || '')), turn);
|
||||
} catch (e) { logReason({ reason: 'audit-fail', error: e && e.message }); }
|
||||
}
|
||||
|
||||
// Самолечение дублей: финальный чокпоинт перед записью. Ловит ВСЕ исходы (reconcile-успех,
|
||||
// срыв/без-ключа = прежний раздутый proto, уже накопленные дубли) — на выходе всегда чисто.
|
||||
// Трогает только 6 корзин + Историю; реестр СВ (hidden/nextSvId), шаги, тема — нетронуты.
|
||||
const finalProto = collapseProtocol(toWrite);
|
||||
|
||||
const finalProto = proto;
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||||
mkdirSync(workDir, { recursive: true });
|
||||
// Атомарная запись (temp→rename): параллельная сессия не увидит полузаписанный файл.
|
||||
writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2));
|
||||
writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp }));
|
||||
writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp, turn, realPromptTurns: bounds }));
|
||||
|
||||
const idxFile = join(secdir, 'содержание.md');
|
||||
let idxMd = '';
|
||||
try { if (existsSync(idxFile)) idxMd = readFileSync(idxFile, 'utf-8'); } catch { idxMd = ''; }
|
||||
const upd = upsertIndexEntry(idxMd, {
|
||||
slug: work, title: work,
|
||||
goal: (toWrite.subject && toWrite.subject.trim()) ? toWrite.subject.trim() : '(дело)',
|
||||
status: toWrite.status || 'открыто',
|
||||
goal: (finalProto.subject && finalProto.subject.trim()) ? finalProto.subject.trim() : '(дело)',
|
||||
status: finalProto.status || 'открыто',
|
||||
date: stamp,
|
||||
});
|
||||
writeFileAtomic(idxFile, upd);
|
||||
|
||||
if (closing) {
|
||||
// Финализация: нарезка сырья на файлы ходов (по ходу) + Шаги по спанам, затем гашение флажка.
|
||||
finalProto.steps = mergeStepsPreservingText(finalProto.steps, rawText, session, bounds);
|
||||
const { files, steps } = prepareTurnFiles(rawText, finalProto);
|
||||
const hodyDir = join(workDir, 'ходы');
|
||||
mkdirSync(hodyDir, { recursive: true });
|
||||
for (const f of files) writeFileSync(join(hodyDir, f.name), f.content, 'utf-8');
|
||||
finalProto.steps = steps;
|
||||
writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2));
|
||||
writeFileAtomic(join(workDir, 'protocol.md'), renderProtocol(finalProto, { work, date: stamp, turn, realPromptTurns: bounds }));
|
||||
writeFlag(session, { mode: 'off' });
|
||||
} else {
|
||||
// Обычный ход: сохранить продвинутый курсор (прочие поля флажка целы).
|
||||
writeFlag(session, { ...readFlag(session), spanCursor: lastIndex });
|
||||
}
|
||||
} catch { /* fail-quiet: сырьё уже записано */ }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ function isRealUserPrompt(msg) {
|
||||
}
|
||||
|
||||
// Текст результата инструмента: строка как есть; массив блоков → склейка text-блоков.
|
||||
const MAX_RESULT_CHARS = 1200;
|
||||
// Без обрезки: секретарь должен видеть ПОЛНОЕ содержимое (линзы ловят ошибки/пропуски).
|
||||
function resultText(content) {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
@@ -33,10 +33,6 @@ function resultText(content) {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function truncateResult(s) {
|
||||
const t = String(s ?? '');
|
||||
return t.length > MAX_RESULT_CHARS ? t.slice(0, MAX_RESULT_CHARS) + '…' : t;
|
||||
}
|
||||
|
||||
/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input,result?}] }.
|
||||
* result привязывается к действию по tool_use.id === tool_result.tool_use_id (усечён до предела);
|
||||
@@ -78,7 +74,7 @@ export function parseLastExchange(transcriptText) {
|
||||
}
|
||||
const actions = raw.map((a) => {
|
||||
const out = { tool: a.tool, input: a.input };
|
||||
if (a.id != null && results[a.id] != null) out.result = truncateResult(results[a.id]);
|
||||
if (a.id != null && results[a.id] != null) out.result = String(results[a.id] ?? '');
|
||||
return out;
|
||||
});
|
||||
return { user, assistant, actions };
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('parseLastExchange — захват выдачи инструмента
|
||||
const ex = parseLastExchange(t);
|
||||
expect(ex.actions[0].result).toBe('строка вывода');
|
||||
});
|
||||
it('длинный результат усечён и оканчивается маркером …', () => {
|
||||
it('длинный результат НЕ обрезается (полная картина для секретаря)', () => {
|
||||
const big = 'x'.repeat(5000);
|
||||
const t = [
|
||||
JSON.stringify({ message: { role: 'user', content: 'в' } }),
|
||||
@@ -73,8 +73,8 @@ describe('parseLastExchange — захват выдачи инструмента
|
||||
{ type: 'tool_result', tool_use_id: 'tu_2', content: big }] } }),
|
||||
].join('\n');
|
||||
const ex = parseLastExchange(t);
|
||||
expect(ex.actions[0].result.length).toBeLessThan(big.length);
|
||||
expect(ex.actions[0].result.endsWith('…')).toBe(true);
|
||||
expect(ex.actions[0].result).toBe(big); // целиком
|
||||
expect(ex.actions[0].result.endsWith('…')).toBe(false);
|
||||
});
|
||||
it('без совпадающего id результат не привязывается — старая форма {tool,input} цела', () => {
|
||||
const t = [
|
||||
|
||||
Reference in New Issue
Block a user