opencode-orchestrator-uni 0.3.0 → 0.4.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.
@@ -352,6 +352,42 @@ export function reportWarnings(schemaId: string, value: unknown): string[] {
352
352
  }
353
353
  }
354
354
 
355
+ if (schemaId === "context-lint/v1") {
356
+ const files = list("files").filter(isPlainObject)
357
+ const findings = files.flatMap((file) => (Array.isArray(file.findings) ? file.findings.filter(isPlainObject) : []))
358
+ const majorIds = findings
359
+ .filter((finding) => finding.severity === "MAJOR")
360
+ .map((finding) => finding.id)
361
+ .filter((id): id is string => typeof id === "string")
362
+ const closing = strings("closing_list")
363
+ // The verdict is a function of the findings, and `closing_list` is the set of
364
+ // MAJOR ids: a report that claims `pass` over its own MAJOR finding, or that
365
+ // lists something else, is a report whose closing_list cannot be trusted.
366
+ if (value.verdict === "pass" && majorIds.length > 0) {
367
+ warnings.push("verdict=pass with a MAJOR finding: verdict must be fail")
368
+ }
369
+ // Set equality, not just containment: a repeated id hides a missing one.
370
+ const uniqueClosing = new Set(closing)
371
+ if (
372
+ uniqueClosing.size !== closing.length ||
373
+ closing.length !== majorIds.length ||
374
+ closing.some((id) => !majorIds.includes(id))
375
+ ) {
376
+ warnings.push(`closing_list must equal the MAJOR finding ids (${majorIds.join(", ") || "none"})`)
377
+ }
378
+ if (value.verdict === "pass" && files.length === 0) {
379
+ warnings.push("verdict=pass with files=[]: no file was measured")
380
+ }
381
+ if (value.truncated === true) {
382
+ warnings.push(
383
+ "truncated=true: the finding list is capped; MAJOR findings are prioritized and a dropped MAJOR leaves a MAJOR marker, MINOR/INFO may be missing",
384
+ )
385
+ if (value.verdict === "pass") {
386
+ warnings.push("truncated=true with verdict=pass: the capped list may hide a later finding; re-run after fixes")
387
+ }
388
+ }
389
+ }
390
+
355
391
  return warnings
356
392
  }
357
393
 
package/lib/tools.ts CHANGED
@@ -1,12 +1,16 @@
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 four tools in the `orch_uni` namespace
5
+ * (effective names `orch_uni_validate_report`, `orch_uni_context_lint`,
6
+ * `orch_uni_snapshot`, `orch_uni_sessions`) and returns a cleanup that stops the
7
+ * 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),
@@ -14,7 +18,7 @@
14
18
  *
15
19
  * Every tool answers with a JSON text body. Failures are typed (`E_*`) codes in
16
20
  * 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
21
+ * All four are read-only: they never write to the workspace or the config; the
18
22
  * only state `sessions` keeps is a plugin-scoped session index in `ctx.storage`
19
23
  * (see the `sessions` tool notes below).
20
24
  */
@@ -22,6 +26,7 @@ import { readdir, readFile } from "node:fs/promises"
22
26
  import path from "node:path"
23
27
  import { fileURLToPath } from "node:url"
24
28
  import type { Plugin } from "@opencode/plugin"
29
+ import { lintContext } from "./context-lint"
25
30
  import { checkReport, type JsonSchemaNode, type ValidationCode } from "./report-schema"
26
31
  import { DEFAULT_EXCLUDE, isSnapshotFailure, snapshotTree } from "./snapshot"
27
32
 
@@ -29,7 +34,12 @@ import { DEFAULT_EXCLUDE, isSnapshotFailure, snapshotTree } from "./snapshot"
29
34
  export const TOOL_NAMESPACE = "orch_uni"
30
35
 
31
36
  /** 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"]
37
+ export const TOOL_IDS: readonly string[] = [
38
+ "orch_uni_validate_report",
39
+ "orch_uni_context_lint",
40
+ "orch_uni_snapshot",
41
+ "orch_uni_sessions",
42
+ ]
33
43
 
34
44
  /** Directory holding the shipped report schemas (`schemas/*.json`). */
35
45
  export const SCHEMAS_DIRECTORY = fileURLToPath(new URL("../schemas/", import.meta.url))
@@ -144,6 +154,36 @@ const VALIDATE_REPORT_INPUT = {
144
154
  },
145
155
  } as const
146
156
 
157
+ /** `context_lint` input contract. */
158
+ const CONTEXT_LINT_INPUT = {
159
+ type: "object",
160
+ properties: {
161
+ path: {
162
+ type: "string",
163
+ description:
164
+ "File or directory to inspect, absolute or relative to the location directory. Default: the location directory.",
165
+ },
166
+ max_lines: { type: "number", description: "AGENTS/CLAUDE line budget. Default: 200." },
167
+ max_tokens: { type: "number", description: "AGENTS/CLAUDE estimated token budget. Default: 2500." },
168
+ max_words: {
169
+ type: "number",
170
+ description: "SPEC word budget. Default: 1500 (the authoring target); the documented ceiling is 2000.",
171
+ },
172
+ max_file_bytes: { type: "number", description: "Largest file that may be read, in bytes. Default: 2000000." },
173
+ max_files: { type: "number", description: "Most files a directory target may expand to. Default: 500." },
174
+ max_findings: {
175
+ type: "number",
176
+ description:
177
+ "Per-file cap: MINOR/INFO findings kept, and the hard cap for MAJOR (excess becomes one MAJOR truncated finding). Default: 100.",
178
+ },
179
+ max_total_findings: {
180
+ type: "number",
181
+ description:
182
+ "Global cap for all findings; MAJOR are kept first, the MAJOR overflow becomes one MAJOR truncated marker in closing_list. Default: 1000.",
183
+ },
184
+ },
185
+ } as const
186
+
147
187
  /** `snapshot` input contract. */
148
188
  const SNAPSHOT_INPUT = {
149
189
  type: "object",
@@ -437,6 +477,75 @@ export async function registerAuditTools(
437
477
  return json({ root: outcome.root, files: outcome.files, aggregate: outcome.aggregate })
438
478
  }
439
479
 
480
+ const contextLintTool = async (
481
+ input: unknown,
482
+ toolContext: { readonly signal?: AbortSignal } | undefined,
483
+ ): Promise<{ content: string }> => {
484
+ // A failure is not a report: it carries the `context-lint-failure/v1` id so it
485
+ // validates against that schema and never passes as a `context-lint/v1` report.
486
+ const lintFailure = (code: string, message: string, extra: Record<string, unknown> = {}) =>
487
+ failure(code, message, { schema: "context-lint-failure/v1", ...extra })
488
+ const record = input === undefined ? {} : asRecord(input)
489
+ if (record === undefined)
490
+ return lintFailure(
491
+ "E_INPUT",
492
+ "input must be an object ({path?, max_lines?, max_tokens?, max_words?, max_file_bytes?, max_files?, max_findings?, max_total_findings?})",
493
+ )
494
+ const {
495
+ path: target,
496
+ max_lines,
497
+ max_tokens,
498
+ max_words,
499
+ max_file_bytes,
500
+ max_files,
501
+ max_findings,
502
+ max_total_findings,
503
+ } = record
504
+ if (target !== undefined && (typeof target !== "string" || target === "")) {
505
+ return lintFailure("E_INPUT", '"path" must be a non-empty string')
506
+ }
507
+ for (const [key, value] of [
508
+ ["max_lines", max_lines],
509
+ ["max_tokens", max_tokens],
510
+ ["max_words", max_words],
511
+ ["max_file_bytes", max_file_bytes],
512
+ ["max_files", max_files],
513
+ ["max_findings", max_findings],
514
+ ["max_total_findings", max_total_findings],
515
+ ] as const) {
516
+ if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) {
517
+ return lintFailure("E_INPUT", `"${key}" must be a positive number`)
518
+ }
519
+ }
520
+ // The host may call an executor without a context (`execute(input, {})`), and
521
+ // some builds pass no signal at all: fall back to a local controller instead
522
+ // of throwing on `undefined.signal`.
523
+ const controller = new AbortController()
524
+ const signal = toolContext?.signal ?? controller.signal
525
+ if (signal.aborted) return lintFailure("E_ABORTED", "context lint aborted")
526
+ const outcome = await lintContext({
527
+ root: directory,
528
+ ...(target === undefined ? {} : { target: target as string }),
529
+ ...(max_lines === undefined ? {} : { maxLines: max_lines as number }),
530
+ ...(max_tokens === undefined ? {} : { maxTokens: max_tokens as number }),
531
+ ...(max_words === undefined ? {} : { maxWords: max_words as number }),
532
+ ...(max_file_bytes === undefined ? {} : { maxFileBytes: max_file_bytes as number }),
533
+ ...(max_files === undefined ? {} : { maxFiles: max_files as number }),
534
+ ...(max_findings === undefined ? {} : { maxFindingsPerFile: max_findings as number }),
535
+ ...(max_total_findings === undefined ? {} : { maxTotalFindings: max_total_findings as number }),
536
+ signal,
537
+ })
538
+ if (!outcome.ok) {
539
+ const extra: Record<string, unknown> = {}
540
+ if (outcome.path !== undefined) extra.path = outcome.path
541
+ if (outcome.bytes !== undefined) extra.bytes = outcome.bytes
542
+ if (outcome.files !== undefined) extra.files = outcome.files
543
+ if (outcome.max !== undefined) extra.max = outcome.max
544
+ return lintFailure(outcome.code, outcome.message, extra)
545
+ }
546
+ return json(outcome)
547
+ }
548
+
440
549
  const sessionsTool = async (
441
550
  input: unknown,
442
551
  toolContext: { readonly signal: AbortSignal; readonly sessionID: string },
@@ -548,7 +657,27 @@ export async function registerAuditTools(
548
657
  editor.namespace({
549
658
  name: TOOL_NAMESPACE,
550
659
  description:
551
- "Deterministic, read-only tools of the orchestrator agent kit (SGR validation, state snapshots, session accounting).",
660
+ "Deterministic, read-only tools of the orchestrator agent kit (SGR validation, context-file lint, state snapshots, session accounting).",
661
+ })
662
+ editor.add({
663
+ name: "context_lint",
664
+ description:
665
+ "Deterministic, read-only audit of AGENTS.md/SPEC.md/CLAUDE.md: line/token/word budget, distinct vague phrases, duplicate lines, " +
666
+ "broken relative links, placeholders, dates in the first three lines, required sections, AC test columns. " +
667
+ "A directory target is scanned to depth 3 and capped at max_files (the walk stops at the first file above the limit); " +
668
+ "a scan started at the root also picks up fixture trees such as eval/ (pass an explicit path for the canonical files); " +
669
+ "undocumented directories below depth 3, unclosed fences and links that resolve outside the workspace root are reported " +
670
+ "as INFO findings and never checked silently. Commands count only inside code, a Task table or a Commands section; " +
671
+ "tokens are estimated as Cyrillic/3 + the rest/4. " +
672
+ "Returns JSON {schema, ok, verdict, closing_list, files, summary, truncated?}; each file carries lines, words, chars " +
673
+ "and tokens_est. A per-file cap drops findings and sets truncated=true, max_total_findings keeps MAJOR first and turns " +
674
+ "the MAJOR overflow into one MAJOR truncated finding in closing_list. A file target is capped by max_file_bytes, " +
675
+ 'also while it is read. A failure is JSON {schema:"context-lint-failure/v1", ok:false, code, message, path?, bytes?, ' +
676
+ "files?, max?} with a typed code " +
677
+ "(E_INPUT|E_NOT_FOUND|E_NO_FILES|E_IO|E_ABORTED|E_LIMIT_FILE|E_LIMIT_FILES|E_OUT_OF_ROOT).",
678
+ input: CONTEXT_LINT_INPUT,
679
+ options: { namespace: TOOL_NAMESPACE, codemode: true },
680
+ execute: contextLintTool,
552
681
  })
553
682
  editor.add({
554
683
  name: "validate_report",
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.0",
4
+ "version": "0.4.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
+ }