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:
Дмитрий
2026-06-23 14:45:31 +03:00
parent 8dc5c0e94d
commit 2b6170313b
17 changed files with 1810 additions and 148 deletions
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** — в этой же спеке (страж-ноп + кап показа).
+8 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+50 -25
View File
@@ -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 — я: привет');
});
});
+30 -30
View File
@@ -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);
}
+18 -1
View File
@@ -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', () => {
+9 -2
View File
@@ -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}`);
+16
View File
@@ -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: [],
+3 -2
View File
@@ -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');
+7
View File
@@ -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 категорий + стабильная тема', () => {
+64
View File
@@ -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 };
}
+98
View File
@@ -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);
});
});
+98 -60
View File
@@ -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);
}
+2 -6
View File
@@ -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 };
+3 -3
View File
@@ -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 = [