opencode-orchestrator-uni 0.5.1 → 0.5.2

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.
@@ -0,0 +1,482 @@
1
+ # Reference
2
+
3
+ Deep reference for the orchestrator kit: the write guard, the five tools, the
4
+ session protocol, model and locale configuration, and precedence rules.
5
+
6
+ [Back to the README](../../README.md)
7
+
8
+ The kit registers on setup via `ctx.agent.transform` / `ctx.reference.transform`.
9
+ The `audit-kit` reference points at this package's per-locale `docs/<locale>/`
10
+ directory; OpenCode lists it as an additional local directory, so agents read
11
+ `<reference path>/call-mechanics.md` directly. The reference ships
12
+ `call-mechanics.md`, `process-ready-loop.md`, `root-runbook.md`,
13
+ `instruction-other-session.md`, `context-efficiency.md` and
14
+ `session-discipline.md` — for example
15
+ `<reference path>/process-ready-loop.md`.
16
+
17
+ ## Write Guard
18
+
19
+ The orchestrator itself has `edit` denied on `*`. The flat `permissions` are only
20
+ a narrow fallback allowlist for audit/state artifacts, and the real guard is the
21
+ `permission.hook("evaluate")` hook in `lib/write-guard.ts` with path-root
22
+ semantics.
23
+
24
+ Auditors are read-only at the tool level for `edit`/`write`/`patch` (`edit: deny`);
25
+ the prompt forbids all other writes. A shell write (redirect, `sed -i`, `tee`) is
26
+ an accepted risk (I-M15), not a sandbox.
27
+
28
+ - The session directory is read per event (`session.get`), so a moved session is
29
+ evaluated against its new directory. If the lookup fails, the setup directory
30
+ is used and a warning is written — fail-open.
31
+ - A resource outside the session directory is denied: the guard is the workspace
32
+ boundary, so `reports/`, `docs/planning/` and `audit-*` are not writable
33
+ outside it. Inside it the first **root** segment decides.
34
+ - `reports/` is a reserved artifact root — the whole subtree is writable (even
35
+ `reports/src/x.ts`), so project code must not live there (a
36
+ `packages/reports/**` path counts as an artifact by contract).
37
+ `docs/planning/**/*.md` is allowed (nested Markdown at any depth; a `.ts` file
38
+ is not). `docs` is a separate branch, not a source root — every other
39
+ `docs/**` path is denied.
40
+ - The source roots (`src`, `lib`, `test`, `tests`, `agents`, `tools`, `schemas`,
41
+ `eval`, `.github`) are denied even under an artifact-looking prefix
42
+ (`src/reports/x.ts`, `src/docs/planning/x.md`).
43
+ - Root artifact files (`case-*`, `risks-*`, `handoff-*`, `decisions-*`,
44
+ `state.md`) are allowed only in the location root, under `reports/`,
45
+ `docs/planning/` or an `audit-*` directory — `app/state.md` is denied.
46
+ - The hook runs after the flat rules, so a local `edit: allow` for `orchestrator`
47
+ is closed too. The flat fallback carries only that artifact set as
48
+ location-root rules (`reports/*`, `case-*.md`, `risks-*.md`, `handoff-*.md`,
49
+ `decisions-*.md`, `state.md`, `docs/planning/*.md`) plus `audit-*/…`. The broad
50
+ `*/state.md`-style patterns are gone, so `src/state.md` is denied even without
51
+ the hook, but last-match still cannot express root priority everywhere
52
+ (`audit-x/src/case-y.md`) — a warning is written.
53
+ - The fallback is narrower than the hook for nested artifact roots:
54
+ `lab/x/reports/*` and similar paths are written only with the hook (the root
55
+ rules cover `reports/*` at the location root only).
56
+ - A failing or timed-out session lookup (250 ms bound) falls back to the setup
57
+ directory and writes the warning once per guard; for a moved session an
58
+ absolute artifact path may then be denied (fail-closed for that class). A
59
+ single `sessionWarned` flag without a counter is accepted: failures after the
60
+ first warning stay silent.
61
+ - `shell` stays allowed for git and checks. A shell write and symlinks/junctions
62
+ (the host matches the normalized lexical path, not `realpath`) are accepted
63
+ risks, as is fail-open (a broken guard never breaks the session), governed by
64
+ the prompt rather than a sandbox. A tool denial is a role rule — the edit goes
65
+ to a subagent.
66
+
67
+ ## Tools
68
+
69
+ The plugin registers five deterministic, **read-only** tools in the `orch_uni`
70
+ namespace.
71
+
72
+ | Effective name | Read-only | What it does |
73
+ | -------------------------- | --------- | ------------------------------------------------------------------------------- |
74
+ | `orch_uni_validate_report` | yes | Extract the SGR JSON block of a report, resolve its schema, validate it |
75
+ | `orch_uni_context_lint` | yes | Deterministic audit of AGENTS.md/SPEC.md: budget, vague phrases, links, sections |
76
+ | `orch_uni_snapshot` | yes | Deterministic revision of a directory tree (per-file sha256 + aggregate hash) |
77
+ | `orch_uni_sessions` | yes | Child sessions of a session: agent, model, cost, tokens, time |
78
+ | `orch_uni_session_stats` | yes | Session protocol counters: prompt, compactions, tools/turn, capped outputs |
79
+
80
+ Every tool answers with a JSON text body. Failures are **typed codes** inside the
81
+ body (`E_INPUT`, `E_NO_JSON`, …) instead of thrown tool errors, so an agent can
82
+ branch on the code without parsing prose. None of the five writes to the
83
+ workspace, the session store or the configuration.
84
+
85
+ ### `orch_uni_validate_report`
86
+
87
+ ```jsonc
88
+ { "text": "<report text>" } // or
89
+ { "path": "reports/empiric-r1.md", // absolute, or relative to the location directory
90
+ "schema": "empiric/v1" } // optional: expected schema id
91
+ ```
92
+
93
+ Provide exactly one of `text` / `path`. The tool takes the **last** fenced ```` ```json ````
94
+ block that parses and carries a `schema` field, resolves the schema by that field and
95
+ validates the document.
96
+
97
+ ```jsonc
98
+ { "valid": true, "schema": "empiric/v1", "errors": [], "warnings": [ … ] }
99
+ { "valid": false, "code": "E_INVALID", "schema": "empiric/v1",
100
+ "errors": [ { "path": "$.findings[0].severity", "message": "expected one of …" } ],
101
+ "warnings": [] }
102
+ ```
103
+
104
+ | Code | Meaning |
105
+ | ------------------- | ---------------------------------------------------------------- |
106
+ | `E_INPUT` | Bad call: neither/both of `text`/`path`, wrong types, unreadable file |
107
+ | `E_NO_JSON` | No fenced ```` ```json ```` block found |
108
+ | `E_PARSE` | A block exists but none parses as JSON |
109
+ | `E_UNKNOWN_SCHEMA` | The report names an id that is not shipped (or no id at all) |
110
+ | `E_SCHEMA_MISMATCH` | The report names another schema than the expected one |
111
+ | `E_INVALID` | The document does not satisfy its schema (see `errors`) |
112
+ | `E_SCHEMA_IO` | `schemas/*.json` unreadable/malformed (see `directory`, `file`) |
113
+ | `E_ABORTED` | The session was stopped while reading the report |
114
+
115
+ `warnings` carry the prompt rules a *structurally valid* report can still miss — things
116
+ JSON Schema cannot express: a missing `rc` on an `empiric` claim whose `method` is
117
+ `run|measure|compare_base`, `self_check` items other than 1–19, an `invariant`
118
+ invariant that is `violated` without a counterexample, an uncovered mandatory
119
+ `lines_checked` line, `meta`'s `verdict` diverging from its `gate`, and
120
+ `redteam`'s `closing_list`/`verdict` invariants.
121
+
122
+ The schemas ship as `schemas/*.json` and are derived **strictly** from the SGR
123
+ sections of `agents/*.md`, which stay the source of truth: the test suite parses every
124
+ agent's SGR template and fails when a prompt field or enum value is not accepted by its
125
+ schema. `context-lint-failure/v1` is the typed failure envelope of
126
+ `orch_uni_context_lint` — it has no agent template and is never a report. Schemas set
127
+ `additionalProperties` permissively — agents may add fields of their
128
+ own (`verdict_label`, `check`, …).
129
+
130
+ ### `orch_uni_context_lint`
131
+
132
+ ```jsonc
133
+ { "path": "AGENTS.md" } // file or directory, relative to the location
134
+ { "path": ".", "max_lines": 150, "max_tokens": 2000 }
135
+ { "path": ".", "max_words": 2000 } // SPEC word budget; default 1500 (target), ceiling 2000
136
+ { "path": ".", "max_file_bytes": 500000, "max_files": 100, "max_findings": 50, "max_total_findings": 500 }
137
+ ```
138
+
139
+ Deterministic, read-only audit of `AGENTS.md`, `SPEC.md` and `CLAUDE.md`. It returns
140
+ `{ schema: "context-lint/v1", ok: true, verdict, closing_list, files, summary, truncated? }`;
141
+ each finding carries `id`, `severity`, `kind`, `evidence`, `detail` and `fix`. Checks:
142
+ line/token/word budget, distinct vague phrases, duplicate lines, broken relative links,
143
+ placeholders, dates/versions in the first three lines, required sections and AC test
144
+ columns. Links and explicit targets are confined by their **real** path: a symlink or
145
+ junction that resolves outside the root is skipped, and such a target is refused
146
+ (`E_OUT_OF_ROOT`). Code fences, inline code and HTML comments are masked, so a sample is
147
+ never a rule: structural checks (commands, boundaries, verification) read the full mask,
148
+ while requirements, AC ids and non-goals read the fence mask. Link targets are
149
+ streamed from the line scanner one at a time (no candidate array), so memory does
150
+ not grow with the number of links; checks are capped per file at 50 000
151
+ candidates, the signal is checked per line and every 100 candidates, and the
152
+ link caches keep at most 5 000 entries, so an abort wins; 2001 links are checked
153
+ normally, 50 000 valid links still pass, and the 50 001st candidate makes the list
154
+ incomplete (`truncated: true` plus a MAJOR `link candidates truncated` marker in
155
+ `closing_list`), so a capped run can never pass. Broken
156
+ links have no separate per-file cap: the finding caps bound the output and report the
157
+ overflow. A line is scanned however long it is: the link scanner and the placeholder regex
158
+ are linear, so a 4100-character line is checked like any other and a broken link or
159
+ placeholder on it is the real finding, not a marker. Duplicate lines have no inner cap.
160
+ UNC/network targets are never probed. Measured (bun 1.4.2, Windows, fresh
161
+ process): 50 001 broken links with `max_findings: 2` → ~1.4 s and ~67 MB peak
162
+ RSS; 50k plain lines → ~0.14 s / ~65 MB; a 2 MB single line with links →
163
+ ~3.1 s / ~116 MB (~41 MB without links). Masking reuses a line as-is when it
164
+ has no backtick and no `<pre>`/`<code>`/`<!--`, so it never copies a line
165
+ character by character, and a 2 MB single line stays capped at 50 000
166
+ candidates instead of materializing every link.
167
+ Limits: ≤ 2 MB per file, ≤ 500 files per scan. `max_findings` (per file) is N per
168
+ severity: up to N MAJOR and N MINOR/INFO are kept, and any excess is replaced by one
169
+ marker (MAJOR when a MAJOR was dropped, otherwise INFO); a MAJOR marker stays in
170
+ `closing_list`. `max_total_findings` (global, default 1000) keeps MAJOR
171
+ findings first; when MAJOR overflow, `max_total` findings plus one MAJOR `truncated`
172
+ marker are kept. A capped list sets `truncated: true` instead of hiding findings.
173
+ Markers are additional to `max_findings` and do not consume its budget: a capped
174
+ link-candidate list and a capped findings list report one marker each in the same run, so
175
+ `max_findings: 2` over 20 001 broken links yields 2 kept findings plus 2 markers. Cap
176
+ markers point at `file:1` in `evidence`.
177
+ Non-goals are counted as top-level items: a wrapped paragraph is one item, inline
178
+ `;`/`,`/`and` and their localized equivalents split only at the top level (not inside
179
+ `(…)`, `[…]`, backticks of any width or `<code>`), and a bullet at 0–4 spaces under the label is an item — fenced,
180
+ `<pre>` and deeper-indented code is not.
181
+ Placeholders are `<...>` (three or more dots) and `TODO`/`TBD`/`FIXME`; `<DoD>` and
182
+ `<name>` are markup.
183
+ `max_lines` is the single 200-line root budget the tool applies: the 60/200/100
184
+ per-level figures in [`docs/`](../) are authoring guidance, not separate tool limits.
185
+ `max_words` defaults to the 1500-word SPEC target; pass `max_words: 2000` to enforce the
186
+ documented ceiling instead.
187
+ Typed failures: `E_INPUT`, `E_NOT_FOUND`, `E_NO_FILES`, `E_IO`, `E_ABORTED`,
188
+ `E_LIMIT_FILE`, `E_LIMIT_FILES`, `E_OUT_OF_ROOT`.
189
+ A failure is a typed payload `{schema:"context-lint-failure/v1", ok:false, code, message, path?, bytes?, files?, max?}`
190
+ that validates only against `context-lint-failure/v1`; a report is
191
+ `{schema:"context-lint/v1", ok, verdict, closing_list, files, summary, truncated?}` and every file carries `lines`,
192
+ `words`, `chars` and `tokens_est` (tokens are estimated as Cyrillic/3 + the rest/4). A directory
193
+ target is scanned to depth 3, and `max_files` refuses early: the walk stops at the first file above
194
+ the limit. Nothing is dropped silently: a directory below the depth limit, a link or junction that
195
+ resolves outside the workspace root, and an unclosed fence, `<pre>` or HTML comment are INFO
196
+ findings, so a `pass` never hides an unchecked region. An unclosed `<code>` is a
197
+ line-local mask: it hides the rest of its line and is not reported (only fence,
198
+ `<pre>` and HTML comments span lines and get an INFO marker). An abort is observed
199
+ between files, every 2048 lines of a line-oriented check and at each gate, so the
200
+ worst-case latency is one mask pass over a file capped by `max_file_bytes` (tens of
201
+ milliseconds per 2 MB), not the whole scan. Commands count only inside code, a Task table
202
+ or a Commands section, an HTML comment such as `<!-- npm install -->` is not a command, and `make`
203
+ needs a target, so prose such as "Make sure …" is not a command. A scan started at the root also
204
+ picks up fixture trees such as `eval/`; pass an explicit path for the canonical files.
205
+
206
+ ### `orch_uni_snapshot`
207
+
208
+ ```jsonc
209
+ { "path": "lab/stack-test", "exclude": ["tmp"] } // exclude is optional and additive
210
+ ```
211
+
212
+ ```jsonc
213
+ { "root": "C:\\…\\lab\\stack-test",
214
+ "files": [ { "path": "a.txt", "sha256": "…", "bytes": 1 } ],
215
+ "aggregate": "…" }
216
+ ```
217
+
218
+ - `aggregate = sha256` over the sorted lines `"<relpath>\0<sha256>\n"` (`/` separators,
219
+ codepoint order) — stable across runs, machines and traversal order, and it changes
220
+ whenever one file's path or content changes.
221
+ - Excluded by default (always): `node_modules`, `.git`, `.ae`; `exclude` adds names,
222
+ whole relative paths or relative prefixes (`lab/tmp`). Symlinks are skipped.
223
+ - Limits: ≤ 5000 files, ≤ 50 MiB per file — `E_LIMIT_FILES` / `E_LIMIT_BYTES` (fail the
224
+ whole snapshot; a truncated snapshot would be a wrong aggregate). `E_NOT_FOUND` for a
225
+ missing path or a non-directory, `E_ABORTED` when the session is stopped.
226
+
227
+ ### `orch_uni_sessions`
228
+
229
+ ```jsonc
230
+ {} // children of the calling session
231
+ { "sessionID": "ses_…", "recursive": true } // whole subtree of another session
232
+ ```
233
+
234
+ ```jsonc
235
+ { "sessionID": "ses_parent", "recursive": false, "source": "session.index",
236
+ "authoritative": false, "indexSize": 1, "count": 1,
237
+ "limitations": [ … ],
238
+ "sessions": [ { "sessionID": "ses_child", "parentID": "ses_parent", "agent": "explore",
239
+ "model": "deepseek/deepseek-flash", "variant": "default", "title": "…",
240
+ "cost": 0.0005, "tokens": { "input": 3491, "output": 2 },
241
+ "created": "2026-10-15T04:01:06.627Z", "updated": "…" } ] }
242
+ ```
243
+
244
+ `source` tells which route produced the answer:
245
+
246
+ - `session.list` — this build exposes a session listing API; the answer is
247
+ **authoritative** and complete (`recursive` walks the subtree page by page).
248
+ - `session.index` — the fallback used by OpenCode 2.0.28: `ctx.session` has
249
+ `get/create/…` but **no `list`**, so children cannot be enumerated from the store. The
250
+ plugin subscribes to `session.created` / `session.deleted` events at setup and keeps
251
+ `child → parent` in its plugin-scoped storage; per-child details are read from
252
+ `ctx.session.get()` at call time. The answer is **not authoritative**; the caveats are
253
+ returned in `limitations` (sessions created before the plugin instance started are
254
+ invisible, and an event gap hides children created while the plugin was unloaded).
255
+ - `E_NO_API` — neither route exists. The context-scan fallback (looking for the
256
+ `subagent` tool parts in `ctx.session.context`) was evaluated and rejected: in-flight
257
+ children carry no session id in their tool part and compaction drops the history.
258
+ Open question: a plugin-facing session listing (or a `parentID` filter on
259
+ `ctx.session.context`) would remove the fallback entirely.
260
+
261
+ `E_INPUT` for malformed arguments, `E_NOT_FOUND` when the parent session does not exist,
262
+ `E_ABORTED` when the session is stopped.
263
+
264
+ ### `orch_uni_session_stats`
265
+
266
+ ```jsonc
267
+ {} // counters of the calling session
268
+ { "sessionID": "ses_…" } // any session on this server, not only a child
269
+ ```
270
+
271
+ ```jsonc
272
+ { "ok": true, "sessionID": "ses_…",
273
+ "counters": {
274
+ "requests": 12, "promptTokensLast": 151204, "promptTokensMax": 161002,
275
+ "promptTokensMedian": 148900, "band": 1, "compactions": 1,
276
+ "compactionAttempts": 1, "compactionFailures": 0, "compactionSource": "event",
277
+ "reminders": 2, "toolCalls": 31, "outputsCapped": 1,
278
+ "outputCharsSaved": 18422, "errors": 0,
279
+ "toolCallsByTool": { "shell": 18, "bash": 6, "webfetch": 2, "read": 5 },
280
+ "toolOutputsCappedByTool": { "shell": 1, "bash": 0, "webfetch": 0, "read": 0 }
281
+ },
282
+ "limits": { "promptTokensSoft": 150000, "maxCompactions": 1, "maxToolOutputChars": 20000 },
283
+ "overBudget": true, "compactionLimitReached": true, "toolsPerTurn": 2.58,
284
+ "tracked": true,
285
+ "guard": { "available": true, "hooksRegistered": true, "sessionEnabled": true,
286
+ "toolOutputEnabled": true,
287
+ "toolOutputTools": ["shell", "bash", "webfetch"],
288
+ "compactionSource": "event", "eventLive": true,
289
+ "streamFailures": 0, "unkeyedCalls": 0 } }
290
+ ```
291
+
292
+ `sessionID` may name **any session on this server**, not only a child of the caller;
293
+ that is deliberate — the orchestrator uses the report to control every slice it runs.
294
+ Counters are per plugin instance, and `tracked` is `true` once any event or hook
295
+ observed the session; a `session.deleted` event forgets it. `band` is the highest
296
+ budget level reached (`0` below the soft threshold); `promptTokensMedian` is the
297
+ median over the last 256 requests; `toolCalls` and the per-tool counters include
298
+ nested Code Mode calls; `toolCallsByTool` and `toolOutputsCappedByTool` split calls
299
+ and capped outputs by the normalized tool name (namespace stripped, lower-case);
300
+ `outputCharsSaved` is a character count, not bytes, and an upper estimate (the
301
+ injected stub is subtracted). `compactions` counts confirmed
302
+ `session.compaction.ended` events only; `compactionAttempts` counts every
303
+ `session.compaction` hook call, so on a host with a dead or absent event stream
304
+ attempts keep growing while `compactions` and `compactionLimitReached` stay put (a
305
+ failed attempt is never a stop signal). A dead stream is reported once in
306
+ `guard.streamFailures`; the per-session `errors` counts hook failures only, so a
307
+ transport incident is not attributed to every session. Duplicate compaction events
308
+ are counted once only when the host supplies `event.id` or a durable position;
309
+ without either identity a re-delivered event may be counted again (fail-open in the
310
+ direction of counting). A
311
+ session the store does not return and this instance never observed is
312
+ `E_NOT_FOUND` (recognized by the typed `_tag`/`name`/`code` — `Session.NotFoundError`,
313
+ `SessionNotFoundError`, `NotFoundError`, `ENOENT` — with a narrow message fallback);
314
+ `session.get` returning `undefined` for an untracked session is the same not-found
315
+ answer; a transient `session.get` failure (a lock, permission, `unknown database`) is
316
+ `E_INTERNAL`; a known session with zero requests is a valid report with
317
+ `tracked: false`. Without `session.get`,
318
+ an untracked session is `E_NO_API`, a tracked one is `ok` with `session: null` and a
319
+ `limitations` entry. Codes: `E_INPUT`, `E_NOT_FOUND`, `E_INTERNAL`, `E_NO_API`,
320
+ `E_UNAVAILABLE` (the guard is not registered or its hooks failed), `E_ABORTED`.
321
+
322
+ ## Session Hygiene
323
+
324
+ The plugin implements the [`session-discipline.md`](./session-discipline.md)
325
+ protocol: a session is one slice, the prompt budget is ≤150k tokens, at most one
326
+ compaction, tool output goes to a file, and a slice ends with a handoff.
327
+
328
+ The runtime part is three hooks (`session.context`, `session.compaction`,
329
+ `tool.execute.after`) plus a compaction-event subscription:
330
+
331
+ - `session.context` — estimates the prompt (system + messages) and, when it crosses
332
+ a budget level, adds one short reminder. The reminder is injected **once, when a
333
+ new level is crossed**, not on every request; within a level the text is stable,
334
+ so the prefix cache is not broken.
335
+ - compaction events — the `session.compaction` hook is registered on **every** host:
336
+ it counts attempts (`compactionAttempts`) and sends a tentative note to the
337
+ nearest request; the note names no compaction number and claims no limit. Only
338
+ `session.compaction.ended` confirms a compaction (deduplicated by `event.id` or
339
+ the durable position) and raises a confirmed note with the number and the limit;
340
+ `session.compaction.failed` increments `compactionFailures`, drops the tentative
341
+ note and keeps a confirmed note that is still waiting for delivery. If the event
342
+ stream ends or throws, `guard.eventLive` turns `false`,
343
+ `guard.compactionSource` becomes `hook` and `guard.streamFailures` becomes `1`
344
+ (the per-session `errors` stays `0`); the hook keeps counting attempts, but no
345
+ confirmed compaction and no false "limit reached" is invented on that path.
346
+ - `tool.execute.after` — for `shell`, `bash`, `webfetch` it caps output over
347
+ `maxChars`: the full text goes to a private per-process root
348
+ `os.tmpdir()/opencode-tool-output-<random>/` (POSIX 0700 for the root and the
349
+ session directory, 0600 for the file). Windows ignores mode (measured: 0666),
350
+ so privacy there rests on the ACL of `os.tmpdir()` and is not checked by code.
351
+ The model gets a head and the path; the root is created with
352
+ `mkdtemp` at plugin setup, so a pre-created `opencode-tool-output` directory or
353
+ junction is never used; on POSIX the root must belong to the current uid with no
354
+ group/other access, otherwise the guard disables itself with a warning (fail-open:
355
+ results are untouched). Session directories (`s-<uuid>`) and files
356
+ (`c-<uuid>.txt`, written with `wx`) are random, and each `realpath` is proven
357
+ inside the root. The root is removed when the plugin unloads (best effort). A
358
+ lone surrogate is normalized once (U+FFFD), so the file, the head and the
359
+ counters describe the same string. Residual risk accepted: a same-user attacker
360
+ can still swap a session directory between `realpath` and `writeFile` (Node has
361
+ no `openat`). If `TMPDIR` points inside the workspace, the files land there
362
+ instead. `read`, `write`, `edit`, `execute` and the kit's own tools are not
363
+ capped, and a kit tool named without its namespace (`session_stats`) is refused
364
+ in the config with a warning.
365
+
366
+ `maxCompactions: 0` disables the compaction limit. `guard.toolOutputEnabled` is
367
+ `true` only when the private root was created **and** the effective tool list is
368
+ non-empty; `guard.toolOutputTools` is that list, and `guard.unkeyedCalls` counts
369
+ hook calls without a non-empty `sessionID` (they are dropped, not merged into an
370
+ empty bucket). An empty `tools` list disables the cap with a warning. The guard is
371
+ fail-open: a hook error neither breaks the request nor corrupts the result. Either
372
+ part can be disabled with its `enabled` flag:
373
+
374
+ ```jsonc
375
+ {
376
+ "plugins": [
377
+ {
378
+ "package": "opencode-orchestrator-uni",
379
+ "options": {
380
+ "sessionGuard": { "enabled": true, "promptTokensSoft": 150000, "maxCompactions": 1 },
381
+ "toolOutputGuard": {
382
+ "enabled": true,
383
+ "maxChars": 20000,
384
+ "headChars": 4000,
385
+ "tools": ["shell", "bash", "webfetch"]
386
+ }
387
+ }
388
+ }
389
+ ]
390
+ }
391
+ ```
392
+
393
+ Slice metrics — `orch_uni_session_stats`; closing a slice — the `/handoff` command.
394
+
395
+ ## Models
396
+
397
+ Agent definitions carry no `model`; models are configured on the plugin entry in
398
+ `opencode.json(c)`:
399
+
400
+ ```jsonc
401
+ {
402
+ "$schema": "https://opencode.ai/config.json",
403
+ "plugins": [
404
+ {
405
+ "package": "opencode-orchestrator-uni",
406
+ "options": {
407
+ // Default for every kit agent.
408
+ "model": "deepseek/deepseek-flash",
409
+ // Per-agent overrides (id -> string or expanded form).
410
+ "agents": {
411
+ "meta": "deepseek/deepseek-reasoner",
412
+ "fix-runner": { "providerID": "anthropic", "model": "claude-sonnet-4-5", "variant": "high" }
413
+ }
414
+ }
415
+ }
416
+ ]
417
+ }
418
+ ```
419
+
420
+ - `model` — default model for every kit agent; `agents.<id>` — per-agent override.
421
+ Both accept `"provider/model[#variant]"` or `{ providerID, model, variant }`.
422
+ - Without options, kit agents keep no model, so subagents inherit the session's model.
423
+ - Invalid values are ignored with a warning (`[orchestrator.uni] …`).
424
+ - Subagents run on their configured model; the primary `orchestrator` runs on the
425
+ session's model, so a model set for `orchestrator` is informational for clients
426
+ (as of OpenCode 2.0.28).
427
+ - An `agents` config entry or a local Markdown agent file that defines its own
428
+ model still wins — configuration is applied after package plugins.
429
+
430
+ ## Locale
431
+
432
+ Agent definitions and process documents ship per locale — `agents/<locale>/`
433
+ and `docs/<locale>/`; currently `ru` and `en` (the English content is a
434
+ translation of the Russian originals). The kit picks one locale in this order:
435
+
436
+ 1. `options.locale` (`"ru"` or `"en"`; full tags such as `"ru-RU"` are accepted);
437
+ 2. auto-detection: `LC_ALL` / `LC_MESSAGES` / `LANG`, then the runtime's Intl
438
+ locale (the OS setting);
439
+ 3. the default: `en`.
440
+
441
+ ```jsonc
442
+ {
443
+ "$schema": "https://opencode.ai/config.json",
444
+ "plugins": [
445
+ {
446
+ "package": "opencode-orchestrator-uni",
447
+ "options": { "locale": "ru", "model": "deepseek/deepseek-flash" }
448
+ }
449
+ ]
450
+ }
451
+ ```
452
+
453
+ A requested locale that ships no content falls back to the default locale and is
454
+ reported with a warning in the plugin log. Both locales register the same agents
455
+ under the same ids — switching the locale only changes the language of the
456
+ prompts, the process documents and the reference description.
457
+
458
+ ## Precedence
459
+
460
+ Agent **definitions** are only filled in for ids that are not defined elsewhere: an
461
+ agent with the same id coming from your configuration — an `agents` config entry, or a
462
+ Markdown file under `~/.config/opencode/agents/` (or `.opencode/agents/`) — wins and the
463
+ plugin skips its definition. A **configured model** (see [Models](#models)) is still
464
+ applied to kit agents; a model defined by your own agent entry or file wins over it,
465
+ because configuration is applied after package plugins. The same skip-if-taken rule
466
+ applies to the `audit-kit` reference.
467
+
468
+ If you previously installed the kit by copying agent files into your config directory,
469
+ **remove those copies** after enabling the plugin, otherwise the stale copies keep
470
+ overriding the plugin-provided definitions.
471
+
472
+ ## Notes
473
+
474
+ - One runtime dependency: [`gray-matter`](https://www.npmjs.com/package/gray-matter)
475
+ for frontmatter parsing. OpenCode installs package plugin dependencies automatically.
476
+ - Plugin id: `orchestrator.uni`.
477
+ - The package loads directly from TypeScript: `exports` points at `index.ts`.
478
+ There is no build step and no `dist/` (same pattern as `@opencode/plugin-figma`).
479
+ - Ships only the files listed in `package.json#files`: `index.ts`, `lib/`, `schemas/`,
480
+ `agents/`, `docs/`, `README.md`, `README.ru.md`, `LICENSE`.
481
+
482
+ <p align="right">(<a href="../../README.md">back to README</a>)</p>