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/lib/tools.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  /**
2
2
  * tools — registration of the kit's deterministic, read-only audit tools.
3
3
  *
4
- * `registerAuditTools(ctx)` publishes three tools in the `orch_uni` namespace
5
- * (effective names `orch_uni_validate_report`, `orch_uni_snapshot`, `orch_uni_sessions`)
6
- * and returns a cleanup that stops the background session-index subscription:
4
+ * `registerAuditTools(ctx)` publishes five tools in the `orch_uni` namespace
5
+ * (effective names `orch_uni_validate_report`, `orch_uni_context_lint`,
6
+ * `orch_uni_snapshot`, `orch_uni_sessions`, `orch_uni_session_stats`) and returns
7
+ * a cleanup that stops the background session-index subscription:
7
8
  *
8
9
  * - `validate_report` — extract the SGR JSON block of an agent report, resolve
9
10
  * its schema, validate it and report prompt-level warnings;
11
+ * - `context_lint` — deterministic, read-only checks for AGENTS.md/SPEC.md/CLAUDE.md:
12
+ * budget, vague phrases, duplicates, broken relative links, placeholders,
13
+ * dates in the first lines, required sections;
10
14
  * - `snapshot` — deterministic revision of a directory tree (per-file sha256 +
11
15
  * aggregate hash) for "the state the verdict was fixed on";
12
16
  * - `sessions` — child sessions of a session (agent, model, cost, tokens, time),
13
- * read through the plugin's session API only.
17
+ * read through the plugin's session API only;
18
+ * - `session_stats` — the session-protocol counters and limits of one session.
14
19
  *
15
20
  * Every tool answers with a JSON text body. Failures are typed (`E_*`) codes in
16
21
  * the body rather than thrown tool errors, so an agent can branch on the code.
17
- * All three are read-only: they never write to the workspace or the config; the
22
+ * All five are read-only: they never write to the workspace or the config; the
18
23
  * only state `sessions` keeps is a plugin-scoped session index in `ctx.storage`
19
24
  * (see the `sessions` tool notes below).
20
25
  */
@@ -22,14 +27,22 @@ import { readdir, readFile } from "node:fs/promises"
22
27
  import path from "node:path"
23
28
  import { fileURLToPath } from "node:url"
24
29
  import type { Plugin } from "@opencode/plugin"
30
+ import { lintContext } from "./context-lint"
25
31
  import { checkReport, type JsonSchemaNode, type ValidationCode } from "./report-schema"
32
+ import { compactionLimitReached, type SessionBudgetReport } from "./session-budget"
26
33
  import { DEFAULT_EXCLUDE, isSnapshotFailure, snapshotTree } from "./snapshot"
27
34
 
28
35
  /** Tool namespace; effective names become `orch_uni_<name>`. */
29
36
  export const TOOL_NAMESPACE = "orch_uni"
30
37
 
31
38
  /** Effective tool names as they appear to a model. */
32
- export const TOOL_IDS: readonly string[] = ["orch_uni_validate_report", "orch_uni_snapshot", "orch_uni_sessions"]
39
+ export const TOOL_IDS: readonly string[] = [
40
+ "orch_uni_validate_report",
41
+ "orch_uni_context_lint",
42
+ "orch_uni_snapshot",
43
+ "orch_uni_sessions",
44
+ "orch_uni_session_stats",
45
+ ]
33
46
 
34
47
  /** Directory holding the shipped report schemas (`schemas/*.json`). */
35
48
  export const SCHEMAS_DIRECTORY = fileURLToPath(new URL("../schemas/", import.meta.url))
@@ -134,6 +147,31 @@ function asRecord(input: unknown): Record<string, unknown> | undefined {
134
147
  : undefined
135
148
  }
136
149
 
150
+ /**
151
+ * `session.get` failures that mean the session is genuinely absent.
152
+ *
153
+ * A live host may throw with an empty message but a typed `_tag`/`name`/`code`;
154
+ * the message regex is only a narrow fallback, so a transient `unknown database`
155
+ * or `unknown host` stays `E_INTERNAL` and cannot masquerade as a missing session.
156
+ */
157
+ function isNotFoundError(error: unknown): boolean {
158
+ const record = asRecord(error)
159
+ if (record !== undefined) {
160
+ for (const field of ["_tag", "name", "code"] as const) {
161
+ const value = record[field]
162
+ if (
163
+ value === "Session.NotFoundError" ||
164
+ value === "SessionNotFoundError" ||
165
+ value === "NotFoundError" ||
166
+ value === "ENOENT"
167
+ ) {
168
+ return true
169
+ }
170
+ }
171
+ }
172
+ return /session.*not.?found|unknown session/i.test(errorMessage(error))
173
+ }
174
+
137
175
  /** `validate_report` input contract. */
138
176
  const VALIDATE_REPORT_INPUT = {
139
177
  type: "object",
@@ -144,6 +182,36 @@ const VALIDATE_REPORT_INPUT = {
144
182
  },
145
183
  } as const
146
184
 
185
+ /** `context_lint` input contract. */
186
+ const CONTEXT_LINT_INPUT = {
187
+ type: "object",
188
+ properties: {
189
+ path: {
190
+ type: "string",
191
+ description:
192
+ "File or directory to inspect, absolute or relative to the location directory. Default: the location directory.",
193
+ },
194
+ max_lines: { type: "number", description: "AGENTS/CLAUDE line budget. Default: 200." },
195
+ max_tokens: { type: "number", description: "AGENTS/CLAUDE estimated token budget. Default: 2500." },
196
+ max_words: {
197
+ type: "number",
198
+ description: "SPEC word budget. Default: 1500 (the authoring target); the documented ceiling is 2000.",
199
+ },
200
+ max_file_bytes: { type: "number", description: "Largest file that may be read, in bytes. Default: 2000000." },
201
+ max_files: { type: "number", description: "Most files a directory target may expand to. Default: 500." },
202
+ max_findings: {
203
+ type: "number",
204
+ description:
205
+ "Per-file cap: MINOR/INFO findings kept, and the hard cap for MAJOR (excess becomes one MAJOR truncated finding). Default: 100.",
206
+ },
207
+ max_total_findings: {
208
+ type: "number",
209
+ description:
210
+ "Global cap for all findings; MAJOR are kept first, the MAJOR overflow becomes one MAJOR truncated marker in closing_list. Default: 1000.",
211
+ },
212
+ },
213
+ } as const
214
+
147
215
  /** `snapshot` input contract. */
148
216
  const SNAPSHOT_INPUT = {
149
217
  type: "object",
@@ -167,6 +235,14 @@ const SESSIONS_INPUT = {
167
235
  },
168
236
  } as const
169
237
 
238
+ /** `session_stats` input contract. */
239
+ const SESSION_STATS_INPUT = {
240
+ type: "object",
241
+ properties: {
242
+ sessionID: { type: "string", description: "Session to report. Default: the calling session." },
243
+ },
244
+ } as const
245
+
170
246
  interface SessionInfoLike {
171
247
  readonly id?: string
172
248
  readonly parentID?: string | null
@@ -349,10 +425,12 @@ export function resolveRoot(directory: string, input: string): string {
349
425
  return path.isAbsolute(input) ? path.normalize(input) : path.resolve(directory, input)
350
426
  }
351
427
 
352
- /** Optional seams for tests; the plugin calls this with no options. */
428
+ /** Optional seams for tests; the plugin calls this with the session-budget report. */
353
429
  export interface RegisterAuditToolsOptions {
354
430
  /** Directory to read `schemas/*.json` from. Defaults to the shipped `schemas/`. */
355
431
  readonly schemasDirectory?: string
432
+ /** Session counters from the runtime session guard; absent in isolation. */
433
+ readonly sessionBudget?: SessionBudgetReport
356
434
  }
357
435
 
358
436
  /**
@@ -437,6 +515,75 @@ export async function registerAuditTools(
437
515
  return json({ root: outcome.root, files: outcome.files, aggregate: outcome.aggregate })
438
516
  }
439
517
 
518
+ const contextLintTool = async (
519
+ input: unknown,
520
+ toolContext: { readonly signal?: AbortSignal } | undefined,
521
+ ): Promise<{ content: string }> => {
522
+ // A failure is not a report: it carries the `context-lint-failure/v1` id so it
523
+ // validates against that schema and never passes as a `context-lint/v1` report.
524
+ const lintFailure = (code: string, message: string, extra: Record<string, unknown> = {}) =>
525
+ failure(code, message, { schema: "context-lint-failure/v1", ...extra })
526
+ const record = input === undefined ? {} : asRecord(input)
527
+ if (record === undefined)
528
+ return lintFailure(
529
+ "E_INPUT",
530
+ "input must be an object ({path?, max_lines?, max_tokens?, max_words?, max_file_bytes?, max_files?, max_findings?, max_total_findings?})",
531
+ )
532
+ const {
533
+ path: target,
534
+ max_lines,
535
+ max_tokens,
536
+ max_words,
537
+ max_file_bytes,
538
+ max_files,
539
+ max_findings,
540
+ max_total_findings,
541
+ } = record
542
+ if (target !== undefined && (typeof target !== "string" || target === "")) {
543
+ return lintFailure("E_INPUT", '"path" must be a non-empty string')
544
+ }
545
+ for (const [key, value] of [
546
+ ["max_lines", max_lines],
547
+ ["max_tokens", max_tokens],
548
+ ["max_words", max_words],
549
+ ["max_file_bytes", max_file_bytes],
550
+ ["max_files", max_files],
551
+ ["max_findings", max_findings],
552
+ ["max_total_findings", max_total_findings],
553
+ ] as const) {
554
+ if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) {
555
+ return lintFailure("E_INPUT", `"${key}" must be a positive number`)
556
+ }
557
+ }
558
+ // The host may call an executor without a context (`execute(input, {})`), and
559
+ // some builds pass no signal at all: fall back to a local controller instead
560
+ // of throwing on `undefined.signal`.
561
+ const controller = new AbortController()
562
+ const signal = toolContext?.signal ?? controller.signal
563
+ if (signal.aborted) return lintFailure("E_ABORTED", "context lint aborted")
564
+ const outcome = await lintContext({
565
+ root: directory,
566
+ ...(target === undefined ? {} : { target: target as string }),
567
+ ...(max_lines === undefined ? {} : { maxLines: max_lines as number }),
568
+ ...(max_tokens === undefined ? {} : { maxTokens: max_tokens as number }),
569
+ ...(max_words === undefined ? {} : { maxWords: max_words as number }),
570
+ ...(max_file_bytes === undefined ? {} : { maxFileBytes: max_file_bytes as number }),
571
+ ...(max_files === undefined ? {} : { maxFiles: max_files as number }),
572
+ ...(max_findings === undefined ? {} : { maxFindingsPerFile: max_findings as number }),
573
+ ...(max_total_findings === undefined ? {} : { maxTotalFindings: max_total_findings as number }),
574
+ signal,
575
+ })
576
+ if (!outcome.ok) {
577
+ const extra: Record<string, unknown> = {}
578
+ if (outcome.path !== undefined) extra.path = outcome.path
579
+ if (outcome.bytes !== undefined) extra.bytes = outcome.bytes
580
+ if (outcome.files !== undefined) extra.files = outcome.files
581
+ if (outcome.max !== undefined) extra.max = outcome.max
582
+ return lintFailure(outcome.code, outcome.message, extra)
583
+ }
584
+ return json(outcome)
585
+ }
586
+
440
587
  const sessionsTool = async (
441
588
  input: unknown,
442
589
  toolContext: { readonly signal: AbortSignal; readonly sessionID: string },
@@ -537,18 +684,124 @@ export async function registerAuditTools(
537
684
  } catch (error) {
538
685
  const message = error instanceof Error ? error.message : String(error)
539
686
  if (message === "E_ABORTED" || toolContext.signal.aborted) return failure("E_ABORTED", "listing aborted")
540
- if (getFn !== undefined && /not ?found|unknown/i.test(message)) {
687
+ if (getFn !== undefined && isNotFoundError(error)) {
541
688
  return failure("E_NOT_FOUND", `session ${parentID} was not found: ${message}`, { sessionID: parentID })
542
689
  }
543
690
  return failure("E_INTERNAL", message, { sessionID: parentID })
544
691
  }
545
692
  }
546
693
 
694
+ const sessionStatsTool = async (
695
+ input: unknown,
696
+ toolContext: { readonly signal: AbortSignal; readonly sessionID: string },
697
+ ): Promise<{ content: string }> => {
698
+ const record = input === undefined ? {} : asRecord(input)
699
+ if (record === undefined) return failure("E_INPUT", "input must be an object ({sessionID?})")
700
+ const { sessionID } = record
701
+ if (sessionID !== undefined && (typeof sessionID !== "string" || sessionID === "")) {
702
+ return failure("E_INPUT", '"sessionID" must be a non-empty string')
703
+ }
704
+ if (toolContext.signal.aborted) return failure("E_ABORTED", "report aborted")
705
+ const target = (sessionID as string | undefined) ?? toolContext.sessionID
706
+ const budget = options.sessionBudget
707
+ if (budget === undefined) {
708
+ return failure("E_UNAVAILABLE", "the session guard is not registered in this plugin instance", {
709
+ sessionID: target,
710
+ })
711
+ }
712
+ if (!budget.available) {
713
+ return failure("E_UNAVAILABLE", "the session guard is unavailable: its hooks could not be registered", {
714
+ sessionID: target,
715
+ })
716
+ }
717
+ const tracked = budget.tracked(target)
718
+ const counters = budget.stats(target)
719
+ const { session: sessionConfig, toolOutput } = budget.config
720
+ const guard = budget.guard()
721
+ const getFn = sessionMethod<SessionGetFn>(session, "get")
722
+ if (getFn === undefined && !tracked) {
723
+ return failure(
724
+ "E_NO_API",
725
+ "this OpenCode build exposes no session.get() and the session is not tracked by this plugin instance",
726
+ { sessionID: target },
727
+ )
728
+ }
729
+ let info: Record<string, unknown> | undefined
730
+ let getError: unknown
731
+ let getMissing = false
732
+ if (getFn !== undefined) {
733
+ try {
734
+ const fetched: SessionInfoLike | null | undefined = await getFn({ sessionID: target })
735
+ if (fetched === undefined || fetched === null) getMissing = true
736
+ else info = sessionSummary(fetched)
737
+ } catch (error) {
738
+ getError = error
739
+ }
740
+ }
741
+ // A session this instance never observed is only "not found" when the store
742
+ // says so; a transient error (lock, permission, transport) stays `E_INTERNAL`
743
+ // instead of masquerading as a missing session. A known session with zero
744
+ // requests is a valid `tracked:false` report. `session.get` returning
745
+ // `undefined` without an error is the store's "no such session" answer.
746
+ if (info === undefined && !tracked) {
747
+ if (getError !== undefined) {
748
+ const message = errorMessage(getError)
749
+ if (isNotFoundError(getError)) {
750
+ return failure("E_NOT_FOUND", `session ${target} was not found: ${message}`, { sessionID: target })
751
+ }
752
+ return failure("E_INTERNAL", message, { sessionID: target })
753
+ }
754
+ if (getMissing) {
755
+ return failure("E_NOT_FOUND", `session ${target} was not found`, { sessionID: target })
756
+ }
757
+ }
758
+ return json({
759
+ ok: true,
760
+ sessionID: target,
761
+ counters,
762
+ limits: {
763
+ promptTokensSoft: sessionConfig.promptTokensSoft,
764
+ maxCompactions: sessionConfig.maxCompactions,
765
+ maxToolOutputChars: toolOutput.maxChars,
766
+ },
767
+ overBudget: counters.promptTokensMax > sessionConfig.promptTokensSoft,
768
+ compactionLimitReached: compactionLimitReached(budget.config, counters.compactions),
769
+ toolsPerTurn: counters.requests > 0 ? Math.round((counters.toolCalls / counters.requests) * 100) / 100 : 0,
770
+ tracked,
771
+ guard,
772
+ ...(getFn === undefined
773
+ ? { session: null, limitations: ["session.get is unavailable in this build"] }
774
+ : info === undefined
775
+ ? {}
776
+ : { session: info }),
777
+ })
778
+ }
779
+
547
780
  await context.tool.transform((editor) => {
548
781
  editor.namespace({
549
782
  name: TOOL_NAMESPACE,
550
783
  description:
551
- "Deterministic, read-only tools of the orchestrator agent kit (SGR validation, state snapshots, session accounting).",
784
+ "Deterministic, read-only tools of the orchestrator agent kit (SGR validation, context-file lint, state snapshots, session accounting).",
785
+ })
786
+ editor.add({
787
+ name: "context_lint",
788
+ description:
789
+ "Deterministic, read-only audit of AGENTS.md/SPEC.md/CLAUDE.md: line/token/word budget, distinct vague phrases, duplicate lines, " +
790
+ "broken relative links, placeholders, dates in the first three lines, required sections, AC test columns. " +
791
+ "A directory target is scanned to depth 3 and capped at max_files (the walk stops at the first file above the limit); " +
792
+ "a scan started at the root also picks up fixture trees such as eval/ (pass an explicit path for the canonical files); " +
793
+ "undocumented directories below depth 3, unclosed fences and links that resolve outside the workspace root are reported " +
794
+ "as INFO findings and never checked silently. Commands count only inside code, a Task table or a Commands section; " +
795
+ "tokens are estimated as Cyrillic/3 + the rest/4. " +
796
+ "Returns JSON {schema, ok, verdict, closing_list, files, summary, truncated?}; each file carries lines, words, chars " +
797
+ "and tokens_est. A per-file cap drops findings and sets truncated=true, max_total_findings keeps MAJOR first and turns " +
798
+ "the MAJOR overflow into one MAJOR truncated finding in closing_list. A file target is capped by max_file_bytes, " +
799
+ 'also while it is read. A failure is JSON {schema:"context-lint-failure/v1", ok:false, code, message, path?, bytes?, ' +
800
+ "files?, max?} with a typed code " +
801
+ "(E_INPUT|E_NOT_FOUND|E_NO_FILES|E_IO|E_ABORTED|E_LIMIT_FILE|E_LIMIT_FILES|E_OUT_OF_ROOT).",
802
+ input: CONTEXT_LINT_INPUT,
803
+ options: { namespace: TOOL_NAMESPACE, codemode: true },
804
+ execute: contextLintTool,
552
805
  })
553
806
  editor.add({
554
807
  name: "validate_report",
@@ -578,6 +831,20 @@ export async function registerAuditTools(
578
831
  options: { namespace: TOOL_NAMESPACE, codemode: true },
579
832
  execute: sessionsTool,
580
833
  })
834
+ editor.add({
835
+ name: "session_stats",
836
+ description:
837
+ "Session protocol counters of a session: requests, estimated prompt tokens (last/max), budget band, confirmed " +
838
+ "compactions and compaction attempts, failed compactions, the live compaction source, reminders injected, tool " +
839
+ "calls and capped tool outputs (totals and per normalized tool name), chars kept out of context, plus the " +
840
+ "configured limits and toolsPerTurn. Read-only; counters belong to this plugin instance. `sessionID` may name any " +
841
+ "session on this server, not only a child of the caller. Returns JSON {ok, sessionID, counters, limits, overBudget, " +
842
+ "compactionLimitReached, toolsPerTurn, tracked, guard, session?, limitations?} or a typed code " +
843
+ "(E_INPUT|E_NOT_FOUND|E_INTERNAL|E_NO_API|E_UNAVAILABLE|E_ABORTED).",
844
+ input: SESSION_STATS_INPUT,
845
+ options: { namespace: TOOL_NAMESPACE, codemode: true },
846
+ execute: sessionStatsTool,
847
+ })
581
848
  })
582
849
 
583
850
  return () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-orchestrator-uni",
4
- "version": "0.3.1",
4
+ "version": "0.5.0",
5
5
  "description": "Native OpenCode V2 plugin that registers the orchestrator agent kit (root orchestrator, audit subagents, fix-runner) and exposes the kit's process documents as the audit-kit reference.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -0,0 +1,29 @@
1
+ {
2
+ "id": "context-lint-failure/v1",
3
+ "schema": {
4
+ "type": "object",
5
+ "required": ["schema", "ok", "code", "message"],
6
+ "properties": {
7
+ "schema": { "type": "string", "const": "context-lint-failure/v1" },
8
+ "ok": { "type": "boolean", "const": false },
9
+ "code": {
10
+ "type": "string",
11
+ "enum": [
12
+ "E_INPUT",
13
+ "E_NOT_FOUND",
14
+ "E_NO_FILES",
15
+ "E_IO",
16
+ "E_ABORTED",
17
+ "E_LIMIT_FILE",
18
+ "E_LIMIT_FILES",
19
+ "E_OUT_OF_ROOT"
20
+ ]
21
+ },
22
+ "message": { "type": "string" },
23
+ "path": { "type": "string" },
24
+ "bytes": { "type": "number" },
25
+ "files": { "type": "number" },
26
+ "max": { "type": "number" }
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "id": "context-lint/v1",
3
+ "schema": {
4
+ "type": "object",
5
+ "required": ["schema", "verdict", "closing_list", "files", "summary"],
6
+ "properties": {
7
+ "schema": { "type": "string", "const": "context-lint/v1" },
8
+ "ok": { "type": "boolean" },
9
+ "verdict": { "type": "string", "enum": ["pass", "fail"] },
10
+ "closing_list": { "type": "array", "items": { "type": "string" } },
11
+ "truncated": { "type": "boolean" },
12
+ "files": {
13
+ "type": "array",
14
+ "items": {
15
+ "type": "object",
16
+ "required": ["path", "kind", "lines", "words", "tokens_est", "findings"],
17
+ "properties": {
18
+ "path": { "type": "string" },
19
+ "kind": { "type": "string", "enum": ["agents", "spec", "claude", "other"] },
20
+ "lines": { "type": "number" },
21
+ "words": { "type": "number" },
22
+ "chars": { "type": "number" },
23
+ "tokens_est": { "type": "number" },
24
+ "truncated": { "type": "boolean" },
25
+ "findings": {
26
+ "type": "array",
27
+ "items": {
28
+ "type": "object",
29
+ "required": ["id", "severity", "kind", "evidence", "detail", "fix"],
30
+ "properties": {
31
+ "id": { "type": "string" },
32
+ "severity": { "type": "string", "enum": ["MAJOR", "MINOR", "INFO"] },
33
+ "kind": {
34
+ "type": "string",
35
+ "enum": [
36
+ "budget",
37
+ "vague",
38
+ "duplicate",
39
+ "broken_link",
40
+ "missing_section",
41
+ "conflict",
42
+ "unverified_command",
43
+ "other"
44
+ ]
45
+ },
46
+ "evidence": { "type": "string" },
47
+ "detail": { "type": "string" },
48
+ "fix": { "type": "string" }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ },
55
+ "summary": { "type": "string" }
56
+ }
57
+ }
58
+ }