@a2f0/agent-tool 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +288 -0
  2. package/package.json +36 -0
  3. package/skills/cross-agent-review/SKILL.md +46 -0
  4. package/skills/open-pr/SKILL.md +31 -0
  5. package/skills/reset/SKILL.md +25 -0
  6. package/skills/ship-pr/SKILL.md +48 -0
  7. package/skills/squash-merge/SKILL.md +39 -0
  8. package/src/cli.ts +121 -0
  9. package/src/config.ts +101 -0
  10. package/src/doctor.ts +23 -0
  11. package/src/git/currentBranchPr.ts +114 -0
  12. package/src/git/environment.ts +10 -0
  13. package/src/git/prContext.ts +497 -0
  14. package/src/harnesses.ts +53 -0
  15. package/src/index.ts +9 -0
  16. package/src/pr/assertBranchPushed.ts +21 -0
  17. package/src/pr/assertNoClaudeBranding.ts +52 -0
  18. package/src/pr/baseFreshness.ts +67 -0
  19. package/src/pr/mergeChecks.ts +90 -0
  20. package/src/pr/openPr.ts +80 -0
  21. package/src/pr/prNumberSuffix.ts +43 -0
  22. package/src/pr/squashMerge.ts +229 -0
  23. package/src/pr/subjectLine.ts +21 -0
  24. package/src/pr/validateCommitSubject.ts +12 -0
  25. package/src/review/destinationPaths.ts +154 -0
  26. package/src/review/executable.ts +34 -0
  27. package/src/review/pinnedReviewInput.ts +91 -0
  28. package/src/review/rawGitTree.ts +301 -0
  29. package/src/review/reviewEffort.ts +54 -0
  30. package/src/review/reviewOutput.ts +62 -0
  31. package/src/review/reviewPrompt.ts +104 -0
  32. package/src/review/reviewSnapshot.ts +67 -0
  33. package/src/review/runReview.ts +62 -0
  34. package/src/review/solicitClaudeCodeReview.ts +140 -0
  35. package/src/review/solicitCodexReview.ts +205 -0
  36. package/src/review/solicitOpencodeReview.ts +233 -0
  37. package/src/runAgentToolAction.ts +100 -0
  38. package/src/skills/bundled.ts +8 -0
  39. package/src/skills/install.ts +92 -0
  40. package/src/version/bumpVersions.ts +196 -0
  41. package/src/version/lockfileVersions.ts +43 -0
  42. package/src/version/packageVersion.ts +112 -0
  43. package/src/version/prepareVersions.ts +135 -0
  44. package/src/version/resolveVersionConflicts.ts +126 -0
  45. package/src/version/versionPreparationGit.ts +127 -0
@@ -0,0 +1,62 @@
1
+ import { writeSync } from "node:fs";
2
+
3
+ import { reviewOutputProblem } from "./reviewOutput";
4
+
5
+ /** The environment a spawned reviewer runs with; it replaces, not extends. */
6
+ export type ReviewerEnv = Record<string, string | undefined>;
7
+
8
+ /** One reviewer-CLI run: its exit code and the review text it produced. */
9
+ interface ReviewAttempt {
10
+ readonly exitCode: number;
11
+ readonly review: string;
12
+ }
13
+
14
+ /**
15
+ * Attempts per review. Two, not more: the degenerate-output failure this
16
+ * guards — a reviewer exiting 0 having produced no verdict-signed review — is
17
+ * stochastic and rare, so one retry converts most flakes into reviews, while
18
+ * more would stack multi-minute runs behind a reviewer that is genuinely
19
+ * broken.
20
+ */
21
+ export const MAX_REVIEW_ATTEMPTS = 2;
22
+
23
+ /**
24
+ * Run a reviewer up to `MAX_REVIEW_ATTEMPTS` times, relay the review it
25
+ * settles on, and map the outcome to an exit code.
26
+ *
27
+ * A nonzero CLI exit is returned as-is with no retry — those failures (auth,
28
+ * credits, missing binary) are deterministic, and retrying stacks minutes on a
29
+ * lost cause. An exit-0 run whose output fails the verdict gate is retried;
30
+ * only the final attempt's output is relayed, so a caller never reads a
31
+ * discarded degenerate attempt as the review.
32
+ *
33
+ * The relay uses writeSync, not process.stdout.write: when stdout is a pipe —
34
+ * which is how every calling agent runs this — the async write queues, and
35
+ * `process.exit` in index.ts drops whatever has not drained. That silently
36
+ * truncated reviews at the 64 KiB pipe buffer, taking the trailing verdict
37
+ * line with them while the in-memory check below still saw a complete review
38
+ * and exited 0.
39
+ */
40
+ export function relayReviewWithRetry(
41
+ command: string,
42
+ runAttempt: () => ReviewAttempt,
43
+ ): number {
44
+ let last: ReviewAttempt = { exitCode: 1, review: "" };
45
+ for (let attempt = 1; attempt <= MAX_REVIEW_ATTEMPTS; attempt += 1) {
46
+ last = runAttempt();
47
+ if (last.exitCode !== 0) {
48
+ writeSync(1, last.review);
49
+ return last.exitCode;
50
+ }
51
+ const problem = reviewOutputProblem(last.review);
52
+ if (problem === null) {
53
+ writeSync(1, last.review);
54
+ return 0;
55
+ }
56
+ process.stderr.write(
57
+ `${command} exited 0 but produced no usable review (attempt ${attempt}/${MAX_REVIEW_ATTEMPTS}): ${problem}\n`,
58
+ );
59
+ }
60
+ writeSync(1, last.review);
61
+ return 1;
62
+ }
@@ -0,0 +1,140 @@
1
+ import { reviewExecutable } from "./executable";
2
+ import { loadConfig } from "../config";
3
+ import { spawnSync } from "node:child_process";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ MAX_BUFFER_BYTES,
8
+ resolveReviewContext,
9
+ spawnExitCode,
10
+ } from "../git/prContext";
11
+ import { withPinnedReviewInput } from "./pinnedReviewInput";
12
+ import {
13
+ DEFAULT_CLAUDE_EFFORT,
14
+ type ReviewEffort,
15
+ resolveReviewEffort,
16
+ } from "./reviewEffort";
17
+ import { buildReviewPrompt, CLAUDE_ACCESS_NOTE } from "./reviewPrompt";
18
+ import { type ReviewerEnv, relayReviewWithRetry } from "./runReview";
19
+
20
+ /**
21
+ * Tools the reviewer gets. Read-only, and deliberately not empty: the diff alone
22
+ * does not show whether a changed line is safe, so the good findings come from
23
+ * reading around it — an unchanged branch further up the file, the source-shape
24
+ * baseline a file has to stay under, the callers a signature change breaks.
25
+ *
26
+ * `Bash` is withheld for two reasons, neither of which is reliability. A review
27
+ * needs no shell, so every Bash call is a turn spent earning a denial (`--print`
28
+ * denies what would need approval, hands the denial back, and carries on). And
29
+ * the session's context is a PR diff — attacker-influenceable text on a public
30
+ * repo — which is not something to hand a shell.
31
+ *
32
+ * `--safe-mode` also disables project/user instructions, skills, plugins,
33
+ * hooks, and MCP servers so the branch cannot expand the reviewer's authority.
34
+ * Browser integration and session persistence are disabled explicitly too.
35
+ * `dontAsk` fails closed on unapproved access, while the absolute Read rule
36
+ * confines file inspection to the immutable snapshot.
37
+ */
38
+ const REVIEW_TOOLS = ["Read", "Grep", "Glob"] as const;
39
+
40
+ function absoluteClaudeReadRule(snapshotRoot: string): string {
41
+ const normalized = path.resolve(snapshotRoot).replaceAll("\\", "/");
42
+ return `Read(//${normalized.replace(/^\/+/, "")}/**)`;
43
+ }
44
+
45
+ /**
46
+ * Build the `claude` argv for a non-interactive review at the given effort
47
+ * level. The prompt itself goes over stdin, not argv.
48
+ */
49
+ export function buildClaudeReviewArgs(
50
+ effort: ReviewEffort,
51
+ snapshotRoot: string,
52
+ model?: string | null,
53
+ ): string[] {
54
+ return [
55
+ ...(model ? ["--model", model] : []),
56
+ "--effort",
57
+ effort,
58
+ "--print",
59
+ "--safe-mode",
60
+ "--no-chrome",
61
+ "--no-session-persistence",
62
+ "--permission-mode",
63
+ "dontAsk",
64
+ "--tools",
65
+ REVIEW_TOOLS.join(","),
66
+ "--allowedTools",
67
+ absoluteClaudeReadRule(snapshotRoot),
68
+ ];
69
+ }
70
+
71
+ /**
72
+ * Run `claude` over an already-built review prompt and relay whatever it says.
73
+ *
74
+ * Returns nonzero both when the CLI fails outright and when it exits 0 having
75
+ * produced something that is not a review — after one retry of the latter, since
76
+ * that failure is stochastic — so a caller's fallback chain treats a degenerate
77
+ * review the same as a crashed one.
78
+ */
79
+ export function spawnClaudeReview(
80
+ prompt: string,
81
+ effort: ReviewEffort,
82
+ snapshotRoot: string,
83
+ env: ReviewerEnv = process.env,
84
+ ): number {
85
+ const executable = reviewExecutable("claude", env);
86
+ if (executable === undefined) {
87
+ process.stderr.write("Claude executable unavailable outside the repository.\n");
88
+ return 1;
89
+ }
90
+ return relayReviewWithRetry("claude", () => {
91
+ // Captured rather than inherited: a review has to be read to be judged, and
92
+ // `claude` exits 0 whether it reviewed the diff or merely said it would.
93
+ // `--print` emits the review in one final block, so nothing streams anyway.
94
+ const result = spawnSync(
95
+ executable,
96
+ buildClaudeReviewArgs(effort, snapshotRoot, loadConfig().review.claudeModel),
97
+ {
98
+ stdio: ["pipe", "pipe", "inherit"],
99
+ input: prompt,
100
+ encoding: "utf8",
101
+ maxBuffer: MAX_BUFFER_BYTES,
102
+ timeout: loadConfig().review.timeoutMs,
103
+ cwd: snapshotRoot,
104
+ // Passed explicitly so `claude` resolves against this PATH rather than
105
+ // the one the runtime snapshotted at startup.
106
+ env,
107
+ },
108
+ );
109
+ return {
110
+ exitCode: spawnExitCode("claude", result),
111
+ review: result.stdout ?? "",
112
+ };
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Ask the local `claude` CLI to review the current branch's diff. Branch/PR/base
118
+ * are derived from git + GitHub; when the branch has no PR yet the diff is taken
119
+ * against the default branch, so a review can run before the PR is opened. The
120
+ * prompt is streamed via stdin (not argv) to avoid "Argument list too long"
121
+ * failures on large PRs. The effort level defaults to `xhigh` for Claude.
122
+ */
123
+ export function solicitClaudeCodeReview(
124
+ rootDir: string,
125
+ effortArg?: string,
126
+ ): number {
127
+ const effort = resolveReviewEffort(effortArg, DEFAULT_CLAUDE_EFFORT);
128
+ const context = resolveReviewContext();
129
+ return withPinnedReviewInput(rootDir, context, (input) => {
130
+ const prompt = buildReviewPrompt({
131
+ context,
132
+ diff: input.diff,
133
+ reviewInstructions: input.reviewInstructions,
134
+ accessNote: CLAUDE_ACCESS_NOTE,
135
+ repositoryRoot: input.snapshotRoot,
136
+ });
137
+
138
+ return spawnClaudeReview(prompt, effort, input.snapshotRoot);
139
+ });
140
+ }
@@ -0,0 +1,205 @@
1
+ import { reviewExecutable } from "./executable";
2
+ import { loadConfig } from "../config";
3
+ import { spawnSync } from "node:child_process";
4
+ import {
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import path from "node:path";
11
+
12
+ import {
13
+ MAX_BUFFER_BYTES,
14
+ resolveReviewContext,
15
+ spawnExitCode,
16
+ } from "../git/prContext";
17
+ import { withPinnedReviewInput } from "./pinnedReviewInput";
18
+ import {
19
+ DEFAULT_CODEX_EFFORT,
20
+ type ReviewEffort,
21
+ resolveReviewEffort,
22
+ } from "./reviewEffort";
23
+ import { buildReviewPrompt, CODEX_ACCESS_NOTE } from "./reviewPrompt";
24
+ import { type ReviewerEnv, relayReviewWithRetry } from "./runReview";
25
+
26
+ /** How much transcript tail to relay when a codex attempt fails outright. */
27
+ const TRANSCRIPT_TAIL_CHARS = 2000;
28
+
29
+ /**
30
+ * Build the `codex exec` argv. `exec`, not `review`: `codex review` writes its
31
+ * own prompt, so it carries no verdict line to gate on and interleaves its
32
+ * findings with an investigative transcript. `exec` takes this repo's prompt —
33
+ * the same verdict-gated one Claude reviews under — reading it from stdin
34
+ * (`-`) to dodge argv limits. A custom permission profile denies the host
35
+ * filesystem by default and exposes only the immutable review snapshot plus
36
+ * Codex's minimal runtime paths. The effort is pinned so the review never
37
+ * silently inherits `~/.codex/config.toml`, and the final message is written
38
+ * alone to `lastMessageFile`, so what gets relayed is the review, not the
39
+ * transcript.
40
+ *
41
+ * `--ignore-user-config` keeps the session hermetic. The sandbox confines only
42
+ * model-generated shell commands, so MCP servers configured in
43
+ * `~/.codex/config.toml` would still load and could mutate external state on
44
+ * behalf of that attacker-influenceable diff — and `-c mcp_servers={}` cannot
45
+ * remove them, since table overrides merge instead of replacing (verified
46
+ * against codex 0.145: `codex mcp list` is unchanged under that override).
47
+ * Ignoring the user config drops those servers wholesale; auth still works,
48
+ * and everything the review needs is pinned explicitly right here.
49
+ * `--disable plugins/hooks/apps` closes the remaining gaps: plugin-provided
50
+ * MCP servers, trusted hooks, and app connectors all live outside
51
+ * `config.toml`, so ignoring the config alone would leave them active. Hosted
52
+ * web search is disabled separately because it is not governed by the sandbox
53
+ * network permission.
54
+ * Codex also runs ephemerally from a temporary non-repository cwd so
55
+ * branch-controlled AGENTS.md files are not injected into its prompt. Model
56
+ * commands inherit no host environment, and strict config validation makes an
57
+ * unsupported permission profile fail closed.
58
+ */
59
+ export function buildCodexReviewArgs(
60
+ effort: ReviewEffort,
61
+ lastMessageFile: string,
62
+ snapshotRoot: string,
63
+ codexRuntimeRoot: string,
64
+ model?: string | null,
65
+ ): string[] {
66
+ const readableRoots = [...new Set([codexRuntimeRoot, snapshotRoot])];
67
+ const filesystemPermissions = [
68
+ '":root"="deny"',
69
+ '":minimal"="read"',
70
+ ...readableRoots.map((root) => `${JSON.stringify(root)}="read"`),
71
+ ];
72
+ return [
73
+ "exec",
74
+ ...(model ? ["--model", model] : []),
75
+ "--ignore-user-config",
76
+ "--ignore-rules",
77
+ "--strict-config",
78
+ "--ephemeral",
79
+ "--disable",
80
+ "plugins",
81
+ "--disable",
82
+ "hooks",
83
+ "--disable",
84
+ "apps",
85
+ "--skip-git-repo-check",
86
+ "-c",
87
+ `model_reasoning_effort="${effort}"`,
88
+ "-c",
89
+ 'web_search="disabled"',
90
+ "-c",
91
+ 'default_permissions="review-snapshot"',
92
+ "-c",
93
+ `permissions.review-snapshot.filesystem={${filesystemPermissions.join(",")}}`,
94
+ "-c",
95
+ "permissions.review-snapshot.network.enabled=false",
96
+ "-c",
97
+ 'shell_environment_policy.inherit="none"',
98
+ "--color",
99
+ "never",
100
+ "--output-last-message",
101
+ lastMessageFile,
102
+ "-",
103
+ ];
104
+ }
105
+
106
+ /** Content of the last-message file, or "" when codex never wrote one. */
107
+ function readLastMessage(lastMessageFile: string): string {
108
+ try {
109
+ return readFileSync(lastMessageFile, "utf8");
110
+ } catch {
111
+ return "";
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Run `codex exec` over an already-built review prompt and relay the final
117
+ * message it writes.
118
+ *
119
+ * The review is read from `--output-last-message`, not the process streams:
120
+ * codex prints its session transcript — including a verbatim echo of the
121
+ * prompt and diff — to stderr and the final message to stdout, and inheriting
122
+ * either would bury the verdict in noise (the exact failure this replaces).
123
+ * Both streams are captured; on a failed launch the stderr tail is relayed so
124
+ * the error is still diagnosable. Degenerate exit-0 output is retried once via
125
+ * the shared gate, same as the Claude path.
126
+ */
127
+ export function spawnCodexReview(
128
+ prompt: string,
129
+ effort: ReviewEffort,
130
+ snapshotRoot: string,
131
+ env: ReviewerEnv = process.env,
132
+ ): number {
133
+ const codexExecutable = reviewExecutable("codex", env);
134
+ if (codexExecutable === undefined) {
135
+ process.stderr.write("Codex executable unavailable outside the repository.\n");
136
+ return 1;
137
+ }
138
+ const outDir = mkdtempSync(path.join(tmpdir(), "agent-tool-codex-"));
139
+ const codexRuntimeRoot = path.dirname(codexExecutable);
140
+ let attempt = 0;
141
+ try {
142
+ return relayReviewWithRetry("codex", () => {
143
+ // A fresh file per attempt, so a retry that crashes before writing can
144
+ // never be read as the previous attempt's stale message.
145
+ attempt += 1;
146
+ const lastMessageFile = path.join(outDir, `review-${attempt}.md`);
147
+ const result = spawnSync(
148
+ codexExecutable,
149
+ buildCodexReviewArgs(
150
+ effort,
151
+ lastMessageFile,
152
+ snapshotRoot,
153
+ codexRuntimeRoot,
154
+ loadConfig().review.codexModel,
155
+ ),
156
+ {
157
+ stdio: ["pipe", "pipe", "pipe"],
158
+ input: prompt,
159
+ encoding: "utf8",
160
+ maxBuffer: MAX_BUFFER_BYTES,
161
+ timeout: loadConfig().review.timeoutMs,
162
+ // Codex loads project AGENTS.md from its cwd. Keep cwd neutral and
163
+ // grant read-only access to the reviewed repository explicitly.
164
+ cwd: outDir,
165
+ env,
166
+ },
167
+ );
168
+ const exitCode = spawnExitCode("codex", result);
169
+ const transcript = result.stderr ?? "";
170
+ if (exitCode !== 0 && transcript.length > 0) {
171
+ process.stderr.write(
172
+ `codex transcript (tail):\n${transcript.slice(-TRANSCRIPT_TAIL_CHARS)}\n`,
173
+ );
174
+ }
175
+ return { exitCode, review: readLastMessage(lastMessageFile) };
176
+ });
177
+ } finally {
178
+ rmSync(outDir, { recursive: true, force: true });
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Ask the local `codex` CLI to review the current branch's diff. Branch/PR/base
184
+ * are derived from git + GitHub; when the branch has no PR yet the diff is
185
+ * taken against the default branch, so a review can run before the PR is
186
+ * opened. The effort level defaults to `high` for Codex.
187
+ */
188
+ export function solicitCodexReview(
189
+ rootDir: string,
190
+ effortArg?: string,
191
+ ): number {
192
+ const effort = resolveReviewEffort(effortArg, DEFAULT_CODEX_EFFORT);
193
+ const context = resolveReviewContext();
194
+ return withPinnedReviewInput(rootDir, context, (input) => {
195
+ const prompt = buildReviewPrompt({
196
+ context,
197
+ diff: input.diff,
198
+ reviewInstructions: input.reviewInstructions,
199
+ accessNote: CODEX_ACCESS_NOTE,
200
+ repositoryRoot: input.snapshotRoot,
201
+ });
202
+
203
+ return spawnCodexReview(prompt, effort, input.snapshotRoot);
204
+ });
205
+ }
@@ -0,0 +1,233 @@
1
+ import { reviewExecutable } from "./executable";
2
+ import { loadConfig, DEFAULT_CONFIG } from "../config";
3
+ import { spawnSync } from "node:child_process";
4
+ import { mkdtempSync, realpathSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import path from "node:path";
7
+
8
+ import {
9
+ MAX_BUFFER_BYTES,
10
+ resolveReviewContext,
11
+ spawnExitCode,
12
+ } from "../git/prContext";
13
+ import { withPinnedReviewInput } from "./pinnedReviewInput";
14
+ import {
15
+ DEFAULT_OPENCODE_EFFORT,
16
+ type ReviewEffort,
17
+ resolveReviewEffort,
18
+ } from "./reviewEffort";
19
+ import { buildReviewPrompt, OPENCODE_ACCESS_NOTE } from "./reviewPrompt";
20
+ import { type ReviewerEnv, relayReviewWithRetry } from "./runReview";
21
+
22
+ /**
23
+ * The model every opencode review runs under. Pinned so the review never
24
+ * silently inherits whatever the ambient opencode config selects, and so the
25
+ * fallback works when the *other* agents are out of credits but this provider
26
+ * is not.
27
+ */
28
+ const OPENCODE_MODEL = DEFAULT_CONFIG.review.opencodeModel;
29
+
30
+ /**
31
+ * Name of the read-only reviewer agent defined in the inline config. A
32
+ * distinctive name keeps a same-named agent in the user's global config from
33
+ * being shadowed or shadowing this one; `OPENCODE_CONFIG_CONTENT` has the
34
+ * highest precedence among the non-managed config tiers anyway.
35
+ */
36
+ const OPENCODE_REVIEW_AGENT = "agent-tool-review";
37
+
38
+ /**
39
+ * Map the shared effort vocabulary to a `--variant` deepseek-v4-pro accepts.
40
+ * The skill's levels are `low | medium | high | xhigh | max`; this model's
41
+ * variants are `low | medium | high | max`, so `xhigh` collapses onto `max`.
42
+ */
43
+ export function resolveOpencodeVariant(
44
+ effort: ReviewEffort,
45
+ variants: Record<string, string> = DEFAULT_CONFIG.review.opencodeVariants,
46
+ ): string {
47
+ return variants[effort] ?? effort;
48
+ }
49
+
50
+ /**
51
+ * Inline opencode config (`OPENCODE_CONFIG_CONTENT`) that defines the reviewer
52
+ * agent and its confinement. This is the hermetic substitute for opencode
53
+ * having no per-run "safe mode": every tool is denied by default and only the
54
+ * read-only `read`/`glob`/`grep`/`list` tools are re-allowed, and reads
55
+ * outside its own (empty) cwd are gated by `external_directory` to the
56
+ * snapshot alone.
57
+ *
58
+ * The default deny matters because opencode's built-in ruleset starts from
59
+ * `"*": "allow"` and still loads the user's global config, so any MCP server
60
+ * defined there would otherwise hand the reviewer its tools. Opencode appends
61
+ * agent rules after its defaults and the last matching rule wins, so `"*"`
62
+ * must come first here for the specific allows after it to take effect. The
63
+ * named denies repeat the default for the tools that matter most.
64
+ *
65
+ * The snapshot path appears both as `mkdtempSync` wrote it and as `realpath`
66
+ * resolves it, because on macOS the temp dir is a symlink (`/var` →
67
+ * `/private/var`) and the model may pass either spelling; a denied spelling
68
+ * only costs the model a retry, but both spellings allowed costs nothing.
69
+ */
70
+ export function buildOpencodeInlineConfig(snapshotRoot: string, model = OPENCODE_MODEL): string {
71
+ const normalized = path.resolve(snapshotRoot).replaceAll("\\", "/");
72
+ const resolved = realpathSync(snapshotRoot).replaceAll("\\", "/");
73
+ const externalDirectory: Record<string, string> = { "*": "deny" };
74
+ for (const root of new Set([normalized, resolved])) {
75
+ externalDirectory[`${root}/**`] = "allow";
76
+ }
77
+ return JSON.stringify({
78
+ $schema: "https://opencode.ai/config.json",
79
+ agent: {
80
+ [OPENCODE_REVIEW_AGENT]: {
81
+ description:
82
+ "Read-only reviewer confined to the immutable repository snapshot.",
83
+ prompt:
84
+ "You are a read-only code reviewer. Use only the read, glob, and grep tools; never modify files, run commands, or fetch anything.",
85
+ model,
86
+ permission: {
87
+ "*": "deny",
88
+ read: "allow",
89
+ glob: "allow",
90
+ grep: "allow",
91
+ list: "allow",
92
+ edit: "deny",
93
+ bash: "deny",
94
+ question: "deny",
95
+ skill: "deny",
96
+ task: "deny",
97
+ webfetch: "deny",
98
+ websearch: "deny",
99
+ external_directory: externalDirectory,
100
+ },
101
+ },
102
+ },
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Build the `opencode run` argv for a non-interactive review. The prompt goes
108
+ * over stdin (opencode reads stdin when no message positional is given), so a
109
+ * large diff never hits argv limits. `--pure` drops external plugins, and the
110
+ * confinement itself lives in the inline config passed through the
111
+ * environment: opencode has no per-run flag for permissions, and
112
+ * `--dangerously-skip-permissions` would only *add* authority, never remove
113
+ * it.
114
+ */
115
+ export function buildOpencodeReviewArgs(variant: string, model = OPENCODE_MODEL): string[] {
116
+ return [
117
+ "run",
118
+ "--model",
119
+ model,
120
+ "--variant",
121
+ variant,
122
+ "--agent",
123
+ OPENCODE_REVIEW_AGENT,
124
+ "--pure",
125
+ ];
126
+ }
127
+
128
+ /**
129
+ * How much stderr tail to relay when an opencode attempt fails outright.
130
+ */
131
+ const TRANSCRIPT_TAIL_CHARS = 2000;
132
+
133
+ /**
134
+ * Run `opencode run` over an already-built review prompt and relay whatever it
135
+ * prints to stdout — the final message alone, since run mode keeps its TUI
136
+ * noise on stderr. Exit codes and the verdict gate behave exactly like the
137
+ * Claude and Codex paths: a nonzero CLI exit is returned as-is, and an exit-0
138
+ * run without a verdict-signed review is retried once before failing.
139
+ *
140
+ * Both streams are captured: opencode prints its session transcript — tool
141
+ * call echo, progress lines, and the like — to stderr, and inheriting it
142
+ * would bury the review under noise on every run. On a failed launch the
143
+ * stderr tail is relayed so the error is still diagnosable (the Codex path
144
+ * makes the same trade-off).
145
+ *
146
+ * The reviewer runs from a fresh, empty, non-repository cwd so nothing
147
+ * branch-controlled loads: not the snapshot's `AGENTS.md` or `opencode.json`,
148
+ * and not its `.opencode/` directory. `OPENCODE_CONFIG_DIR` points at that
149
+ * same empty directory so no user-defined agents, commands, or plugins from
150
+ * `~/.config/opencode` ride along either; `--pure` additionally disables
151
+ * external plugins. That directory is *config* only: opencode's credentials
152
+ * live in its data directory (`~/.local/share/opencode/auth.json` on macOS),
153
+ * which `OPENCODE_CONFIG_DIR` does not touch, so the deepseek auth the review
154
+ * needs still resolves. Global config still loads, but the inline config's
155
+ * agent-scoped deny rules take precedence over whatever it sets.
156
+ */
157
+ export function spawnOpencodeReview(
158
+ prompt: string,
159
+ effort: ReviewEffort,
160
+ snapshotRoot: string,
161
+ env: ReviewerEnv = process.env,
162
+ ): number {
163
+ const executable = reviewExecutable("opencode", env);
164
+ if (executable === undefined) {
165
+ process.stderr.write("OpenCode executable unavailable outside the repository.\n");
166
+ return 1;
167
+ }
168
+ const model = loadConfig().review.opencodeModel;
169
+ const outDir = mkdtempSync(path.join(tmpdir(), "agent-tool-opencode-"));
170
+ try {
171
+ return relayReviewWithRetry("opencode", () => {
172
+ const result = spawnSync(
173
+ executable,
174
+ buildOpencodeReviewArgs(resolveOpencodeVariant(effort, loadConfig().review.opencodeVariants), model),
175
+ {
176
+ stdio: ["pipe", "pipe", "pipe"],
177
+ input: prompt,
178
+ encoding: "utf8",
179
+ maxBuffer: MAX_BUFFER_BYTES,
180
+ timeout: loadConfig().review.timeoutMs,
181
+ // Neutral cwd: opencode would otherwise load the snapshot's
182
+ // branch-controlled config and instructions into the reviewer.
183
+ cwd: outDir,
184
+ env: {
185
+ ...env,
186
+ OPENCODE_CONFIG_CONTENT: buildOpencodeInlineConfig(snapshotRoot, model),
187
+ OPENCODE_CONFIG_DIR: outDir,
188
+ },
189
+ },
190
+ );
191
+ const exitCode = spawnExitCode("opencode", result);
192
+ const transcript = result.stderr ?? "";
193
+ if (exitCode !== 0 && transcript.length > 0) {
194
+ process.stderr.write(
195
+ `opencode transcript (tail):\n${transcript.slice(-TRANSCRIPT_TAIL_CHARS)}\n`,
196
+ );
197
+ }
198
+ return {
199
+ exitCode,
200
+ review: result.stdout ?? "",
201
+ };
202
+ });
203
+ } finally {
204
+ rmSync(outDir, { recursive: true, force: true });
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Ask the local `opencode` CLI to review the current branch's diff with
210
+ * deepseek-v4-pro. Branch/PR/base are derived from git + GitHub; when the
211
+ * branch has no PR yet the diff is taken against the default branch, so a
212
+ * review can run before the PR is opened. The prompt is streamed via stdin
213
+ * (not argv) to avoid "Argument list too long" failures on large PRs. The
214
+ * effort level defaults to `high` for opencode.
215
+ */
216
+ export function solicitOpencodeReview(
217
+ rootDir: string,
218
+ effortArg?: string,
219
+ ): number {
220
+ const effort = resolveReviewEffort(effortArg, DEFAULT_OPENCODE_EFFORT);
221
+ const context = resolveReviewContext();
222
+ return withPinnedReviewInput(rootDir, context, (input) => {
223
+ const prompt = buildReviewPrompt({
224
+ context,
225
+ diff: input.diff,
226
+ reviewInstructions: input.reviewInstructions,
227
+ accessNote: OPENCODE_ACCESS_NOTE,
228
+ repositoryRoot: input.snapshotRoot,
229
+ });
230
+
231
+ return spawnOpencodeReview(prompt, effort, input.snapshotRoot);
232
+ });
233
+ }