opencode-orchestrator-uni 0.3.1 → 0.5.0

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.
package/README.md CHANGED
@@ -8,13 +8,18 @@ ships the **orchestrator agent kit**:
8
8
  - `orchestrator` — the root orchestrator (primary agent) with the audit loop as a
9
9
  built-in mode;
10
10
  - read-only audit subagents — `factcheck`, `empiric`, `invariant`,
11
- `arbiter`, `meta`, `redteam`;
11
+ `arbiter`, `meta`, `redteam`, `context-lint`;
12
12
  - `fix-runner` — the only writing subagent (applies the "to close" list);
13
13
  - the kit's process documents, exposed as the **`audit-kit` reference**
14
14
  (`call-mechanics.md`, `process-ready-loop.md`, `root-runbook.md`,
15
- `instruction-other-session.md`);
16
- - three deterministic, read-only tools — `orch_uni_validate_report`, `orch_uni_snapshot`,
17
- `orch_uni_sessions` (see [Tools](#tools)).
15
+ `instruction-other-session.md`, `context-efficiency.md`, `session-discipline.md`);
16
+ - five deterministic, read-only tools — `orch_uni_validate_report`, `orch_uni_context_lint`,
17
+ `orch_uni_snapshot`, `orch_uni_sessions`, `orch_uni_session_stats` (see [Tools](#tools));
18
+ - three slash commands — `/agents-md`, `/spec` and `/handoff` (see [Commands](#commands)).
19
+
20
+ Read-only audit agents are enforced by `edit: deny` plus the prompt ban; their shell
21
+ permission stays allowed so they can run verification commands (an accepted risk, no
22
+ permission is relaxed).
18
23
 
19
24
  The package is loaded directly from TypeScript: `exports` points at
20
25
  [`index.ts`](./index.ts). There is **no build step and no `dist/`** — OpenCode's
@@ -34,31 +39,49 @@ On setup (`ctx.agent.transform` / `ctx.reference.transform`):
34
39
  | `arbiter` | `subagent` | Read-only: resolves disputed findings only |
35
40
  | `meta` | `subagent` | Read-only: meta-audit of reports (pass/fail gate) |
36
41
  | `redteam` | `subagent` | Read-only: acceptance red team (release gates) |
42
+ | `context-lint` | `subagent` | Read-only: audits AGENTS.md/SPEC.md for token efficiency |
37
43
  | `fix-runner` | `subagent` | Writer: applies the fix list and proves closure |
38
44
 
39
45
  All agents ship **without a model**: models are deployment configuration (see
40
46
  [Models](#models)). Definitions live in [`agents/`](./agents) as plain Markdown agent
41
47
  files and are parsed at setup time — the files are the single source of truth.
42
48
 
49
+ "Read-only" here means `edit` is denied and the prompt forbids writes; `shell` stays
50
+ allowed so an auditor can run the verification commands it needs (accepted risk
51
+ I-M15). The audit loop treats that as a contract, not as a sandbox.
52
+
43
53
  The `audit-kit` reference points at this package's per-locale `docs/<locale>/`
44
54
  directory (see [Locale](#locale)). OpenCode lists it in session context as an
45
55
  additional local directory, so the orchestrator (and any other agent) can read
46
56
  the process documents directly, for example `<reference path>/call-mechanics.md`.
47
57
 
48
- The plugin also registers three deterministic, **read-only** tools in the `orch_uni`
49
- namespace — see [Tools](#tools).
58
+ The plugin also registers five deterministic, **read-only** tools in the `orch_uni`
59
+ namespace and three slash commands — see [Tools](#tools) and [Commands](#commands).
60
+
61
+ ## Commands
62
+
63
+ | Command | What it does |
64
+ | --- | --- |
65
+ | `/agents-md` | Injects the AGENTS.md audit/draft workflow: `context-efficiency.md` template, `orch_uni_context_lint`, `context-lint`, approval before writing. |
66
+ | `/spec` | Injects the SPEC.md workflow: brief, clarifying questions, EARS/AC/traceability, `orch_uni_context_lint`, `context-lint`, approval before writing. |
67
+ | `/handoff` | Injects the slice-closing workflow: session and working-tree facts, the `session-discipline.md` handoff template, writes `docs/planning/handoff-<YYYY-MM-DD>.md`. |
68
+
69
+ A command is a prompt, not a writer: its handler injects a workflow prompt into the
70
+ current session. The orchestrator then follows the usual evidence and approval rules.
50
71
 
51
72
  ## Tools
52
73
 
53
74
  | Effective name | Read-only | What it does |
54
75
  | ------------------------ | --------- | ----------------------------------------------------------------------------- |
55
76
  | `orch_uni_validate_report` | yes | Extract the SGR JSON block of a report, resolve its schema, validate it |
77
+ | `orch_uni_context_lint` | yes | Deterministic audit of AGENTS.md/SPEC.md: budget, vague phrases, links, sections |
56
78
  | `orch_uni_snapshot` | yes | Deterministic revision of a directory tree (per-file sha256 + aggregate hash) |
57
79
  | `orch_uni_sessions` | yes | Child sessions of a session: agent, model, cost, tokens, time |
80
+ | `orch_uni_session_stats` | yes | Session protocol counters: prompt, compactions, tools/turn, capped outputs |
58
81
 
59
82
  Every tool answers with a JSON text body. Failures are **typed codes** inside the body
60
83
  (`E_INPUT`, `E_NO_JSON`, …) instead of thrown tool errors, so an agent can branch on the
61
- code without parsing prose. None of the three writes to the workspace, the session store
84
+ code without parsing prose. None of the five writes to the workspace, the session store
62
85
  or the configuration.
63
86
 
64
87
  ### `orch_uni_validate_report`
@@ -98,12 +121,90 @@ invariant that is `violated` without a counterexample, an uncovered mandatory
98
121
  `lines_checked` line, `meta`'s `verdict` diverging from its `gate`, and
99
122
  `redteam`'s `closing_list`/`verdict` invariants.
100
123
 
101
- The seven schemas ship as `schemas/*.json` and are derived **strictly** from the SGR
124
+ The schemas ship as `schemas/*.json` and are derived **strictly** from the SGR
102
125
  sections of `agents/*.md`, which stay the source of truth: the test suite parses every
103
126
  agent's SGR template and fails when a prompt field or enum value is not accepted by its
104
- schema. Schemas set `additionalProperties` permissively — agents may add fields of their
127
+ schema. `context-lint-failure/v1` is the typed failure envelope of
128
+ `orch_uni_context_lint` — it has no agent template and is never a report. Schemas set
129
+ `additionalProperties` permissively — agents may add fields of their
105
130
  own (`verdict_label`, `check`, …).
106
131
 
132
+ ### `orch_uni_context_lint`
133
+
134
+ ```jsonc
135
+ { "path": "AGENTS.md" } // file or directory, relative to the location
136
+ { "path": ".", "max_lines": 150, "max_tokens": 2000 }
137
+ { "path": ".", "max_words": 2000 } // SPEC word budget; default 1500 (target), ceiling 2000
138
+ { "path": ".", "max_file_bytes": 500000, "max_files": 100, "max_findings": 50, "max_total_findings": 500 }
139
+ ```
140
+
141
+ Deterministic, read-only audit of `AGENTS.md`, `SPEC.md` and `CLAUDE.md`. It returns
142
+ `{ schema: "context-lint/v1", ok: true, verdict, closing_list, files, summary, truncated? }`;
143
+ each finding carries `id`, `severity`, `kind`, `evidence`, `detail` and `fix`. Checks:
144
+ line/token/word budget, distinct vague phrases, duplicate lines, broken relative links,
145
+ placeholders, dates/versions in the first three lines, required sections and AC test
146
+ columns. Links and explicit targets are confined by their **real** path: a symlink or
147
+ junction that resolves outside the root is skipped, and such a target is refused
148
+ (`E_OUT_OF_ROOT`). Code fences, inline code and HTML comments are masked, so a sample is
149
+ never a rule: structural checks (commands, boundaries, verification) read the full mask,
150
+ while requirements, AC ids and non-goals read the fence mask. Link targets are
151
+ streamed from the line scanner one at a time (no candidate array), so memory does
152
+ not grow with the number of links; checks are capped per file at 50 000
153
+ candidates, the signal is checked per line and every 100 candidates, and the
154
+ link caches keep at most 5 000 entries, so an abort wins; 2001 links are checked
155
+ normally, 50 000 valid links still pass, and the 50 001st candidate makes the list
156
+ incomplete (`truncated: true` plus a MAJOR `link candidates truncated` marker in
157
+ `closing_list`), so a capped run can never pass. Broken
158
+ links have no separate per-file cap: the finding caps bound the output and report the
159
+ overflow. A line is scanned however long it is: the link scanner and the placeholder regex
160
+ are linear, so a 4100-character line is checked like any other and a broken link or
161
+ placeholder on it is the real finding, not a marker. Duplicate lines have no inner cap.
162
+ UNC/network targets are never probed. Measured (bun 1.4.2, Windows, fresh
163
+ process): 50 001 broken links with `max_findings: 2` → ~1.4 s and ~67 MB peak
164
+ RSS; 50k plain lines → ~0.14 s / ~65 MB; a 2 MB single line with links →
165
+ ~3.1 s / ~116 MB (~41 MB without links). Masking reuses a line as-is when it
166
+ has no backtick and no `<pre>`/`<code>`/`<!--`, so it never copies a line
167
+ character by character, and a 2 MB single line stays capped at 50 000
168
+ candidates instead of materializing every link.
169
+ Limits: ≤ 2 MB per file, ≤ 500 files per scan. `max_findings` (per file) is N per
170
+ severity: up to N MAJOR and N MINOR/INFO are kept, and any excess is replaced by one
171
+ marker (MAJOR when a MAJOR was dropped, otherwise INFO); a MAJOR marker stays in
172
+ `closing_list`. `max_total_findings` (global, default 1000) keeps MAJOR
173
+ findings first; when MAJOR overflow, `max_total` findings plus one MAJOR `truncated`
174
+ marker are kept. A capped list sets `truncated: true` instead of hiding findings.
175
+ Markers are additional to `max_findings` and do not consume its budget: a capped
176
+ link-candidate list and a capped findings list report one marker each in the same run, so
177
+ `max_findings: 2` over 20 001 broken links yields 2 kept findings plus 2 markers. Cap
178
+ markers point at `file:1` in `evidence`.
179
+ Non-goals are counted as top-level items: a wrapped paragraph is one item, inline
180
+ `;`/`,`/`and`/`и` split only at the top level (not inside `(…)`, `[…]`, backticks of any
181
+ width or `<code>`), and a bullet at 0–4 spaces under the label is an item — fenced,
182
+ `<pre>` and deeper-indented code is not.
183
+ Placeholders are `<...>` (three or more dots) and `TODO`/`TBD`/`FIXME`; `<DoD>` and
184
+ `<name>` are markup.
185
+ `max_lines` is the single 200-line root budget the tool applies: the 60/200/100
186
+ per-level figures in [`docs/`](./docs) are authoring guidance, not separate tool limits.
187
+ `max_words` defaults to the 1500-word SPEC target; pass `max_words: 2000` to enforce the
188
+ documented ceiling instead.
189
+ Typed failures: `E_INPUT`, `E_NOT_FOUND`, `E_NO_FILES`, `E_IO`, `E_ABORTED`,
190
+ `E_LIMIT_FILE`, `E_LIMIT_FILES`, `E_OUT_OF_ROOT`.
191
+ A failure is a typed payload `{schema:"context-lint-failure/v1", ok:false, code, message, path?, bytes?, files?, max?}`
192
+ that validates only against `context-lint-failure/v1`; a report is
193
+ `{schema:"context-lint/v1", ok, verdict, closing_list, files, summary, truncated?}` and every file carries `lines`,
194
+ `words`, `chars` and `tokens_est` (tokens are estimated as Cyrillic/3 + the rest/4). A directory
195
+ target is scanned to depth 3, and `max_files` refuses early: the walk stops at the first file above
196
+ the limit. Nothing is dropped silently: a directory below the depth limit, a link or junction that
197
+ resolves outside the workspace root, and an unclosed fence, `<pre>` or HTML comment are INFO
198
+ findings, so a `pass` never hides an unchecked region. An unclosed `<code>` is a
199
+ line-local mask: it hides the rest of its line and is not reported (only fence,
200
+ `<pre>` and HTML comments span lines and get an INFO marker). An abort is observed
201
+ between files, every 2048 lines of a line-oriented check and at each gate, so the
202
+ worst-case latency is one mask pass over a file capped by `max_file_bytes` (tens of
203
+ milliseconds per 2 MB), not the whole scan. Commands count only inside code, a Task table
204
+ or a Commands section, an HTML comment such as `<!-- npm install -->` is not a command, and `make`
205
+ needs a target, so prose such as "Make sure …" is not a command. A scan started at the root also
206
+ picks up fixture trees such as `eval/`; pass an explicit path for the canonical files.
207
+
107
208
  ### `orch_uni_snapshot`
108
209
 
109
210
  ```jsonc
@@ -162,6 +263,134 @@ own (`verdict_label`, `check`, …).
162
263
  `E_INPUT` for malformed arguments, `E_NOT_FOUND` when the parent session does not exist,
163
264
  `E_ABORTED` when the session is stopped.
164
265
 
266
+ ### `orch_uni_session_stats`
267
+
268
+ ```jsonc
269
+ {} // counters of the calling session
270
+ { "sessionID": "ses_…" } // any session on this server, not only a child
271
+ ```
272
+
273
+ ```jsonc
274
+ { "ok": true, "sessionID": "ses_…",
275
+ "counters": {
276
+ "requests": 12, "promptTokensLast": 151204, "promptTokensMax": 161002,
277
+ "promptTokensMedian": 148900, "band": 1, "compactions": 1,
278
+ "compactionAttempts": 1, "compactionFailures": 0, "compactionSource": "event",
279
+ "reminders": 2, "toolCalls": 31, "outputsCapped": 1,
280
+ "outputCharsSaved": 18422, "errors": 0,
281
+ "toolCallsByTool": { "shell": 18, "bash": 6, "webfetch": 2, "read": 5 },
282
+ "toolOutputsCappedByTool": { "shell": 1, "bash": 0, "webfetch": 0, "read": 0 }
283
+ },
284
+ "limits": { "promptTokensSoft": 150000, "maxCompactions": 1, "maxToolOutputChars": 20000 },
285
+ "overBudget": true, "compactionLimitReached": true, "toolsPerTurn": 2.58,
286
+ "tracked": true,
287
+ "guard": { "available": true, "hooksRegistered": true, "sessionEnabled": true,
288
+ "toolOutputEnabled": true,
289
+ "toolOutputTools": ["shell", "bash", "webfetch"],
290
+ "compactionSource": "event", "eventLive": true,
291
+ "streamFailures": 0, "unkeyedCalls": 0 } }
292
+ ```
293
+
294
+ `sessionID` may name **any session on this server**, not only a child of the caller;
295
+ that is deliberate — the orchestrator uses the report to control every slice it runs.
296
+ Counters are per plugin instance, and `tracked` is `true` once any event or hook
297
+ observed the session; a `session.deleted` event forgets it. `band` is the highest
298
+ budget level reached (`0` below the soft threshold); `promptTokensMedian` is the
299
+ median over the last 256 requests; `toolCalls` and the per-tool counters include
300
+ nested Code Mode calls; `toolCallsByTool` and `toolOutputsCappedByTool` split calls
301
+ and capped outputs by the normalized tool name (namespace stripped, lower-case);
302
+ `outputCharsSaved` is a character count, not bytes, and an upper estimate (the
303
+ injected stub is subtracted). `compactions` counts confirmed
304
+ `session.compaction.ended` events only; `compactionAttempts` counts every
305
+ `session.compaction` hook call, so on a host with a dead or absent event stream
306
+ attempts keep growing while `compactions` and `compactionLimitReached` stay put (a
307
+ failed attempt is never a stop signal). A dead stream is reported once in
308
+ `guard.streamFailures`; the per-session `errors` counts hook failures only, so a
309
+ transport incident is not attributed to every session. Duplicate compaction events
310
+ are counted once only when the host supplies `event.id` or a durable position;
311
+ without either identity a re-delivered event may be counted again (fail-open in the
312
+ direction of counting). A
313
+ session the store does not return and this instance never observed is
314
+ `E_NOT_FOUND` (recognized by the typed `_tag`/`name`/`code` — `Session.NotFoundError`,
315
+ `SessionNotFoundError`, `NotFoundError`, `ENOENT` — with a narrow message fallback);
316
+ `session.get` returning `undefined` for an untracked session is the same not-found
317
+ answer; a transient `session.get` failure (a lock, permission, `unknown database`) is
318
+ `E_INTERNAL`; a known session with zero requests is a valid report with
319
+ `tracked: false`. Without `session.get`,
320
+ an untracked session is `E_NO_API`, a tracked one is `ok` with `session: null` and a
321
+ `limitations` entry. Codes: `E_INPUT`, `E_NOT_FOUND`, `E_INTERNAL`, `E_NO_API`,
322
+ `E_UNAVAILABLE` (the guard is not registered or its hooks failed), `E_ABORTED`.
323
+
324
+ ## Session hygiene
325
+
326
+ The plugin implements the [`session-discipline.md`](./docs/en/session-discipline.md)
327
+ protocol: a session is one slice, the prompt budget is ≤150k tokens, at most one
328
+ compaction, tool output goes to a file, and a slice ends with a handoff.
329
+
330
+ The runtime part is three hooks (`session.context`, `session.compaction`,
331
+ `tool.execute.after`) plus a compaction-event subscription:
332
+
333
+ - `session.context` — estimates the prompt (system + messages) and, when it crosses
334
+ a budget level, adds one short reminder. The reminder is injected **once, when a
335
+ new level is crossed**, not on every request; within a level the text is stable,
336
+ so the prefix cache is not broken.
337
+ - compaction events — the `session.compaction` hook is registered on **every** host:
338
+ it counts attempts (`compactionAttempts`) and sends a tentative note to the
339
+ nearest request; the note names no compaction number and claims no limit. Only
340
+ `session.compaction.ended` confirms a compaction (deduplicated by `event.id` or
341
+ the durable position) and raises a confirmed note with the number and the limit;
342
+ `session.compaction.failed` increments `compactionFailures`, drops the tentative
343
+ note and keeps a confirmed note that is still waiting for delivery. If the event
344
+ stream ends or throws, `guard.eventLive` turns `false`,
345
+ `guard.compactionSource` becomes `hook` and `guard.streamFailures` becomes `1`
346
+ (the per-session `errors` stays `0`); the hook keeps counting attempts, but no
347
+ confirmed compaction and no false "limit reached" is invented on that path.
348
+ - `tool.execute.after` — for `shell`, `bash`, `webfetch` it caps output over
349
+ `maxChars`: the full text goes to a private per-process root
350
+ `os.tmpdir()/opencode-tool-output-<random>/` (POSIX 0700 for the root and the
351
+ session directory, 0600 for the file). Windows ignores mode (measured: 0666),
352
+ so privacy there rests on the ACL of `os.tmpdir()` and is not checked by code.
353
+ The model gets a head and the path; the root is created with `mkdtemp` at plugin
354
+ setup, so a
355
+ pre-created `opencode-tool-output` directory or junction is never used; on POSIX
356
+ the root must belong to the current uid with no group/other access, otherwise
357
+ the guard disables itself with a warning (fail-open: results are untouched).
358
+ Session directories (`s-<uuid>`) and files (`c-<uuid>.txt`, written with `wx`)
359
+ are random, and each `realpath` is proven inside the root. The root is removed
360
+ when the plugin unloads (best effort). A lone surrogate is normalized once
361
+ (U+FFFD), so the file, the head and the counters describe the same string.
362
+ Residual risk accepted: a same-user
363
+ attacker can still swap a session directory between `realpath` and `writeFile`
364
+ (Node has no `openat`). If `TMPDIR` points inside the workspace, the files land
365
+ there instead. `read`, `write`, `edit`, `execute` and the kit's own tools are
366
+ not capped, and a kit tool named without its namespace (`session_stats`) is
367
+ refused in the config with a warning.
368
+
369
+ `maxCompactions: 0` disables the compaction limit. The guard is fail-open: a hook
370
+ error neither breaks the request nor corrupts the result. Either part can be
371
+ disabled with its `enabled` flag:
372
+
373
+ ```jsonc
374
+ {
375
+ "plugins": [
376
+ {
377
+ "package": "opencode-orchestrator-uni",
378
+ "options": {
379
+ "sessionGuard": { "enabled": true, "promptTokensSoft": 150000, "maxCompactions": 1 },
380
+ "toolOutputGuard": {
381
+ "enabled": true,
382
+ "maxChars": 20000,
383
+ "headChars": 4000,
384
+ "tools": ["shell", "bash", "webfetch"]
385
+ }
386
+ }
387
+ }
388
+ ]
389
+ }
390
+ ```
391
+
392
+ Slice metrics — `orch_uni_session_stats`; closing a slice — the `/handoff` command.
393
+
165
394
  ## Models
166
395
 
167
396
  Agent definitions carry no `model`; models are configured on the plugin entry in
@@ -309,7 +538,7 @@ npm run lint:fix # biome check --write . (applies safe fixes)
309
538
 
310
539
  The `check:locales` and `verify:kit` scripts need a git checkout of the kit — the npm package ships no `tools/` directory, so in an installed package they are unavailable (`stand: unavailable` plus your own reproducible checks).
311
540
 
312
- `npm test` scans only `./test/` — the shipped suite (93 tests in 8 files, up from
541
+ `npm test` scans only `./test/` — the shipped suite (400 tests in 10 files, up from
313
542
  72 in 5 before the identifier guards). A bare `bun test` from the repository root is red by
314
543
  design: the positional argument of `bun test` is a path substring filter, so the
315
544
  bare command also picks up the intentionally failing fixtures under `eval/cases/`
package/README.ru.md CHANGED
@@ -8,13 +8,18 @@
8
8
  - `orchestrator` — root-оркестратор (основной агент) со встроенным режимом
9
9
  аудиторского цикла;
10
10
  - read-only субагенты аудита — `factcheck`, `empiric`, `invariant`,
11
- `arbiter`, `meta`, `redteam`;
11
+ `arbiter`, `meta`, `redteam`, `context-lint`;
12
12
  - `fix-runner` — единственный пишущий субагент (применяет список «к закрытию»);
13
13
  - процессные документы комплекта, доступные как **reference `audit-kit`**
14
14
  (`call-mechanics.md`, `process-ready-loop.md`, `root-runbook.md`,
15
- `instruction-other-session.md`);
16
- - три детерминированных read-only инструмента — `orch_uni_validate_report`,
17
- `orch_uni_snapshot`, `orch_uni_sessions` (см. [Инструменты](#инструменты)).
15
+ `instruction-other-session.md`, `context-efficiency.md`, `session-discipline.md`);
16
+ - пять детерминированных read-only инструментов — `orch_uni_validate_report`,
17
+ `orch_uni_context_lint`, `orch_uni_snapshot`, `orch_uni_sessions`,
18
+ `orch_uni_session_stats` (см. [Инструменты](#инструменты));
19
+ - три слэш-команды — `/agents-md`, `/spec` и `/handoff` (см. [Команды](#команды)).
20
+
21
+ Read-only аудиторы держатся на `edit: deny` плюс запрете в промпте; shell-разрешение
22
+ остаётся, чтобы запускать проверки (принятый риск, ни одно разрешение не ослаблено).
18
23
 
19
24
  Пакет загружается напрямую из TypeScript: `exports` указывает на
20
25
  [`index.ts`](./index.ts). **Шага сборки и каталога `dist/` нет** — Bun-овый
@@ -34,6 +39,7 @@
34
39
  | `arbiter` | `subagent` | Read-only: разбирает только спорные находки |
35
40
  | `meta` | `subagent` | Read-only: мета-аудит отчётов (гейт pass/fail) |
36
41
  | `redteam` | `subagent` | Read-only: приёмочный ред-тим (релизные ворота) |
42
+ | `context-lint` | `subagent` | Read-only: аудит AGENTS.md/SPEC.md на token-эффективность |
37
43
  | `fix-runner` | `subagent` | Пишущий: применяет список правок и доказывает закрытие |
38
44
 
39
45
  Все агенты поставляются **без модели**: модели — это конфигурация развёртывания
@@ -41,26 +47,45 @@
41
47
  Markdown-файлы агентов и парсятся при setup — файлы являются единственным
42
48
  источником истины.
43
49
 
50
+ «Read-only» здесь означает: `edit` запрещён и промпт запрещает запись; `shell`
51
+ остаётся разрешён, чтобы аудитор мог выполнить нужные проверки (принятый риск
52
+ I-M15). Аудиторский цикл считает это контрактом, а не песочницей.
53
+
44
54
  Reference `audit-kit` указывает на per-locale каталог `docs/<locale>/` пакета
45
55
  (см. [Локаль](#локаль)). OpenCode перечисляет его в контексте сессии как
46
56
  дополнительный локальный каталог, поэтому оркестратор (и любой другой агент)
47
57
  может читать процессные документы напрямую, например
48
58
  `<reference path>/call-mechanics.md`.
49
59
 
50
- Плагин также регистрирует три детерминированных **read-only** инструмента в
51
- пространстве имён `orch_uni` — см. [Инструменты](#инструменты).
60
+ Плагин также регистрирует пять детерминированных **read-only** инструментов в
61
+ пространстве имён `orch_uni` и три слэш-команды — см. [Инструменты](#инструменты)
62
+ и [Команды](#команды).
63
+
64
+ ## Команды
65
+
66
+ | Команда | Что делает |
67
+ | --- | --- |
68
+ | `/agents-md` | Вставляет workflow аудита/черновика AGENTS.md: шаблон `context-efficiency.md`, `orch_uni_context_lint`, `context-lint`, подтверждение перед записью. |
69
+ | `/spec` | Вставляет workflow SPEC.md: бриф, уточняющие вопросы, EARS/AC/трассировка, `orch_uni_context_lint`, `context-lint`, подтверждение перед записью. |
70
+ | `/handoff` | Вставляет workflow закрытия среза: факты сессии и рабочего дерева, шаблон handoff из `session-discipline.md`, запись `docs/planning/handoff-<YYYY-MM-DD>.md`. |
71
+
72
+ Команда — это промпт, а не писатель: обработчик вставляет workflow-промпт в
73
+ текущую сессию. Дальше оркестратор работает по обычным правилам evidence и
74
+ подтверждения.
52
75
 
53
76
  ## Инструменты
54
77
 
55
78
  | Имя | Read-only | Что делает |
56
79
  | -------------------------- | --------- | ----------------------------------------------------------------------------- |
57
80
  | `orch_uni_validate_report` | да | Достаёт SGR JSON-блок отчёта, разрешает его схему, валидирует |
81
+ | `orch_uni_context_lint` | да | Детерминированный аудит AGENTS.md/SPEC.md: бюджет, vague, ссылки, секции |
58
82
  | `orch_uni_snapshot` | да | Детерминированная ревизия дерева каталогов (sha256 на файл + агрегатный хэш) |
59
83
  | `orch_uni_sessions` | да | Дочерние сессии сессии: агент, модель, стоимость, токены, время |
84
+ | `orch_uni_session_stats` | да | Счётчики протокола сессии: промпт, компакции, тулы/ход, урезанные выводы |
60
85
 
61
86
  Каждый инструмент отвечает JSON-текстом. Ошибки — **типизированные коды** внутри
62
87
  тела (`E_INPUT`, `E_NO_JSON`, …), а не исключения, чтобы агент мог ветвиться по
63
- коду, не разбирая прозу. Ни один из трёх ничего не пишет в воркспейс, хранилище
88
+ коду, не разбирая прозу. Ни один из пяти ничего не пишет в воркспейс, хранилище
64
89
  сессий или конфигурацию.
65
90
 
66
91
  ### `orch_uni_validate_report`
@@ -99,12 +124,87 @@ claim'а `empiric` с `method` = `run|measure|compare_base`, элементы `s
99
124
  непокрытая обязательная строка `lines_checked`, расхождение `verdict` и `gate` у
100
125
  `meta`, инварианты `closing_list`/`verdict` у `redteam`.
101
126
 
102
- Семь схем лежат в `schemas/*.json` и выведены **строго** из SGR-секций
127
+ Схемы лежат в `schemas/*.json` и выведены **строго** из SGR-секций
103
128
  `agents/*.md`, которые остаются источником истины: тестовый набор парсит
104
129
  SGR-шаблон каждого агента и падает, когда поле промпта или значение enum не
105
- принимается его схемой. Схемы разрешают `additionalProperties` — агенты могут
130
+ принимается его схемой. `context-lint-failure/v1` — типизированный конверт ошибки
131
+ `orch_uni_context_lint`: у него нет шаблона агента, и отчётом он не является. Схемы
132
+ разрешают `additionalProperties` — агенты могут
106
133
  добавлять свои поля (`verdict_label`, `check`, …).
107
134
 
135
+ ### `orch_uni_context_lint`
136
+
137
+ ```jsonc
138
+ { "path": "AGENTS.md" } // файл или каталог, относительно локации
139
+ { "path": ".", "max_lines": 150, "max_tokens": 2000 }
140
+ { "path": ".", "max_words": 2000 } // бюджет слов SPEC; по умолчанию 1500 (цель), потолок 2000
141
+ { "path": ".", "max_file_bytes": 500000, "max_files": 100, "max_findings": 50, "max_total_findings": 500 }
142
+ ```
143
+
144
+ Детерминированный read-only аудит `AGENTS.md`, `SPEC.md` и `CLAUDE.md`. Возвращает
145
+ `{ schema: "context-lint/v1", ok: true, verdict, closing_list, files, summary, truncated? }`;
146
+ каждая находка несёт `id`, `severity`, `kind`, `evidence`, `detail` и `fix`. Проверки:
147
+ бюджет строк/токенов/слов, различные vague-фразы, дубли строк, битые относительные ссылки,
148
+ плейсхолдеры, даты/версии в первых трёх строках, обязательные секции и колонка Test
149
+ у AC. Ссылки и явные цели ограничены **реальным** путём: symlink/junction, ведущий за
150
+ пределы корня, пропускается, а такая цель отклоняется (`E_OUT_OF_ROOT`). Код-фенсы,
151
+ inline-код и HTML-комментарии маскируются: пример не считается правилом — структурные
152
+ проверки (команды, границы, проверка) читают полную маску, а требования, AC-id и
153
+ non-goals — маску фенсов. Цели ссылок передаются из построчного сканера по одной
154
+ (массива кандидатов нет), поэтому память не растёт с числом ссылок; проверки ограничены
155
+ на файл 50 000 кандидатами, сигнал проверяется на каждой строке и каждые 100 кандидатов,
156
+ а кэши ссылок держат не более 5 000 записей, поэтому abort срабатывает; 2001 ссылка
157
+ проверяется обычным образом, 50 000 валидных ссылок дают pass, а 50 001-й кандидат делает
158
+ список неполным (`truncated: true` плюс MAJOR-маркер `link candidates truncated` в
159
+ `closing_list`), он не может дать pass.
160
+ Отдельного потолка на
161
+ битые ссылки нет: вывод ограничивают общие лимиты находок, а переполнение помечается.
162
+ Строка сканируется любой длины: сканер ссылок и регулярное выражение плейсхолдеров линейны,
163
+ поэтому 4100-символьная строка проверяется как любая другая, а битая ссылка или
164
+ плейсхолдер на ней — настоящая находка, а не маркер. Внутреннего потолка дублей нет. Цели
165
+ UNC/сетевые пути никогда не пробуются. Замер (bun 1.4.2, Windows, свежий процесс):
166
+ 50 001 битая ссылка с `max_findings: 2` → ~1,4 с и ~67 МБ пикового RSS; 50 тыс.
167
+ обычных строк → ~0,14 с / ~65 МБ; одна строка 2 МБ со ссылками → ~3,1 с / ~116 МБ
168
+ (~41 МБ без ссылок). Маскирование переиспользует строку как есть, если в ней нет
169
+ backtick и `<pre>`/`<code>`/`<!--`, поэтому не копирует строку посимвольно, а строка
170
+ в 2 МБ остаётся ограниченной 50 000 кандидатами, а не материализует все ссылки.
171
+ Лимиты: ≤ 2 МБ на файл, ≤ 500 файлов за прогон. `max_findings` (на файл) — это N на
172
+ severity: сохраняются до N MAJOR и N MINOR/INFO, излишек заменяется одним маркером
173
+ (MAJOR, если выпал MAJOR, иначе INFO); MAJOR-маркер остаётся в `closing_list`.
174
+ `max_total_findings` (глобально, по умолчанию 1000)
175
+ сохраняет сначала MAJOR; при переполнении MAJOR остаются `max_total` находок плюс один
176
+ MAJOR-маркер `truncated`. Усечённый список помечается `truncated: true`, а не скрывается.
177
+ Маркеры дополнительны к `max_findings` и не расходуют его бюджет: маркер усечённых
178
+ кандидатов-ссылок и маркер усечённых находок дают по маркеру в одном прогоне, поэтому
179
+ `max_findings: 2` на 20 001 битой ссылке даёт 2 сохранённые находки плюс 2 маркера. Маркеры
180
+ указывают на `file:1` в `evidence`.
181
+ Non-goals считаются как элементы верхнего уровня: перенесённый абзац — один элемент,
182
+ inline `;`/`,`/`and`/`и` делят только верхний уровень (не внутри `(…)`, `[…]`, бэктиков
183
+ любой длины или `<code>`), а bullet с отступом 0–4 пробела под лейблом — элемент; фенсы,
184
+ `<pre>` и код с большим отступом — нет.
185
+ Плейсхолдеры — `<...>` (три и более точки) и `TODO`/`TBD`/`FIXME`; `<DoD>` и `<name>` —
186
+ разметка.
187
+ `max_lines` — единственный бюджет корня (200 строк), который применяет инструмент;
188
+ цифры 60/200/100 по уровням в [`docs/`](./docs) — ориентир для авторов, а не отдельные
189
+ лимиты инструмента. `max_words` по умолчанию равен цели 1500 слов SPEC; передайте
190
+ `max_words: 2000`, чтобы применять документированный потолок. Типизированные ошибки: `E_INPUT`, `E_NOT_FOUND`, `E_NO_FILES`,
191
+ `E_IO`, `E_ABORTED`, `E_LIMIT_FILE`, `E_LIMIT_FILES`, `E_OUT_OF_ROOT`.
192
+ Ошибка — типизированный payload `{schema:"context-lint-failure/v1", ok:false, code, message, path?, bytes?, files?, max?}`,
193
+ который валидируется только по `context-lint-failure/v1`; отчёт —
194
+ `{schema:"context-lint/v1", ok, verdict, closing_list, files, summary, truncated?}`, и каждый файл несёт `lines`,
195
+ `words`, `chars` и `tokens_est` (токены оцениваются как кириллица/3 + прочее/4). Каталог
196
+ сканируется до глубины 3, а `max_files` отказывает рано: обход останавливается на первом файле
197
+ выше лимита. Ничего не пропадает молча: каталог ниже лимита глубины, ссылка или junction вне
198
+ корня workspace и незакрытые fence, `<pre>` или HTML-комментарий дают INFO-находки, поэтому `pass`
199
+ не скрывает непроверенную область. Незакрытый `<code>` — строчная маска: он скрывает
200
+ остаток своей строки и не репортится (INFO дают только fence, `<pre>` и HTML-комментарий,
201
+ которые тянутся через строки). Отмена наблюдается между файлами, каждые 2048 строк
202
+ построчной проверки и в каждом гейте, поэтому худшая латентность — один проход маски по
203
+ файлу под `max_file_bytes` (десятки мс на 2 МБ), а не весь скан. Команды считаются только в коде, таблице задач или секции
204
+ Commands, HTML-комментарий вроде `<!-- npm install -->` командой не является, а `make` требует
205
+ цели, поэтому проза «Make sure …» — не команда. Скан от корня подхватывает и фикстуры вроде
206
+ `eval/`; передайте явный путь, если нужны только канонические файлы.
207
+
108
208
  ### `orch_uni_snapshot`
109
209
 
110
210
  ```jsonc
@@ -165,6 +265,136 @@ SGR-шаблон каждого агента и падает, когда пол
165
265
  `E_INPUT` при некорректных аргументах, `E_NOT_FOUND`, когда родительской сессии
166
266
  нет, `E_ABORTED`, когда сессия остановлена.
167
267
 
268
+ ### `orch_uni_session_stats`
269
+
270
+ ```jsonc
271
+ {} // счётчики вызывающей сессии
272
+ { "sessionID": "ses_…" } // любая сессия на этом сервере, не только дочерняя
273
+ ```
274
+
275
+ ```jsonc
276
+ { "ok": true, "sessionID": "ses_…",
277
+ "counters": {
278
+ "requests": 12, "promptTokensLast": 151204, "promptTokensMax": 161002,
279
+ "promptTokensMedian": 148900, "band": 1, "compactions": 1,
280
+ "compactionAttempts": 1, "compactionFailures": 0, "compactionSource": "event",
281
+ "reminders": 2, "toolCalls": 31, "outputsCapped": 1,
282
+ "outputCharsSaved": 18422, "errors": 0,
283
+ "toolCallsByTool": { "shell": 18, "bash": 6, "webfetch": 2, "read": 5 },
284
+ "toolOutputsCappedByTool": { "shell": 1, "bash": 0, "webfetch": 0, "read": 0 }
285
+ },
286
+ "limits": { "promptTokensSoft": 150000, "maxCompactions": 1, "maxToolOutputChars": 20000 },
287
+ "overBudget": true, "compactionLimitReached": true, "toolsPerTurn": 2.58,
288
+ "tracked": true,
289
+ "guard": { "available": true, "hooksRegistered": true, "sessionEnabled": true,
290
+ "toolOutputEnabled": true,
291
+ "toolOutputTools": ["shell", "bash", "webfetch"],
292
+ "compactionSource": "event", "eventLive": true,
293
+ "streamFailures": 0, "unkeyedCalls": 0 } }
294
+ ```
295
+
296
+ `sessionID` может называть **любую сессию на этом сервере**, не только дочернюю
297
+ вызывающего; это осознанно — по отчёту оркестратор контролирует все свои срезы.
298
+ Счётчики принадлежат инстансу плагина, а `tracked` равен `true`, как только сессию
299
+ наблюдало любое событие или хук; событие `session.deleted` её забывает. `band` —
300
+ максимум достигнутого уровня бюджета (`0` ниже мягкого порога);
301
+ `promptTokensMedian` — медиана по окну последних 256 запросов; `toolCalls` и
302
+ per-tool счётчики включают вложенные вызовы Code Mode; `toolCallsByTool` и
303
+ `toolOutputsCappedByTool` разносят вызовы и урезанные выводы по нормализованному
304
+ имени инструмента (namespace снят, регистр нижний); `outputCharsSaved` — число
305
+ символов, не байтов, и верхняя оценка (внедрённая заглушка вычитается).
306
+ `compactions` считает только подтверждённые `session.compaction.ended`;
307
+ `compactionAttempts` — каждый вызов хука `session.compaction`, поэтому на хосте с
308
+ умершим или отсутствующим потоком событий попытки растут, а `compactions` и
309
+ `compactionLimitReached` стоят (неудачная попытка не становится стоп-сигналом).
310
+ Умерший поток событий один раз виден в `guard.streamFailures`; per-session
311
+ `errors` считает только ошибки хуков, поэтому транспортная авария не
312
+ приписывается каждой сессии. Дубликаты событий компакции считаются один раз
313
+ только при наличии `event.id` или durable-позиции; без identity повторная
314
+ доставка может пересчитать событие (fail-open в сторону счёта).
315
+ Сессия, которую не отдаёт хранилище и не наблюдал этот инстанс, — `E_NOT_FOUND`
316
+ (распознаётся по типизированным `_tag`/`name`/`code` — `Session.NotFoundError`,
317
+ `SessionNotFoundError`, `NotFoundError`, `ENOENT` — с узким fallback по сообщению);
318
+ `session.get`, вернувший `undefined` для ненаблюдаемой сессии, — тот же ответ
319
+ «не найдено»; transient-ошибка `session.get` (блокировка, права, `unknown
320
+ database`) — `E_INTERNAL`; известная сессия без запросов — валидный отчёт с
321
+ `tracked: false`. Без `session.get` ненаблюдаемая сессия —
322
+ `E_NO_API`, наблюдаемая — `ok` с `session: null` и записью в `limitations`.
323
+ Коды: `E_INPUT`, `E_NOT_FOUND`, `E_INTERNAL`, `E_NO_API`, `E_UNAVAILABLE`
324
+ (guard не зарегистрирован или его хуки не установились), `E_ABORTED`.
325
+
326
+ ## Гигиена сессии
327
+
328
+ Плагин реализует протокол [`session-discipline.md`](./docs/ru/session-discipline.md):
329
+ сессия = один срез, бюджет промпта ≤150k токенов, ≤1 компакция, вывод инструментов
330
+ — в файл, в конце среза — handoff.
331
+
332
+ Runtime-часть — три хука (`session.context`, `session.compaction`,
333
+ `tool.execute.after`) и подписка на события компакции:
334
+
335
+ - `session.context` — оценивает промпт (system + messages) и при переходе на
336
+ новый уровень бюджета добавляет одно короткое напоминание. Напоминание
337
+ инжектится **один раз при переходе уровня**, а не в каждом запросе; внутри
338
+ уровня текст стабилен, поэтому префикс-кэш не рвётся.
339
+ - события компакции — хук `session.compaction` регистрируется **всегда**: он
340
+ считает попытки (`compactionAttempts`) и отдаёт tentative-напоминание ближайшему
341
+ запросу; напоминание не называет номер компакции и не заявляет лимит.
342
+ Подтверждает компакцию только `session.compaction.ended` (дедуп по `event.id`
343
+ или durable-позиции) и выставляет confirmed-note с номером и лимитом;
344
+ `session.compaction.failed` увеличивает `compactionFailures`, снимает
345
+ tentative-note и сохраняет confirmed-note, ещё ждущий доставки. Если поток
346
+ событий завершился или упал, `guard.eventLive` становится `false`,
347
+ `guard.compactionSource` — `"hook"`, а `guard.streamFailures` — `1`
348
+ (per-session `errors` остаётся `0`); хук продолжает считать попытки, но
349
+ подтверждённых компакций и ложного «лимит исчерпан» на этом пути нет.
350
+ - `tool.execute.after` — для `shell`, `bash`, `webfetch` урезает вывод больше
351
+ `maxChars`: полный текст пишется в приватный корень на инстанс плагина
352
+ `os.tmpdir()/opencode-tool-output-<random>/` (POSIX: 0700 на корень и каталог
353
+ сессии, 0600 на файл), модель получает голову и путь. На Windows mode не
354
+ действует (замер: 0666), приватность там держится только на ACL каталога
355
+ `os.tmpdir()` и кодом не проверяется. Корень создаётся `mkdtemp` при setup,
356
+ поэтому заранее созданный каталог или
357
+ junction `opencode-tool-output` не используется; на POSIX корень обязан
358
+ принадлежать текущему uid и не давать доступа group/other, иначе guard
359
+ отключается с warning (fail-open: результат не трогается). Каталог сессии
360
+ (`s-<uuid>`) и файл (`c-<uuid>.txt`, запись `wx`) — случайные, их `realpath`
361
+ проверяется внутри корня. При выгрузке плагина корень удаляется best effort.
362
+ Строка с lone surrogate нормализуется один раз (U+FFFD), поэтому файл, голова и
363
+ счётчики описывают одну и ту же строку. Остаточный риск принят: same-user
364
+ атакующий всё ещё может подменить каталог
365
+ сессии между `realpath` и `writeFile` (в Node нет `openat`). Если `TMPDIR`
366
+ указывает внутрь воркспейса, файлы попадут туда. `read`, `write`, `edit`,
367
+ `execute` и инструменты комплекта не урезаются; kit-имя без namespace
368
+ (`session_stats`) отбрасывается в конфиге с warning.
369
+
370
+ `maxCompactions: 0` отключает лимит компакций. `guard.toolOutputEnabled` равен
371
+ `true` только когда приватный корень создан **и** эффективный список инструментов
372
+ непуст; `guard.toolOutputTools` — этот список, `guard.unkeyedCalls` — вызовы
373
+ хуков без непустого `sessionID` (отбрасываются, а не сливаются в бакет `""`).
374
+ Пустой `tools` выключает кап с warning. Guard fail-open: ошибка хука не
375
+ ломает запрос и не портит результат. Отключается флагами `enabled`:
376
+
377
+ ```jsonc
378
+ {
379
+ "plugins": [
380
+ {
381
+ "package": "opencode-orchestrator-uni",
382
+ "options": {
383
+ "sessionGuard": { "enabled": true, "promptTokensSoft": 150000, "maxCompactions": 1 },
384
+ "toolOutputGuard": {
385
+ "enabled": true,
386
+ "maxChars": 20000,
387
+ "headChars": 4000,
388
+ "tools": ["shell", "bash", "webfetch"]
389
+ }
390
+ }
391
+ }
392
+ ]
393
+ }
394
+ ```
395
+
396
+ Метрики среза — `orch_uni_session_stats`; закрытие среза — команда `/handoff`.
397
+
168
398
  ## Модели
169
399
 
170
400
  Определения агентов не несут `model`; модели настраиваются у записи плагина в
@@ -316,7 +546,7 @@ npm run lint:fix # biome check --write . (применяет безопас
316
546
 
317
547
  Команды `check:locales` и `verify:kit` требуют git-чекаута комплекта — в npm-пакете каталог `tools/` не поставляется, поэтому в установленном пакете они недоступны (`stand: unavailable` и собственные воспроизводимые проверки).
318
548
 
319
- `npm test` сканирует только `./test/` — поставляемый набор (93 теста в 8 файлах;
549
+ `npm test` сканирует только `./test/` — поставляемый набор (400 тестов в 10 файлах;
320
550
  до стражей идентификаторов было 72 в 5). Голый `bun test` из корня репозитория
321
551
  красный по замыслу: позиционный аргумент `bun test` — это фильтр-подстрока пути,
322
552
  поэтому голая команда подхватывает и специально падающие фикстуры в