revu-ai 0.1.2 → 0.2.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/CHANGELOG.md +39 -0
- package/README.md +75 -12
- package/dist/cache/cross-reference.d.ts +37 -0
- package/dist/cache/cross-reference.js +94 -0
- package/dist/cache/cross-reference.js.map +1 -0
- package/dist/cli.js +106 -12
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +9 -10
- package/dist/config.js.map +1 -1
- package/dist/discovery.js +8 -1
- package/dist/discovery.js.map +1 -1
- package/dist/findings.d.ts +18 -0
- package/dist/findings.js +22 -0
- package/dist/findings.js.map +1 -0
- package/dist/forges/dedup.d.ts +17 -0
- package/dist/forges/dedup.js +42 -0
- package/dist/forges/dedup.js.map +1 -0
- package/dist/forges/diff-lines.d.ts +12 -0
- package/dist/forges/diff-lines.js +93 -0
- package/dist/forges/diff-lines.js.map +1 -0
- package/dist/forges/github/api.d.ts +70 -0
- package/dist/forges/github/api.js +102 -0
- package/dist/forges/github/api.js.map +1 -0
- package/dist/forges/github/index.d.ts +10 -0
- package/dist/forges/github/index.js +292 -0
- package/dist/forges/github/index.js.map +1 -0
- package/dist/forges/gitlab/index.d.ts +7 -0
- package/dist/forges/gitlab/index.js +13 -0
- package/dist/forges/gitlab/index.js.map +1 -0
- package/dist/forges/post-cmd.d.ts +20 -0
- package/dist/forges/post-cmd.js +54 -0
- package/dist/forges/post-cmd.js.map +1 -0
- package/dist/forges/registry.d.ts +5 -0
- package/dist/forges/registry.js +24 -0
- package/dist/forges/registry.js.map +1 -0
- package/dist/forges/render.d.ts +18 -0
- package/dist/forges/render.js +59 -0
- package/dist/forges/render.js.map +1 -0
- package/dist/forges/types.d.ts +65 -0
- package/dist/forges/types.js +2 -0
- package/dist/forges/types.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +3 -1
- package/dist/init.js +6 -3
- package/dist/init.js.map +1 -1
- package/dist/mcp/aggregator.d.ts +9 -1
- package/dist/mcp/aggregator.js +39 -3
- package/dist/mcp/aggregator.js.map +1 -1
- package/dist/mcp/server.d.ts +8 -0
- package/dist/mcp/server.js +60 -5
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.d.ts +38 -2
- package/dist/mcp/tools.js +36 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/output/github.d.ts +1 -1
- package/dist/output/github.js +3 -7
- package/dist/output/github.js.map +1 -1
- package/dist/output/json.d.ts +3 -1
- package/dist/output/json.js +4 -7
- package/dist/output/json.js.map +1 -1
- package/dist/output/pretty.d.ts +1 -1
- package/dist/output/pretty.js +2 -8
- package/dist/output/pretty.js.map +1 -1
- package/dist/prompts/review-system.d.ts +3 -1
- package/dist/prompts/review-system.js +47 -2
- package/dist/prompts/review-system.js.map +1 -1
- package/dist/providers/claude-code.d.ts +7 -5
- package/dist/providers/claude-code.js +77 -15
- package/dist/providers/claude-code.js.map +1 -1
- package/dist/providers/opencode.d.ts +6 -0
- package/dist/providers/opencode.js +653 -0
- package/dist/providers/opencode.js.map +1 -0
- package/dist/providers/registry.d.ts +7 -7
- package/dist/providers/registry.js +29 -21
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/types.d.ts +8 -1
- package/dist/runner.d.ts +6 -1
- package/dist/runner.js +71 -26
- package/dist/runner.js.map +1 -1
- package/dist/scaffold-paths.d.ts +10 -0
- package/dist/scaffold-paths.js +25 -0
- package/dist/scaffold-paths.js.map +1 -0
- package/dist/types.d.ts +38 -3
- package/dist/types.js +4 -7
- package/dist/types.js.map +1 -1
- package/examples/github-workflow.yml +51 -10
- package/package.json +16 -10
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { createServer as createNetServer } from "node:net";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Agent, setGlobalDispatcher } from "undici";
|
|
6
|
+
import { createOpencode } from "@opencode-ai/sdk";
|
|
7
|
+
/**
|
|
8
|
+
* Node's built-in `fetch` (undici) has a default `headersTimeout` of 5 minutes:
|
|
9
|
+
* if the response headers don't arrive in time, the request dies with a
|
|
10
|
+
* generic "fetch failed". The opencode SDK's `session.prompt(...)` is a
|
|
11
|
+
* blocking POST — opencode doesn't return the HTTP response until the agent's
|
|
12
|
+
* loop is fully done. A long Grok inference (>5 min) hits this ceiling
|
|
13
|
+
* silently; in revu-ai's logs it surfaces as `errorMessage: "fetch failed"`
|
|
14
|
+
* with `durationMs: ~305000`, NOT a clean revu timeout.
|
|
15
|
+
*
|
|
16
|
+
* Disable both undici timeouts (headers + body) for the whole process so
|
|
17
|
+
* revu's own per-rule `--timeout-ms` is the only ceiling. Affects every
|
|
18
|
+
* `globalThis.fetch` call in this Node process — fine for a CLI tool;
|
|
19
|
+
* something to revisit if revu-ai is ever embedded as a library.
|
|
20
|
+
*
|
|
21
|
+
* Tracked upstream as opencode-ai#15555.
|
|
22
|
+
*/
|
|
23
|
+
let dispatcherExtended = false;
|
|
24
|
+
function ensureLongRunningDispatcher() {
|
|
25
|
+
if (dispatcherExtended)
|
|
26
|
+
return;
|
|
27
|
+
dispatcherExtended = true;
|
|
28
|
+
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }));
|
|
29
|
+
}
|
|
30
|
+
import { buildSystemPrompt } from "../prompts/review-system.js";
|
|
31
|
+
import { buildUserPrompt } from "../prompts/review-user.js";
|
|
32
|
+
import { buildInitSystemPrompt } from "../prompts/init-system.js";
|
|
33
|
+
import { buildInitUserPrompt } from "../prompts/init-user.js";
|
|
34
|
+
import { startSidecar } from "../mcp/server.js";
|
|
35
|
+
const REVIEW_TOOL_OVERRIDES = {
|
|
36
|
+
write: false,
|
|
37
|
+
edit: false,
|
|
38
|
+
multiedit: false,
|
|
39
|
+
patch: false,
|
|
40
|
+
todowrite: false,
|
|
41
|
+
webfetch: false,
|
|
42
|
+
// Subagents (`task`) do their work in a separate opencode session whose
|
|
43
|
+
// events get muddled into the parent's stream — and they cost extra
|
|
44
|
+
// tokens for no review benefit at the rule's scope. The system prompt
|
|
45
|
+
// tells the model not to use them; this is the enforcement.
|
|
46
|
+
task: false,
|
|
47
|
+
// `question` is opencode's interactive user-input prompt. In a headless
|
|
48
|
+
// CI run there's no human to answer, so the agent just hangs forever
|
|
49
|
+
// waiting for a reply (this was the consistent "one rule silent for
|
|
50
|
+
// minutes" pattern in CI). Disable.
|
|
51
|
+
question: false,
|
|
52
|
+
};
|
|
53
|
+
const SCAFFOLD_TOOL_OVERRIDES = {
|
|
54
|
+
write: false,
|
|
55
|
+
edit: false,
|
|
56
|
+
multiedit: false,
|
|
57
|
+
patch: false,
|
|
58
|
+
webfetch: false,
|
|
59
|
+
task: false,
|
|
60
|
+
question: false,
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Bash allowlist for the opencode harness.
|
|
64
|
+
*
|
|
65
|
+
* **Pattern precedence is "last matching rule wins"** per opencode's
|
|
66
|
+
* permissions docs. The catch-all `"*": "deny"` must therefore come FIRST,
|
|
67
|
+
* with specific allows AFTER — otherwise the catch-all overrides every
|
|
68
|
+
* specific rule and bash gets silently denied for every command. (Earlier
|
|
69
|
+
* revu-ai versions had the catch-all last, which is why CI agents started
|
|
70
|
+
* reverse-engineering diffs from `.git/refs/*` instead of running `git diff`
|
|
71
|
+
* — every bash call was denied with no error visible to the user.)
|
|
72
|
+
*
|
|
73
|
+
* **Safety caveat (vs. the claude-code harness):** opencode's `*` matches
|
|
74
|
+
* zero or more of any character including spaces and shell metacharacters.
|
|
75
|
+
* So `"cat *": "allow"` will permit `cat foo > /tmp/x` if the model issues
|
|
76
|
+
* the redirect. opencode lacks a per-call gate equivalent to the Agent
|
|
77
|
+
* SDK's `canUseTool`, so we can't reject shell-redirects/chains/substitution
|
|
78
|
+
* the way `isReadOnlyShellCommand` does. Residual defenses:
|
|
79
|
+
*
|
|
80
|
+
* 1. The reviewer system prompt tells the agent to use only read-only
|
|
81
|
+
* commands.
|
|
82
|
+
* 2. `permission.edit: "deny"` blocks opencode's built-in edit tools.
|
|
83
|
+
* 3. The catch-all denies bash bins not explicitly listed below.
|
|
84
|
+
* 4. CI runs in ephemeral GHA runners — nothing persistent to corrupt.
|
|
85
|
+
*
|
|
86
|
+
* `tests/opencode-bash.test.ts` documents the intended-allow set and the
|
|
87
|
+
* adversarial commands the agent must avoid; treat it as the contract,
|
|
88
|
+
* with claude-code as the fallback if a hostile model ever matters.
|
|
89
|
+
*/
|
|
90
|
+
const READ_ONLY_BASH = {
|
|
91
|
+
// Catch-all FIRST — last-match-wins means this is the floor that
|
|
92
|
+
// specific allows below override.
|
|
93
|
+
"*": "deny",
|
|
94
|
+
// git read-only subcommands.
|
|
95
|
+
"git diff": "allow",
|
|
96
|
+
"git diff *": "allow",
|
|
97
|
+
"git log": "allow",
|
|
98
|
+
"git log *": "allow",
|
|
99
|
+
"git show *": "allow",
|
|
100
|
+
"git status": "allow",
|
|
101
|
+
"git status *": "allow",
|
|
102
|
+
"git ls-files": "allow",
|
|
103
|
+
"git ls-files *": "allow",
|
|
104
|
+
"git rev-parse *": "allow",
|
|
105
|
+
"git blame *": "allow",
|
|
106
|
+
// file inspection.
|
|
107
|
+
"cat *": "allow",
|
|
108
|
+
"head *": "allow",
|
|
109
|
+
"tail *": "allow",
|
|
110
|
+
"ls": "allow",
|
|
111
|
+
"ls *": "allow",
|
|
112
|
+
"wc *": "allow",
|
|
113
|
+
"find *": "allow",
|
|
114
|
+
"rg": "allow",
|
|
115
|
+
"rg *": "allow",
|
|
116
|
+
"grep *": "allow",
|
|
117
|
+
"echo *": "allow",
|
|
118
|
+
"pwd": "allow",
|
|
119
|
+
"stat *": "allow",
|
|
120
|
+
"file *": "allow",
|
|
121
|
+
"basename *": "allow",
|
|
122
|
+
"dirname *": "allow",
|
|
123
|
+
};
|
|
124
|
+
/** Test-only export so `tests/opencode-bash.test.ts` can cross-validate
|
|
125
|
+
* every intended-allow command against `isReadOnlyShellCommand`. */
|
|
126
|
+
export const __READ_ONLY_BASH_FOR_TESTS = READ_ONLY_BASH;
|
|
127
|
+
export const opencodeProvider = (cfg) => ({
|
|
128
|
+
name: "opencode",
|
|
129
|
+
async run(input) {
|
|
130
|
+
const start = Date.now();
|
|
131
|
+
ensureLongRunningDispatcher();
|
|
132
|
+
const { providerID, modelID } = parseModel(cfg);
|
|
133
|
+
const abort = new AbortController();
|
|
134
|
+
if (input.signal) {
|
|
135
|
+
input.signal.addEventListener("abort", () => abort.abort(), { once: true });
|
|
136
|
+
}
|
|
137
|
+
let timedOut = false;
|
|
138
|
+
let stuck = false;
|
|
139
|
+
const timer = input.timeoutMs && input.timeoutMs > 0
|
|
140
|
+
? setTimeout(() => {
|
|
141
|
+
timedOut = true;
|
|
142
|
+
abort.abort();
|
|
143
|
+
}, input.timeoutMs)
|
|
144
|
+
: undefined;
|
|
145
|
+
// Stuck detector: if no activity events arrive for 90 seconds, treat
|
|
146
|
+
// the agent as stuck and abort. opencode children can die silently —
|
|
147
|
+
// and with undici's per-fetch timeouts disabled, a half-open socket
|
|
148
|
+
// would otherwise wait until the wall-clock timeout. Reset on every
|
|
149
|
+
// event the SSE stream surfaces; fire when the gap grows too large.
|
|
150
|
+
const STUCK_TIMEOUT_MS = 90_000;
|
|
151
|
+
let stuckTimer;
|
|
152
|
+
const armStuckTimer = () => {
|
|
153
|
+
if (stuckTimer)
|
|
154
|
+
clearTimeout(stuckTimer);
|
|
155
|
+
stuckTimer = setTimeout(() => {
|
|
156
|
+
stuck = true;
|
|
157
|
+
abort.abort();
|
|
158
|
+
}, STUCK_TIMEOUT_MS);
|
|
159
|
+
};
|
|
160
|
+
armStuckTimer();
|
|
161
|
+
const port = await getFreePort();
|
|
162
|
+
const config = {
|
|
163
|
+
mcp: {
|
|
164
|
+
revu: {
|
|
165
|
+
type: "remote",
|
|
166
|
+
url: input.mcp.url,
|
|
167
|
+
headers: {
|
|
168
|
+
Authorization: `Bearer ${input.mcp.authToken}`,
|
|
169
|
+
"X-Revu-Rule-Id": input.ruleId,
|
|
170
|
+
},
|
|
171
|
+
enabled: true,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
// Auto-register the requested model under the provider so opencode's
|
|
175
|
+
// catalog lag doesn't bite. opencode's built-in xai/google/anthropic
|
|
176
|
+
// providers handle auth + base URL via their own env vars; this just
|
|
177
|
+
// tells opencode "yes, this model id is valid", letting users pin to
|
|
178
|
+
// models the baked-in models.dev cache may not list yet (e.g.,
|
|
179
|
+
// `grok-4-1-fast-reasoning` at the time of writing).
|
|
180
|
+
//
|
|
181
|
+
// `options.timeout` extends opencode's per-LLM-call HTTP timeout to
|
|
182
|
+
// match the user's per-rule budget. opencode defaults to 5 min, which
|
|
183
|
+
// a slow inference call can blow through; the failure surfaces as
|
|
184
|
+
// `errorMessage: "fetch failed"` and the rule errors. Aligning both
|
|
185
|
+
// timeouts means revu's `--timeout-ms` is the single boundary.
|
|
186
|
+
provider: providerConfig(providerID, modelID, input.timeoutMs),
|
|
187
|
+
permission: {
|
|
188
|
+
edit: "deny",
|
|
189
|
+
bash: READ_ONLY_BASH,
|
|
190
|
+
webfetch: "deny",
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
let opencode;
|
|
194
|
+
const isolated = startIsolatedOpencode({
|
|
195
|
+
port,
|
|
196
|
+
signal: abort.signal,
|
|
197
|
+
timeout: 30_000,
|
|
198
|
+
config,
|
|
199
|
+
});
|
|
200
|
+
try {
|
|
201
|
+
opencode = await isolated.opencode;
|
|
202
|
+
const { client, server } = opencode;
|
|
203
|
+
void server;
|
|
204
|
+
const session = await client.session.create({ body: { title: `revu-${input.ruleId}` } });
|
|
205
|
+
const sessionId = session.data?.id;
|
|
206
|
+
if (!sessionId) {
|
|
207
|
+
return errorResult(input.ruleId, start, "opencode: failed to create session");
|
|
208
|
+
}
|
|
209
|
+
const eventLoop = startEventLoop(client, sessionId, input, abort.signal, armStuckTimer);
|
|
210
|
+
const promptResp = await client.session.prompt({
|
|
211
|
+
path: { id: sessionId },
|
|
212
|
+
body: {
|
|
213
|
+
model: { providerID, modelID },
|
|
214
|
+
system: buildSystemPrompt({
|
|
215
|
+
ruleId: input.ruleId,
|
|
216
|
+
rulesContent: input.rulesContent,
|
|
217
|
+
reviewTarget: input.reviewTarget,
|
|
218
|
+
...(input.priorFindings ? { priorFindings: input.priorFindings } : {}),
|
|
219
|
+
...(input.priorHeadSha ? { priorHeadSha: input.priorHeadSha } : {}),
|
|
220
|
+
}),
|
|
221
|
+
tools: REVIEW_TOOL_OVERRIDES,
|
|
222
|
+
parts: [{ type: "text", text: buildUserPrompt(input.reviewTarget) }],
|
|
223
|
+
},
|
|
224
|
+
// Honour the abort signal at the HTTP layer too — without this, a
|
|
225
|
+
// timeout fires `abort.abort()`, kills the opencode child process,
|
|
226
|
+
// but the in-flight fetch keeps waiting for a response that never
|
|
227
|
+
// comes.
|
|
228
|
+
signal: abort.signal,
|
|
229
|
+
});
|
|
230
|
+
eventLoop.stop();
|
|
231
|
+
if (timedOut) {
|
|
232
|
+
return timeoutResult(input.ruleId, start, input.timeoutMs);
|
|
233
|
+
}
|
|
234
|
+
// hey-api wraps non-2xx in `error`; 2xx populates `data`. opencode can
|
|
235
|
+
// also return a 2xx with an unexpected shape (data without info) when
|
|
236
|
+
// the server crashes mid-prompt — guard every hop instead of just the
|
|
237
|
+
// first.
|
|
238
|
+
if (promptResp.error) {
|
|
239
|
+
return errorResult(input.ruleId, start, formatOpencodeError(promptResp.error));
|
|
240
|
+
}
|
|
241
|
+
const info = promptResp.data?.info;
|
|
242
|
+
if (!info) {
|
|
243
|
+
return errorResult(input.ruleId, start, "opencode returned no message info — server may have errored mid-prompt");
|
|
244
|
+
}
|
|
245
|
+
if (info.error) {
|
|
246
|
+
return errorResult(input.ruleId, start, formatOpencodeError(info.error));
|
|
247
|
+
}
|
|
248
|
+
void server;
|
|
249
|
+
return { ruleId: input.ruleId, ok: true, durationMs: Date.now() - start };
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
if (timedOut)
|
|
253
|
+
return timeoutResult(input.ruleId, start, input.timeoutMs);
|
|
254
|
+
if (stuck)
|
|
255
|
+
return errorResult(input.ruleId, start, `stuck — no activity for ${STUCK_TIMEOUT_MS / 1000}s`);
|
|
256
|
+
if (abort.signal.aborted) {
|
|
257
|
+
return errorResult(input.ruleId, start, "opencode run aborted");
|
|
258
|
+
}
|
|
259
|
+
return errorResult(input.ruleId, start, formatThrown(e));
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
if (timer)
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
if (stuckTimer)
|
|
265
|
+
clearTimeout(stuckTimer);
|
|
266
|
+
try {
|
|
267
|
+
opencode?.server.close();
|
|
268
|
+
}
|
|
269
|
+
catch { /* shutdown best-effort */ }
|
|
270
|
+
isolated.cleanup();
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
export const opencodeScaffoldProvider = (cfg) => ({
|
|
275
|
+
name: "opencode",
|
|
276
|
+
async run(input) {
|
|
277
|
+
const start = Date.now();
|
|
278
|
+
ensureLongRunningDispatcher();
|
|
279
|
+
const { providerID, modelID } = parseModel(cfg);
|
|
280
|
+
const abort = new AbortController();
|
|
281
|
+
if (input.signal) {
|
|
282
|
+
input.signal.addEventListener("abort", () => abort.abort(), { once: true });
|
|
283
|
+
}
|
|
284
|
+
let timedOut = false;
|
|
285
|
+
const timer = input.timeoutMs && input.timeoutMs > 0
|
|
286
|
+
? setTimeout(() => {
|
|
287
|
+
timedOut = true;
|
|
288
|
+
abort.abort();
|
|
289
|
+
}, input.timeoutMs)
|
|
290
|
+
: undefined;
|
|
291
|
+
const filesWritten = [];
|
|
292
|
+
const sidecar = await startSidecar({
|
|
293
|
+
repoRoot: input.repoRoot,
|
|
294
|
+
scaffold: {
|
|
295
|
+
onFileWritten: (rel) => {
|
|
296
|
+
filesWritten.push(rel);
|
|
297
|
+
input.onFileWritten?.(rel);
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
const port = await getFreePort();
|
|
302
|
+
const config = {
|
|
303
|
+
mcp: {
|
|
304
|
+
revu: {
|
|
305
|
+
type: "remote",
|
|
306
|
+
url: sidecar.url,
|
|
307
|
+
headers: {
|
|
308
|
+
Authorization: `Bearer ${sidecar.authToken}`,
|
|
309
|
+
"X-Revu-Rule-Id": "__scaffold__",
|
|
310
|
+
},
|
|
311
|
+
enabled: true,
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
// See review path comment — auto-register the requested model so
|
|
315
|
+
// opencode's catalog lag doesn't reject newly-released model ids,
|
|
316
|
+
// and align the per-LLM-call HTTP timeout with the per-rule budget.
|
|
317
|
+
provider: providerConfig(providerID, modelID, input.timeoutMs),
|
|
318
|
+
permission: {
|
|
319
|
+
edit: "deny",
|
|
320
|
+
bash: READ_ONLY_BASH,
|
|
321
|
+
webfetch: "deny",
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
let opencode;
|
|
325
|
+
const isolated = startIsolatedOpencode({
|
|
326
|
+
port,
|
|
327
|
+
signal: abort.signal,
|
|
328
|
+
timeout: 30_000,
|
|
329
|
+
config,
|
|
330
|
+
});
|
|
331
|
+
try {
|
|
332
|
+
opencode = await isolated.opencode;
|
|
333
|
+
const client = opencode.client;
|
|
334
|
+
const session = await client.session.create({ body: { title: "revu-scaffold" } });
|
|
335
|
+
const sessionId = session.data?.id;
|
|
336
|
+
if (!sessionId) {
|
|
337
|
+
return scaffoldError(start, filesWritten, "opencode: failed to create session");
|
|
338
|
+
}
|
|
339
|
+
const eventLoop = startScaffoldEventLoop(client, sessionId, input, abort.signal);
|
|
340
|
+
const promptResp = await client.session.prompt({
|
|
341
|
+
path: { id: sessionId },
|
|
342
|
+
body: {
|
|
343
|
+
model: { providerID, modelID },
|
|
344
|
+
system: buildOpencodeScaffoldSystemPrompt(input.force),
|
|
345
|
+
tools: SCAFFOLD_TOOL_OVERRIDES,
|
|
346
|
+
parts: [{ type: "text", text: buildInitUserPrompt({ repoRoot: input.repoRoot, force: input.force }) }],
|
|
347
|
+
},
|
|
348
|
+
signal: abort.signal,
|
|
349
|
+
});
|
|
350
|
+
eventLoop.stop();
|
|
351
|
+
if (timedOut) {
|
|
352
|
+
return scaffoldTimeoutResult(start, filesWritten, input.timeoutMs);
|
|
353
|
+
}
|
|
354
|
+
// Same defensive shape as the review path — see opencodeProvider.run.
|
|
355
|
+
if (promptResp.error) {
|
|
356
|
+
return scaffoldError(start, filesWritten, formatOpencodeError(promptResp.error));
|
|
357
|
+
}
|
|
358
|
+
const info = promptResp.data?.info;
|
|
359
|
+
if (!info) {
|
|
360
|
+
return scaffoldError(start, filesWritten, "opencode returned no message info — server may have errored mid-prompt");
|
|
361
|
+
}
|
|
362
|
+
if (info.error) {
|
|
363
|
+
return scaffoldError(start, filesWritten, formatOpencodeError(info.error));
|
|
364
|
+
}
|
|
365
|
+
return { ok: true, durationMs: Date.now() - start, filesWritten };
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
if (timedOut)
|
|
369
|
+
return scaffoldTimeoutResult(start, filesWritten, input.timeoutMs);
|
|
370
|
+
if (abort.signal.aborted) {
|
|
371
|
+
return scaffoldError(start, filesWritten, "opencode scaffold aborted");
|
|
372
|
+
}
|
|
373
|
+
return scaffoldError(start, filesWritten, formatThrown(e));
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
if (timer)
|
|
377
|
+
clearTimeout(timer);
|
|
378
|
+
try {
|
|
379
|
+
opencode?.server.close();
|
|
380
|
+
}
|
|
381
|
+
catch { /* shutdown best-effort */ }
|
|
382
|
+
isolated.cleanup();
|
|
383
|
+
await sidecar.shutdown().catch(() => { });
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
/**
|
|
388
|
+
* Spawn an opencode server with an isolated `XDG_DATA_HOME` so each
|
|
389
|
+
* concurrent invocation gets its own sqlite database. Without this, parallel
|
|
390
|
+
* fan-out collides on `~/.local/share/opencode/opencode.db` — multiple
|
|
391
|
+
* processes try to set `PRAGMA journal_mode = WAL` against the same file at
|
|
392
|
+
* startup, the OS lock loses one, and the server exits 1.
|
|
393
|
+
*
|
|
394
|
+
* The trick that makes this safe under `Promise.all`: `createOpencode`
|
|
395
|
+
* captures `process.env` synchronously inside its body before the first
|
|
396
|
+
* `await` (it forwards env to a `cross-spawn`-launched child process). So if
|
|
397
|
+
* we set `XDG_DATA_HOME`, call `createOpencode` (kicking off the synchronous
|
|
398
|
+
* launch), and restore `XDG_DATA_HOME` — all in one synchronous block — no
|
|
399
|
+
* other coroutine can interleave between mutation and capture, even though
|
|
400
|
+
* other rules' `run()` calls are also racing through the same code path.
|
|
401
|
+
*/
|
|
402
|
+
function startIsolatedOpencode(options) {
|
|
403
|
+
const dataDir = mkdtempSync(join(tmpdir(), "revu-opencode-"));
|
|
404
|
+
const previousXDG = process.env["XDG_DATA_HOME"];
|
|
405
|
+
process.env["XDG_DATA_HOME"] = dataDir;
|
|
406
|
+
let promise;
|
|
407
|
+
try {
|
|
408
|
+
// SDK runs body synchronously up to its first `await`, capturing
|
|
409
|
+
// `process.env` for the spawned child along the way.
|
|
410
|
+
promise = createOpencode(options);
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
if (previousXDG === undefined)
|
|
414
|
+
delete process.env["XDG_DATA_HOME"];
|
|
415
|
+
else
|
|
416
|
+
process.env["XDG_DATA_HOME"] = previousXDG;
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
opencode: promise,
|
|
420
|
+
cleanup: () => {
|
|
421
|
+
try {
|
|
422
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
/* best-effort */
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
function parseModel(cfg) {
|
|
431
|
+
const provider = cfg.provider;
|
|
432
|
+
const model = cfg.model;
|
|
433
|
+
if (!provider || !model) {
|
|
434
|
+
throw new Error("opencode harness requires --provider and --model. Examples: --provider xai --model grok-4-1-fast-reasoning, --provider google --model gemini-2.5-pro, --provider anthropic --model claude-sonnet-4-6.");
|
|
435
|
+
}
|
|
436
|
+
return { providerID: provider, modelID: model };
|
|
437
|
+
}
|
|
438
|
+
/** Build the inline `provider` block for opencode's Config: registers the
|
|
439
|
+
* exact model id under the chosen provider so opencode's catalog lag doesn't
|
|
440
|
+
* reject brand-new model ids. We also keep `options.timeout` so opencode's
|
|
441
|
+
* internal AI-SDK call timeout matches our per-rule budget — this is a
|
|
442
|
+
* belt-and-braces guard alongside the global undici dispatcher above. */
|
|
443
|
+
function providerConfig(providerID, modelID, timeoutMs) {
|
|
444
|
+
return {
|
|
445
|
+
[providerID]: {
|
|
446
|
+
models: { [modelID]: {} },
|
|
447
|
+
...(timeoutMs && timeoutMs > 0 ? { options: { timeout: timeoutMs } } : {}),
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function startEventLoop(client, sessionId, input, signal, onAnyEvent) {
|
|
452
|
+
const onActivity = input.onActivity;
|
|
453
|
+
if (!onActivity && !onAnyEvent)
|
|
454
|
+
return { stop: () => { } };
|
|
455
|
+
let stopped = false;
|
|
456
|
+
// opencode emits `message.part.updated` for every chunk of a tool's input
|
|
457
|
+
// as it streams in (e.g. you'll see Glob({}) → Glob(**/*.[jt]s) for the
|
|
458
|
+
// same callID). Track which callIDs we've already announced to keep the
|
|
459
|
+
// progress UI clean — emit on the first sighting that has a non-empty
|
|
460
|
+
// input, ignore later updates for the same callID.
|
|
461
|
+
const announcedTools = new Set();
|
|
462
|
+
void (async () => {
|
|
463
|
+
try {
|
|
464
|
+
const sub = await client.event.subscribe({ signal });
|
|
465
|
+
for await (const ev of sub.stream) {
|
|
466
|
+
if (stopped)
|
|
467
|
+
break;
|
|
468
|
+
onAnyEvent?.();
|
|
469
|
+
if (onActivity)
|
|
470
|
+
emitActivityFromEvent(ev, sessionId, onActivity, announcedTools);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
/* event stream errors are non-fatal — they're cosmetic progress */
|
|
475
|
+
}
|
|
476
|
+
})();
|
|
477
|
+
return {
|
|
478
|
+
stop: () => {
|
|
479
|
+
stopped = true;
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function startScaffoldEventLoop(client, sessionId, input, signal) {
|
|
484
|
+
if (!input.onActivity)
|
|
485
|
+
return { stop: () => { } };
|
|
486
|
+
const onActivity = input.onActivity;
|
|
487
|
+
let stopped = false;
|
|
488
|
+
const announcedTools = new Set();
|
|
489
|
+
void (async () => {
|
|
490
|
+
try {
|
|
491
|
+
const sub = await client.event.subscribe({ signal });
|
|
492
|
+
for await (const ev of sub.stream) {
|
|
493
|
+
if (stopped)
|
|
494
|
+
break;
|
|
495
|
+
emitActivityFromEvent(ev, sessionId, onActivity, announcedTools);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
/* same robustness as review path */
|
|
500
|
+
}
|
|
501
|
+
})();
|
|
502
|
+
return {
|
|
503
|
+
stop: () => {
|
|
504
|
+
stopped = true;
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function emitActivityFromEvent(ev, sessionId, onActivity, announcedTools) {
|
|
509
|
+
if (ev.type !== "message.part.updated")
|
|
510
|
+
return;
|
|
511
|
+
const part = ev.properties.part;
|
|
512
|
+
// Each opencode server is per-rule-isolated (own port, own XDG_DATA_HOME),
|
|
513
|
+
// so every event on this stream — main session OR any `task(...)` subagent
|
|
514
|
+
// session it spawned — belongs to this rule. Don't filter by sessionId; if
|
|
515
|
+
// we did, subagent tool calls would silently fall on the floor and the
|
|
516
|
+
// progress UI would look frozen while the subagent is grinding.
|
|
517
|
+
void sessionId;
|
|
518
|
+
if (part.type === "tool") {
|
|
519
|
+
if (announcedTools.has(part.callID))
|
|
520
|
+
return;
|
|
521
|
+
const input = (part.state.input ?? {});
|
|
522
|
+
if (Object.keys(input).length === 0) {
|
|
523
|
+
// Wait for the input to actually arrive — the next update will have it.
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
announcedTools.add(part.callID);
|
|
527
|
+
const name = mapOpencodeToolName(part.tool);
|
|
528
|
+
onActivity({ kind: "tool", name, detail: summarizeOpencodeToolPart(part) });
|
|
529
|
+
}
|
|
530
|
+
else if (part.type === "text") {
|
|
531
|
+
const trimmed = part.text.trim();
|
|
532
|
+
if (trimmed)
|
|
533
|
+
onActivity({ kind: "text", detail: truncate(trimmed.replace(/\s+/g, " "), 120) });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/** Translate opencode's tool names to the Claude-Code-shaped names that the CLI's
|
|
537
|
+
* progress renderer already knows how to format (so output is consistent across harnesses). */
|
|
538
|
+
function mapOpencodeToolName(name) {
|
|
539
|
+
switch (name) {
|
|
540
|
+
case "bash": return "Bash";
|
|
541
|
+
case "read": return "Read";
|
|
542
|
+
case "grep": return "Grep";
|
|
543
|
+
case "glob": return "Glob";
|
|
544
|
+
case "list": return "Glob";
|
|
545
|
+
case "write": return "Write";
|
|
546
|
+
case "edit": return "Edit";
|
|
547
|
+
default:
|
|
548
|
+
// MCP tools come through as `<server>_<tool>` in opencode; translate to mcp__<server>__<tool>.
|
|
549
|
+
if (name.startsWith("revu_"))
|
|
550
|
+
return `mcp__revu__${name.slice("revu_".length)}`;
|
|
551
|
+
return name;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function summarizeOpencodeToolPart(part) {
|
|
555
|
+
const i = (part.state.input ?? {});
|
|
556
|
+
if (part.tool === "bash" && typeof i["command"] === "string") {
|
|
557
|
+
return truncate(i["command"].replace(/\s+/g, " "), 90);
|
|
558
|
+
}
|
|
559
|
+
if (part.tool === "read" && typeof i["filePath"] === "string")
|
|
560
|
+
return i["filePath"];
|
|
561
|
+
if (part.tool === "grep" && typeof i["pattern"] === "string") {
|
|
562
|
+
const path = typeof i["path"] === "string" ? ` in ${i["path"]}` : "";
|
|
563
|
+
return `${i["pattern"]}${path}`;
|
|
564
|
+
}
|
|
565
|
+
if ((part.tool === "glob" || part.tool === "list") && typeof i["pattern"] === "string") {
|
|
566
|
+
return i["pattern"];
|
|
567
|
+
}
|
|
568
|
+
if (part.tool.startsWith("revu_")) {
|
|
569
|
+
if (typeof i["severity"] === "string" && typeof i["path"] === "string") {
|
|
570
|
+
const line = typeof i["line"] === "number" ? `:${i["line"]}` : "";
|
|
571
|
+
return `${i["severity"]} ${i["path"]}${line}`;
|
|
572
|
+
}
|
|
573
|
+
if (typeof i["path"] === "string")
|
|
574
|
+
return i["path"];
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
return truncate(JSON.stringify(i), 90);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return "";
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** Variant of the scaffold system prompt that tells the agent to use
|
|
584
|
+
* `mcp__revu__write_rule_file` (sidecar tool) instead of opencode's built-in `write`. */
|
|
585
|
+
function buildOpencodeScaffoldSystemPrompt(force) {
|
|
586
|
+
const base = buildInitSystemPrompt({ force });
|
|
587
|
+
return base
|
|
588
|
+
.replace(/- `Write` — restricted to.*?\.\n/, "- `mcp__revu__write_rule_file` — the ONLY way to create rule files. Pass `path` (repo-relative, must end in `.revu.md`) and `content`. The server enforces path safety and rejects out-of-tree paths.\n")
|
|
589
|
+
.replace(/You cannot Edit existing files\..*$/m, "You cannot Edit existing files. You cannot run tests, builds, or arbitrary code. The built-in `write` and `edit` tools are disabled — use `mcp__revu__write_rule_file` to create rule files.")
|
|
590
|
+
.replace(/Write each file with `Write`/g, "Write each file with `mcp__revu__write_rule_file`");
|
|
591
|
+
}
|
|
592
|
+
async function getFreePort() {
|
|
593
|
+
return new Promise((resolve, reject) => {
|
|
594
|
+
const srv = createNetServer();
|
|
595
|
+
srv.unref();
|
|
596
|
+
srv.on("error", reject);
|
|
597
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
598
|
+
const addr = srv.address();
|
|
599
|
+
if (typeof addr === "object" && addr) {
|
|
600
|
+
const p = addr.port;
|
|
601
|
+
srv.close(() => resolve(p));
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
srv.close();
|
|
605
|
+
reject(new Error("could not allocate free port for opencode server"));
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
function formatOpencodeError(err) {
|
|
611
|
+
if (typeof err === "object" && err !== null) {
|
|
612
|
+
const e = err;
|
|
613
|
+
const msg = e.data?.message ?? e.name ?? "unknown error";
|
|
614
|
+
const provider = e.data?.providerID ? ` [${e.data.providerID}]` : "";
|
|
615
|
+
return `opencode${provider}: ${msg}`;
|
|
616
|
+
}
|
|
617
|
+
return `opencode: ${String(err)}`;
|
|
618
|
+
}
|
|
619
|
+
function formatThrown(e) {
|
|
620
|
+
const msg = e?.message ?? String(e);
|
|
621
|
+
if (/ENOENT|spawn opencode/i.test(msg)) {
|
|
622
|
+
return "opencode binary not found on PATH. Install opencode (https://opencode.ai/docs/install/) before using --harness opencode.";
|
|
623
|
+
}
|
|
624
|
+
return msg;
|
|
625
|
+
}
|
|
626
|
+
function errorResult(ruleId, start, message) {
|
|
627
|
+
return { ruleId, ok: false, durationMs: Date.now() - start, errorMessage: message };
|
|
628
|
+
}
|
|
629
|
+
function timeoutResult(ruleId, start, timeoutMs) {
|
|
630
|
+
return {
|
|
631
|
+
ruleId,
|
|
632
|
+
ok: false,
|
|
633
|
+
durationMs: Date.now() - start,
|
|
634
|
+
errorMessage: `timed out after ${timeoutMs}ms`,
|
|
635
|
+
timedOut: true,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function scaffoldError(start, filesWritten, message) {
|
|
639
|
+
return { ok: false, durationMs: Date.now() - start, filesWritten, errorMessage: message };
|
|
640
|
+
}
|
|
641
|
+
function scaffoldTimeoutResult(start, filesWritten, timeoutMs) {
|
|
642
|
+
return {
|
|
643
|
+
ok: false,
|
|
644
|
+
durationMs: Date.now() - start,
|
|
645
|
+
filesWritten,
|
|
646
|
+
errorMessage: `timed out after ${timeoutMs}ms`,
|
|
647
|
+
timedOut: true,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function truncate(s, n) {
|
|
651
|
+
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
652
|
+
}
|
|
653
|
+
//# sourceMappingURL=opencode.js.map
|