@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.
- package/README.md +288 -0
- package/package.json +36 -0
- package/skills/cross-agent-review/SKILL.md +46 -0
- package/skills/open-pr/SKILL.md +31 -0
- package/skills/reset/SKILL.md +25 -0
- package/skills/ship-pr/SKILL.md +48 -0
- package/skills/squash-merge/SKILL.md +39 -0
- package/src/cli.ts +121 -0
- package/src/config.ts +101 -0
- package/src/doctor.ts +23 -0
- package/src/git/currentBranchPr.ts +114 -0
- package/src/git/environment.ts +10 -0
- package/src/git/prContext.ts +497 -0
- package/src/harnesses.ts +53 -0
- package/src/index.ts +9 -0
- package/src/pr/assertBranchPushed.ts +21 -0
- package/src/pr/assertNoClaudeBranding.ts +52 -0
- package/src/pr/baseFreshness.ts +67 -0
- package/src/pr/mergeChecks.ts +90 -0
- package/src/pr/openPr.ts +80 -0
- package/src/pr/prNumberSuffix.ts +43 -0
- package/src/pr/squashMerge.ts +229 -0
- package/src/pr/subjectLine.ts +21 -0
- package/src/pr/validateCommitSubject.ts +12 -0
- package/src/review/destinationPaths.ts +154 -0
- package/src/review/executable.ts +34 -0
- package/src/review/pinnedReviewInput.ts +91 -0
- package/src/review/rawGitTree.ts +301 -0
- package/src/review/reviewEffort.ts +54 -0
- package/src/review/reviewOutput.ts +62 -0
- package/src/review/reviewPrompt.ts +104 -0
- package/src/review/reviewSnapshot.ts +67 -0
- package/src/review/runReview.ts +62 -0
- package/src/review/solicitClaudeCodeReview.ts +140 -0
- package/src/review/solicitCodexReview.ts +205 -0
- package/src/review/solicitOpencodeReview.ts +233 -0
- package/src/runAgentToolAction.ts +100 -0
- package/src/skills/bundled.ts +8 -0
- package/src/skills/install.ts +92 -0
- package/src/version/bumpVersions.ts +196 -0
- package/src/version/lockfileVersions.ts +43 -0
- package/src/version/packageVersion.ts +112 -0
- package/src/version/prepareVersions.ts +135 -0
- package/src/version/resolveVersionConflicts.ts +126 -0
- 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
|
+
}
|