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.
Files changed (90) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +75 -12
  3. package/dist/cache/cross-reference.d.ts +37 -0
  4. package/dist/cache/cross-reference.js +94 -0
  5. package/dist/cache/cross-reference.js.map +1 -0
  6. package/dist/cli.js +106 -12
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +9 -10
  10. package/dist/config.js.map +1 -1
  11. package/dist/discovery.js +8 -1
  12. package/dist/discovery.js.map +1 -1
  13. package/dist/findings.d.ts +18 -0
  14. package/dist/findings.js +22 -0
  15. package/dist/findings.js.map +1 -0
  16. package/dist/forges/dedup.d.ts +17 -0
  17. package/dist/forges/dedup.js +42 -0
  18. package/dist/forges/dedup.js.map +1 -0
  19. package/dist/forges/diff-lines.d.ts +12 -0
  20. package/dist/forges/diff-lines.js +93 -0
  21. package/dist/forges/diff-lines.js.map +1 -0
  22. package/dist/forges/github/api.d.ts +70 -0
  23. package/dist/forges/github/api.js +102 -0
  24. package/dist/forges/github/api.js.map +1 -0
  25. package/dist/forges/github/index.d.ts +10 -0
  26. package/dist/forges/github/index.js +292 -0
  27. package/dist/forges/github/index.js.map +1 -0
  28. package/dist/forges/gitlab/index.d.ts +7 -0
  29. package/dist/forges/gitlab/index.js +13 -0
  30. package/dist/forges/gitlab/index.js.map +1 -0
  31. package/dist/forges/post-cmd.d.ts +20 -0
  32. package/dist/forges/post-cmd.js +54 -0
  33. package/dist/forges/post-cmd.js.map +1 -0
  34. package/dist/forges/registry.d.ts +5 -0
  35. package/dist/forges/registry.js +24 -0
  36. package/dist/forges/registry.js.map +1 -0
  37. package/dist/forges/render.d.ts +18 -0
  38. package/dist/forges/render.js +59 -0
  39. package/dist/forges/render.js.map +1 -0
  40. package/dist/forges/types.d.ts +65 -0
  41. package/dist/forges/types.js +2 -0
  42. package/dist/forges/types.js.map +1 -0
  43. package/dist/index.d.ts +2 -2
  44. package/dist/index.js +1 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/init.d.ts +3 -1
  47. package/dist/init.js +6 -3
  48. package/dist/init.js.map +1 -1
  49. package/dist/mcp/aggregator.d.ts +9 -1
  50. package/dist/mcp/aggregator.js +39 -3
  51. package/dist/mcp/aggregator.js.map +1 -1
  52. package/dist/mcp/server.d.ts +8 -0
  53. package/dist/mcp/server.js +60 -5
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/mcp/tools.d.ts +38 -2
  56. package/dist/mcp/tools.js +36 -1
  57. package/dist/mcp/tools.js.map +1 -1
  58. package/dist/output/github.d.ts +1 -1
  59. package/dist/output/github.js +3 -7
  60. package/dist/output/github.js.map +1 -1
  61. package/dist/output/json.d.ts +3 -1
  62. package/dist/output/json.js +4 -7
  63. package/dist/output/json.js.map +1 -1
  64. package/dist/output/pretty.d.ts +1 -1
  65. package/dist/output/pretty.js +2 -8
  66. package/dist/output/pretty.js.map +1 -1
  67. package/dist/prompts/review-system.d.ts +3 -1
  68. package/dist/prompts/review-system.js +47 -2
  69. package/dist/prompts/review-system.js.map +1 -1
  70. package/dist/providers/claude-code.d.ts +7 -5
  71. package/dist/providers/claude-code.js +77 -15
  72. package/dist/providers/claude-code.js.map +1 -1
  73. package/dist/providers/opencode.d.ts +6 -0
  74. package/dist/providers/opencode.js +653 -0
  75. package/dist/providers/opencode.js.map +1 -0
  76. package/dist/providers/registry.d.ts +7 -7
  77. package/dist/providers/registry.js +29 -21
  78. package/dist/providers/registry.js.map +1 -1
  79. package/dist/providers/types.d.ts +8 -1
  80. package/dist/runner.d.ts +6 -1
  81. package/dist/runner.js +71 -26
  82. package/dist/runner.js.map +1 -1
  83. package/dist/scaffold-paths.d.ts +10 -0
  84. package/dist/scaffold-paths.js +25 -0
  85. package/dist/scaffold-paths.js.map +1 -0
  86. package/dist/types.d.ts +38 -3
  87. package/dist/types.js +4 -7
  88. package/dist/types.js.map +1 -1
  89. package/examples/github-workflow.yml +51 -10
  90. 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