opencode-orchestrator-uni 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@
5
5
  - **Root (главный агент)** — не аудирует: формирует бриф, запускает субагентов, сводит отчёты; по «недоказуемым» — решение владельца: root оформляет вопрос, фиксирует риск и эскалирует. Ведёт реестр принятых рисков, вносит правки отдельными коммитами, решает «ship».
6
6
  - **Субагенты** (read-only): `factcheck`, `empiric`, `invariant`, `arbiter`, (на воротах) `redteam`, `meta`.
7
7
 
8
+ Сессионная гигиена среза — границы, бюджет контекста (≤150k токенов, ≤1 компакция) и handoff — в `session-discipline.md`.
9
+
8
10
  ## Шаблон брифа `case-<id>.md` (общий для всех шагов)
9
11
 
10
12
  ```
@@ -26,7 +28,7 @@
26
28
  3. **Свод root'ом**: распарсить JSON-блоки; вычислить вердикты (ready/not_ready), открытые MAJOR, пересечения и расхождения (по location+механизму). Материализовать свод-артефакт `reports/summary-<id>.md` (определение — `call-mechanics.md` §4): по каждой MAJOR — {id, verifier_role, verdict, evidence}; refuted-список (явный, даже пустой); принятые риски; ревизия состояния (хэш каталога/коммит).
27
29
  4. **Верификация MAJOR (перекрёстная, fail-closed)**: каждая MAJOR-находка подтверждается независимо **сессией противоположной роли** (не автором находки) — `subagent(agent="<sibling>", sessionID=<сессия противоположной роли>, prompt="Находка <id> контрагента: <текст>. Evidence автора: <команда→вывод>. Ревизия состояния: <хэш каталога/коммит>. Подтверди или опровергни своим воспроизведением; дефолт — не подтверждена. Верни: {id, verifier_role, verdict: confirmed|refuted|partial|unavailable, evidence}.")`. `verifier_role` ≠ роль автора; `partial`/`unavailable` = «не подтверждена»; `refuted` — в список refuted для сводки; сбой/таймаут верификатора — «не подтверждена» + шум. Не подтверждена → в правки не идёт (спор → шаг 5, арбитр); статусы верификации пишутся в свод `reports/summary-<id>.md` (шаг 3). Если MAJOR-находка независимо воспроизведена обеими ролями в их собственных прогонах — взаимное подтверждение засчитывается как верификация (обе стороны в свод), отдельный прогон не требуется. sessionID при вызове обязателен: без него это новая сессия, а не продолжение — дефект шага.
28
30
  5. **Арбитр — только при расхождениях**: `subagent(agent="arbiter", prompt="Спорные пункты: <пункт → evidence A → evidence B>. По каждому: подтверждён/опровергнут/недоказуем + команда/цитата.")`. Вердикт свода (и верификация/арбитраж) фиксируются ДО правок и **вместе с ревизией состояния** (хэш каталога/коммит); любая правка после фиксации вердикта → повторный прогон (re-audit) либо явное понижение вердикта: до вердикта — не править.
29
- 6. **Правки** по порогу: микро — root сам; средние/крупные — `fix-runner` (вход: список «к закрытию» — id, локация, механизм, **класс/область** (пути + правило поиска однотипных мест), repro-до, критерий-после, severity — плюс границы и **бюджет/лимит (время, число правок)**; выход: fix-runner/v1 с before/after/`proof`/тестами). Отдельные коммиты `fix-<round>`; обновить реестр: `closed` / `accepted-risk` (с причиной). Root проверяет дельту раунда — `git diff <round-base>..HEAD --stat` (не только `git show --stat`) — до re-audit. Стенд раунда (если в комплекте поставляется проверочный стенд — инструмент `tools/kit-verify` каталога комплекта; в раундах по другим проектам — только если стенд явно включён в границы вызова): baseline стенда в промпт фиксера (если вывод передан — фиксер не дублирует прогон); закрытие — повторный прогон стенда; оба вывода — в поле `stand` отчёта. Стенд поставляется в git-чекауте комплекта: в npm-пакете каталог `tools/` не поставляется → `stand: unavailable` и собственные воспроизводимые проверки. Статусы `fix-runner`: `needs_decision` → решение root (при споре — арбитр; если решение за владельцем — эскалация) с фиксацией в реестре рисков; `not_closed` → повторная итерация с тем же id; `budget_exceeded` → расширить бюджет решением владельца либо разбить список и продолжить с того же id (коммиты и worktree сохранить до закрытия или уборки); поле `out_of_scope` (замечено вне списка) → включить в следующий список «к закрытию» или эскалировать. Статус **`no_report`** (отчёта нет: фиксер упал/таймаут) — root фиксирует в своде выполненную работу (worktree, ветка, base SHA) и `no_report`; ветку/коммиты НЕ сливать; приоритет — resume в том же worktree; иначе убрать: `git worktree remove --force` и решить судьбу ветки. Частичный коммит без отчёта в основную ветку не вливается. Несколько `fix-runner` — только в отдельных worktree **вне дерева проекта** (у каждого своя ветка `fix/<round>-<n>`); путь пространства сверяется префиксами путей (`git -C <project> rev-parse --show-toplevel` не должен быть префиксом канонического пути пространства; иначе — `needs_decision`, пространство не создаётся и не используется); перед запуском root считает пересечение **наборов файлов** (пути + правило поиска класса), а не только списков: пересечение ⇒ сериализация или разнесение путей. Посадка сессии в её пространство: `session_move({sessionID, directory})` (если доступен), иначе `cd <абс. путь>` + абсолютные пути; ни то ни другое — параллельный запуск запрещён. В свежем worktree зависимости ставятся до прогонов (`npm ci` / `bun install`). После завершения root сводит результаты строго по **полному hex SHA** из отчёта: `commit` принимается только как `^[0-9a-f]{40}$` — проверка ДО любых git-команд (имена веток из отчётов для сведения не использовать; `check-ref-format` и `rev-parse --verify` барьером не являются — резолвят имена); SHA — отдельным аргументом в кавычках; семантические проверки: `git cat-file -t <sha>` = `commit`, `git merge-base --is-ancestor <base> <sha>`, `git diff --quiet <base> <sha>` ложно (дельта непуста), `<sha> != <base>`; `Already up to date` (пустая дельта) ⇒ `not_consolidated`, не `closed`; merge/cherry-pick по SHA, прогоняет проверки на сведённом состоянии и только тогда фиксирует `closed`; worktree не удалять до подтверждённого переноса коммита, после — `git worktree remove --force` и осознанное решение по ветке; повторный запуск после инцидента — resume в том же worktree либо явное удаление/переименование ветки решением root'а (повторный `git worktree add -b` падает при занятой ветке), `locked` worktree — `git worktree unlock` либо `git worktree remove -f -f`.
31
+ 6. **Правки**: только `fix-runner` — root код, доки и тесты не правит; его запись ограничена артефактами аудита/состояния (вход: список «к закрытию» — id, локация, механизм, **класс/область** (пути + правило поиска однотипных мест), repro-до, критерий-после, severity — плюс границы и **бюджет/лимит (время, число правок)**; выход: fix-runner/v1 с before/after/`proof`/тестами). Отдельные коммиты `fix-<round>`; обновить реестр: `closed` / `accepted-risk` (с причиной). Root проверяет дельту раунда — `git diff <round-base>..HEAD --stat` (не только `git show --stat`) — до re-audit. Стенд раунда (если в комплекте поставляется проверочный стенд — инструмент `tools/kit-verify` каталога комплекта; в раундах по другим проектам — только если стенд явно включён в границы вызова): baseline стенда в промпт фиксера (если вывод передан — фиксер не дублирует прогон); закрытие — повторный прогон стенда; оба вывода — в поле `stand` отчёта. Стенд поставляется в git-чекауте комплекта: в npm-пакете каталог `tools/` не поставляется → `stand: unavailable` и собственные воспроизводимые проверки. Статусы `fix-runner`: `needs_decision` → решение root (при споре — арбитр; если решение за владельцем — эскалация) с фиксацией в реестре рисков; `not_closed` → повторная итерация с тем же id; `budget_exceeded` → расширить бюджет решением владельца либо разбить список и продолжить с того же id (коммиты и worktree сохранить до закрытия или уборки); поле `out_of_scope` (замечено вне списка) → включить в следующий список «к закрытию» или эскалировать. Статус **`no_report`** (отчёта нет: фиксер упал/таймаут) — root фиксирует в своде выполненную работу (worktree, ветка, base SHA) и `no_report`; ветку/коммиты НЕ сливать; приоритет — resume в том же worktree; иначе убрать: `git worktree remove --force` и решить судьбу ветки. Частичный коммит без отчёта в основную ветку не вливается. Несколько `fix-runner` — только в отдельных worktree **вне дерева проекта** (у каждого своя ветка `fix/<round>-<n>`); путь пространства сверяется префиксами путей (`git -C <project> rev-parse --show-toplevel` не должен быть префиксом канонического пути пространства; иначе — `needs_decision`, пространство не создаётся и не используется); перед запуском root считает пересечение **наборов файлов** (пути + правило поиска класса), а не только списков: пересечение ⇒ сериализация или разнесение путей. Посадка сессии в её пространство: `session_move({sessionID, directory})` (если доступен), иначе `cd <абс. путь>` + абсолютные пути; ни то ни другое — параллельный запуск запрещён. В свежем worktree зависимости ставятся до прогонов (`npm ci` / `bun install`). После завершения root сводит результаты строго по **полному hex SHA** из отчёта: `commit` принимается только как `^[0-9a-f]{40}$` — проверка ДО любых git-команд (имена веток из отчётов для сведения не использовать; `check-ref-format` и `rev-parse --verify` барьером не являются — резолвят имена); SHA — отдельным аргументом в кавычках; семантические проверки: `git cat-file -t <sha>` = `commit`, `git merge-base --is-ancestor <base> <sha>`, `git diff --quiet <base> <sha>` ложно (дельта непуста), `<sha> != <base>`; `Already up to date` (пустая дельта) ⇒ `not_consolidated`, не `closed`; merge/cherry-pick по SHA, прогоняет проверки на сведённом состоянии и только тогда фиксирует `closed`; worktree не удалять до подтверждённого переноса коммита, после — `git worktree remove --force` и осознанное решение по ветке; повторный запуск после инцидента — resume в том же worktree либо явное удаление/переименование ветки решением root'а (повторный `git worktree add -b` падает при занятой ветке), `locked` worktree — `git worktree unlock` либо `git worktree remove -f -f`.
30
32
  7. **Re-audit = resume те же сессии** (sessionID из шага 2): «Правки: <диапазон + фактический список изменённых файлов>. Проверь закрытие своих находок и отсутствие регрессий; верни обновлённый SGR.» — исторически так и делалось (продолжение сессий GLM/DS). sessionID обязателен: вызов без него (новая сессия вместо продолжения) — дефект шага. Re-audit и `closed` — на **сведённом** состоянии: доказательство приёмки (after/stand) перепривязывается к сведённому commit SHA; `closed` без отчёта фиксера (`no_report`) запрещён; нет ключа `commit` или `workspace` в отчёте фиксера ⇒ `not_closed`.
31
33
  8. **Повторяй шаги 3–7, пока не достигнут полный ready** (число кругов не ограничено; все роли ready, MAJOR=0). С 3-го круга нерешённые пункты — арбитр поштучно; повторяющиеся ложные — снять и записать как шум аудита.
32
34
  9. **Ворота (релиз; порядок: ред-тим → мета)**: замороженный коммит → `redteam` (мандат фальсификации + корпуса/стенды) → цикл по его находкам (MAJOR — с верификацией сессией противоположной роли) → `meta` (на вход: отчёты + свод `reports/summary-<id>.md`; evidence, полнота, отсутствие «зелёные тесты = проверено»).
@@ -0,0 +1,225 @@
1
+ # Гигиена сессии: как вести разработку
2
+
3
+ Документ задаёт протокол работы сессии: где её границы, какой бюджет контекста,
4
+ когда делегировать, как обращаться с выводом инструментов и что остаётся после
5
+ сессии. Он дополняет `context-efficiency.md` (бюджет файлов инструкций) и
6
+ `root-runbook.md` (аудиторский цикл): там — про артефакты и вердикты, здесь —
7
+ про то, как не сжечь контекст и не потерять решения по дороге.
8
+
9
+ ## Зачем это нужно
10
+
11
+ - Контекст — расходуемый ресурс, а не память проекта. С ростом входа качество
12
+ падает даже на простых задачах (context rot); «влезло в окно» не значит
13
+ «модель это использует».
14
+ - Компакция — lossy-пересказ. После неё агент помнит не решения, а их краткое
15
+ изложение; повторная компакция пересказывает пересказ.
16
+ - Замер длинной сессии: 13 253 хода, 19 компакций, медиана промпта выросла с
17
+ 314k до 780k токенов. Почти всё это — повторная пересылка истории, а не работа.
18
+ Это внутренний замер проекта-потребителя: артефакт в репозиторий не приложен,
19
+ из этого репозитория цифры не воспроизводятся.
20
+
21
+ Цель протокола — держать рабочее состояние маленьким, а решения — в файлах,
22
+ которые переживают сессию.
23
+
24
+ ## 1. Сессия = срез
25
+
26
+ Сессия — это один срез с измеримым DoD, а не весь проект.
27
+
28
+ | Правило | Значение |
29
+ | --- | --- |
30
+ | Бюджет оценки промпта | ≤ 150k токенов |
31
+ | Компакций на срез | ≤ 1 |
32
+ | Стоп-сигнал | первая компакция |
33
+ | Обязательный выход | handoff + коммит |
34
+
35
+ - При первом признаке приближения к бюджету — не «дожимать», а закрывать срез:
36
+ handoff, коммит, новая сессия.
37
+ - После первой компакции срез закрывается в любом случае: суммаризация уже
38
+ потеряла детали, и дальнейшая работа идёт по пересказу.
39
+ - Резать работу по волнам, гейтам или пакетам правок; смена фазы (план →
40
+ реализация → приёмка) — всегда новая сессия.
41
+
42
+ ## 2. Разделение ролей
43
+
44
+ - Разведка и проверка («найти», «понять», «воспроизвести») — субагентам: у них
45
+ чистый контекст, а в ответ возвращается 1–2k токенов выводов, а не дамп.
46
+ - Реализация — одному пишущему субагенту на рабочее пространство (root сам код, доки и тесты не правит — только артефакты аудита/состояния): связанные решения должны
47
+ приниматься в одном контексте (иначе параллельные агенты расходятся в стиле и
48
+ контрактах).
49
+ - Решения, сведение и приёмка — root-оркестратору.
50
+ - Аудиторский цикл не меняется: re-audit — resume тех же сессий; короткие
51
+ аудиторские сессии бюджету не угрожают.
52
+
53
+ Практическое правило: делегируй **исследование** широко, **реализацию** — узко,
54
+ **интеграцию** держи централизованной.
55
+
56
+ ## 3. Батчинг инструментов
57
+
58
+ Каждый ход — это полная пересылка промпта. Один инструмент за ход умножает
59
+ бюджет на число мелочей.
60
+
61
+ - ≥ 3 независимые команды за ход или один скрипт вместо пяти-десяти вызовов.
62
+ - Длинные команды WSL выноси в скрипт-обёртку: строка на 250 символов в каждом
63
+ ходе дороже, чем файл, который читается один раз.
64
+ - Для чтения файлов — `read` с `limit`/`offset`, а не `cat` целиком.
65
+ - Цель по метрике: ≥ 2 инструментов на ход.
66
+
67
+ ## 4. Вывод инструментов
68
+
69
+ - Всё, что больше 20k символов, — в файл; в контекст идёт голова (4k) и путь.
70
+ Это делает guard автоматически для `shell`, `bash`, `webfetch`.
71
+ - Скриншоты и бинарные вложения — файлом и путём, не base64 в контексте.
72
+ - `execute` (Code Mode) guard не режет: ограничивай результат в самом скрипте —
73
+ возвращай сводку и путь, а не сырой JSON на сотни килобайт.
74
+ - `read`, `write`, `edit` и инструменты комплекта не урезаются: у них свой
75
+ контракт, и потеря содержимого ломает работу.
76
+
77
+ ## 5. Состояние в файлах
78
+
79
+ - `handoff` — обязательный выход среза (шаблон ниже); путь по умолчанию —
80
+ `docs/planning/handoff-<YYYY-MM-DD>.md`, если каталога нет — корень проекта.
81
+ - `decisions` — решения и причины; одно решение — одна запись; путь по умолчанию —
82
+ `docs/planning/decisions-<YYYY-MM-DD>.md`.
83
+ - `state` — ветка, гейты, открытые риски, следующие три шага; обновляется перед
84
+ новой сессией; путь по умолчанию — `docs/planning/state.md`.
85
+ - Новая сессия начинается с `state` и целевого раздела SPEC, а не с пересказа
86
+ прошлой сессии.
87
+
88
+ ## 6. Модели
89
+
90
+ - По умолчанию — дешёвая модель; дорогая — только на архитектурные решения,
91
+ ревью и разбор спорного.
92
+ - Правки документов, чтение и механические проверки не требуют дорогой модели.
93
+ - Замер: 223 хода дорогой модели — $63, 104 хода дешёвой — $0.09. Один
94
+ переключатель модели экономит больше, чем любая оптимизация промпта. Это
95
+ внутренний замер проекта-потребителя: артефакт в репозиторий не приложен, из
96
+ этого репозитория цифры не воспроизводятся.
97
+
98
+ ## 7. Метрики
99
+
100
+ | Метрика | Цель | Где смотреть |
101
+ | --- | --- | --- |
102
+ | Оценка промпта (медиана) | ≤ 150k | `orch_uni_session_stats` |
103
+ | Компакций на срез | ≤ 1 | `orch_uni_session_stats` |
104
+ | Инструментов на ход (`toolCalls/requests`, он же `toolsPerTurn`) | ≥ 2 | `orch_uni_session_stats` |
105
+ | Доля урезанных выводов среди вызовов инструмента (`toolOutputsCappedByTool.shell / toolCallsByTool.shell`; так же `bash`, `webfetch`) | < 5% | `orch_uni_session_stats` |
106
+ | Handoff в конце среза | есть | рабочее дерево |
107
+
108
+ `orch_uni_session_stats` отдаёт счётчики текущего инстанса плагина и настроенные
109
+ лимиты; исторические прогоны считаются по базе сессий на стороне проекта.
110
+
111
+ ## 8. Guard
112
+
113
+ Guard — это хуки плагина и подписка на события компакции, а не запреты:
114
+
115
+ - `session.context` — оценивает промпт и добавляет одно короткое напоминание при
116
+ переходе на новый уровень бюджета. Напоминание инжектится один раз при
117
+ переходе уровня, а не в каждом запросе; внутри уровня текст стабилен, поэтому
118
+ префикс-кэш не рвётся.
119
+ - события компакции — хук `session.compaction` регистрируется всегда: он считает
120
+ попытки (`compactionAttempts`) и отдаёт tentative-напоминание ближайшему
121
+ запросу при задержке терминального события; напоминание не называет номер
122
+ компакции и не заявляет лимит. Подтверждает компакцию только
123
+ `session.compaction.ended` (дедуп по `event.id` или durable-позиции); он же
124
+ двигает `compactions` и лимит. `session.compaction.failed` увеличивает
125
+ `compactionFailures`, снимает tentative-note и сохраняет confirmed-note, ещё
126
+ ждущий доставки. Если поток событий завершился или упал, guard выставляет
127
+ `eventLive: false`, `compactionSource: "hook"` и `streamFailures: 1`
128
+ (per-session `errors` остаётся `0`), хук уже зарегистрирован и попытки
129
+ продолжают считаться; подтверждённых компакций и стопа на этом пути нет —
130
+ ложного «лимит исчерпан» не бывает. Источник, liveness и сбои потока видны в
131
+ `guard`.
132
+ - `tool.execute.after` — урезает вывод настроенных инструментов и складывает
133
+ полный текст в приватный корень на инстанс плагина
134
+ `os.tmpdir()/opencode-tool-output-<random>/` (POSIX: 0700 на корень и каталог
135
+ сессии, 0600 на файл). На Windows mode не действует (замер: 0666 у корня,
136
+ каталога сессии и файла): приватность там держится только на ACL каталога
137
+ `os.tmpdir()` и кодом не проверяется. Корень создаётся `mkdtemp` при
138
+ setup, поэтому заранее созданный каталог или junction `opencode-tool-output` не
139
+ используется; на POSIX корень обязан принадлежать текущему uid и не давать
140
+ доступа group/other, иначе guard отключается с warning (fail-open: результат не
141
+ трогается). Каталог сессии (`s-<uuid>`) и файл (`c-<uuid>.txt`, запись `wx`) —
142
+ случайные, их `realpath` проверяется внутри корня; при выгрузке плагина корень
143
+ удаляется best effort. Строка с lone surrogate нормализуется один раз (U+FFFD),
144
+ и файл, голова и счётчики описывают уже одну и ту же строку. Остаточный риск
145
+ принят: same-user атакующий всё ещё может
146
+ подменить каталог сессии между `realpath` и `writeFile` (в Node нет `openat`).
147
+ Если `TMPDIR` указывает внутрь воркспейса, файлы попадут туда.
148
+
149
+ Конфигурация (значения по умолчанию):
150
+
151
+ ```jsonc
152
+ {
153
+ "sessionGuard": {
154
+ "enabled": true,
155
+ "promptTokensSoft": 150000,
156
+ "maxCompactions": 1
157
+ },
158
+ "toolOutputGuard": {
159
+ "enabled": true,
160
+ "maxChars": 20000,
161
+ "headChars": 4000,
162
+ "tools": ["shell", "bash", "webfetch"]
163
+ }
164
+ }
165
+ ```
166
+
167
+ `maxCompactions: 0` отключает лимит компакций. `guard.toolOutputEnabled` равен
168
+ `true` только когда приватный корень создан **и** эффективный список инструментов
169
+ непуст; `guard.toolOutputTools` — этот список (после нормализации и отбрасывания
170
+ защищённых и kit-имён), `guard.unkeyedCalls` — число вызовов хуков без непустого
171
+ `sessionID` (такой ввод отбрасывается, а не сливается в бакет `""`). Пустой
172
+ `tools` — явный или после отбрасывания всех имён — выключает кап с warning.
173
+ Принятые ограничения: каталог сессии `s-<uuid>` живёт до выгрузки плагина — он не
174
+ удаляется ни при `session.deleted`, ни после отброшенного капа (например, при
175
+ неизменяемом результате), а освобождается вместе с корнем; нормализация lone
176
+ surrogate опирается на ES2024 `String.prototype.isWellFormed`/`toWellFormed`
177
+ (минимальная версия рантайма в `engines` не зафиксирована — на старом рантайме кап
178
+ деградирует fail-open: `errors` +1, результат не тронут).
179
+ В счётчиках `toolCalls` и
180
+ per-tool счётчики включают вложенные вызовы Code Mode, а `outputCharsSaved` —
181
+ верхняя оценка в символах (внедрённая заглушка вычитается). `toolCallsByTool` и
182
+ `toolOutputsCappedByTool` разносят вызовы и урезанные выводы по нормализованному
183
+ имени инструмента (namespace снят, регистр нижний). Дубликаты событий компакции
184
+ считаются один раз только при наличии `event.id` или durable-позиции; без
185
+ identity повторная доставка может пересчитать событие (fail-open в сторону
186
+ счёта).
187
+
188
+ Guard fail-open: ошибка хука не ломает запрос и не портит результат инструмента.
189
+ Он не пишет в рабочее дерево (кроме случая `TMPDIR` внутри воркспейса — см.
190
+ выше) и не блокирует действия — только напоминает и урезает вывод. Отключить
191
+ можно любым из флагов `enabled`.
192
+
193
+ ## Шаблон handoff
194
+
195
+ ```md
196
+ # Handoff: <срез> — <YYYY-MM-DD>
197
+
198
+ <!-- Файл: docs/planning/handoff-<YYYY-MM-DD>.md; нет каталога — корень проекта. -->
199
+
200
+ ## Цель и DoD
201
+ Одно предложение цели и проверяемый критерий готовности.
202
+
203
+ ## Сделано
204
+ - Что изменено; чем подтверждено (команда, тест, коммит).
205
+
206
+ ## Не сделано
207
+ - Что осталось и почему (блокер, решение владельца, следующая волна).
208
+
209
+ ## Решения
210
+ - Решение — причина; ссылка на документ или коммит.
211
+
212
+ ## Риски и ограничения
213
+ - Принятый риск — кем и до какого момента.
214
+
215
+ ## Следующий шаг
216
+ Одно конкретное действие для следующей сессии.
217
+ ```
218
+
219
+ ## Чек-лист среза
220
+
221
+ - [ ] Срез закрыт по DoD, а не по «кончилось время».
222
+ - [ ] Handoff записан и ссылается на доказательства.
223
+ - [ ] Решения и риски зафиксированы в файлах, а не только в переписке.
224
+ - [ ] Рабочее дерево чистое или явно описано в handoff.
225
+ - [ ] Следующая сессия начинается с `state` и SPEC, а не с пересказа.
package/index.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  * - The `audit-kit` reference is registered only if that name is free; it points
25
25
  * at this package's `docs/` directory so sessions can read the process
26
26
  * documents directly.
27
- * - Slash commands (`/agents-md`, `/spec`) are registered through
27
+ * - Slash commands (`/agents-md`, `/spec`, `/handoff`) are registered through
28
28
  * `ctx.command.transform`; their handlers inject a workflow prompt into the
29
29
  * current session and never write files themselves.
30
30
  *
@@ -53,7 +53,9 @@ import { fileURLToPath } from "node:url"
53
53
  import type { Agent, Model, Plugin, Reference } from "@opencode/plugin"
54
54
  import matter from "gray-matter"
55
55
  import { registerKitCommands } from "./lib/commands"
56
+ import { createSessionBudget, readSessionBudgetOptions, safeJson } from "./lib/session-budget"
56
57
  import { registerAuditTools } from "./lib/tools"
58
+ import { createWriteGuard } from "./lib/write-guard"
57
59
 
58
60
  /** Stable plugin id: diagnostics, enable/disable selectors, storage scope. */
59
61
  export const PLUGIN_ID = "orchestrator.uni"
@@ -118,10 +120,12 @@ function localeCandidates(): string[] {
118
120
  const REFERENCE_DESCRIPTIONS: Record<Locale, string> = {
119
121
  ru:
120
122
  "Процессные документы комплекта оркестратора (аудиторский цикл): call-mechanics.md, " +
121
- "process-ready-loop.md, root-runbook.md, instruction-other-session.md, context-efficiency.md.",
123
+ "process-ready-loop.md, root-runbook.md, instruction-other-session.md, context-efficiency.md, " +
124
+ "session-discipline.md.",
122
125
  en:
123
126
  "Process documents of the orchestrator agent kit (audit loop): call-mechanics.md, " +
124
- "process-ready-loop.md, root-runbook.md, instruction-other-session.md, context-efficiency.md.",
127
+ "process-ready-loop.md, root-runbook.md, instruction-other-session.md, context-efficiency.md, " +
128
+ "session-discipline.md.",
125
129
  }
126
130
 
127
131
  /** Frontmatter keys this loader understands; anything else is reported and ignored. */
@@ -353,7 +357,7 @@ function readLocaleOptions(options: unknown): Locale | undefined {
353
357
  if (raw === undefined) return undefined
354
358
  const locale = parseLocaleTag(raw)
355
359
  if (!locale) {
356
- warn(`options.locale: expected one of ${SUPPORTED_LOCALES.join("|")}, got ${JSON.stringify(raw)}, ignored`)
360
+ warn(`options.locale: expected one of ${SUPPORTED_LOCALES.join("|")}, got ${safeJson(raw)}, ignored`)
357
361
  }
358
362
  return locale
359
363
  }
@@ -408,25 +412,53 @@ async function setup(ctx: Plugin.Context): Promise<Plugin.Cleanup | undefined> {
408
412
  })
409
413
 
410
414
  // Deterministic, read-only audit tools: `orch_uni_validate_report`,
411
- // `orch_uni_context_lint`, `orch_uni_snapshot`, `orch_uni_sessions` (see
412
- // `lib/tools.ts` and README «Tools»). The tools cleanup only stops the
413
- // background session-index subscription; the commands cleanup disposes the
414
- // command transform registration. A failure after the tools are registered
415
- // must abort that subscription too — otherwise a half-registered plugin leaks
416
- // an event listener for the lifetime of the server.
417
- const toolsCleanup = await registerAuditTools(ctx)
415
+ // `orch_uni_context_lint`, `orch_uni_snapshot`, `orch_uni_sessions`,
416
+ // `orch_uni_session_stats` (see `lib/tools.ts` and README «Tools»). The session
417
+ // budget registers the protocol hooks (context/compaction/tool output); the
418
+ // tools cleanup stops the background session-index subscription; the commands
419
+ // cleanup disposes the command transform registration. A failure after the
420
+ // tools are registered must abort that subscription too — otherwise a
421
+ // half-registered plugin leaks an event listener for the lifetime of the server.
422
+ const sessionBudget = await createSessionBudget(ctx, readSessionBudgetOptions(ctx.options, warn), locale, warn)
423
+ // The path-root write guard enforces the orchestrator's artifact-only edit
424
+ // contract beyond what flat `permissions` can express (see `lib/write-guard.ts`).
425
+ // It registers on the host's permission hook and stays a no-op on a host without
426
+ // that API — a missing hook is reported, never fatal. Registration happens
427
+ // before the tools so a later failure disposes the guard with the rest of the chain.
428
+ const writeGuard = await createWriteGuard(ctx, {
429
+ locationDirectory: String(ctx.location.directory),
430
+ warn,
431
+ })
432
+ let toolsCleanup: () => void
433
+ try {
434
+ toolsCleanup = await registerAuditTools(ctx, { sessionBudget })
435
+ } catch (error) {
436
+ await writeGuard.cleanup()
437
+ await sessionBudget.cleanup()
438
+ throw error
439
+ }
418
440
  let commandsCleanup: Plugin.Cleanup
419
441
  try {
420
442
  commandsCleanup = await registerKitCommands(ctx, locale)
421
443
  } catch (error) {
422
444
  toolsCleanup()
445
+ await writeGuard.cleanup()
446
+ await sessionBudget.cleanup()
423
447
  throw error
424
448
  }
425
449
  return async () => {
426
450
  try {
427
451
  await commandsCleanup()
428
452
  } finally {
429
- toolsCleanup()
453
+ try {
454
+ toolsCleanup()
455
+ } finally {
456
+ try {
457
+ await writeGuard.cleanup()
458
+ } finally {
459
+ await sessionBudget.cleanup()
460
+ }
461
+ }
430
462
  }
431
463
  }
432
464
  }
package/lib/commands.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Slash commands of the kit: `/agents-md` and `/spec`.
2
+ * Slash commands of the kit: `/agents-md`, `/spec` and `/handoff`.
3
3
  *
4
4
  * Commands are prompts, not writers: `execute` injects a short workflow prompt
5
5
  * into the current session, and the orchestrator runs it with the usual
6
6
  * evidence/approval rules. The detailed templates and checklists live in
7
- * `docs/<locale>/context-efficiency.md`.
7
+ * `docs/<locale>/context-efficiency.md` and `docs/<locale>/session-discipline.md`.
8
8
  */
9
9
  import type { Plugin } from "@opencode/plugin"
10
10
 
@@ -12,17 +12,22 @@ type CommandLocale = "ru" | "en"
12
12
 
13
13
  /** Arguments typed after the slash command, without a duplicated command token. */
14
14
  function commandArgs(text: string): string {
15
- return text.replace(/^\s*\/(?:agents-md|spec)\b/i, "").trim()
15
+ return text.replace(/^\s*\/(?:agents-md|spec|handoff)\b/i, "").trim()
16
16
  }
17
17
 
18
- const DESCRIPTIONS: Record<CommandLocale, { readonly agentsMd: string; readonly spec: string }> = {
18
+ const DESCRIPTIONS: Record<
19
+ CommandLocale,
20
+ { readonly agentsMd: string; readonly spec: string; readonly handoff: string }
21
+ > = {
19
22
  ru: {
20
23
  agentsMd: "Создать или проверить AGENTS.md по шаблону context-efficiency.md.",
21
24
  spec: "Создать или проверить SPEC.md по шаблону context-efficiency.md.",
25
+ handoff: "Сформировать handoff среза по шаблону session-discipline.md.",
22
26
  },
23
27
  en: {
24
28
  agentsMd: "Create or audit AGENTS.md using the context-efficiency.md template.",
25
29
  spec: "Create or audit SPEC.md using the context-efficiency.md template.",
30
+ handoff: "Write the slice handoff using the session-discipline.md template.",
26
31
  },
27
32
  }
28
33
 
@@ -84,14 +89,43 @@ function specPrompt(locale: CommandLocale, brief: string): string {
84
89
  ].join("\n")
85
90
  }
86
91
 
87
- /** Register `/agents-md` and `/spec`; the cleanup disposes the transform registration. */
92
+ function handoffPrompt(locale: CommandLocale, goal: string): string {
93
+ if (locale === "ru") {
94
+ return [
95
+ "[Команда /handoff]",
96
+ `Срез: ${goal}.`,
97
+ "",
98
+ "Работай по документу session-discipline.md из reference audit-kit.",
99
+ "1. Собери факты только из текущей сессии и рабочего дерева: что сделано, чем подтверждено (команды, тесты, коммиты), что не сделано и почему.",
100
+ "2. Заполни шаблон handoff из session-discipline.md: цель и DoD, сделано, не сделано, решения и причины, риски/принятые ограничения, следующий шаг.",
101
+ "3. Запиши файл `docs/planning/handoff-<YYYY-MM-DD>.md` (нет каталога — корень проекта) инструментом `write`, не shell-heredoc.",
102
+ "4. Ничего не додумывай: неизвестное помечай «не проверено»; секреты и токены в handoff не переноси.",
103
+ "5. Покажи путь файла, краткий diff и открытые вопросы; канонические документы не меняй без подтверждения.",
104
+ "6. Если дерево грязное — предложи коммит по правилам проекта; не коммить без подтверждения владельца.",
105
+ ].join("\n")
106
+ }
107
+ return [
108
+ "[Command /handoff]",
109
+ `Slice: ${goal}.`,
110
+ "",
111
+ "Follow session-discipline.md from the audit-kit reference.",
112
+ "1. Collect facts from the current session and the working tree only: what is done, what proves it (commands, tests, commits), what is not done and why.",
113
+ "2. Fill the handoff template from session-discipline.md: goal and DoD, done, not done, decisions and reasons, risks/accepted limits, next step.",
114
+ "3. Write `docs/planning/handoff-<YYYY-MM-DD>.md` (use the project root when the directory is absent) with the `write` tool, not a shell heredoc.",
115
+ "4. Do not invent facts: mark the unknown as `unverified`; never copy secrets or tokens into the handoff.",
116
+ "5. Show the file path, a short diff and open questions; do not change canonical documents without approval.",
117
+ "6. If the working tree is dirty, propose a commit per the project rules; do not commit without the owner's approval.",
118
+ ].join("\n")
119
+ }
120
+
121
+ /** Register `/agents-md`, `/spec` and `/handoff`; the cleanup disposes the transform registration. */
88
122
  export async function registerKitCommands(ctx: Plugin.Context, locale: CommandLocale): Promise<Plugin.Cleanup> {
89
123
  // The command API is optional in a host build. A missing `list` was already
90
124
  // tolerated; a missing `transform` used to throw out of `setup` and take every
91
125
  // agent and tool with it, so it degrades to a warning plus a no-op cleanup.
92
126
  const command = (ctx as Partial<Plugin.Context>).command
93
127
  if (command === undefined || typeof command.transform !== "function") {
94
- console.warn("orch-uni: this host has no command API; /agents-md and /spec are not registered")
128
+ console.warn("orch-uni: this host has no command API; /agents-md, /spec and /handoff are not registered")
95
129
  return async () => {}
96
130
  }
97
131
  let existing = new Set<string>()
@@ -141,6 +175,28 @@ export async function registerKitCommands(ctx: Plugin.Context, locale: CommandLo
141
175
  },
142
176
  })
143
177
  }
178
+ if (!existing.has("handoff")) {
179
+ editor.add({
180
+ name: "handoff",
181
+ description: DESCRIPTIONS[locale].handoff,
182
+ execute: async (input) => {
183
+ await ctx.session.prompt({
184
+ sessionID: input.sessionID,
185
+ text: handoffPrompt(
186
+ locale,
187
+ commandArgs(input.prompt.text) ||
188
+ (locale === "ru"
189
+ ? "текущая сессия — опиши срез по фактам"
190
+ : "the current session — describe the slice from facts"),
191
+ ),
192
+ ...(input.prompt.files === undefined ? {} : { files: input.prompt.files }),
193
+ ...(input.prompt.agents === undefined ? {} : { agents: input.prompt.agents }),
194
+ ...(input.prompt.skills === undefined ? {} : { skills: input.prompt.skills }),
195
+ delivery: input.delivery,
196
+ })
197
+ },
198
+ })
199
+ }
144
200
  })
145
201
  return async () => {
146
202
  await registration.dispose()