diffowl 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,3658 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import chalk2 from "chalk";
6
+ import ora from "ora";
7
+ import { createInterface } from "readline/promises";
8
+
9
+ // src/config.ts
10
+ import { access, readFile, writeFile, mkdir } from "fs/promises";
11
+ import { existsSync } from "fs";
12
+ import { join, dirname } from "path";
13
+ import { parse, stringify } from "yaml";
14
+ import { z, ZodError } from "zod";
15
+ var DEFAULT_CONFIG = {
16
+ model: "opencode-go/big-pickle",
17
+ server: {
18
+ port: 4096,
19
+ auto_start: true
20
+ },
21
+ context: {
22
+ depth: "default"
23
+ },
24
+ reasoning: {
25
+ effort: "auto"
26
+ },
27
+ retention: {
28
+ hook_log_kb: 1024
29
+ },
30
+ timeout: 300,
31
+ // 5 minutes
32
+ min_confidence: "medium",
33
+ include: ["**/*"],
34
+ exclude: [
35
+ "**/*.test.*",
36
+ "**/*.spec.*",
37
+ "**/*.lock",
38
+ "**/node_modules/**",
39
+ "**/dist/**",
40
+ "**/build/**",
41
+ "**/.git/**"
42
+ ],
43
+ rules: [],
44
+ skip_doc_only: false,
45
+ verbose: false
46
+ };
47
+ var CONFIG_FILENAME = ".diffowl.yml";
48
+ var ReviewConfidenceSchema = z.enum(["low", "medium", "high"]);
49
+ var ReviewContextDepthSchema = z.enum(["shallow", "default"]);
50
+ var ReasoningEffortSchema = z.enum([
51
+ "auto",
52
+ "none",
53
+ "minimal",
54
+ "low",
55
+ "medium",
56
+ "high",
57
+ "max",
58
+ "xhigh"
59
+ ]);
60
+ var ModelSchema = z.string().trim().min(1, "model must not be empty").regex(/^[^/\s]+\/\S+$/, "model must use provider/model format");
61
+ var stringArraySchema = z.array(z.string().trim().min(1));
62
+ var DiffOwlConfigSchema = z.object({
63
+ model: ModelSchema.default(DEFAULT_CONFIG.model),
64
+ server: z.object({
65
+ port: z.number().int().min(1).max(65535).default(DEFAULT_CONFIG.server.port),
66
+ auto_start: z.boolean().default(DEFAULT_CONFIG.server.auto_start)
67
+ }).strict().default(DEFAULT_CONFIG.server),
68
+ context: z.object({
69
+ depth: ReviewContextDepthSchema.default(DEFAULT_CONFIG.context.depth)
70
+ }).strict().default(DEFAULT_CONFIG.context),
71
+ reasoning: z.object({
72
+ effort: ReasoningEffortSchema.default(DEFAULT_CONFIG.reasoning.effort)
73
+ }).strict().default(DEFAULT_CONFIG.reasoning),
74
+ retention: z.object({
75
+ hook_log_kb: z.number().int().nonnegative().default(DEFAULT_CONFIG.retention.hook_log_kb)
76
+ }).strict().default(DEFAULT_CONFIG.retention),
77
+ timeout: z.number().int().positive().default(DEFAULT_CONFIG.timeout),
78
+ min_confidence: ReviewConfidenceSchema.default(DEFAULT_CONFIG.min_confidence),
79
+ include: stringArraySchema.default(DEFAULT_CONFIG.include),
80
+ exclude: stringArraySchema.default(DEFAULT_CONFIG.exclude),
81
+ rules: stringArraySchema.default(DEFAULT_CONFIG.rules),
82
+ skip_doc_only: z.boolean().default(DEFAULT_CONFIG.skip_doc_only),
83
+ verbose: z.boolean().default(DEFAULT_CONFIG.verbose)
84
+ }).strict();
85
+ function parseModel(value) {
86
+ return ModelSchema.parse(value);
87
+ }
88
+ function parseReviewContextDepth(value) {
89
+ return ReviewContextDepthSchema.parse(value);
90
+ }
91
+ function parseReasoningEffort(value) {
92
+ return ReasoningEffortSchema.parse(value);
93
+ }
94
+ function parseConfigInput(value) {
95
+ return DiffOwlConfigSchema.parse(value ?? {});
96
+ }
97
+ function formatZodError(err) {
98
+ return err.issues.map((issue) => {
99
+ const path = issue.path.length > 0 ? issue.path.join(".") : "config";
100
+ return `${path}: ${issue.message}`;
101
+ }).join("; ");
102
+ }
103
+ function findConfigPath() {
104
+ let dir = process.cwd();
105
+ while (true) {
106
+ const candidate = join(dir, CONFIG_FILENAME);
107
+ if (existsSync(candidate)) return candidate;
108
+ const parent = join(dir, "..");
109
+ if (parent === dir) break;
110
+ dir = parent;
111
+ }
112
+ return join(process.cwd(), CONFIG_FILENAME);
113
+ }
114
+ async function loadConfig() {
115
+ const configPath = findConfigPath();
116
+ if (!existsSync(configPath)) {
117
+ return { ...DEFAULT_CONFIG };
118
+ }
119
+ try {
120
+ const raw = await readFile(configPath, "utf-8");
121
+ return parseConfigInput(parse(raw));
122
+ } catch (err) {
123
+ const message = err instanceof ZodError ? formatZodError(err) : err instanceof Error ? err.message : String(err);
124
+ throw new Error(`Failed to load ${configPath}: ${message}`);
125
+ }
126
+ }
127
+ async function saveConfig(config) {
128
+ const configPath = findConfigPath();
129
+ const content = stringify(DiffOwlConfigSchema.parse(config), { lineWidth: 0 });
130
+ await writeFile(configPath, content, "utf-8");
131
+ return configPath;
132
+ }
133
+ function getDiffOwlDir() {
134
+ return join(getProjectRoot(), ".diffowl");
135
+ }
136
+ function getProjectRoot() {
137
+ return dirname(findConfigPath());
138
+ }
139
+ async function ensureDiffOwlDir() {
140
+ const dir = getDiffOwlDir();
141
+ try {
142
+ await access(dir);
143
+ } catch {
144
+ await mkdir(dir, { recursive: true });
145
+ }
146
+ return dir;
147
+ }
148
+ function configExists() {
149
+ return existsSync(findConfigPath());
150
+ }
151
+
152
+ // src/opencode/client.ts
153
+ import { createOpencodeClient as createOpencodeClient2 } from "@opencode-ai/sdk";
154
+
155
+ // src/opencode/server.ts
156
+ import { execa } from "execa";
157
+ import { existsSync as existsSync2 } from "fs";
158
+ import { readFile as readFile2, writeFile as writeFile2, unlink } from "fs/promises";
159
+ import { join as join2 } from "path";
160
+ var HEALTH_TIMEOUT_MS = 2e3;
161
+ var STARTUP_WAIT_MS = 3e3;
162
+ var MAX_RETRIES = 10;
163
+ async function isServerRunning(port) {
164
+ try {
165
+ const controller = new AbortController();
166
+ const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
167
+ const res = await fetch(`http://127.0.0.1:${port}/global/health`, {
168
+ signal: controller.signal
169
+ });
170
+ clearTimeout(timeout);
171
+ return res.ok;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+ async function ensureServer(port) {
177
+ const baseUrl = `http://127.0.0.1:${port}`;
178
+ if (await isServerRunning(port)) {
179
+ return baseUrl;
180
+ }
181
+ await spawnServer(port);
182
+ for (let i = 0; i < MAX_RETRIES; i++) {
183
+ await sleep(STARTUP_WAIT_MS / MAX_RETRIES);
184
+ if (await isServerRunning(port)) {
185
+ return baseUrl;
186
+ }
187
+ }
188
+ await sleep(STARTUP_WAIT_MS);
189
+ if (await isServerRunning(port)) {
190
+ return baseUrl;
191
+ }
192
+ throw new Error(
193
+ `Failed to start OpenCode server on port ${port}. Is opencode installed? (npm i -g opencode-ai)`
194
+ );
195
+ }
196
+ async function checkOpencodeInstalled() {
197
+ const isWin = process.platform === "win32";
198
+ const checkCmd = isWin ? "where" : "which";
199
+ try {
200
+ await execa(checkCmd, ["opencode"]);
201
+ } catch {
202
+ try {
203
+ await execa("opencode", ["--version"], { timeout: 5e3 });
204
+ } catch {
205
+ throw new Error(
206
+ "opencode not found. Install it: npm i -g opencode-ai\nSee: https://opencode.ai/docs/"
207
+ );
208
+ }
209
+ }
210
+ }
211
+ async function spawnServer(port) {
212
+ const dir = await ensureDiffOwlDir();
213
+ const pidFile = join2(dir, "server.pid");
214
+ await checkOpencodeInstalled();
215
+ const subprocess = execa("opencode", ["serve", "--port", String(port)], {
216
+ detached: true,
217
+ stdio: "ignore",
218
+ cleanup: false
219
+ });
220
+ void subprocess.catch(() => {
221
+ });
222
+ if (subprocess.pid) {
223
+ await writeFile2(pidFile, String(subprocess.pid), "utf-8");
224
+ }
225
+ subprocess.unref();
226
+ }
227
+ async function stopServer() {
228
+ const dir = getDiffOwlDir();
229
+ const pidFile = join2(dir, "server.pid");
230
+ if (!existsSync2(pidFile)) return false;
231
+ let pid;
232
+ try {
233
+ pid = parseInt(await readFile2(pidFile, "utf-8"), 10);
234
+ } catch {
235
+ try {
236
+ await unlink(pidFile);
237
+ } catch {
238
+ }
239
+ return false;
240
+ }
241
+ try {
242
+ process.kill(pid, 0);
243
+ } catch {
244
+ try {
245
+ await unlink(pidFile);
246
+ } catch {
247
+ }
248
+ return false;
249
+ }
250
+ if (!await isOpencodeProcess(pid)) {
251
+ try {
252
+ await unlink(pidFile);
253
+ } catch {
254
+ }
255
+ return false;
256
+ }
257
+ try {
258
+ process.kill(pid, "SIGTERM");
259
+ await unlink(pidFile);
260
+ return true;
261
+ } catch {
262
+ return false;
263
+ }
264
+ }
265
+ async function isOpencodeProcess(pid) {
266
+ const isWin = process.platform === "win32";
267
+ try {
268
+ if (isWin) {
269
+ try {
270
+ const { stdout: stdout2 } = await execa("powershell", [
271
+ "-NoProfile",
272
+ "-Command",
273
+ `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`
274
+ ]);
275
+ if (stdout2.toLowerCase().includes("opencode")) {
276
+ return true;
277
+ }
278
+ } catch {
279
+ }
280
+ try {
281
+ const { stdout: stdout2 } = await execa("wmic", [
282
+ "process",
283
+ "where",
284
+ `ProcessId=${pid}`,
285
+ "get",
286
+ "CommandLine"
287
+ ]);
288
+ if (stdout2.toLowerCase().includes("opencode")) {
289
+ return true;
290
+ }
291
+ } catch {
292
+ }
293
+ const { stdout } = await execa("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
294
+ return stdout.toLowerCase().includes("opencode");
295
+ } else {
296
+ const { stdout } = await execa("ps", ["-p", String(pid), "-o", "command="]);
297
+ return stdout.toLowerCase().includes("opencode");
298
+ }
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+ function sleep(ms) {
304
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
305
+ }
306
+
307
+ // src/opencode/agent.ts
308
+ var REVIEW_AGENT_PROMPT = `You are DiffOwl, a meticulous senior code reviewer. Your job is to review git changes and provide actionable feedback as structured JSON.
309
+
310
+ You MAY think step-by-step internally, but your VISIBLE output must follow this contract exactly:
311
+
312
+ 1. Output a single line with the text: FINAL_REVIEW_JSON
313
+ 2. On the next line, output a single JSON object with this exact shape (no markdown fences, no comments, no trailing commas):
314
+
315
+ {
316
+ "summary": "1-3 sentences describing what the changes do and the overall risk profile.",
317
+ "findings": [
318
+ {
319
+ "severity": "error" | "warning" | "info",
320
+ "file": "relative/path/from/repo/root.ts",
321
+ "line": 123,
322
+ "evidence": "Quote the exact 1-2 lines of code showing the concern",
323
+ "title": "Short, specific issue title",
324
+ "body": "Concrete description of the problem, its impact, and a focused suggestion for how to fix it.",
325
+ "confidence": "high" | "medium" | "low"
326
+ }
327
+ ]
328
+ }
329
+
330
+ 3. Do not wrap the JSON in backticks or markdown code fences.
331
+ 4. Do not print any other text before or after the JSON line. No greetings, no explanations, no scratchpad, no commentary.
332
+
333
+ Semantics and constraints:
334
+ - "severity":
335
+ - "error" \u2014 Bugs, security vulnerabilities, crashes, data loss, or behavior that is very likely wrong and should block merge.
336
+ - "warning" \u2014 Error-handling gaps, race conditions, performance issues, surprising behavior that is likely problematic but not an immediate blocker.
337
+ - "info" \u2014 Non-blocking suggestions that are clearly improvements but may be subjective or low risk.
338
+ - "file": must be a path that exists in the repository and that is relevant to the diff you inspected.
339
+ - "line": the 1-based line number in that file that best anchors the issue (usually the first changed line or the line where the problem manifests).
340
+ - "evidence": Quote the exact 1-2 lines of code showing the concern. If you cannot quote exact code lines supporting your finding, you MUST downgrade the confidence to "low".
341
+ - "title": one short sentence fragment that could be used as a PR comment subject line.
342
+ - "body": 2-6 sentences that describe:
343
+ 1) what is wrong,
344
+ 2) why it matters (risk/impact),
345
+ 3) how to fix or improve it in concrete terms.
346
+ - "confidence":
347
+ - "high" \u2014 You are very confident this is a real issue based on the code you can see.
348
+ - "medium" \u2014 You are reasonably confident but missing some surrounding context.
349
+ - "low" \u2014 You are speculating or extrapolating beyond the visible code.
350
+
351
+ Review rules:
352
+ - Focus on substantive issues: bugs, security, logic errors, edge cases, error handling, performance.
353
+ - Prefer high-confidence findings. If you are speculating, label it "low" confidence. If you are uncertain but see a real risk, label it "medium". Only use "high" when the issue is clearly present in the visible code.
354
+ - Do NOT nitpick formatting, naming style, or cosmetic preferences.
355
+ - Do NOT suggest changes that would alter behavior without a clear, justified benefit.
356
+ - It is OK for "findings" to be an empty array if you see no meaningful issues.
357
+
358
+ Required review passes:
359
+ - Behavior and compatibility: Look for changed defaults, contracts, edge cases, and user-visible behavior regressions.
360
+ - Failure modes and error handling: Look for hangs, swallowed errors, misleading success, unbounded retries, unsafe fallbacks, and timeout behavior.
361
+ - State, lifecycle, and concurrency: Look at process/session ownership, file writes, hooks, ports, signals, async settle logic, and cleanup paths.
362
+ - Paths, environment, and portability: Check cwd vs project root, monorepos, Windows/POSIX behavior, PATH assumptions, symlinks, and shell quoting.
363
+ - Security and permissions: Check command execution, path injection, unintended reads/writes, log leakage, and permission-boundary changes.
364
+ - Output, config, and observability consistency: Check CLI output, markdown reports, hook logs, config semantics, diagnostics, truncation, and timing labels agree.
365
+ - Tests for changed behavior: Report specific missing tests for new branches, config modes, output sections, or failure paths when the gap creates regression risk.
366
+ - Performance and boundedness: Look for unbounded scans, large-file/diff cliffs, slow hook behavior, and expensive operations in common paths.
367
+ - Data filtering/loss: Look for data silently dropped, hidden, duplicated, parsed with a fallback, or reported inconsistently.
368
+ `;
369
+ function buildReviewPrompt(mode, customRules, include, exclude, localContext, depth = "default") {
370
+ const modeInstruction = mode === "staged" ? "Review the currently staged changes." : mode === "commit" ? "Review the selected commit." : "Review the last commit.";
371
+ let prompt = `${modeInstruction}
372
+
373
+ DiffOwl has already collected the diff and likely-relevant local context below. Use this context first.
374
+
375
+ ${reviewDepthInstruction(depth)}
376
+
377
+ Then provide your review following the format in your instructions.`;
378
+ if (localContext) {
379
+ prompt += `
380
+
381
+ ${localContext}`;
382
+ }
383
+ if (include && include.length > 0 && !(include.length === 1 && include[0] === "**/*")) {
384
+ prompt += `
385
+
386
+ Only review files that match these patterns: ${include.join(", ")}`;
387
+ }
388
+ if (exclude && exclude.length > 0) {
389
+ prompt += `
390
+
391
+ Ignore and do NOT review files that match these patterns: ${exclude.join(", ")}`;
392
+ }
393
+ if (customRules.length > 0) {
394
+ prompt += `
395
+
396
+ Additional review rules for this project:
397
+ ${customRules.map((r) => `- ${r}`).join("\n")}`;
398
+ }
399
+ return prompt;
400
+ }
401
+ function reviewDepthInstruction(depth) {
402
+ switch (depth) {
403
+ case "shallow":
404
+ return [
405
+ "Review depth: shallow.",
406
+ "This is a cheap, surface-level review from the provided context only.",
407
+ "Look for obvious local bugs such as off-by-one errors, inverted conditions, unsafe null handling, missing awaits, and implementation anti-patterns visible in the diff.",
408
+ "Do not claim a field, branch, call path, or validation is missing or ignored unless the provided context directly proves it. If relevant snippets are truncated, either skip the finding or mark it low confidence."
409
+ ].join("\n");
410
+ case "default":
411
+ return [
412
+ "Review depth: default.",
413
+ "Use tools for targeted exploration when the provided context is not enough.",
414
+ "This mode should catch more than surface-level diff bugs, including ignored fields, missed wiring, incorrect assumptions about adjacent code, and behavior mismatches around changed symbols.",
415
+ "Before reporting that a field, branch, validation, call, or config value is missing or ignored, inspect the relevant nearby definitions or call sites with tools when they are not fully present in the context."
416
+ ].join("\n");
417
+ }
418
+ }
419
+
420
+ // src/opencode/review-parser.ts
421
+ import { z as z2 } from "zod";
422
+ var ReviewSeveritySchema = z2.preprocess(
423
+ (value) => typeof value === "string" ? value.toLowerCase() : value,
424
+ z2.enum(["error", "warning", "info"])
425
+ );
426
+ var ReviewConfidenceSchema2 = z2.preprocess(
427
+ (value) => typeof value === "string" ? value.toLowerCase() : value,
428
+ z2.enum(["low", "medium", "high"])
429
+ ).catch("low");
430
+ var ReviewFindingLineSchema = z2.preprocess(
431
+ (value) => typeof value === "string" ? Number(value) : value,
432
+ z2.number().int().positive()
433
+ );
434
+ var ReviewFindingSchema = z2.object({
435
+ severity: ReviewSeveritySchema,
436
+ file: z2.string().trim().min(1),
437
+ line: ReviewFindingLineSchema,
438
+ evidence: z2.string().nullish(),
439
+ title: z2.string().trim().min(1),
440
+ body: z2.string().trim().min(1),
441
+ confidence: ReviewConfidenceSchema2
442
+ });
443
+ var ReviewJsonSchema = z2.object({
444
+ summary: z2.string(),
445
+ findings: z2.array(z2.unknown())
446
+ });
447
+ function parseStructuredReview(raw) {
448
+ const marker = "FINAL_REVIEW_JSON";
449
+ const markerIndex = raw.indexOf(marker);
450
+ const usedFallbackJson = markerIndex === -1;
451
+ const afterMarker = markerIndex === -1 ? raw : raw.slice(markerIndex + marker.length);
452
+ const firstBrace = afterMarker.indexOf("{");
453
+ const lastBrace = afterMarker.lastIndexOf("}");
454
+ if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
455
+ throw new Error(
456
+ markerIndex === -1 ? `Review did not contain a valid JSON object. Raw response preview: ${previewRawResponse(raw)}` : `Review did not include a valid JSON object after FINAL_REVIEW_JSON. Raw response preview: ${previewRawResponse(raw)}`
457
+ );
458
+ }
459
+ const jsonText = afterMarker.slice(firstBrace, lastBrace + 1);
460
+ let parsed;
461
+ try {
462
+ parsed = JSON.parse(jsonText);
463
+ } catch (err) {
464
+ throw new Error(
465
+ `Failed to parse review JSON: ${err.message}. Raw response preview: ${previewRawResponse(raw)}`
466
+ );
467
+ }
468
+ const root = ReviewJsonSchema.safeParse(parsed);
469
+ if (!root.success) {
470
+ throw new Error(
471
+ `Review JSON is missing required fields: summary or findings. Raw response preview: ${previewRawResponse(raw)}`
472
+ );
473
+ }
474
+ const findings = [];
475
+ const diagnostics = usedFallbackJson ? ["Review JSON did not include FINAL_REVIEW_JSON marker; parsed fallback JSON object."] : [];
476
+ const seen = /* @__PURE__ */ new Set();
477
+ for (const [index, item] of root.data.findings.entries()) {
478
+ const finding = ReviewFindingSchema.safeParse(item);
479
+ if (!finding.success) {
480
+ diagnostics.push(`Dropped malformed finding at index ${index}.`);
481
+ continue;
482
+ }
483
+ const key = `${finding.data.severity}:${finding.data.file}:${finding.data.line}:${finding.data.title}`;
484
+ if (seen.has(key)) {
485
+ continue;
486
+ }
487
+ seen.add(key);
488
+ findings.push({
489
+ severity: finding.data.severity,
490
+ file: finding.data.file,
491
+ line: finding.data.line,
492
+ ...finding.data.evidence != null ? { evidence: finding.data.evidence } : {},
493
+ title: finding.data.title,
494
+ body: finding.data.body,
495
+ confidence: finding.data.confidence
496
+ });
497
+ }
498
+ return {
499
+ summary: root.data.summary,
500
+ findings,
501
+ ...diagnostics.length > 0 ? { diagnostics } : {}
502
+ };
503
+ }
504
+ function previewRawResponse(raw) {
505
+ const compact = raw.replace(/\s+/g, " ").trim();
506
+ return compact.length > 500 ? `${compact.slice(0, 500)}...` : compact || "<empty>";
507
+ }
508
+ function looksLikeCompleteStructuredReview(text) {
509
+ const markerIndex = text.indexOf("FINAL_REVIEW_JSON");
510
+ if (markerIndex === -1) return false;
511
+ const afterMarker = text.slice(markerIndex + "FINAL_REVIEW_JSON".length);
512
+ const firstBrace = afterMarker.indexOf("{");
513
+ const lastBrace = afterMarker.lastIndexOf("}");
514
+ if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) return false;
515
+ const jsonText = afterMarker.slice(firstBrace, lastBrace + 1);
516
+ if (!jsonText.endsWith("}")) return false;
517
+ let openCount = 0;
518
+ let closeCount = 0;
519
+ let inString = false;
520
+ let escapeNext = false;
521
+ for (let i = 0; i < jsonText.length; i++) {
522
+ const char = jsonText[i];
523
+ if (escapeNext) {
524
+ escapeNext = false;
525
+ continue;
526
+ }
527
+ if (char === "\\") {
528
+ escapeNext = true;
529
+ continue;
530
+ }
531
+ if (char === '"') {
532
+ inString = !inString;
533
+ continue;
534
+ }
535
+ if (!inString) {
536
+ if (char === "{") openCount++;
537
+ else if (char === "}") closeCount++;
538
+ }
539
+ }
540
+ if (openCount === 0 || openCount !== closeCount) return false;
541
+ try {
542
+ return ReviewJsonSchema.safeParse(JSON.parse(jsonText)).success;
543
+ } catch {
544
+ return false;
545
+ }
546
+ }
547
+
548
+ // src/opencode/settlement.ts
549
+ function createReviewSettlementCoordinator(options) {
550
+ let settled = false;
551
+ let fullResponse = "";
552
+ let lastCheckedLength = 0;
553
+ let reconciliationRunning = false;
554
+ let timeoutRequested = false;
555
+ let lastReconciliationError;
556
+ const settle = (outcome, value) => {
557
+ if (settled) return;
558
+ settled = true;
559
+ clearTimeout(safetyTimeout);
560
+ clearInterval(reconciliationInterval);
561
+ options.onAbort();
562
+ if (outcome === "resolve") {
563
+ options.resolve(value);
564
+ } else {
565
+ options.reject(value);
566
+ }
567
+ };
568
+ const acceptText = (text) => {
569
+ if (text.length > fullResponse.length) {
570
+ fullResponse = text;
571
+ options.onText?.(fullResponse);
572
+ }
573
+ const trimmed = fullResponse.trim();
574
+ const endsWithBrace = trimmed.endsWith("}");
575
+ const lengthDelta = fullResponse.length - lastCheckedLength;
576
+ if (lengthDelta > 500 || endsWithBrace) {
577
+ lastCheckedLength = fullResponse.length;
578
+ if (looksLikeCompleteStructuredReview(fullResponse)) {
579
+ settle("resolve", fullResponse);
580
+ return true;
581
+ }
582
+ }
583
+ return false;
584
+ };
585
+ const reconcile = async (isTimeout) => {
586
+ if (settled || reconciliationRunning) return;
587
+ reconciliationRunning = true;
588
+ try {
589
+ const result = await options.reconcile();
590
+ if (result?.error) {
591
+ settle("reject", result.error);
592
+ return;
593
+ }
594
+ if (result?.reconciliationError) {
595
+ lastReconciliationError = result.reconciliationError;
596
+ } else if (result) {
597
+ lastReconciliationError = void 0;
598
+ }
599
+ if (result?.text && acceptText(result.text)) {
600
+ return;
601
+ }
602
+ if (isTimeout || timeoutRequested) {
603
+ const suffix = lastReconciliationError ? ` Last session reconciliation error: ${lastReconciliationError.message}` : "";
604
+ settle(
605
+ "reject",
606
+ new Error(
607
+ `Review timed out.${suffix}`,
608
+ lastReconciliationError ? { cause: lastReconciliationError } : void 0
609
+ )
610
+ );
611
+ }
612
+ } finally {
613
+ reconciliationRunning = false;
614
+ }
615
+ };
616
+ const safetyTimeout = setTimeout(() => {
617
+ timeoutRequested = true;
618
+ void reconcile(true);
619
+ }, options.timeoutMs);
620
+ const reconciliationInterval = setInterval(
621
+ () => void reconcile(false),
622
+ options.reconciliationIntervalMs ?? 1e3
623
+ );
624
+ return {
625
+ acceptText,
626
+ finish: () => {
627
+ if (settled || acceptText(fullResponse)) return;
628
+ settle(
629
+ "reject",
630
+ new Error("OpenCode event stream ended before a complete review was received.")
631
+ );
632
+ },
633
+ isSettled: () => settled,
634
+ reject: (error) => settle("reject", error),
635
+ resolve: (text) => settle("resolve", text)
636
+ };
637
+ }
638
+
639
+ // src/opencode/tools.ts
640
+ var FALLBACK_TOOL_IDS = [
641
+ "apply_patch",
642
+ "bash",
643
+ "edit",
644
+ "glob",
645
+ "grep",
646
+ "question",
647
+ "read",
648
+ "skill",
649
+ "task",
650
+ "todowrite",
651
+ "webfetch",
652
+ "write"
653
+ ];
654
+ var READ_SEARCH_TOOLS = /* @__PURE__ */ new Set(["glob", "grep", "read"]);
655
+ var PERMISSION_REPLY_TIMEOUT_MS = 5e3;
656
+ async function buildToolPolicy(client, depth) {
657
+ const available = new Set(FALLBACK_TOOL_IDS);
658
+ try {
659
+ const result = await client.tool?.ids?.();
660
+ if (Array.isArray(result?.data)) {
661
+ for (const id of result.data) {
662
+ if (typeof id === "string") {
663
+ available.add(id);
664
+ }
665
+ }
666
+ }
667
+ } catch {
668
+ }
669
+ const allowed = allowedToolsForDepth(depth);
670
+ const policy = {};
671
+ for (const id of available) {
672
+ policy[id] = allowed.has(id);
673
+ }
674
+ return policy;
675
+ }
676
+ function allowedToolsForDepth(depth) {
677
+ if (depth === "shallow") {
678
+ return /* @__PURE__ */ new Set();
679
+ }
680
+ return READ_SEARCH_TOOLS;
681
+ }
682
+ async function replyToPermissionRequest(client, permission, onProgress) {
683
+ const response = "reject";
684
+ onProgress?.({
685
+ type: "session",
686
+ message: `OpenCode permission ${response}: ${permission.title ?? permission.type}`,
687
+ sessionId: permission.sessionID
688
+ });
689
+ await withTimeout(
690
+ replyWithAvailableEndpoint(client, permission, response),
691
+ PERMISSION_REPLY_TIMEOUT_MS
692
+ );
693
+ }
694
+ async function replyWithAvailableEndpoint(client, permission, response) {
695
+ if (client.permission?.reply) {
696
+ await client.permission.reply(
697
+ { requestID: permission.id },
698
+ { body: { reply: response, message: "DiffOwl review depth policy" } }
699
+ );
700
+ return;
701
+ }
702
+ if (client.postSessionIdPermissionsPermissionId) {
703
+ await client.postSessionIdPermissionsPermissionId({
704
+ path: { id: permission.sessionID, permissionID: permission.id },
705
+ body: { response }
706
+ });
707
+ }
708
+ }
709
+ function extractPermissionRequest(payload, sessionId) {
710
+ if (!payload || typeof payload !== "object") {
711
+ return void 0;
712
+ }
713
+ const event = payload;
714
+ if (typeof event.type !== "string" || !event.properties || typeof event.properties !== "object") {
715
+ return void 0;
716
+ }
717
+ const properties = event.properties;
718
+ if (properties["sessionID"] !== sessionId) {
719
+ return void 0;
720
+ }
721
+ if (event.type === "permission.updated") {
722
+ const id = properties["id"];
723
+ const type = properties["type"];
724
+ if (typeof id !== "string" || id.trim() === "" || typeof type !== "string" || type.trim() === "") {
725
+ return void 0;
726
+ }
727
+ const title = properties["title"];
728
+ return {
729
+ id,
730
+ sessionID: sessionId,
731
+ type,
732
+ ...typeof title === "string" ? { title } : {}
733
+ };
734
+ }
735
+ if (event.type === "permission.asked") {
736
+ const id = properties["id"];
737
+ const permission = properties["permission"];
738
+ if (typeof id !== "string" || id.trim() === "" || typeof permission !== "string" || permission.trim() === "") {
739
+ return void 0;
740
+ }
741
+ const patterns = properties["patterns"];
742
+ return {
743
+ id,
744
+ sessionID: sessionId,
745
+ type: permission,
746
+ ...Array.isArray(patterns) && patterns.every((pattern) => typeof pattern === "string") ? { title: patterns.join(", ") } : {}
747
+ };
748
+ }
749
+ return void 0;
750
+ }
751
+ async function withTimeout(promise, ms) {
752
+ let timeout;
753
+ try {
754
+ return await Promise.race([
755
+ promise,
756
+ new Promise((_, reject) => {
757
+ timeout = setTimeout(() => reject(new Error("permission reply timed out")), ms);
758
+ })
759
+ ]);
760
+ } finally {
761
+ if (timeout) {
762
+ clearTimeout(timeout);
763
+ }
764
+ }
765
+ }
766
+
767
+ // src/opencode/provider-payload.ts
768
+ import { z as z3 } from "zod";
769
+ var ProviderModelSchema = z3.object({
770
+ id: z3.string(),
771
+ status: z3.string().nullish().transform((value) => value ?? void 0),
772
+ reasoning: z3.boolean().nullish().transform((value) => value ?? void 0),
773
+ capabilities: z3.object({
774
+ reasoning: z3.boolean().nullish().transform((value) => value ?? void 0)
775
+ }).nullish().transform((value) => value ?? void 0),
776
+ variants: z3.record(z3.string(), z3.unknown()).nullish().transform((value) => value ?? void 0)
777
+ }).passthrough();
778
+ var ProviderSchema = z3.object({
779
+ id: z3.string(),
780
+ models: z3.record(z3.string(), z3.unknown()).nullish().transform((models) => {
781
+ if (!models) return void 0;
782
+ return Object.fromEntries(
783
+ Object.entries(models).flatMap(([key, model]) => {
784
+ const parsed = ProviderModelSchema.safeParse(model);
785
+ return parsed.success ? [[key, parsed.data]] : [];
786
+ })
787
+ );
788
+ })
789
+ }).passthrough();
790
+ var ProviderPayloadSchema = z3.object({
791
+ connected: z3.array(z3.string()).nullish().transform((value) => value ?? []),
792
+ all: z3.array(z3.unknown()).nullish().transform(
793
+ (providers) => (providers ?? []).flatMap((provider) => {
794
+ const parsed = ProviderSchema.safeParse(provider);
795
+ return parsed.success ? [parsed.data] : [];
796
+ })
797
+ )
798
+ }).passthrough();
799
+ function parseProviderPayload(response) {
800
+ if (!response || typeof response !== "object") return void 0;
801
+ return ProviderPayloadSchema.safeParse(response.data).data;
802
+ }
803
+
804
+ // src/opencode/models.ts
805
+ import { createOpencodeClient } from "@opencode-ai/sdk";
806
+ async function getAvailableModels(port, options = {}) {
807
+ if (!await isServerRunning(port)) {
808
+ if (options.autoStart === false) {
809
+ return [];
810
+ }
811
+ try {
812
+ await ensureServer(port);
813
+ } catch {
814
+ return [];
815
+ }
816
+ }
817
+ const client = createOpencodeClient({
818
+ baseUrl: `http://127.0.0.1:${port}`
819
+ });
820
+ try {
821
+ const payload = parseProviderPayload(await client.provider.list());
822
+ return listAvailableModels(payload);
823
+ } catch {
824
+ return [];
825
+ }
826
+ }
827
+ function listAvailableModels(payload) {
828
+ if (!payload) return [];
829
+ return payload.all.filter((provider) => payload.connected.includes(provider.id)).flatMap(
830
+ (provider) => Object.values(provider.models ?? {}).filter((model) => model.status === "active" || !model.status).map((model) => `${provider.id}/${model.id}`)
831
+ ).sort();
832
+ }
833
+
834
+ // src/opencode/client.ts
835
+ function extractEventPayload(event) {
836
+ if (!event || typeof event !== "object") return void 0;
837
+ const payload = event.payload;
838
+ if (!payload || typeof payload !== "object") return void 0;
839
+ return payload;
840
+ }
841
+ async function runReview(options) {
842
+ const { mode, config, localContext, depth, onProgress } = options;
843
+ const port = config.server.port;
844
+ const directoryOptions = opencodeDirectoryOptions();
845
+ const timings = [];
846
+ const connectStart = performance.now();
847
+ if (!await isServerRunning(port)) {
848
+ throw new Error(`OpenCode server is not running on port ${port}.`);
849
+ }
850
+ onProgress?.({ type: "server", message: `Connected to OpenCode on port ${port}.` });
851
+ const client = createOpencodeClient2({
852
+ baseUrl: `http://127.0.0.1:${port}`
853
+ });
854
+ recordTiming(timings, onProgress, "opencode-connect", "OpenCode client connection", connectStart);
855
+ const sessionStart = performance.now();
856
+ const session = await withOpenCodeDiagnostics(
857
+ "session-create",
858
+ { port },
859
+ () => client.session.create({
860
+ ...directoryOptions,
861
+ body: {}
862
+ })
863
+ );
864
+ const sessionId = extractSessionId(session);
865
+ recordTiming(timings, onProgress, "session-create", "OpenCode session creation", sessionStart);
866
+ onProgress?.({ type: "session", message: "Created review session.", sessionId });
867
+ const toolPolicyStart = performance.now();
868
+ const tools = await buildToolPolicy(client, depth);
869
+ recordTiming(timings, onProgress, "tool-policy", "OpenCode tool policy", toolPolicyStart);
870
+ const promptStart = performance.now();
871
+ const prompt = buildReviewPrompt(
872
+ mode,
873
+ config.rules,
874
+ config.include,
875
+ config.exclude,
876
+ localContext,
877
+ depth
878
+ );
879
+ recordTiming(timings, onProgress, "prompt-build", "Review prompt build", promptStart);
880
+ const parts = config.model.split("/");
881
+ const providerID = parts[0];
882
+ const modelID = parts.slice(1).join("/");
883
+ const reasoning = await resolveReasoningVariant(
884
+ client,
885
+ providerID,
886
+ modelID,
887
+ config.reasoning.effort
888
+ );
889
+ let fullResponse = "";
890
+ const eventsController = new AbortController();
891
+ const eventStart = performance.now();
892
+ const sseResult = await withOpenCodeDiagnostics(
893
+ "event-stream-connect",
894
+ { port, sessionId },
895
+ () => client.global.event({
896
+ signal: eventsController.signal
897
+ })
898
+ );
899
+ recordTiming(timings, onProgress, "event-stream", "OpenCode event stream connection", eventStart);
900
+ const responsePromise = handledAwaitable(
901
+ new Promise((resolve2, reject) => {
902
+ const assistantMessageIds = /* @__PURE__ */ new Set();
903
+ const textPartsByMessageId = /* @__PURE__ */ new Map();
904
+ const settlement = createReviewSettlementCoordinator({
905
+ timeoutMs: config.timeout * 1e3,
906
+ reconcile: () => reconcileSessionMessages(client, directoryOptions, sessionId),
907
+ onAbort: () => eventsController.abort(),
908
+ onText: (text) => {
909
+ fullResponse = text;
910
+ onProgress?.({
911
+ type: "output",
912
+ message: `Review response received (${fullResponse.length} chars).`,
913
+ characters: fullResponse.length
914
+ });
915
+ },
916
+ resolve: resolve2,
917
+ reject
918
+ });
919
+ (async () => {
920
+ try {
921
+ for await (const event of sseResult.stream) {
922
+ if (settlement.isSettled()) break;
923
+ const payload = extractEventPayload(event);
924
+ if (!payload) continue;
925
+ const permission = extractPermissionRequest(payload, sessionId);
926
+ if (permission) {
927
+ void replyToPermissionRequest(client, permission, onProgress).catch((err) => {
928
+ onProgress?.({
929
+ type: "session",
930
+ message: `OpenCode permission reply failed: ${err instanceof Error ? err.message : String(err)}`,
931
+ sessionId
932
+ });
933
+ });
934
+ continue;
935
+ }
936
+ const sessionError = extractSessionError(payload, sessionId);
937
+ if (sessionError) {
938
+ settlement.reject(sessionError);
939
+ break;
940
+ }
941
+ if (payload.type === "message.part.updated" && payload.properties?.part?.sessionID === sessionId) {
942
+ const part = payload.properties.part;
943
+ if (part.type === "tool" && typeof part.tool === "string") {
944
+ const state = typeof part.state?.status === "string" ? part.state.status : "unknown";
945
+ const title = typeof part.state?.title === "string" ? part.state.title : part.tool;
946
+ onProgress?.({
947
+ type: "tool",
948
+ message: `${title} (${state})`,
949
+ tool: part.tool,
950
+ status: state
951
+ });
952
+ }
953
+ if (part.type === "text" && typeof part.messageID === "string" && typeof part.text === "string" && part.text) {
954
+ textPartsByMessageId.set(part.messageID, part.text);
955
+ if (assistantMessageIds.has(part.messageID) && settlement.acceptText(part.text)) {
956
+ break;
957
+ }
958
+ }
959
+ }
960
+ if (payload.type === "message.updated" && payload.properties?.info?.sessionID === sessionId) {
961
+ const msg = payload.properties?.info;
962
+ if (msg?.role === "assistant" && typeof msg.id === "string") {
963
+ assistantMessageIds.add(msg.id);
964
+ const text = textPartsByMessageId.get(msg.id);
965
+ if (text && settlement.acceptText(text)) {
966
+ break;
967
+ }
968
+ if (msg.error) {
969
+ const message = typeof msg.error.data?.message === "string" ? msg.error.data.message : "Review failed";
970
+ settlement.reject(new Error(message));
971
+ break;
972
+ }
973
+ }
974
+ }
975
+ if (payload.type === "session.status" && payload.properties?.sessionID === sessionId) {
976
+ const status = payload.properties.status;
977
+ if (!status || typeof status.type !== "string") continue;
978
+ const message = status.type === "retry" ? `OpenCode retrying: ${typeof status.message === "string" ? status.message : "unknown error"}` : `OpenCode session ${status.type}.`;
979
+ onProgress?.({ type: "session", message, sessionId });
980
+ }
981
+ if (payload.type === "session.idle" && payload.properties?.sessionID === sessionId && fullResponse.length > 0) {
982
+ onProgress?.({ type: "idle", message: "OpenCode session is idle." });
983
+ settlement.finish();
984
+ break;
985
+ }
986
+ }
987
+ if (!settlement.isSettled()) {
988
+ settlement.finish();
989
+ }
990
+ } catch (streamErr) {
991
+ if (!settlement.isSettled() && !eventsController.signal.aborted) {
992
+ settlement.reject(
993
+ describeOpenCodeError(streamErr, "event-stream-read", { port, sessionId })
994
+ );
995
+ }
996
+ }
997
+ })();
998
+ })
999
+ );
1000
+ onProgress?.({ type: "session", message: "Sending review prompt.", sessionId });
1001
+ const promptSendStart = performance.now();
1002
+ await withOpenCodeDiagnostics(
1003
+ "prompt-send",
1004
+ { port, sessionId },
1005
+ () => client.session.promptAsync({
1006
+ path: { id: sessionId },
1007
+ ...directoryOptions,
1008
+ body: {
1009
+ system: REVIEW_AGENT_PROMPT,
1010
+ model: { providerID, modelID },
1011
+ tools,
1012
+ ...reasoning.variant ? { variant: reasoning.variant } : {},
1013
+ parts: [{ type: "text", text: prompt }]
1014
+ }
1015
+ })
1016
+ );
1017
+ recordTiming(timings, onProgress, "prompt-send", "OpenCode prompt request", promptSendStart);
1018
+ const agentWaitStart = performance.now();
1019
+ const raw = await withOpenCodeDiagnostics(
1020
+ "agent-wait",
1021
+ { port, sessionId },
1022
+ () => responsePromise
1023
+ );
1024
+ recordTiming(timings, onProgress, "agent-wait", "OpenCode review generation", agentWaitStart);
1025
+ const parseStart = performance.now();
1026
+ const report = parseStructuredReview(raw);
1027
+ recordTiming(timings, onProgress, "parse-review", "Review JSON parsing", parseStart);
1028
+ const diagnostics = [...report.diagnostics ?? [], ...reasoning.diagnostics];
1029
+ return {
1030
+ report: { ...report, ...diagnostics.length > 0 ? { diagnostics } : {}, timings },
1031
+ sessionId
1032
+ };
1033
+ }
1034
+ function extractSessionError(payload, sessionId) {
1035
+ if (!payload || typeof payload !== "object") return void 0;
1036
+ const event = payload;
1037
+ if (event.type !== "session.error" || event.properties?.sessionID !== sessionId) {
1038
+ return void 0;
1039
+ }
1040
+ return new Error(`OpenCode session failed: ${describeSessionError(event.properties.error)}`);
1041
+ }
1042
+ function extractSessionMessageResult(response) {
1043
+ if (!response || typeof response !== "object") return void 0;
1044
+ const data = response.data;
1045
+ if (!Array.isArray(data)) return void 0;
1046
+ for (let index = data.length - 1; index >= 0; index--) {
1047
+ const message = data[index];
1048
+ if (!message || typeof message !== "object") continue;
1049
+ const info = message.info;
1050
+ if (!info || typeof info !== "object" || info.role !== "assistant") {
1051
+ continue;
1052
+ }
1053
+ const error = info.error;
1054
+ if (error) {
1055
+ return { error: new Error(`OpenCode session failed: ${describeSessionError(error)}`) };
1056
+ }
1057
+ const parts = message.parts;
1058
+ if (!Array.isArray(parts)) continue;
1059
+ const text = parts.filter(
1060
+ (part) => Boolean(
1061
+ part && typeof part === "object" && part.type === "text" && typeof part.text === "string"
1062
+ )
1063
+ ).map((part) => part.text).join("");
1064
+ if (text) return { text };
1065
+ }
1066
+ return void 0;
1067
+ }
1068
+ async function reconcileSessionMessages(client, directoryOptions, sessionId) {
1069
+ try {
1070
+ const response = await Promise.race([
1071
+ client.session.messages({
1072
+ path: { id: sessionId },
1073
+ ...directoryOptions,
1074
+ query: { ...directoryOptions.query, limit: 10 }
1075
+ }),
1076
+ new Promise((_, reject) => {
1077
+ setTimeout(() => reject(new Error("Session reconciliation timed out.")), 2e3);
1078
+ })
1079
+ ]);
1080
+ return extractSessionMessageResult(response);
1081
+ } catch (error) {
1082
+ return {
1083
+ reconciliationError: error instanceof Error ? error : new Error(`Session reconciliation failed: ${String(error)}`)
1084
+ };
1085
+ }
1086
+ }
1087
+ function describeSessionError(error) {
1088
+ if (typeof error === "string" && error.trim() !== "") return error;
1089
+ if (!error || typeof error !== "object") return "unknown session error";
1090
+ const value = error;
1091
+ if (typeof value.data?.message === "string" && value.data.message.trim() !== "") {
1092
+ return value.data.message;
1093
+ }
1094
+ if (typeof value.message === "string" && value.message.trim() !== "") {
1095
+ return value.message;
1096
+ }
1097
+ if (typeof value.name === "string" && value.name.trim() !== "") {
1098
+ return value.name;
1099
+ }
1100
+ return "unknown session error";
1101
+ }
1102
+ async function resolveReasoningVariant(client, providerID, modelID, effort) {
1103
+ if (effort === "auto") {
1104
+ return { diagnostics: [] };
1105
+ }
1106
+ const variant = effort;
1107
+ const model = await getProviderModelMetadata(client, providerID, modelID);
1108
+ if (!model) {
1109
+ return { variant, diagnostics: [] };
1110
+ }
1111
+ if (model.reasoning === false) {
1112
+ return {
1113
+ diagnostics: [
1114
+ `Reasoning variant "${variant}" was requested, but ${providerID}/${modelID} does not advertise reasoning support; continuing with provider default.`
1115
+ ]
1116
+ };
1117
+ }
1118
+ if (model.variants && !model.variants.has(variant)) {
1119
+ return {
1120
+ diagnostics: [
1121
+ `Reasoning variant "${variant}" was requested, but ${providerID}/${modelID} does not advertise that variant; continuing with provider default.`
1122
+ ]
1123
+ };
1124
+ }
1125
+ return { variant, diagnostics: [] };
1126
+ }
1127
+ async function getProviderModelMetadata(client, providerID, modelID) {
1128
+ try {
1129
+ const providerList = client.provider?.list;
1130
+ if (!providerList) {
1131
+ return void 0;
1132
+ }
1133
+ const payload = parseProviderPayload(await providerList());
1134
+ if (!payload) return void 0;
1135
+ const provider = payload.all.find((item) => item.id === providerID);
1136
+ const models = provider?.models;
1137
+ if (!models) return void 0;
1138
+ for (const model of Object.values(models)) {
1139
+ if (model.id !== modelID) continue;
1140
+ const reasoning = model.capabilities?.reasoning ?? model.reasoning;
1141
+ const variants = model.variants ? new Set(Object.keys(model.variants)) : void 0;
1142
+ return {
1143
+ ...reasoning !== void 0 ? { reasoning } : {},
1144
+ ...variants ? { variants } : {}
1145
+ };
1146
+ }
1147
+ } catch {
1148
+ }
1149
+ return void 0;
1150
+ }
1151
+ async function withOpenCodeDiagnostics(phase, context, run) {
1152
+ try {
1153
+ return await run();
1154
+ } catch (err) {
1155
+ throw describeOpenCodeError(err, phase, context);
1156
+ }
1157
+ }
1158
+ function describeOpenCodeError(err, phase, context) {
1159
+ const original = err instanceof Error ? err : new Error(String(err));
1160
+ const cause = describeErrorCause(original);
1161
+ const details = [
1162
+ `phase=${phase}`,
1163
+ `server=http://127.0.0.1:${context.port}`,
1164
+ ...context.sessionId ? [`session=${context.sessionId}`] : [],
1165
+ `cause=${cause}`
1166
+ ];
1167
+ const message = `OpenCode request failed (${details.join(", ")}).`;
1168
+ return new Error(message, { cause: original });
1169
+ }
1170
+ function describeErrorCause(err) {
1171
+ const parts = [err.name, err.message].filter(Boolean);
1172
+ const cause = err.cause;
1173
+ if (cause instanceof Error) {
1174
+ parts.push(`cause=${cause.name}: ${cause.message}`);
1175
+ } else if (cause) {
1176
+ parts.push(`cause=${String(cause)}`);
1177
+ }
1178
+ return parts.join(": ") || "unknown error";
1179
+ }
1180
+ function opencodeDirectoryOptions() {
1181
+ return { query: { directory: process.cwd() } };
1182
+ }
1183
+ function handledAwaitable(promise) {
1184
+ promise.catch(() => {
1185
+ });
1186
+ return promise;
1187
+ }
1188
+ function extractSessionId(response) {
1189
+ if (!response || typeof response !== "object") {
1190
+ throw new Error("OpenCode session response missing id.");
1191
+ }
1192
+ const data = response.data;
1193
+ if (!data || typeof data !== "object") {
1194
+ throw new Error("OpenCode session response missing id.");
1195
+ }
1196
+ const id = data.id;
1197
+ if (typeof id !== "string" || id.trim() === "") {
1198
+ throw new Error("OpenCode session response missing id.");
1199
+ }
1200
+ return id;
1201
+ }
1202
+ function recordTiming(timings, onProgress, phase, label, start) {
1203
+ const ms = performance.now() - start;
1204
+ timings.push({ phase, label, ms });
1205
+ onProgress?.({ type: "timing", message: `${label}: ${formatDuration(ms)}`, phase, ms });
1206
+ }
1207
+ function formatDuration(ms) {
1208
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
1209
+ return `${(ms / 1e3).toFixed(1)}s`;
1210
+ }
1211
+
1212
+ // src/opencode/guidance.ts
1213
+ function getOpenCodeFailureGuidance(message) {
1214
+ const normalized = message.toLowerCase();
1215
+ if (normalized.includes("opencode not found") || normalized.includes("opencode: command not found") || normalized.includes("enoent") && normalized.includes("opencode")) {
1216
+ return [
1217
+ "Install OpenCode: npm install --global opencode-ai",
1218
+ "Run `opencode`, connect a provider, then retry the DiffOwl command.",
1219
+ "Docs: https://opencode.ai/docs/"
1220
+ ];
1221
+ }
1222
+ if (normalized.includes("unauthorized") || normalized.includes("authentication") || normalized.includes("invalid api key") || normalized.includes("missing api key") || /\b(401|403)\b/.test(normalized) || normalized.includes("no active provider") || normalized.includes("no connected provider") || normalized.includes("model not found") || normalized.includes("unknown model")) {
1223
+ return [
1224
+ "Run `opencode` and connect or re-authenticate a provider.",
1225
+ "Confirm a model is available, then retry the DiffOwl command."
1226
+ ];
1227
+ }
1228
+ if (normalized.includes("server is not running") || normalized.includes("failed to start opencode server") || normalized.includes("econnrefused") || normalized.includes("connection refused")) {
1229
+ return ["Start the managed server: diffowl server start", "Then retry the DiffOwl command."];
1230
+ }
1231
+ if (normalized.includes("timed out") || normalized.includes("timeout")) {
1232
+ return ["Retry with less context: diffowl review --depth shallow"];
1233
+ }
1234
+ return [];
1235
+ }
1236
+
1237
+ // src/opencode/model-selection.ts
1238
+ function canSelectModelInteractively(inputIsTTY, outputIsTTY) {
1239
+ return inputIsTTY === true && outputIsTTY === true;
1240
+ }
1241
+ function selectModel(models, currentModel, answer, allowKeepCurrent) {
1242
+ const trimmed = answer.trim();
1243
+ if (trimmed === "") {
1244
+ if (allowKeepCurrent) return { type: "kept", model: currentModel };
1245
+ if (models.length === 0) return { type: "invalid" };
1246
+ return { type: "selected", model: models[0] };
1247
+ }
1248
+ if (!/^\d+$/.test(trimmed)) return { type: "invalid" };
1249
+ const selection = Number.parseInt(trimmed, 10);
1250
+ if (!Number.isInteger(selection) || selection < 1 || selection > models.length) {
1251
+ return { type: "invalid" };
1252
+ }
1253
+ return { type: "selected", model: models[selection - 1] };
1254
+ }
1255
+
1256
+ // src/git/hooks.ts
1257
+ import { appendFile, chmod, mkdir as mkdir2, readFile as readFile4, readdir, unlink as unlink2, writeFile as writeFile4 } from "fs/promises";
1258
+ import {
1259
+ closeSync,
1260
+ existsSync as existsSync3,
1261
+ openSync,
1262
+ readFileSync,
1263
+ unlinkSync,
1264
+ writeFileSync,
1265
+ writeSync
1266
+ } from "fs";
1267
+ import { dirname as dirname2, join as join3 } from "path";
1268
+ import { fileURLToPath } from "url";
1269
+ import { execa as execa2 } from "execa";
1270
+ import { z as z4 } from "zod";
1271
+
1272
+ // src/review/retention.ts
1273
+ import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
1274
+ async function trimHookLog(logFile, maxBytes) {
1275
+ if (maxBytes === 0) return;
1276
+ try {
1277
+ const content = await readFile3(logFile);
1278
+ if (content.length <= maxBytes) return;
1279
+ await writeFile3(logFile, content.subarray(content.length - maxBytes));
1280
+ } catch {
1281
+ }
1282
+ }
1283
+
1284
+ // src/git/hooks.ts
1285
+ var HOOK_MARKER = "# diffowl-managed";
1286
+ var HOOK_END_MARKER = "# end-diffowl";
1287
+ var HOOK_SHEBANG = "#!/bin/sh";
1288
+ function loggedStdio(outFd) {
1289
+ return ["ignore", outFd, outFd];
1290
+ }
1291
+ async function getHooksDir() {
1292
+ const { stdout } = await execa2("git", ["rev-parse", "--git-dir"]);
1293
+ return join3(stdout.trim(), "hooks");
1294
+ }
1295
+ async function installHook() {
1296
+ const hooksDir = await getHooksDir();
1297
+ const hookPath = join3(hooksDir, "post-commit");
1298
+ const command = await resolveHookCommand();
1299
+ if (existsSync3(hookPath)) {
1300
+ const existing = await readFile4(hookPath, "utf-8");
1301
+ const base = existing.includes(HOOK_MARKER) || existing.includes("# commitdog-managed") ? removeManagedSection(existing) : existing.trimEnd();
1302
+ const hookSection = generateManagedSection(command);
1303
+ const updated = base && !isOnlyShebangs(base) ? `${base}
1304
+
1305
+ ${hookSection}` : generateHookScript(command);
1306
+ await writeFile4(hookPath, updated, "utf-8");
1307
+ } else {
1308
+ await writeFile4(hookPath, generateHookScript(command), "utf-8");
1309
+ }
1310
+ await chmod(hookPath, 493);
1311
+ return hookPath;
1312
+ }
1313
+ async function uninstallHook() {
1314
+ const hooksDir = await getHooksDir();
1315
+ const hookPath = join3(hooksDir, "post-commit");
1316
+ if (!existsSync3(hookPath)) return false;
1317
+ const content = await readFile4(hookPath, "utf-8");
1318
+ if (!content.includes(HOOK_MARKER) && !content.includes("# commitdog-managed")) return false;
1319
+ const cleaned = removeManagedSection(content);
1320
+ if (isOnlyShebangs(cleaned) || cleaned === "") {
1321
+ await unlink2(hookPath);
1322
+ } else {
1323
+ await writeFile4(hookPath, cleaned + "\n", "utf-8");
1324
+ }
1325
+ return true;
1326
+ }
1327
+ async function isHookInstalled() {
1328
+ const hooksDir = await getHooksDir();
1329
+ const hookPath = join3(hooksDir, "post-commit");
1330
+ if (!existsSync3(hookPath)) return false;
1331
+ const content = await readFile4(hookPath, "utf-8");
1332
+ return content.includes(HOOK_MARKER);
1333
+ }
1334
+ var HookFailureSchema = z4.object({
1335
+ commit: z4.string().min(1).optional(),
1336
+ exitCode: z4.number().int(),
1337
+ timestamp: z4.string(),
1338
+ message: z4.string().optional()
1339
+ });
1340
+ async function checkRecentHookFailure() {
1341
+ const statusPath = join3(getDiffOwlDir(), "last-hook-status.json");
1342
+ if (!existsSync3(statusPath)) {
1343
+ return void 0;
1344
+ }
1345
+ try {
1346
+ const raw = await readFile4(statusPath, "utf-8");
1347
+ const parsed = HookFailureSchema.safeParse(JSON.parse(raw));
1348
+ if (!parsed.success) return void 0;
1349
+ const { commit, exitCode, timestamp, message } = parsed.data;
1350
+ if (exitCode === 0) {
1351
+ return void 0;
1352
+ }
1353
+ const failureTime = new Date(timestamp).getTime();
1354
+ const oneHourAgo = Date.now() - 60 * 60 * 1e3;
1355
+ if (Number.isNaN(failureTime) || failureTime < oneHourAgo) {
1356
+ return void 0;
1357
+ }
1358
+ return {
1359
+ ...commit ? { commit } : {},
1360
+ exitCode,
1361
+ timestamp,
1362
+ ...message ? { message } : {}
1363
+ };
1364
+ } catch {
1365
+ return void 0;
1366
+ }
1367
+ }
1368
+ function formatHookFailure(failure) {
1369
+ const detail = failure.message ? `: ${failure.message}` : "";
1370
+ const header = `Post-commit hook failed at ${new Date(failure.timestamp).toLocaleString()}${detail}. Check .diffowl/hook.log`;
1371
+ if (!failure.commit) return header;
1372
+ return `${header}
1373
+ Retry:
1374
+ diffowl review --commit ${failure.commit}
1375
+ diffowl review --commit ${failure.commit} --depth shallow`;
1376
+ }
1377
+ async function runHookReview() {
1378
+ const dir = await ensureDiffOwlDir();
1379
+ const logFile = join3(dir, "hook.log");
1380
+ const latestReport = join3(dir, "reviews", "latest.md");
1381
+ const lockFile = join3(dir, "hook-review.lock");
1382
+ const commit = await getHeadCommit();
1383
+ await enqueuePendingReview(dir, commit);
1384
+ if (!acquireHookReviewLock(lockFile)) {
1385
+ console.log(`diffowl: review queued for ${commit}; another hook review is already running`);
1386
+ return;
1387
+ }
1388
+ const config = await loadConfig();
1389
+ await trimHookLog(logFile, config.retention.hook_log_kb * 1024);
1390
+ const outFd = openSync(logFile, "a");
1391
+ try {
1392
+ writeSync(
1393
+ outFd,
1394
+ `diffowl: review worker started at ${(/* @__PURE__ */ new Date()).toString()}; latest report: ${latestReport}
1395
+ `
1396
+ );
1397
+ const command = await resolveHookCommand();
1398
+ const prefix = command.pathDirs?.join(":");
1399
+ const existingPath = process.env["PATH"] ?? "";
1400
+ const envPath = prefix ? `${prefix}:${existingPath}` : existingPath;
1401
+ const subprocess = execa2(process.execPath, [fileURLToPath(import.meta.url), "hook-worker"], {
1402
+ detached: true,
1403
+ cleanup: false,
1404
+ cwd: process.cwd(),
1405
+ stdio: loggedStdio(outFd),
1406
+ env: {
1407
+ ...process.env,
1408
+ PATH: envPath,
1409
+ DIFFOWL_HOOK_LOCK: lockFile
1410
+ }
1411
+ });
1412
+ void subprocess.catch(() => {
1413
+ });
1414
+ if (subprocess.pid) {
1415
+ writeFileSync(lockFile, String(subprocess.pid), "utf-8");
1416
+ }
1417
+ subprocess.unref();
1418
+ console.log(
1419
+ `diffowl: review queued for ${commit}; worker started in background; log: ${logFile}; latest report: ${latestReport}`
1420
+ );
1421
+ } catch (err) {
1422
+ releaseHookReviewLock(lockFile);
1423
+ throw err;
1424
+ } finally {
1425
+ closeSync(outFd);
1426
+ }
1427
+ }
1428
+ async function runPendingHookReviews() {
1429
+ const dir = await ensureDiffOwlDir();
1430
+ const logFile = join3(dir, "hook.log");
1431
+ const cli = fileURLToPath(import.meta.url);
1432
+ const attempted = /* @__PURE__ */ new Set();
1433
+ while (true) {
1434
+ const pending = await listPendingReviews(dir);
1435
+ const next = pending.find((item) => !attempted.has(item.sha));
1436
+ if (!next) return;
1437
+ attempted.add(next.sha);
1438
+ const outFd = openSync(logFile, "a");
1439
+ const resultPath = join3(dir, "pending-reviews", `${next.sha}.result.json`);
1440
+ try {
1441
+ writeSync(outFd, `diffowl: reviewing queued commit ${next.sha}
1442
+ `);
1443
+ try {
1444
+ await unlink2(resultPath);
1445
+ } catch {
1446
+ }
1447
+ const env = { ...process.env };
1448
+ delete env["DIFFOWL_HOOK_LOCK"];
1449
+ env["DIFFOWL_HOOK_RESULT"] = resultPath;
1450
+ try {
1451
+ await execa2(process.execPath, [cli, "review", "--hook", "--commit", next.sha], {
1452
+ cwd: process.cwd(),
1453
+ stdio: loggedStdio(outFd),
1454
+ env
1455
+ });
1456
+ } catch (error) {
1457
+ writeSync(
1458
+ outFd,
1459
+ `diffowl: queued review ${next.sha} failed to run: ${error instanceof Error ? error.message : String(error)}
1460
+ `
1461
+ );
1462
+ continue;
1463
+ }
1464
+ } finally {
1465
+ closeSync(outFd);
1466
+ }
1467
+ const status = await readHookResult(resultPath);
1468
+ if (status?.exitCode !== 0 || status.message) {
1469
+ continue;
1470
+ }
1471
+ try {
1472
+ await unlink2(next.path);
1473
+ } catch (error) {
1474
+ await appendFile(
1475
+ logFile,
1476
+ `diffowl: failed to remove pending marker for ${next.sha}: ${error instanceof Error ? error.message : String(error)}
1477
+ `,
1478
+ "utf-8"
1479
+ ).catch(() => {
1480
+ });
1481
+ continue;
1482
+ }
1483
+ try {
1484
+ await unlink2(resultPath);
1485
+ } catch {
1486
+ }
1487
+ }
1488
+ }
1489
+ async function enqueuePendingReview(dir, sha) {
1490
+ const pendingDir = join3(dir, "pending-reviews");
1491
+ await mkdir2(pendingDir, { recursive: true });
1492
+ const marker = join3(pendingDir, sha);
1493
+ if (existsSync3(marker)) return;
1494
+ await writeFile4(
1495
+ marker,
1496
+ JSON.stringify({ sha, queuedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
1497
+ "utf-8"
1498
+ );
1499
+ }
1500
+ async function listPendingReviews(dir) {
1501
+ const pendingDir = join3(dir, "pending-reviews");
1502
+ let files;
1503
+ try {
1504
+ files = await readdir(pendingDir);
1505
+ } catch {
1506
+ return [];
1507
+ }
1508
+ const markerFiles = new Set(files.filter((file) => !file.endsWith(".result.json")));
1509
+ await Promise.all(
1510
+ files.filter((file) => file.endsWith(".result.json")).filter((file) => !markerFiles.has(file.slice(0, -".result.json".length))).map((file) => unlink2(join3(pendingDir, file)).catch(() => {
1511
+ }))
1512
+ );
1513
+ const pending = await Promise.all(
1514
+ [...markerFiles].map(async (file) => {
1515
+ const path = join3(pendingDir, file);
1516
+ try {
1517
+ const parsed = JSON.parse(await readFile4(path, "utf-8"));
1518
+ if (typeof parsed.sha !== "string" || typeof parsed.queuedAt !== "string") {
1519
+ return void 0;
1520
+ }
1521
+ return { sha: parsed.sha, queuedAt: parsed.queuedAt, path };
1522
+ } catch {
1523
+ return void 0;
1524
+ }
1525
+ })
1526
+ );
1527
+ return pending.filter((item) => item !== void 0).sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
1528
+ }
1529
+ async function getHeadCommit() {
1530
+ const { stdout } = await execa2("git", ["rev-parse", "--verify", "HEAD"]);
1531
+ return stdout.trim();
1532
+ }
1533
+ async function readHookResult(path) {
1534
+ try {
1535
+ const parsed = HookFailureSchema.safeParse(JSON.parse(await readFile4(path, "utf-8")));
1536
+ if (!parsed.success) return void 0;
1537
+ return {
1538
+ ...parsed.data.commit ? { commit: parsed.data.commit } : {},
1539
+ exitCode: parsed.data.exitCode,
1540
+ timestamp: parsed.data.timestamp,
1541
+ ...parsed.data.message ? { message: parsed.data.message } : {}
1542
+ };
1543
+ } catch {
1544
+ return void 0;
1545
+ }
1546
+ }
1547
+ function acquireHookReviewLock(lockFile) {
1548
+ try {
1549
+ const fd = openSync(lockFile, "wx");
1550
+ try {
1551
+ writeSync(fd, String(process.pid));
1552
+ } finally {
1553
+ closeSync(fd);
1554
+ }
1555
+ return true;
1556
+ } catch {
1557
+ if (isHookReviewLockActive(lockFile)) return false;
1558
+ releaseHookReviewLock(lockFile);
1559
+ try {
1560
+ const fd = openSync(lockFile, "wx");
1561
+ try {
1562
+ writeSync(fd, String(process.pid));
1563
+ } finally {
1564
+ closeSync(fd);
1565
+ }
1566
+ return true;
1567
+ } catch {
1568
+ return false;
1569
+ }
1570
+ }
1571
+ }
1572
+ function releaseHookReviewLock(lockFile) {
1573
+ try {
1574
+ unlinkSync(lockFile);
1575
+ } catch {
1576
+ }
1577
+ }
1578
+ function isHookReviewLockActive(lockFile) {
1579
+ try {
1580
+ const pid = Number.parseInt(readFileSync(lockFile, "utf-8"), 10);
1581
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1582
+ process.kill(pid, 0);
1583
+ return true;
1584
+ } catch {
1585
+ return false;
1586
+ }
1587
+ }
1588
+ async function checkHookStale() {
1589
+ let hooksDir;
1590
+ try {
1591
+ hooksDir = await getHooksDir();
1592
+ } catch {
1593
+ return { installed: false, stale: false, reason: "Not a git repository" };
1594
+ }
1595
+ const hookPath = join3(hooksDir, "post-commit");
1596
+ if (!existsSync3(hookPath)) {
1597
+ return { installed: false, stale: false, reason: "No post-commit hook found" };
1598
+ }
1599
+ let content;
1600
+ try {
1601
+ content = await readFile4(hookPath, "utf-8");
1602
+ } catch (err) {
1603
+ const message = err instanceof Error ? err.message : String(err);
1604
+ return { installed: true, stale: false, reason: `Cannot read hook file: ${message}` };
1605
+ }
1606
+ if (!content.includes(HOOK_MARKER)) {
1607
+ return { installed: false, stale: false, reason: "Hook exists but is not diffowl-managed" };
1608
+ }
1609
+ let command;
1610
+ try {
1611
+ command = await resolveHookCommand();
1612
+ } catch (err) {
1613
+ const message = err instanceof Error ? err.message : String(err);
1614
+ return { installed: true, stale: false, reason: `Cannot resolve diffowl command: ${message}` };
1615
+ }
1616
+ const expected = generateManagedSection(command);
1617
+ const actual = extractManagedSection(content);
1618
+ if (!actual) {
1619
+ return { installed: true, stale: true, reason: "Could not extract managed section" };
1620
+ }
1621
+ if (actual.trim() !== expected.trim()) {
1622
+ return {
1623
+ installed: true,
1624
+ stale: true,
1625
+ reason: "Managed section differs from current generator"
1626
+ };
1627
+ }
1628
+ return { installed: true, stale: false };
1629
+ }
1630
+ function extractManagedSection(content) {
1631
+ const lines = content.split("\n");
1632
+ const ourStart = lines.findIndex((line) => line.includes(HOOK_MARKER));
1633
+ if (ourStart === -1) return void 0;
1634
+ const ourEnd = lines.findIndex(
1635
+ (line, index) => index > ourStart && line.includes(HOOK_END_MARKER)
1636
+ );
1637
+ if (ourEnd === -1) return void 0;
1638
+ return lines.slice(ourStart, ourEnd + 1).join("\n");
1639
+ }
1640
+ async function resolveHookCommand() {
1641
+ const diffowl = await resolveCommand("diffowl");
1642
+ const opencode = await resolveCommand("opencode");
1643
+ const node = process.execPath;
1644
+ return {
1645
+ diffowl,
1646
+ node,
1647
+ cli: fileURLToPath(import.meta.url),
1648
+ pathDirs: uniqueDirs([node, diffowl, opencode])
1649
+ };
1650
+ }
1651
+ function uniqueDirs(paths) {
1652
+ const dirs = /* @__PURE__ */ new Set();
1653
+ for (const path of paths) {
1654
+ if (path.includes("/") || path.includes("\\")) {
1655
+ dirs.add(dirname2(path));
1656
+ }
1657
+ }
1658
+ return [...dirs];
1659
+ }
1660
+ async function resolveCommand(command) {
1661
+ const isWin = process.platform === "win32";
1662
+ try {
1663
+ if (isWin) {
1664
+ const { stdout } = await execa2("where", [command]);
1665
+ const lines = stdout.trim().split("\r\n").map((l) => l.trim()).filter(Boolean);
1666
+ return lines[0] || command;
1667
+ } else {
1668
+ const { stdout } = await execa2("which", [command]);
1669
+ return stdout.trim() || command;
1670
+ }
1671
+ } catch {
1672
+ return command;
1673
+ }
1674
+ }
1675
+ function removeManagedSection(content) {
1676
+ let lines = content.split("\n");
1677
+ lines = removeSectionByMarkers(lines, "# diffowl-managed", "# end-diffowl");
1678
+ lines = removeSectionByMarkers(lines, "# commitdog-managed", "# end-commitdog");
1679
+ return lines.join("\n").trim();
1680
+ }
1681
+ function removeSectionByMarkers(lines, startMarker, endMarker) {
1682
+ const start = lines.findIndex((line) => line.includes(startMarker));
1683
+ if (start === -1) return lines;
1684
+ const end = lines.findIndex((line, index) => index > start && line.includes(endMarker));
1685
+ const endIndex = end === -1 ? start : end;
1686
+ return [...lines.slice(0, start), ...lines.slice(endIndex + 1)];
1687
+ }
1688
+ function isOnlyShebangs(content) {
1689
+ const lines = content.split("\n").map((line) => line.trim()).filter(Boolean);
1690
+ return lines.length > 0 && lines.every((line) => line === HOOK_SHEBANG);
1691
+ }
1692
+ function generateHookScript(command) {
1693
+ return `${HOOK_SHEBANG}
1694
+ ${generateManagedSection(command)}`;
1695
+ }
1696
+ function generateManagedSection(command) {
1697
+ const isPath = command.diffowl.includes("/") || command.diffowl.includes("\\");
1698
+ const quotedDiffOwl = shellQuote(command.diffowl);
1699
+ const quotedNode = shellQuote(command.node);
1700
+ const quotedCli = shellQuote(command.cli);
1701
+ const pathPrefix = command.pathDirs.length ? command.pathDirs.join(":") : void 0;
1702
+ const diffowlPathFallback = isPath ? `elif [ -x ${quotedDiffOwl} ]; then
1703
+ ${quotedDiffOwl} hook-run
1704
+ ` : "";
1705
+ const runBlock = `if [ -x ${quotedNode} ] && [ -f ${quotedCli} ]; then
1706
+ ${quotedNode} ${quotedCli} hook-run
1707
+ ${diffowlPathFallback}elif command -v diffowl >/dev/null 2>&1; then
1708
+ diffowl hook-run
1709
+ else
1710
+ echo "diffowl: review not started; diffowl command not found or not executable; log: $DIFFOWL_LOG_FILE"
1711
+ echo "diffowl: review not started at $(date); diffowl command not found or not executable" >>"$DIFFOWL_LOG_FILE"
1712
+ fi`;
1713
+ return `${HOOK_MARKER}
1714
+ # Run diffowl review in the background (non-blocking)
1715
+ DIFFOWL_SEARCH_DIR="$PWD"
1716
+ while [ "$DIFFOWL_SEARCH_DIR" != "/" ] && [ ! -f "$DIFFOWL_SEARCH_DIR/.diffowl.yml" ]; do
1717
+ DIFFOWL_SEARCH_DIR=$(dirname "$DIFFOWL_SEARCH_DIR")
1718
+ done
1719
+ if [ -f "$DIFFOWL_SEARCH_DIR/.diffowl.yml" ]; then
1720
+ DIFFOWL_LOG_DIR="$DIFFOWL_SEARCH_DIR/.diffowl"
1721
+ else
1722
+ DIFFOWL_LOG_DIR=".diffowl"
1723
+ fi
1724
+ DIFFOWL_LOG_FILE="$DIFFOWL_LOG_DIR/hook.log"
1725
+ mkdir -p "$DIFFOWL_LOG_DIR"
1726
+ ${pathPrefix ? `PATH=${shellQuote(pathPrefix)}":$PATH"
1727
+ export PATH
1728
+ ` : ""}
1729
+
1730
+ ${runBlock}
1731
+ ${HOOK_END_MARKER}
1732
+ `;
1733
+ }
1734
+ function shellQuote(value) {
1735
+ return `'${value.replaceAll("'", "'\\''")}'`;
1736
+ }
1737
+
1738
+ // src/git/diff.ts
1739
+ import { execa as execa3 } from "execa";
1740
+ import { basename } from "path";
1741
+ var MAX_DIFF_OUTPUT_BYTES = 2 * 1024 * 1024;
1742
+ async function getLastCommitDiff() {
1743
+ return getCommitDiff("HEAD");
1744
+ }
1745
+ async function getCommitDiff(ref) {
1746
+ const commit = await resolveCommitRef(ref);
1747
+ const raw = await collectGitDiff([
1748
+ "-c",
1749
+ "diff.noprefix=false",
1750
+ "-c",
1751
+ "diff.mnemonicprefix=false",
1752
+ "show",
1753
+ "--format=",
1754
+ "--stat",
1755
+ "--patch",
1756
+ commit
1757
+ ]);
1758
+ return parseDiff(raw.stdout, raw.diagnostics);
1759
+ }
1760
+ async function resolveCommitRef(ref) {
1761
+ const trimmed = ref.trim();
1762
+ if (trimmed === "") {
1763
+ throw new Error("Commit ref must not be empty.");
1764
+ }
1765
+ try {
1766
+ const { stdout } = await execa3("git", [
1767
+ "rev-parse",
1768
+ "--verify",
1769
+ "--quiet",
1770
+ "--end-of-options",
1771
+ `${trimmed}^{commit}`
1772
+ ]);
1773
+ return stdout.trim();
1774
+ } catch {
1775
+ throw new Error(`Invalid commit ref: ${ref}`);
1776
+ }
1777
+ }
1778
+ async function getStagedDiff() {
1779
+ const raw = await collectGitDiff([
1780
+ "-c",
1781
+ "diff.noprefix=false",
1782
+ "-c",
1783
+ "diff.mnemonicprefix=false",
1784
+ "diff",
1785
+ "--staged",
1786
+ "--stat",
1787
+ "--patch"
1788
+ ]);
1789
+ return parseDiff(raw.stdout, raw.diagnostics);
1790
+ }
1791
+ async function collectGitDiff(args) {
1792
+ try {
1793
+ const { stdout } = await execa3("git", args, { maxBuffer: MAX_DIFF_OUTPUT_BYTES });
1794
+ return { stdout, diagnostics: [] };
1795
+ } catch (err) {
1796
+ if (isMaxBufferError(err)) {
1797
+ return {
1798
+ stdout: err.stdout,
1799
+ diagnostics: [
1800
+ `Git diff output exceeded ${formatBytes(MAX_DIFF_OUTPUT_BYTES)}; review context includes the truncated output captured before the limit.`
1801
+ ]
1802
+ };
1803
+ }
1804
+ throw err;
1805
+ }
1806
+ }
1807
+ async function isGitRepo() {
1808
+ try {
1809
+ await execa3("git", ["rev-parse", "--is-inside-work-tree"]);
1810
+ return true;
1811
+ } catch {
1812
+ return false;
1813
+ }
1814
+ }
1815
+ async function hasCommits() {
1816
+ try {
1817
+ await execa3("git", ["rev-parse", "HEAD"]);
1818
+ return true;
1819
+ } catch {
1820
+ return false;
1821
+ }
1822
+ }
1823
+ function parseDiff(raw, diagnostics = []) {
1824
+ const files = [];
1825
+ const lines = raw.split(/\r?\n/).map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
1826
+ for (const line of lines) {
1827
+ const gitDiffPaths = parseGitDiffLine(line);
1828
+ if (gitDiffPaths) {
1829
+ files.push({
1830
+ path: gitDiffPaths.pathB,
1831
+ status: "modified",
1832
+ additions: 0,
1833
+ deletions: 0
1834
+ });
1835
+ continue;
1836
+ }
1837
+ const combinedPath = parseCombinedDiffLine(line);
1838
+ if (combinedPath) {
1839
+ files.push({
1840
+ path: combinedPath,
1841
+ status: "modified",
1842
+ additions: 0,
1843
+ deletions: 0
1844
+ });
1845
+ continue;
1846
+ }
1847
+ const lastFile = files[files.length - 1];
1848
+ if (lastFile) {
1849
+ if (line.startsWith("rename to ")) {
1850
+ const target = unescapePath(line.slice("rename to ".length));
1851
+ lastFile.path = target;
1852
+ lastFile.status = "renamed";
1853
+ continue;
1854
+ }
1855
+ if (line === "--- /dev/null") {
1856
+ lastFile.status = "added";
1857
+ continue;
1858
+ }
1859
+ if (line === "+++ /dev/null") {
1860
+ lastFile.status = "deleted";
1861
+ continue;
1862
+ }
1863
+ if (line.startsWith("+") && !line.startsWith("+++")) {
1864
+ lastFile.additions++;
1865
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
1866
+ lastFile.deletions++;
1867
+ }
1868
+ }
1869
+ }
1870
+ const summary = files.map((f) => `${statusSymbol(f.status)} ${f.path} (+${f.additions}/-${f.deletions})`).join("\n");
1871
+ return { files, raw, summary, ...diagnostics.length > 0 ? { diagnostics } : {} };
1872
+ }
1873
+ function isMaxBufferError(err) {
1874
+ return err !== null && typeof err === "object" && err.isMaxBuffer === true && "stdout" in err && typeof err.stdout === "string";
1875
+ }
1876
+ function formatBytes(bytes) {
1877
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
1878
+ return `${Math.round(bytes / (1024 * 1024) * 10) / 10} MB`;
1879
+ }
1880
+ function parseGitDiffLine(line) {
1881
+ const cleanLine = line.endsWith("\r") ? line.slice(0, -1) : line;
1882
+ if (!cleanLine.startsWith("diff --git ")) return null;
1883
+ const content = cleanLine.slice("diff --git ".length);
1884
+ const paths = [];
1885
+ let i = 0;
1886
+ while (i < content.length && paths.length < 2) {
1887
+ while (i < content.length && content[i] === " ") {
1888
+ i++;
1889
+ }
1890
+ if (i >= content.length) break;
1891
+ if (content[i] === '"') {
1892
+ i++;
1893
+ let path = "";
1894
+ while (i < content.length) {
1895
+ if (content[i] === '"') {
1896
+ i++;
1897
+ break;
1898
+ }
1899
+ if (content[i] === "\\" && i + 1 < content.length) {
1900
+ path += content[i + 1] ?? "";
1901
+ i += 2;
1902
+ } else {
1903
+ path += content[i] ?? "";
1904
+ i++;
1905
+ }
1906
+ }
1907
+ paths.push(path);
1908
+ } else {
1909
+ let start = i;
1910
+ while (i < content.length && content[i] !== " ") {
1911
+ i++;
1912
+ }
1913
+ paths.push(content.slice(start, i));
1914
+ }
1915
+ }
1916
+ if (paths.length !== 2) return null;
1917
+ let pathA = paths[0] ?? "";
1918
+ let pathB = paths[1] ?? "";
1919
+ const matchA = pathA.match(/^([abciow])\//);
1920
+ const matchB = pathB.match(/^([abciow])\//);
1921
+ if (matchA && matchB && matchA[1] !== matchB[1]) {
1922
+ pathA = pathA.slice(2);
1923
+ pathB = pathB.slice(2);
1924
+ } else if (pathA.startsWith("a/") && pathB.startsWith("b/")) {
1925
+ pathA = pathA.slice(2);
1926
+ pathB = pathB.slice(2);
1927
+ }
1928
+ return { pathA, pathB };
1929
+ }
1930
+ function parseCombinedDiffLine(line) {
1931
+ const cleanLine = line.endsWith("\r") ? line.slice(0, -1) : line;
1932
+ let content = "";
1933
+ if (cleanLine.startsWith("diff --cc ")) {
1934
+ content = cleanLine.slice("diff --cc ".length);
1935
+ } else if (cleanLine.startsWith("diff --combined ")) {
1936
+ content = cleanLine.slice("diff --combined ".length);
1937
+ } else {
1938
+ return null;
1939
+ }
1940
+ return unescapePath(content);
1941
+ }
1942
+ function unescapePath(content) {
1943
+ if (content.startsWith('"') && content.endsWith('"')) {
1944
+ let path = "";
1945
+ let i = 1;
1946
+ while (i < content.length - 1) {
1947
+ if (content[i] === "\\" && i + 1 < content.length - 1) {
1948
+ path += content[i + 1] ?? "";
1949
+ i += 2;
1950
+ } else {
1951
+ path += content[i] ?? "";
1952
+ i++;
1953
+ }
1954
+ }
1955
+ return path;
1956
+ }
1957
+ return content;
1958
+ }
1959
+ function statusSymbol(status) {
1960
+ switch (status) {
1961
+ case "added":
1962
+ return "+";
1963
+ case "deleted":
1964
+ return "-";
1965
+ case "renamed":
1966
+ return ">";
1967
+ default:
1968
+ return "~";
1969
+ }
1970
+ }
1971
+ var DOC_FILE_PATTERNS = [
1972
+ /\.md$/i,
1973
+ /\.txt$/i,
1974
+ /\.rst$/i,
1975
+ /\.adoc$/i,
1976
+ /^LICENSE/i,
1977
+ /^CHANGELOG/i,
1978
+ /^CONTRIBUTING/i,
1979
+ /^README/i,
1980
+ /^CODE_OF_CONDUCT/i,
1981
+ /^AUTHORS/i,
1982
+ /^COPYING/i,
1983
+ /^PATENTS/i,
1984
+ /^SECURITY/i,
1985
+ /^PRIVACY/i,
1986
+ /^FAQ/i,
1987
+ /^TODO/i
1988
+ ];
1989
+ function isDocFile(path) {
1990
+ const base = basename(path);
1991
+ return DOC_FILE_PATTERNS.some((pattern) => pattern.test(base));
1992
+ }
1993
+ function isDocOnlyDiff(diff) {
1994
+ return diff.files.length > 0 && diff.files.every((file) => isDocFile(file.path));
1995
+ }
1996
+
1997
+ // src/review/context.ts
1998
+ import { existsSync as existsSync4 } from "fs";
1999
+ import { readFile as readFile6, stat as stat2 } from "fs/promises";
2000
+ import { basename as basename3, dirname as dirname3, extname as extname4, join as join5 } from "path";
2001
+ import picomatch from "picomatch";
2002
+
2003
+ // src/review/ast/index.ts
2004
+ import { extname } from "path";
2005
+
2006
+ // src/review/ast/typescript.ts
2007
+ import { createRequire } from "module";
2008
+ import { join as join4 } from "path";
2009
+ import { pathToFileURL } from "url";
2010
+ var MAX_AST_SYMBOL_CHARS = 8e3;
2011
+ var cachedTs = null;
2012
+ var typescriptAstParser = {
2013
+ id: "typescript",
2014
+ label: "TypeScript",
2015
+ matchesPath(path) {
2016
+ return path.endsWith(".ts") || path.endsWith(".tsx") || path.endsWith(".mts") || path.endsWith(".cts");
2017
+ },
2018
+ extract(input) {
2019
+ return extractTypeScriptAstSymbols(input);
2020
+ }
2021
+ };
2022
+ function tryLoadUserTypescript() {
2023
+ if (cachedTs !== null) return cachedTs;
2024
+ try {
2025
+ const require2 = createRequire(pathToFileURL(join4(process.cwd(), "package.json")));
2026
+ cachedTs = require2("typescript");
2027
+ } catch {
2028
+ try {
2029
+ const fallbackRequire = createRequire(import.meta.url);
2030
+ cachedTs = fallbackRequire("typescript");
2031
+ } catch {
2032
+ cachedTs = void 0;
2033
+ }
2034
+ }
2035
+ return cachedTs;
2036
+ }
2037
+ function extractTypeScriptAstSymbols(input) {
2038
+ if (input.changedLines.length === 0) {
2039
+ return { symbols: [] };
2040
+ }
2041
+ const activeTs = tryLoadUserTypescript();
2042
+ if (!activeTs) {
2043
+ return {
2044
+ symbols: [],
2045
+ diagnostics: ["TypeScript AST unavailable; reviewing from diff and file context only."]
2046
+ };
2047
+ }
2048
+ const sourceFile = activeTs.createSourceFile(
2049
+ input.path,
2050
+ input.content,
2051
+ activeTs.ScriptTarget.Latest,
2052
+ true
2053
+ );
2054
+ const changed = new Set(input.changedLines);
2055
+ const symbols = [];
2056
+ const visit = (node) => {
2057
+ const namedNode = getNamedDeclarationNode(activeTs, node);
2058
+ if (namedNode) {
2059
+ const startLine = sourceFile.getLineAndCharacterOfPosition(namedNode.getStart(sourceFile)).line + 1;
2060
+ const endLine = sourceFile.getLineAndCharacterOfPosition(namedNode.getEnd()).line + 1;
2061
+ if (containsChangedLine(changed, startLine, endLine)) {
2062
+ const text = truncateText(namedNode.getText(sourceFile), MAX_AST_SYMBOL_CHARS);
2063
+ symbols.push({
2064
+ name: getDeclarationName(activeTs, namedNode),
2065
+ kind: getDeclarationKind(activeTs, namedNode),
2066
+ startLine,
2067
+ endLine,
2068
+ text: text.text,
2069
+ truncated: text.truncated
2070
+ });
2071
+ }
2072
+ }
2073
+ activeTs.forEachChild(node, visit);
2074
+ };
2075
+ visit(sourceFile);
2076
+ return { symbols: dedupeAstSymbols(symbols) };
2077
+ }
2078
+ function getNamedDeclarationNode(ts, node) {
2079
+ if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isEnumDeclaration(node) || ts.isMethodDeclaration(node) || ts.isPropertyDeclaration(node)) {
2080
+ return hasIdentifierName(ts, node) ? node : void 0;
2081
+ }
2082
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
2083
+ const statement = findAncestor(node, ts.isVariableStatement);
2084
+ return statement && ts.isSourceFile(statement.parent) ? statement : void 0;
2085
+ }
2086
+ return void 0;
2087
+ }
2088
+ function hasIdentifierName(ts, node) {
2089
+ const name = node.name;
2090
+ return Boolean(name && ts.isIdentifier(name));
2091
+ }
2092
+ function findAncestor(node, predicate) {
2093
+ let current = node.parent;
2094
+ while (current) {
2095
+ if (predicate(current)) return current;
2096
+ current = current.parent;
2097
+ }
2098
+ return void 0;
2099
+ }
2100
+ function getDeclarationName(ts, node) {
2101
+ if (ts.isVariableStatement(node)) {
2102
+ return node.declarationList.declarations.map((declaration) => declaration.name.getText()).join(", ");
2103
+ }
2104
+ if (hasIdentifierName(ts, node)) {
2105
+ return node.name.text;
2106
+ }
2107
+ return "<anonymous>";
2108
+ }
2109
+ function getDeclarationKind(ts, node) {
2110
+ if (ts.isFunctionDeclaration(node)) return "function";
2111
+ if (ts.isClassDeclaration(node)) return "class";
2112
+ if (ts.isInterfaceDeclaration(node)) return "interface";
2113
+ if (ts.isTypeAliasDeclaration(node)) return "type";
2114
+ if (ts.isEnumDeclaration(node)) return "enum";
2115
+ if (ts.isMethodDeclaration(node)) return "method";
2116
+ if (ts.isPropertyDeclaration(node)) return "property";
2117
+ if (ts.isVariableStatement(node)) {
2118
+ const flags = node.declarationList.flags;
2119
+ if (flags & ts.NodeFlags.Const) return "const";
2120
+ if (flags & ts.NodeFlags.Let) return "let";
2121
+ return "var";
2122
+ }
2123
+ return ts.SyntaxKind[node.kind] ?? "symbol";
2124
+ }
2125
+ function dedupeAstSymbols(symbols) {
2126
+ const seen = /* @__PURE__ */ new Set();
2127
+ const unique = [];
2128
+ for (const symbol of symbols.sort((a, b) => a.startLine - b.startLine)) {
2129
+ const key = `${symbol.kind}:${symbol.name}:${symbol.startLine}:${symbol.endLine}`;
2130
+ if (seen.has(key)) continue;
2131
+ seen.add(key);
2132
+ unique.push(symbol);
2133
+ }
2134
+ return unique.slice(0, 20);
2135
+ }
2136
+ function containsChangedLine(changedLines, startLine, endLine) {
2137
+ for (let line = startLine; line <= endLine; line++) {
2138
+ if (changedLines.has(line)) return true;
2139
+ }
2140
+ return false;
2141
+ }
2142
+ function truncateText(text, maxChars) {
2143
+ if (text.length <= maxChars) {
2144
+ return { text, truncated: false };
2145
+ }
2146
+ return {
2147
+ text: `${text.slice(0, maxChars)}
2148
+ ... [truncated ${text.length - maxChars} chars]`,
2149
+ truncated: true
2150
+ };
2151
+ }
2152
+
2153
+ // src/review/ast/index.ts
2154
+ var AST_PARSERS = [typescriptAstParser];
2155
+ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
2156
+ ".c",
2157
+ ".cjs",
2158
+ ".cc",
2159
+ ".cpp",
2160
+ ".cts",
2161
+ ".cs",
2162
+ ".go",
2163
+ ".h",
2164
+ ".hpp",
2165
+ ".java",
2166
+ ".js",
2167
+ ".jsx",
2168
+ ".kt",
2169
+ ".mjs",
2170
+ ".mts",
2171
+ ".php",
2172
+ ".py",
2173
+ ".rb",
2174
+ ".rs",
2175
+ ".swift",
2176
+ ".ts",
2177
+ ".tsx"
2178
+ ]);
2179
+ function extractAstSymbols(path, content, changedLines) {
2180
+ if (changedLines.length === 0) {
2181
+ return { symbols: [] };
2182
+ }
2183
+ const parser = AST_PARSERS.find((candidate) => candidate.matchesPath(path));
2184
+ if (parser) {
2185
+ return parser.extract({ path, content, changedLines });
2186
+ }
2187
+ if (isCodePath(path)) {
2188
+ return {
2189
+ symbols: [],
2190
+ diagnostics: ["Reviewing from diff and file context only."]
2191
+ };
2192
+ }
2193
+ return { symbols: [] };
2194
+ }
2195
+ function isCodePath(path) {
2196
+ return CODE_EXTENSIONS.has(extname(path).toLowerCase());
2197
+ }
2198
+
2199
+ // src/review/context-references.ts
2200
+ import { basename as basename2, extname as extname2 } from "path";
2201
+ import { readFile as readFile5, stat } from "fs/promises";
2202
+ import { execa as execa4 } from "execa";
2203
+ var MAX_REFERENCES_PER_TERM = 8;
2204
+ var MAX_REFERENCE_TERMS = 8;
2205
+ var MAX_REFERENCE_LINE_CHARS = 220;
2206
+ var MAX_BATCH_REFERENCE_MATCHES = 200;
2207
+ var REFERENCE_SEARCH_TIMEOUT_MS = 5e3;
2208
+ var REFERENCE_SNIPPET_RADIUS = 2;
2209
+ var MAX_REFERENCE_SNIPPET_CHARS = 1200;
2210
+ var MAX_REFERENCE_SNIPPET_FILE_BYTES = 256 * 1024;
2211
+ async function buildReferenceContexts(changedFiles, skippedFiles, diagnostics) {
2212
+ const terms = /* @__PURE__ */ new Set();
2213
+ const ignoredPaths = /* @__PURE__ */ new Set([
2214
+ ...changedFiles.map((file) => file.file.path),
2215
+ ...skippedFiles.map((file) => file.path)
2216
+ ]);
2217
+ for (const file of changedFiles) {
2218
+ terms.add(basename2(file.file.path, extname2(file.file.path)));
2219
+ for (const symbol of file.symbols.slice(0, 4)) {
2220
+ terms.add(symbol);
2221
+ }
2222
+ }
2223
+ const validTerms = [...terms].filter((value) => value.length >= 3).slice(0, MAX_REFERENCE_TERMS);
2224
+ if (validTerms.length === 0) {
2225
+ return [];
2226
+ }
2227
+ const allMatches = await findBatchReferences(validTerms, ignoredPaths, diagnostics);
2228
+ const references = [];
2229
+ for (const term of validTerms) {
2230
+ const matches = await addReferenceSnippets(
2231
+ allMatches.filter((match) => (match.fullText ?? match.text).includes(term)).slice(0, MAX_REFERENCES_PER_TERM)
2232
+ );
2233
+ if (matches.length > 0) {
2234
+ references.push({ term, matches });
2235
+ }
2236
+ }
2237
+ return references;
2238
+ }
2239
+ async function findBatchReferences(terms, ignoredPaths, diagnostics) {
2240
+ let matches;
2241
+ try {
2242
+ matches = await findBatchReferencesWithGitGrep(terms, ignoredPaths);
2243
+ } catch (err) {
2244
+ diagnostics.push(`Reference search failed: ${formatReferenceSearchError(err)}.`);
2245
+ return [];
2246
+ }
2247
+ if (matches.length > MAX_BATCH_REFERENCE_MATCHES) {
2248
+ diagnostics.push(
2249
+ `Reference search found ${matches.length} matches; only the first ${MAX_BATCH_REFERENCE_MATCHES} are included.`
2250
+ );
2251
+ }
2252
+ return matches.slice(0, MAX_BATCH_REFERENCE_MATCHES);
2253
+ }
2254
+ function formatReferenceSearchError(err) {
2255
+ if (err && typeof err === "object") {
2256
+ const timedOut = "timedOut" in err && err.timedOut === true;
2257
+ const duration = "durationMs" in err && typeof err.durationMs === "number" ? err.durationMs : 0;
2258
+ if (timedOut) {
2259
+ return duration > 0 ? `timed out after ${duration}ms` : "timed out";
2260
+ }
2261
+ if ("exitCode" in err && typeof err.exitCode === "number") {
2262
+ return `exited with code ${err.exitCode}`;
2263
+ }
2264
+ }
2265
+ if (err instanceof Error) return err.message;
2266
+ return String(err);
2267
+ }
2268
+ async function findBatchReferencesWithGitGrep(terms, ignoredPaths) {
2269
+ try {
2270
+ const args = ["grep", "-n", "--fixed-strings"];
2271
+ for (const term of terms) {
2272
+ args.push("-e", term);
2273
+ }
2274
+ args.push("--");
2275
+ const { stdout } = await execa4("git", args, { timeout: REFERENCE_SEARCH_TIMEOUT_MS });
2276
+ return parseBatchReferenceLines(stdout, ignoredPaths);
2277
+ } catch (err) {
2278
+ if (isNoMatchesExit(err)) return [];
2279
+ throw err;
2280
+ }
2281
+ }
2282
+ function isNoMatchesExit(err) {
2283
+ return typeof err === "object" && err !== null && "exitCode" in err && err.exitCode === 1;
2284
+ }
2285
+ function parseBatchReferenceLines(stdout, ignoredPaths) {
2286
+ return stdout.split("\n").filter(Boolean).map(parseReferenceLine).filter((match) => Boolean(match)).filter((match) => !ignoredPaths.has(match.path));
2287
+ }
2288
+ function parseReferenceLine(line) {
2289
+ const match = line.match(/^(.+?):(\d+):(.*)$/);
2290
+ if (!match) return void 0;
2291
+ return {
2292
+ path: match[1],
2293
+ line: Number(match[2]),
2294
+ text: match[3].trim().slice(0, MAX_REFERENCE_LINE_CHARS),
2295
+ fullText: match[3].trim()
2296
+ };
2297
+ }
2298
+ async function addReferenceSnippets(matches) {
2299
+ const files = /* @__PURE__ */ new Map();
2300
+ await Promise.all(
2301
+ [...new Set(matches.map((match) => match.path))].map(async (path) => {
2302
+ try {
2303
+ const info = await stat(path);
2304
+ if (!info.isFile() || info.size > MAX_REFERENCE_SNIPPET_FILE_BYTES) {
2305
+ return;
2306
+ }
2307
+ const content = await readFile5(path, "utf-8");
2308
+ if (!content.includes("\0")) {
2309
+ files.set(path, content.split("\n"));
2310
+ }
2311
+ } catch {
2312
+ }
2313
+ })
2314
+ );
2315
+ return matches.map((match) => {
2316
+ const lines = files.get(match.path);
2317
+ if (!lines) return match;
2318
+ const start = Math.max(1, match.line - REFERENCE_SNIPPET_RADIUS);
2319
+ const end = Math.min(lines.length, match.line + REFERENCE_SNIPPET_RADIUS);
2320
+ const snippet = lines.slice(start - 1, end).map((line, index) => `${start + index}: ${line}`).join("\n");
2321
+ return {
2322
+ ...match,
2323
+ snippet: truncateSnippet(snippet),
2324
+ snippetStartLine: start,
2325
+ snippetEndLine: end
2326
+ };
2327
+ });
2328
+ }
2329
+ function truncateSnippet(snippet) {
2330
+ if (snippet.length <= MAX_REFERENCE_SNIPPET_CHARS) {
2331
+ return snippet;
2332
+ }
2333
+ return `${snippet.slice(0, MAX_REFERENCE_SNIPPET_CHARS)}
2334
+ ... [truncated]`;
2335
+ }
2336
+
2337
+ // src/review/context-render.ts
2338
+ import { extname as extname3 } from "path";
2339
+ var MAX_DIFF_CHARS = 4e4;
2340
+ var MAX_AST_SYMBOL_CHARS2 = 8e3;
2341
+ var MAX_QUICK_DIFF_CHARS = 12e3;
2342
+ var MAX_QUICK_SYMBOL_CHARS = 4e3;
2343
+ var MAX_QUICK_FILE_CHARS = 4e3;
2344
+ function renderReviewContext(context, options = {}) {
2345
+ const depth = options.depth ?? context.depth;
2346
+ const shallow = depth === "shallow";
2347
+ const lines = [];
2348
+ lines.push("## Local Review Context");
2349
+ lines.push("");
2350
+ lines.push(`Mode: ${context.mode}`);
2351
+ lines.push(`Review depth: ${depth}`);
2352
+ lines.push("");
2353
+ lines.push("### Changed Files");
2354
+ lines.push(context.diff.summary || "No changed files detected.");
2355
+ if (context.skippedFiles.length > 0) {
2356
+ lines.push("");
2357
+ lines.push("Skipped by include/exclude rules:");
2358
+ lines.push(context.skippedFiles.map((file) => `- ${file.path}`).join("\n"));
2359
+ }
2360
+ if (context.diagnostics.length > 0) {
2361
+ lines.push("");
2362
+ lines.push("Context diagnostics:");
2363
+ lines.push(context.diagnostics.map((diagnostic) => `- ${diagnostic}`).join("\n"));
2364
+ }
2365
+ lines.push("");
2366
+ lines.push("### Diff");
2367
+ lines.push(
2368
+ fence(
2369
+ truncateText2(
2370
+ filterDiffRaw(
2371
+ context.diff.raw,
2372
+ new Set(context.changedFiles.map((fileContext) => fileContext.file.path))
2373
+ ),
2374
+ shallow ? MAX_QUICK_DIFF_CHARS : MAX_DIFF_CHARS
2375
+ ).text,
2376
+ "diff"
2377
+ )
2378
+ );
2379
+ lines.push("");
2380
+ for (const fileContext of context.changedFiles) {
2381
+ lines.push(`### File Context: ${fileContext.file.path}`);
2382
+ lines.push(
2383
+ `Status: ${fileContext.file.status}; additions: ${fileContext.file.additions}; deletions: ${fileContext.file.deletions}`
2384
+ );
2385
+ if (fileContext.imports.length > 0) {
2386
+ lines.push("");
2387
+ lines.push("Imports:");
2388
+ lines.push(fileContext.imports.map((line) => `- ${line}`).join("\n"));
2389
+ }
2390
+ if (fileContext.symbols.length > 0) {
2391
+ lines.push("");
2392
+ lines.push("Symbols:");
2393
+ lines.push(fileContext.symbols.map((symbol) => `- ${symbol}`).join("\n"));
2394
+ }
2395
+ lines.push("");
2396
+ if (fileContext.changedLines.length > 0) {
2397
+ lines.push(`Changed lines: ${summarizeLines(fileContext.changedLines)}`);
2398
+ lines.push("");
2399
+ }
2400
+ if (fileContext.astSymbols.length > 0) {
2401
+ lines.push("Changed AST symbols:");
2402
+ for (const symbol of shallow ? fileContext.astSymbols.slice(0, 5) : fileContext.astSymbols) {
2403
+ lines.push(`#### ${symbol.kind} ${symbol.name} (${symbol.startLine}-${symbol.endLine})`);
2404
+ lines.push(
2405
+ fence(
2406
+ truncateText2(symbol.text, shallow ? MAX_QUICK_SYMBOL_CHARS : MAX_AST_SYMBOL_CHARS2).text,
2407
+ languageForPath(fileContext.file.path)
2408
+ )
2409
+ );
2410
+ if (symbol.truncated) {
2411
+ lines.push("_Symbol content truncated._");
2412
+ }
2413
+ lines.push("");
2414
+ }
2415
+ }
2416
+ if (fileContext.content && fileContext.astSymbols.length === 0 && fileContext.shouldRenderContent) {
2417
+ lines.push(
2418
+ fence(
2419
+ shallow ? truncateText2(fileContext.content, MAX_QUICK_FILE_CHARS).text : fileContext.content,
2420
+ languageForPath(fileContext.file.path)
2421
+ )
2422
+ );
2423
+ if (fileContext.truncated) {
2424
+ lines.push("_File content truncated._");
2425
+ }
2426
+ } else if (fileContext.content && fileContext.astSymbols.length === 0) {
2427
+ lines.push("_Full file content omitted because the diff already shows the changed hunks._");
2428
+ } else if (fileContext.content) {
2429
+ lines.push("_Full file content omitted because changed AST symbols are shown._");
2430
+ } else {
2431
+ lines.push(`_File content skipped: ${fileContext.skippedReason ?? "unavailable"}._`);
2432
+ }
2433
+ lines.push("");
2434
+ }
2435
+ if (!shallow && context.relatedFiles.length > 0) {
2436
+ lines.push("### Related Test Files");
2437
+ for (const related of context.relatedFiles) {
2438
+ lines.push(`#### ${related.path}`);
2439
+ lines.push(`Reason: ${related.reason}`);
2440
+ lines.push(fence(related.content, languageForPath(related.path)));
2441
+ if (related.truncated) {
2442
+ lines.push("_File content truncated._");
2443
+ }
2444
+ lines.push("");
2445
+ }
2446
+ }
2447
+ if (!shallow && context.references.length > 0) {
2448
+ lines.push("### Potential Call Flow");
2449
+ lines.push(
2450
+ "These bounded snippets show where changed filenames or symbols are referenced. Use them to reason about callers, output paths, config wiring, tests, and user-visible behavior."
2451
+ );
2452
+ for (const reference of context.references) {
2453
+ lines.push(`#### Term: ${reference.term}`);
2454
+ for (const match of reference.matches) {
2455
+ lines.push(`- ${match.path}:${match.line}: ${match.text}`);
2456
+ if (match.snippet) {
2457
+ lines.push(fence(match.snippet, languageForPath(match.path)));
2458
+ }
2459
+ }
2460
+ lines.push("");
2461
+ }
2462
+ }
2463
+ return lines.join("\n").trim();
2464
+ }
2465
+ function filterDiffRaw(rawDiff, includedPaths) {
2466
+ if (includedPaths.size === 0) {
2467
+ return "No included file diffs.";
2468
+ }
2469
+ const lines = [];
2470
+ let includeCurrentFile = false;
2471
+ for (const line of rawDiff.split(/\r?\n/).map((l) => l.endsWith("\r") ? l.slice(0, -1) : l)) {
2472
+ const gitDiffPaths = parseGitDiffLine(line);
2473
+ if (gitDiffPaths) {
2474
+ includeCurrentFile = includedPaths.has(gitDiffPaths.pathB);
2475
+ }
2476
+ if (includeCurrentFile) {
2477
+ lines.push(line);
2478
+ }
2479
+ }
2480
+ return lines.join("\n");
2481
+ }
2482
+ function summarizeLines(lines) {
2483
+ const sortedLines = [...new Set(lines)].sort((a, b) => a - b);
2484
+ const firstLine = sortedLines[0];
2485
+ if (firstLine === void 0) {
2486
+ return "";
2487
+ }
2488
+ const ranges = [];
2489
+ let start = firstLine;
2490
+ let previous = firstLine;
2491
+ for (const line of sortedLines.slice(1)) {
2492
+ if (line === previous + 1) {
2493
+ previous = line;
2494
+ continue;
2495
+ }
2496
+ ranges.push(formatLineRange(start, previous));
2497
+ start = line;
2498
+ previous = line;
2499
+ }
2500
+ ranges.push(formatLineRange(start, previous));
2501
+ return ranges.join(", ");
2502
+ }
2503
+ function formatLineRange(start, end) {
2504
+ return start === end ? String(start) : `${start}-${end}`;
2505
+ }
2506
+ function truncateText2(text, maxChars) {
2507
+ if (text.length <= maxChars) {
2508
+ return { text, truncated: false };
2509
+ }
2510
+ return {
2511
+ text: `${text.slice(0, maxChars)}
2512
+ ... [truncated ${text.length - maxChars} chars]`,
2513
+ truncated: true
2514
+ };
2515
+ }
2516
+ function fence(content, language = "") {
2517
+ return `\`\`\`${language}
2518
+ ${content.replaceAll("```", "'''")}
2519
+ \`\`\``;
2520
+ }
2521
+ function languageForPath(path) {
2522
+ const ext = extname3(path).slice(1);
2523
+ if (ext === "ts" || ext === "tsx") return "ts";
2524
+ if (ext === "js" || ext === "jsx") return "js";
2525
+ if (ext === "json") return "json";
2526
+ if (ext === "md") return "md";
2527
+ if (ext === "yml" || ext === "yaml") return "yaml";
2528
+ return "";
2529
+ }
2530
+
2531
+ // src/review/context.ts
2532
+ var MAX_FILE_CHARS = 12e3;
2533
+ var MAX_RELATED_FILE_CHARS = 6e3;
2534
+ var MAX_INLINE_FILE_CHARS = 2e3;
2535
+ var MAX_INLINE_FILE_LINES = 80;
2536
+ var MAX_CONTEXT_FILE_BYTES = 512 * 1024;
2537
+ var MIN_CHANGED_RATIO_FOR_INLINE_CONTENT = 0.4;
2538
+ var LOCKFILE_EXCLUDES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb"];
2539
+ async function buildReviewContext(mode, config, depth = config.context.depth, diff) {
2540
+ const diffResult = diff ?? await loadDiffForMode(mode);
2541
+ const reviewableFiles = diffResult.files.filter((file) => shouldReviewFile(file.path, config));
2542
+ const skippedFiles = diffResult.files.filter((file) => !shouldReviewFile(file.path, config));
2543
+ const changedLines = getChangedLinesByFile(diffResult.raw);
2544
+ const changedFileResults = await Promise.all(
2545
+ reviewableFiles.map((file) => buildChangedFileContext(file, changedLines.get(file.path) ?? []))
2546
+ );
2547
+ const changedFiles = changedFileResults.map((result) => result.fileContext);
2548
+ const diagnostics = [...diffResult.diagnostics ?? []];
2549
+ addUniqueDiagnostics(
2550
+ diagnostics,
2551
+ changedFileResults.flatMap((result) => result.diagnostics)
2552
+ );
2553
+ const relatedFiles = depth === "shallow" ? [] : await buildRelatedFileContexts(reviewableFiles);
2554
+ const references = depth === "shallow" ? [] : await buildReferenceContexts(changedFiles, skippedFiles, diagnostics);
2555
+ return {
2556
+ mode,
2557
+ depth,
2558
+ diff: diffResult,
2559
+ changedFiles,
2560
+ skippedFiles,
2561
+ relatedFiles,
2562
+ references,
2563
+ diagnostics
2564
+ };
2565
+ }
2566
+ async function loadDiffForMode(mode) {
2567
+ if (mode === "staged") {
2568
+ return getStagedDiff();
2569
+ }
2570
+ if (mode === "commit") {
2571
+ throw new Error("Commit review context requires an explicit diff.");
2572
+ }
2573
+ return getLastCommitDiff();
2574
+ }
2575
+ async function buildChangedFileContext(file, changedLines) {
2576
+ if (file.status === "deleted") {
2577
+ return {
2578
+ fileContext: {
2579
+ file,
2580
+ imports: [],
2581
+ symbols: [],
2582
+ changedLines,
2583
+ astSymbols: [],
2584
+ truncated: false,
2585
+ shouldRenderContent: false,
2586
+ skippedReason: "deleted file"
2587
+ },
2588
+ diagnostics: []
2589
+ };
2590
+ }
2591
+ const contentResult = await readTextFile(file.path, MAX_FILE_CHARS);
2592
+ if (!contentResult.content) {
2593
+ return {
2594
+ fileContext: {
2595
+ file,
2596
+ imports: [],
2597
+ symbols: [],
2598
+ changedLines,
2599
+ astSymbols: [],
2600
+ truncated: false,
2601
+ shouldRenderContent: false,
2602
+ skippedReason: contentResult.reason
2603
+ },
2604
+ diagnostics: []
2605
+ };
2606
+ }
2607
+ const astResult = extractAstSymbols(file.path, contentResult.content, changedLines);
2608
+ return {
2609
+ fileContext: {
2610
+ file,
2611
+ imports: extractImports(contentResult.content),
2612
+ symbols: mergeSymbols(
2613
+ extractSymbols(contentResult.content),
2614
+ astResult.symbols.map((symbol) => symbol.name)
2615
+ ),
2616
+ changedLines,
2617
+ astSymbols: astResult.symbols,
2618
+ content: contentResult.content,
2619
+ truncated: contentResult.truncated,
2620
+ shouldRenderContent: shouldRenderFullFileContent(file, contentResult.content)
2621
+ },
2622
+ diagnostics: astResult.diagnostics ?? []
2623
+ };
2624
+ }
2625
+ async function buildRelatedFileContexts(files) {
2626
+ const seen = /* @__PURE__ */ new Set();
2627
+ const related = [];
2628
+ for (const file of files) {
2629
+ if (file.status === "deleted") continue;
2630
+ for (const candidate of testCandidates(file.path)) {
2631
+ if (seen.has(candidate) || !existsSync4(candidate)) continue;
2632
+ seen.add(candidate);
2633
+ const result = await readTextFile(candidate, MAX_RELATED_FILE_CHARS);
2634
+ if (!result.content) continue;
2635
+ related.push({
2636
+ path: candidate,
2637
+ reason: `Likely test file for ${file.path}`,
2638
+ content: result.content,
2639
+ truncated: result.truncated
2640
+ });
2641
+ }
2642
+ }
2643
+ return related;
2644
+ }
2645
+ function shouldReviewFile(path, config) {
2646
+ if (LOCKFILE_EXCLUDES.includes(path)) return false;
2647
+ const include = config.include.length > 0 ? config.include : ["**/*"];
2648
+ if (!include.some((pattern) => picomatch.isMatch(path, pattern))) {
2649
+ return false;
2650
+ }
2651
+ return !config.exclude.some((pattern) => picomatch.isMatch(path, pattern));
2652
+ }
2653
+ async function readTextFile(path, maxChars) {
2654
+ try {
2655
+ const info = await stat2(path);
2656
+ if (!info.isFile()) {
2657
+ return { truncated: false, reason: "not a regular file" };
2658
+ }
2659
+ if (info.size > MAX_CONTEXT_FILE_BYTES) {
2660
+ return {
2661
+ truncated: false,
2662
+ reason: `file too large for context (${formatBytes2(info.size)} > ${formatBytes2(MAX_CONTEXT_FILE_BYTES)})`
2663
+ };
2664
+ }
2665
+ const raw = await readFile6(path, "utf-8");
2666
+ if (raw.includes("\0")) {
2667
+ return { truncated: false, reason: "binary file" };
2668
+ }
2669
+ const result = truncateText3(raw, maxChars);
2670
+ return { content: result.text, truncated: result.truncated };
2671
+ } catch (err) {
2672
+ return {
2673
+ truncated: false,
2674
+ reason: err instanceof Error ? err.message : String(err)
2675
+ };
2676
+ }
2677
+ }
2678
+ function extractImports(content) {
2679
+ return content.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("import ") || /^export\s+.*\sfrom\s+/.test(line)).slice(0, 30);
2680
+ }
2681
+ function extractSymbols(content) {
2682
+ const symbols = /* @__PURE__ */ new Set();
2683
+ const patterns = [
2684
+ /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
2685
+ /^(?:export\s+)?(?:class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm,
2686
+ /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=/gm
2687
+ ];
2688
+ for (const pattern of patterns) {
2689
+ for (const match of content.matchAll(pattern)) {
2690
+ symbols.add(match[1]);
2691
+ }
2692
+ }
2693
+ return [...symbols].slice(0, 30);
2694
+ }
2695
+ function shouldRenderFullFileContent(file, content) {
2696
+ const totalLines = content.split("\n").length;
2697
+ if (content.length <= MAX_INLINE_FILE_CHARS && totalLines <= MAX_INLINE_FILE_LINES) {
2698
+ return true;
2699
+ }
2700
+ if (file.status === "added") {
2701
+ return false;
2702
+ }
2703
+ const changedLineCount = file.additions + file.deletions;
2704
+ return changedLineCount / totalLines >= MIN_CHANGED_RATIO_FOR_INLINE_CONTENT;
2705
+ }
2706
+ function mergeSymbols(...groups) {
2707
+ const symbols = /* @__PURE__ */ new Set();
2708
+ for (const group of groups) {
2709
+ for (const symbol of group) {
2710
+ symbols.add(symbol);
2711
+ }
2712
+ }
2713
+ return [...symbols].slice(0, 30);
2714
+ }
2715
+ function getChangedLinesByFile(rawDiff) {
2716
+ const changed = /* @__PURE__ */ new Map();
2717
+ let currentPath;
2718
+ let newLine;
2719
+ for (const line of rawDiff.split(/\r?\n/).map((l) => l.endsWith("\r") ? l.slice(0, -1) : l)) {
2720
+ const gitDiffPaths = parseGitDiffLine(line);
2721
+ if (gitDiffPaths) {
2722
+ currentPath = gitDiffPaths.pathB;
2723
+ continue;
2724
+ }
2725
+ if (line.startsWith("rename to ")) {
2726
+ currentPath = unescapePath(line.slice("rename to ".length));
2727
+ continue;
2728
+ }
2729
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
2730
+ if (hunkMatch) {
2731
+ newLine = Number(hunkMatch[1]);
2732
+ continue;
2733
+ }
2734
+ if (!currentPath || newLine === void 0) continue;
2735
+ if (line.startsWith("+++")) {
2736
+ continue;
2737
+ }
2738
+ if (line.startsWith("+")) {
2739
+ const lines = changed.get(currentPath) ?? [];
2740
+ lines.push(newLine);
2741
+ changed.set(currentPath, lines);
2742
+ newLine++;
2743
+ continue;
2744
+ }
2745
+ if (line.startsWith("-")) {
2746
+ continue;
2747
+ }
2748
+ newLine++;
2749
+ }
2750
+ return changed;
2751
+ }
2752
+ function testCandidates(path) {
2753
+ const dir = dirname3(path);
2754
+ const ext = extname4(path);
2755
+ const base = basename3(path, ext);
2756
+ return [
2757
+ join5(dir, `${base}.test${ext}`),
2758
+ join5(dir, `${base}.spec${ext}`),
2759
+ join5(dir, "__tests__", `${base}.test${ext}`),
2760
+ join5(dir, "__tests__", `${base}.spec${ext}`)
2761
+ ];
2762
+ }
2763
+ function truncateText3(text, maxChars) {
2764
+ if (text.length <= maxChars) {
2765
+ return { text, truncated: false };
2766
+ }
2767
+ return {
2768
+ text: `${text.slice(0, maxChars)}
2769
+ ... [truncated ${text.length - maxChars} chars]`,
2770
+ truncated: true
2771
+ };
2772
+ }
2773
+ function formatBytes2(bytes) {
2774
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
2775
+ return `${Math.round(bytes / (1024 * 1024) * 10) / 10} MB`;
2776
+ }
2777
+ function addUniqueDiagnostics(target, diagnostics) {
2778
+ const seen = new Set(target);
2779
+ for (const diagnostic of diagnostics) {
2780
+ if (seen.has(diagnostic)) continue;
2781
+ seen.add(diagnostic);
2782
+ target.push(diagnostic);
2783
+ }
2784
+ }
2785
+
2786
+ // src/review/formatter.ts
2787
+ import chalk from "chalk";
2788
+ import { writeFile as writeFile5, mkdir as mkdir3 } from "fs/promises";
2789
+ import { existsSync as existsSync5 } from "fs";
2790
+ import { join as join6 } from "path";
2791
+ import { parse as parse2, stringify as stringify2 } from "yaml";
2792
+ function renderMarkdown(report) {
2793
+ const lines = [];
2794
+ lines.push("### Summary");
2795
+ lines.push(report.summary.trim() || "No summary provided.");
2796
+ lines.push("");
2797
+ lines.push("### Issues Found");
2798
+ if (report.findings.length === 0) {
2799
+ lines.push("No issues were reported.");
2800
+ } else {
2801
+ for (const [index, finding] of report.findings.entries()) {
2802
+ lines.push(`#### Finding ${index + 1}`);
2803
+ lines.push(`**[${finding.severity.toUpperCase()}] ${finding.file}:${finding.line}**`);
2804
+ lines.push(finding.title.trim());
2805
+ lines.push("");
2806
+ if (finding.evidence) {
2807
+ lines.push(`> **Evidence:** ${formatMarkdownCodeSpan(finding.evidence)}`);
2808
+ lines.push("");
2809
+ }
2810
+ lines.push(finding.body.trim());
2811
+ lines.push("");
2812
+ }
2813
+ }
2814
+ if (report.suppressedFindings && report.suppressedFindings.length > 0) {
2815
+ lines.push("");
2816
+ lines.push("### Suppressed Findings");
2817
+ lines.push("These findings are outside files changed in this diff.");
2818
+ lines.push("");
2819
+ for (const [index, finding] of report.suppressedFindings.entries()) {
2820
+ lines.push(`#### Finding ${report.findings.length + index + 1}`);
2821
+ lines.push(
2822
+ `**[${finding.severity.toUpperCase()}] ${finding.file}:${finding.line}** (${finding.confidence} confidence)`
2823
+ );
2824
+ lines.push(finding.title.trim());
2825
+ lines.push("");
2826
+ if (finding.evidence) {
2827
+ lines.push(`> **Evidence:** ${formatMarkdownCodeSpan(finding.evidence)}`);
2828
+ lines.push("");
2829
+ }
2830
+ lines.push(finding.body.trim());
2831
+ lines.push("");
2832
+ }
2833
+ }
2834
+ if (report.diagnostics && report.diagnostics.length > 0) {
2835
+ lines.push("");
2836
+ lines.push("### Diagnostics");
2837
+ for (const diagnostic of report.diagnostics) {
2838
+ lines.push(`- ${diagnostic}`);
2839
+ }
2840
+ }
2841
+ lines.push("");
2842
+ lines.push("### Status");
2843
+ lines.push(report.findings.length > 0 ? "Open" : "Resolved");
2844
+ return lines.join("\n");
2845
+ }
2846
+ async function writeMarkdownReport(review, metadata) {
2847
+ const dir = join6(getDiffOwlDir(), "reviews");
2848
+ if (!existsSync5(dir)) {
2849
+ await mkdir3(dir, { recursive: true });
2850
+ }
2851
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2852
+ const filename = `review-${timestamp}.md`;
2853
+ const filepath = join6(dir, filename);
2854
+ const content = `${metadata ? renderReviewFrontmatter(metadata) : ""}# DiffOwl Review
2855
+ _${(/* @__PURE__ */ new Date()).toLocaleString()}_
2856
+
2857
+ ${review}
2858
+ `;
2859
+ await writeFile5(filepath, content, "utf-8");
2860
+ const latestPath = join6(dir, "latest.md");
2861
+ await writeFile5(latestPath, content, "utf-8");
2862
+ return filepath;
2863
+ }
2864
+ function parseReviewMetadata(content) {
2865
+ if (!content.startsWith("---\n")) return void 0;
2866
+ const end = content.indexOf("\n---\n", 4);
2867
+ if (end === -1) return void 0;
2868
+ const parsed = parse2(content.slice(4, end));
2869
+ if (!parsed || typeof parsed !== "object") return void 0;
2870
+ const diffowl = parsed.diffowl;
2871
+ if (!diffowl || typeof diffowl !== "object") return void 0;
2872
+ const sessionId = diffowl.session_id;
2873
+ const projectRoot = diffowl.project_root;
2874
+ if (typeof sessionId !== "string" || sessionId.trim() === "" || typeof projectRoot !== "string" || projectRoot.trim() === "") {
2875
+ return void 0;
2876
+ }
2877
+ return { session_id: sessionId, project_root: projectRoot };
2878
+ }
2879
+ function renderReviewFrontmatter(metadata) {
2880
+ return `---
2881
+ ${stringify2({ diffowl: metadata }, { lineWidth: 0 })}---
2882
+
2883
+ `;
2884
+ }
2885
+ function printHeader() {
2886
+ console.log();
2887
+ console.log(chalk.bold("diffowl") + chalk.dim(" reviewing..."));
2888
+ console.log(chalk.dim("\u2500".repeat(50)));
2889
+ console.log();
2890
+ }
2891
+ function printFooter(report, reportPath) {
2892
+ console.log();
2893
+ console.log(chalk.dim("\u2500".repeat(50)));
2894
+ let errors = 0;
2895
+ let warnings = 0;
2896
+ let infos = 0;
2897
+ for (const finding of report.findings) {
2898
+ switch (finding.severity) {
2899
+ case "error":
2900
+ errors++;
2901
+ break;
2902
+ case "warning":
2903
+ warnings++;
2904
+ break;
2905
+ case "info":
2906
+ infos++;
2907
+ break;
2908
+ }
2909
+ }
2910
+ const parts = [];
2911
+ if (errors > 0) parts.push(chalk.red(`${errors} error${errors > 1 ? "s" : ""}`));
2912
+ if (warnings > 0) parts.push(chalk.yellow(`${warnings} warning${warnings > 1 ? "s" : ""}`));
2913
+ if (infos > 0) parts.push(chalk.blue(`${infos} suggestion${infos > 1 ? "s" : ""}`));
2914
+ if (parts.length === 0) {
2915
+ console.log(chalk.green("\u2713 No issues found. Clean commit!"));
2916
+ } else {
2917
+ console.log(`Found ${parts.join(", ")}`);
2918
+ }
2919
+ if (reportPath) {
2920
+ console.log(chalk.dim(`Report saved: ${reportPath}`));
2921
+ }
2922
+ console.log();
2923
+ }
2924
+ function colorizeMarkdown(text) {
2925
+ const lines = text.split("\n");
2926
+ const colorizedLines = [];
2927
+ let inCodeBlock = false;
2928
+ for (const line of lines) {
2929
+ if (line.trim().startsWith("```")) {
2930
+ inCodeBlock = !inCodeBlock;
2931
+ colorizedLines.push(chalk.dim(line));
2932
+ continue;
2933
+ }
2934
+ if (inCodeBlock) {
2935
+ colorizedLines.push(line);
2936
+ } else {
2937
+ const colorized = line.replace(
2938
+ /\*\*\[(ERROR|WARNING|INFO)\]([^*]*)\*\*/g,
2939
+ (_match, label, rest) => `${colorizeSeverity(label)}${chalk.bold(rest)}`
2940
+ ).replace(/^### (.*)/g, (_match, title) => chalk.bold.underline(title)).replace(/\*\*([^*]+)\*\*/g, (_match, content) => chalk.bold(content));
2941
+ colorizedLines.push(colorized);
2942
+ }
2943
+ }
2944
+ return colorizedLines.join("\n");
2945
+ }
2946
+ function colorizeSeverity(label) {
2947
+ switch (label) {
2948
+ case "ERROR":
2949
+ return chalk.red.bold(`[${label}]`);
2950
+ case "WARNING":
2951
+ return chalk.yellow.bold(`[${label}]`);
2952
+ default:
2953
+ return chalk.blue.bold(`[${label}]`);
2954
+ }
2955
+ }
2956
+ function formatMarkdownCodeSpan(text) {
2957
+ const trimmed = text.trim();
2958
+ let maxRun = 0;
2959
+ let currentRun = 0;
2960
+ for (const char of trimmed) {
2961
+ if (char === "`") {
2962
+ currentRun++;
2963
+ if (currentRun > maxRun) {
2964
+ maxRun = currentRun;
2965
+ }
2966
+ } else {
2967
+ currentRun = 0;
2968
+ }
2969
+ }
2970
+ const delimiter = "`".repeat(maxRun + 1);
2971
+ const pad = trimmed.startsWith("`") || trimmed.endsWith("`") ? " " : "";
2972
+ return `${delimiter}${pad}${trimmed}${pad}${delimiter}`;
2973
+ }
2974
+ function formatExcludedCandidateSummary(belowConfidence, outsideChangedFiles) {
2975
+ const total = belowConfidence + outsideChangedFiles;
2976
+ const reasons = [
2977
+ belowConfidence > 0 ? `${belowConfidence} below the confidence threshold` : void 0,
2978
+ outsideChangedFiles > 0 ? `${outsideChangedFiles} outside changed files` : void 0
2979
+ ].filter((reason) => reason !== void 0);
2980
+ return `${total} candidate${total === 1 ? "" : "s"} excluded from findings: ${reasons.join(" and ")}. Run \`diffowl chat\` to investigate.`;
2981
+ }
2982
+
2983
+ // src/review/report-path.ts
2984
+ import { readFile as readFile7, readdir as readdir2 } from "fs/promises";
2985
+ import { basename as basename4, isAbsolute, join as join7, resolve } from "path";
2986
+ function resolveReviewReportPath(report) {
2987
+ if (isAbsolute(report)) return report;
2988
+ if (report.includes("/") || report.includes("\\")) {
2989
+ return resolve(report);
2990
+ }
2991
+ return join7(getDiffOwlDir(), "reviews", report);
2992
+ }
2993
+ async function listReviewReportPaths() {
2994
+ const reviews = join7(getDiffOwlDir(), "reviews");
2995
+ const entries = await Promise.all([
2996
+ listMarkdownFiles(reviews),
2997
+ listMarkdownFiles(join7(reviews, "resolved"))
2998
+ ]);
2999
+ return entries.flat().filter((path) => basename4(path) !== "latest.md").sort((a, b) => basename4(b).localeCompare(basename4(a)));
3000
+ }
3001
+ function canSelectReviewInteractively(inputIsTTY, outputIsTTY) {
3002
+ return inputIsTTY === true && outputIsTTY === true;
3003
+ }
3004
+ function selectReviewReportPath(paths, answer) {
3005
+ const selection = Number.parseInt(answer.trim(), 10);
3006
+ if (!Number.isInteger(selection) || selection < 1 || selection > paths.length) return void 0;
3007
+ return paths[selection - 1];
3008
+ }
3009
+ async function listMarkdownFiles(dir) {
3010
+ let paths;
3011
+ try {
3012
+ paths = (await readdir2(dir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => join7(dir, entry.name));
3013
+ } catch {
3014
+ return [];
3015
+ }
3016
+ const reports = await Promise.all(
3017
+ paths.map(async (path) => {
3018
+ try {
3019
+ return parseReviewMetadata(await readFile7(path, "utf-8")) ? path : void 0;
3020
+ } catch {
3021
+ return void 0;
3022
+ }
3023
+ })
3024
+ );
3025
+ return reports.filter((path) => path !== void 0);
3026
+ }
3027
+
3028
+ // src/cli.ts
3029
+ import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
3030
+ import { basename as basename5, dirname as dirname4, join as join8 } from "path";
3031
+ import { execa as execa5 } from "execa";
3032
+
3033
+ // package.json
3034
+ var package_default = {
3035
+ name: "diffowl",
3036
+ version: "0.1.0",
3037
+ description: "Local AI code review agent powered by OpenCode",
3038
+ keywords: [
3039
+ "ai",
3040
+ "code-review",
3041
+ "git",
3042
+ "opencode",
3043
+ "pre-commit"
3044
+ ],
3045
+ homepage: "https://github.com/gutierrezje/diffowl#readme",
3046
+ bugs: {
3047
+ url: "https://github.com/gutierrezje/diffowl/issues"
3048
+ },
3049
+ license: "MIT",
3050
+ repository: {
3051
+ type: "git",
3052
+ url: "git+https://github.com/gutierrezje/diffowl.git"
3053
+ },
3054
+ bin: {
3055
+ diffowl: "./dist/cli.js"
3056
+ },
3057
+ files: [
3058
+ "dist"
3059
+ ],
3060
+ type: "module",
3061
+ scripts: {
3062
+ build: "tsup",
3063
+ dev: "tsup --watch",
3064
+ test: "vitest run",
3065
+ typecheck: "tsc --noEmit",
3066
+ lint: "oxlint . && pnpm run typecheck",
3067
+ format: "oxfmt --write .",
3068
+ "format:check": "oxfmt --check .",
3069
+ prepack: "npm run build"
3070
+ },
3071
+ dependencies: {
3072
+ "@opencode-ai/sdk": "^1.15.11",
3073
+ chalk: "^5.6.2",
3074
+ commander: "^14.0.3",
3075
+ execa: "^9.6.1",
3076
+ ora: "^9.4.0",
3077
+ picomatch: "^4.0.4",
3078
+ yaml: "^2.9.0",
3079
+ zod: "^4.4.3"
3080
+ },
3081
+ devDependencies: {
3082
+ "@types/node": "^25.9.1",
3083
+ "@types/picomatch": "^4.0.3",
3084
+ oxfmt: "^0.52.0",
3085
+ oxlint: "^1.67.0",
3086
+ tsup: "^8.5.1",
3087
+ typescript: "^6.0.3",
3088
+ vitest: "^4.1.7"
3089
+ },
3090
+ engines: {
3091
+ node: ">=20"
3092
+ },
3093
+ packageManager: "pnpm@10.15.1"
3094
+ };
3095
+
3096
+ // src/cli.ts
3097
+ async function writeHookStatus(exitCode, commit, message) {
3098
+ try {
3099
+ const dir = await ensureDiffOwlDir();
3100
+ const content = JSON.stringify(
3101
+ {
3102
+ ...commit ? { commit } : {},
3103
+ exitCode,
3104
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3105
+ ...message ? { message } : {}
3106
+ },
3107
+ null,
3108
+ 2
3109
+ );
3110
+ await writeFile6(join8(dir, "last-hook-status.json"), content, "utf-8");
3111
+ const resultPath = process.env["DIFFOWL_HOOK_RESULT"];
3112
+ if (resultPath) {
3113
+ await writeFile6(resultPath, content, "utf-8");
3114
+ }
3115
+ } catch {
3116
+ }
3117
+ }
3118
+ var program = new Command();
3119
+ program.name("diffowl").description("Local AI code review agent powered by OpenCode").version(package_default.version);
3120
+ program.command("review", { isDefault: true }).description("Review the last commit or staged changes").option("--staged", "Review staged changes instead of last commit").option("--commit <ref>", "Review a specific commit ref instead of HEAD").option("--hook", "Running from git hook (non-blocking mode)").option("--depth <depth>", "Review context depth: shallow or default").option(
3121
+ "--reasoning <effort>",
3122
+ "Reasoning variant: auto, none, minimal, low, medium, high, max, or xhigh"
3123
+ ).option("--verbose", "Include suppressed findings and extra review details").action(async (options) => {
3124
+ const hookCommit = options.hook && options.commit ? String(options.commit) : void 0;
3125
+ const hookLock = options.hook ? process.env["DIFFOWL_HOOK_LOCK"] : void 0;
3126
+ if (hookLock) {
3127
+ process.once("exit", () => releaseHookReviewLock(hookLock));
3128
+ }
3129
+ if (options.hook) {
3130
+ await writeHookStatus(0, hookCommit, "Review started.");
3131
+ }
3132
+ const totalStart = performance.now();
3133
+ const timings = [];
3134
+ const gitRepoStart = performance.now();
3135
+ const isRepo = await isGitRepo();
3136
+ recordCliTiming(timings, "git-repo-check", "Git repository check", gitRepoStart);
3137
+ if (!isRepo) {
3138
+ console.error(chalk2.red("Not a git repository"));
3139
+ process.exit(1);
3140
+ }
3141
+ if (!configExists()) {
3142
+ console.log(chalk2.yellow("No .diffowl.yml found. Running first-time setup...\n"));
3143
+ await runInit();
3144
+ }
3145
+ const config = await loadConfigOrExit();
3146
+ if (options.staged && options.commit) {
3147
+ console.error(chalk2.red("Cannot use --staged and --commit together"));
3148
+ process.exit(1);
3149
+ }
3150
+ const mode = options.staged ? "staged" : options.commit ? "commit" : "last-commit";
3151
+ const depth = resolveReviewDepth(options.depth, config);
3152
+ config.reasoning.effort = resolveReasoningEffort(options.reasoning, config);
3153
+ const verbose = Boolean(config.verbose || options.verbose);
3154
+ if (mode !== "staged") {
3155
+ const hasCommitsStart = performance.now();
3156
+ const commitsExist = await hasCommits();
3157
+ recordCliTiming(timings, "git-commit-check", "Git commit check", hasCommitsStart);
3158
+ if (!commitsExist) {
3159
+ console.error(chalk2.red("No commits found in this repository"));
3160
+ process.exit(1);
3161
+ }
3162
+ }
3163
+ printHeader();
3164
+ const hookFailure = await checkRecentHookFailure();
3165
+ if (hookFailure) {
3166
+ console.log(chalk2.yellow(`\u26A0 ${formatHookFailure(hookFailure)}`));
3167
+ console.log();
3168
+ }
3169
+ const diff = mode === "staged" ? await getStagedDiff() : mode === "commit" ? await getCommitDiff(String(options.commit)) : await getLastCommitDiff();
3170
+ if (config.skip_doc_only && isDocOnlyDiff(diff)) {
3171
+ console.warn(chalk2.yellow("Documentation-only changes detected. Skipping review."));
3172
+ const skipContent = buildDocOnlySkipMarkdown(diff);
3173
+ const reportPath = await writeMarkdownReport(skipContent);
3174
+ console.log(chalk2.dim(`Report saved: ${reportPath}`));
3175
+ if (options.hook) {
3176
+ await writeHookStatus(0, hookCommit);
3177
+ }
3178
+ process.exit(0);
3179
+ }
3180
+ const spinner = ora({
3181
+ text: "Building local review context...",
3182
+ color: "cyan",
3183
+ discardStdin: false
3184
+ }).start();
3185
+ process.once("SIGINT", () => {
3186
+ try {
3187
+ spinner.stop();
3188
+ } catch {
3189
+ }
3190
+ console.log(chalk2.yellow("\nReview cancelled by user (Ctrl+C)."));
3191
+ process.exit(130);
3192
+ });
3193
+ process.once("SIGTSTP", () => {
3194
+ try {
3195
+ spinner.stop();
3196
+ } catch {
3197
+ }
3198
+ console.log(chalk2.yellow("\nReview cancelled by user (Ctrl+Z)."));
3199
+ process.exit(146);
3200
+ });
3201
+ try {
3202
+ const contextStart = performance.now();
3203
+ const reviewContext = await buildReviewContext(mode, config, depth, diff);
3204
+ recordCliTiming(timings, "context-build", "Local review context build", contextStart);
3205
+ if (mode === "staged" && reviewContext.diff.files.length === 0) {
3206
+ spinner.stop();
3207
+ console.log(chalk2.yellow("No staged changes to review"));
3208
+ process.exit(0);
3209
+ }
3210
+ const contextRenderStart = performance.now();
3211
+ const localContext = renderReviewContext(reviewContext, { depth });
3212
+ recordCliTiming(timings, "context-render", "Local review context render", contextRenderStart);
3213
+ if (reviewContext.diagnostics.length > 0) {
3214
+ spinner.warn("Local review context built with warnings.");
3215
+ for (const diagnostic of reviewContext.diagnostics) {
3216
+ console.log(chalk2.yellow(` - ${diagnostic}`));
3217
+ }
3218
+ console.log();
3219
+ spinner.start("Connecting to OpenCode...");
3220
+ }
3221
+ spinner.text = "Connecting to OpenCode...";
3222
+ const serverStart = performance.now();
3223
+ await prepareReviewServer(config);
3224
+ recordCliTiming(timings, "server-ensure", "OpenCode server ensure", serverStart);
3225
+ spinner.text = "Reviewing changes...";
3226
+ const reviewStart = performance.now();
3227
+ const reviewResult = await runReview({
3228
+ mode,
3229
+ config,
3230
+ localContext,
3231
+ depth,
3232
+ onProgress: (event) => {
3233
+ spinner.text = formatReviewProgress(event);
3234
+ }
3235
+ });
3236
+ const report = reviewResult.report;
3237
+ recordCliTiming(timings, "review-run", "OpenCode review run", reviewStart);
3238
+ spinner.succeed("Review complete.");
3239
+ console.log();
3240
+ const diagnostics = report.diagnostics ?? [];
3241
+ const confidenceFilter = filterFindingsByConfidence(report.findings, config.min_confidence);
3242
+ report.findings = confidenceFilter.findings;
3243
+ const changedFilesSet = /* @__PURE__ */ new Set();
3244
+ for (const file of reviewContext.changedFiles) {
3245
+ changedFilesSet.add(file.file.path);
3246
+ }
3247
+ const changedFileFilter = filterFindingsByChangedFiles(report.findings, changedFilesSet);
3248
+ report.findings = changedFileFilter.findings;
3249
+ if (changedFileFilter.suppressed.length > 0) {
3250
+ if (verbose) {
3251
+ report.suppressedFindings = changedFileFilter.suppressed;
3252
+ }
3253
+ }
3254
+ if (confidenceFilter.dropped > 0 || changedFileFilter.suppressed.length > 0) {
3255
+ diagnostics.push(
3256
+ formatExcludedCandidateSummary(
3257
+ confidenceFilter.dropped,
3258
+ changedFileFilter.suppressed.length
3259
+ )
3260
+ );
3261
+ }
3262
+ if (diagnostics.length > 0) {
3263
+ report.diagnostics = diagnostics;
3264
+ }
3265
+ const renderStart = performance.now();
3266
+ const markdown = renderMarkdown(report);
3267
+ recordCliTiming(timings, "render-report", "Markdown render", renderStart);
3268
+ const writeStart = performance.now();
3269
+ const reportPath = await writeMarkdownReport(markdown, {
3270
+ session_id: reviewResult.sessionId,
3271
+ project_root: getProjectRoot()
3272
+ });
3273
+ recordCliTiming(timings, "write-report", "Report write", writeStart);
3274
+ recordCliTiming(timings, "total", "Total review command", totalStart);
3275
+ console.log(colorizeMarkdown(markdown));
3276
+ printFooter(report, reportPath);
3277
+ printTimingSummary([...timings, ...report.timings ?? []]);
3278
+ if (options.hook) {
3279
+ await writeHookStatus(0, hookCommit);
3280
+ process.exit(0);
3281
+ }
3282
+ } catch (err) {
3283
+ spinner.stop();
3284
+ const message = err instanceof Error ? err.message : String(err);
3285
+ console.error(chalk2.red(`
3286
+ Review failed: ${message}`));
3287
+ for (const line of getOpenCodeFailureGuidance(message)) {
3288
+ console.log(chalk2.dim(line));
3289
+ }
3290
+ if (options.hook) {
3291
+ await writeHookStatus(1, hookCommit, message);
3292
+ process.exit(0);
3293
+ }
3294
+ process.exit(1);
3295
+ }
3296
+ });
3297
+ program.command("chat").description("Open the OpenCode session for a review").argument("[report]", "Review report path or filename").action(async (report) => {
3298
+ const reportPath = report ? resolveReviewReportPath(report) : await selectReviewInteractively();
3299
+ let content;
3300
+ try {
3301
+ content = await readFile8(reportPath, "utf-8");
3302
+ } catch {
3303
+ console.error(chalk2.red(`Review report not found: ${reportPath}`));
3304
+ process.exit(1);
3305
+ }
3306
+ let metadata;
3307
+ try {
3308
+ metadata = parseReviewMetadata(content);
3309
+ } catch {
3310
+ console.error(chalk2.red(`Invalid review metadata: ${reportPath}`));
3311
+ process.exit(1);
3312
+ }
3313
+ if (!metadata) {
3314
+ console.error(
3315
+ chalk2.red(`Review report does not contain chat session metadata: ${reportPath}`)
3316
+ );
3317
+ process.exit(1);
3318
+ }
3319
+ try {
3320
+ await execa5("opencode", [metadata.project_root, "--session", metadata.session_id], {
3321
+ stdio: "inherit"
3322
+ });
3323
+ } catch (err) {
3324
+ const message = err instanceof Error ? err.message : String(err);
3325
+ console.error(chalk2.red(`Failed to open review session: ${message}`));
3326
+ for (const line of getOpenCodeFailureGuidance(message)) {
3327
+ console.log(chalk2.dim(line));
3328
+ }
3329
+ process.exit(1);
3330
+ }
3331
+ });
3332
+ async function selectReviewInteractively() {
3333
+ if (!canSelectReviewInteractively(process.stdin.isTTY, process.stdout.isTTY)) {
3334
+ console.error(
3335
+ chalk2.red(
3336
+ "Interactive review selection requires a terminal. Pass a report filename or path instead."
3337
+ )
3338
+ );
3339
+ process.exit(1);
3340
+ }
3341
+ const reports = await listReviewReportPaths();
3342
+ if (reports.length === 0) {
3343
+ console.error(chalk2.red("No review reports available. Run `diffowl review` first."));
3344
+ process.exit(1);
3345
+ }
3346
+ console.log(chalk2.bold("\nSelect a review:\n"));
3347
+ for (const [index, report] of reports.entries()) {
3348
+ const resolved = basename5(dirname4(report)) === "resolved";
3349
+ console.log(
3350
+ ` ${chalk2.cyan(`${index + 1}.`)} ${basename5(report)}${resolved ? chalk2.dim(" (resolved)") : ""}`
3351
+ );
3352
+ }
3353
+ const rl = createInterface({
3354
+ input: process.stdin,
3355
+ output: process.stdout
3356
+ });
3357
+ try {
3358
+ while (true) {
3359
+ const selected = selectReviewReportPath(
3360
+ reports,
3361
+ await rl.question(chalk2.yellow("\nReview number: "))
3362
+ );
3363
+ if (selected) return selected;
3364
+ console.log(chalk2.red(`Enter a number between 1 and ${reports.length}.`));
3365
+ }
3366
+ } finally {
3367
+ rl.close();
3368
+ }
3369
+ }
3370
+ function formatReviewProgress(event) {
3371
+ switch (event.type) {
3372
+ case "server":
3373
+ case "session":
3374
+ case "idle":
3375
+ return event.message;
3376
+ case "tool":
3377
+ return `OpenCode tool: ${event.message}`;
3378
+ case "output":
3379
+ return event.message;
3380
+ case "timing":
3381
+ return event.message;
3382
+ }
3383
+ }
3384
+ function resolveReviewDepth(value, config) {
3385
+ if (value === void 0) {
3386
+ return config.context.depth;
3387
+ }
3388
+ try {
3389
+ return parseReviewContextDepth(value);
3390
+ } catch {
3391
+ console.error(chalk2.red(`Invalid review depth: ${String(value)}`));
3392
+ console.error(chalk2.dim("Expected one of: shallow, default"));
3393
+ process.exit(1);
3394
+ }
3395
+ }
3396
+ function resolveReasoningEffort(value, config) {
3397
+ if (value === void 0) {
3398
+ return config.reasoning.effort;
3399
+ }
3400
+ try {
3401
+ return parseReasoningEffort(value);
3402
+ } catch {
3403
+ console.error(chalk2.red(`Invalid reasoning effort: ${String(value)}`));
3404
+ console.error(chalk2.dim("Expected one of: auto, none, minimal, low, medium, high, max, xhigh"));
3405
+ process.exit(1);
3406
+ }
3407
+ }
3408
+ function recordCliTiming(timings, phase, label, start) {
3409
+ timings.push({ phase, label, ms: performance.now() - start });
3410
+ }
3411
+ function printTimingSummary(timings) {
3412
+ if (timings.length === 0) return;
3413
+ const ordered = [
3414
+ ...timings.filter((timing) => timing.phase !== "total"),
3415
+ ...timings.filter((timing) => timing.phase === "total")
3416
+ ];
3417
+ console.log(chalk2.dim("Timing:"));
3418
+ for (const timing of ordered) {
3419
+ console.log(chalk2.dim(` ${timing.label}: ${formatDuration2(timing.ms)}`));
3420
+ }
3421
+ console.log();
3422
+ }
3423
+ function formatDuration2(ms) {
3424
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
3425
+ return `${(ms / 1e3).toFixed(1)}s`;
3426
+ }
3427
+ async function prepareReviewServer(config) {
3428
+ if (config.server.auto_start) {
3429
+ await ensureServer(config.server.port);
3430
+ return;
3431
+ }
3432
+ if (await isServerRunning(config.server.port)) {
3433
+ return;
3434
+ }
3435
+ throw new Error(
3436
+ `OpenCode server is not running on port ${config.server.port}. Start it with \`diffowl server start\` or set server.auto_start: true.`
3437
+ );
3438
+ }
3439
+ program.command("init").description("Set up DiffOwl for this project").action(async () => {
3440
+ await runInit();
3441
+ });
3442
+ async function runInit() {
3443
+ console.log(chalk2.bold("DiffOwl Setup\n"));
3444
+ const config = await loadConfigOrExit();
3445
+ await selectModelInteractively(config, { allowKeepCurrent: false });
3446
+ }
3447
+ program.command("model").description("View or change the AI model").argument("[model]", "Model to use (e.g., opencode-go/big-pickle)").action(async (model) => {
3448
+ const config = await loadConfigOrExit();
3449
+ if (!model) {
3450
+ console.log(chalk2.bold("Current model: ") + chalk2.cyan(config.model));
3451
+ await selectModelInteractively(config, { allowKeepCurrent: true });
3452
+ return;
3453
+ }
3454
+ let parsedModel;
3455
+ try {
3456
+ parsedModel = parseModel(model);
3457
+ } catch {
3458
+ console.error(chalk2.red(`Invalid model: ${model}`));
3459
+ console.error(
3460
+ chalk2.dim("Expected provider/model format, for example opencode-go/big-pickle")
3461
+ );
3462
+ process.exit(1);
3463
+ }
3464
+ config.model = parsedModel;
3465
+ const configPath = await saveConfig(config);
3466
+ console.log(chalk2.green(`\u2713 Model set to ${chalk2.cyan(parsedModel)}`));
3467
+ console.log(chalk2.dim(`Config: ${configPath}`));
3468
+ });
3469
+ async function selectModelInteractively(config, options) {
3470
+ const spinner = ora("Querying available models from OpenCode...").start();
3471
+ let models = [];
3472
+ try {
3473
+ models = await getAvailableModels(config.server.port, {
3474
+ autoStart: config.server.auto_start
3475
+ });
3476
+ spinner.stop();
3477
+ } catch {
3478
+ spinner.fail("Failed to query models from OpenCode server.");
3479
+ }
3480
+ let selectedModel = config.model;
3481
+ if (models.length > 0) {
3482
+ if (!canSelectModelInteractively(process.stdin.isTTY, process.stdout.isTTY)) {
3483
+ console.error(
3484
+ chalk2.red(
3485
+ "Interactive model selection requires a terminal. Pass a model explicitly, for example `diffowl model provider/model`."
3486
+ )
3487
+ );
3488
+ process.exit(1);
3489
+ }
3490
+ console.log(
3491
+ chalk2.bold(
3492
+ options.allowKeepCurrent ? "\nAvailable models configured in OpenCode:" : "Available models configured in OpenCode:"
3493
+ )
3494
+ );
3495
+ models.forEach((m, idx) => {
3496
+ console.log(` ${chalk2.cyan(idx + 1)}. ${m}`);
3497
+ });
3498
+ console.log();
3499
+ const rl = createInterface({
3500
+ input: process.stdin,
3501
+ output: process.stdout
3502
+ });
3503
+ try {
3504
+ while (true) {
3505
+ const promptText = options.allowKeepCurrent ? `Select a model number (1-${models.length}) or press Enter to keep current: ` : `Select a model number (1-${models.length}) [default: 1]: `;
3506
+ const selection = selectModel(
3507
+ models,
3508
+ config.model,
3509
+ await rl.question(chalk2.yellow(promptText)),
3510
+ options.allowKeepCurrent
3511
+ );
3512
+ if (selection.type === "kept") break;
3513
+ if (selection.type === "selected") {
3514
+ selectedModel = selection.model;
3515
+ break;
3516
+ }
3517
+ console.log(chalk2.red("Invalid selection. Please enter a valid number."));
3518
+ }
3519
+ } finally {
3520
+ rl.close();
3521
+ }
3522
+ } else {
3523
+ console.log(chalk2.yellow("\nNo active/connected providers found in OpenCode."));
3524
+ console.log(
3525
+ chalk2.dim("Make sure you run ") + chalk2.cyan("opencode") + chalk2.dim(" to authenticate and set up your providers/keys first.")
3526
+ );
3527
+ console.log(chalk2.dim("Using fallback default model: ") + chalk2.cyan(config.model));
3528
+ console.log();
3529
+ }
3530
+ if (selectedModel !== config.model || !options.allowKeepCurrent) {
3531
+ config.model = selectedModel;
3532
+ const configPath = await saveConfig(config);
3533
+ if (options.allowKeepCurrent) {
3534
+ console.log(chalk2.green(`\u2713 Model set to ${chalk2.cyan(selectedModel)}`));
3535
+ } else {
3536
+ console.log(chalk2.green(`\u2713 Config saved to ${configPath}`));
3537
+ console.log(chalk2.dim(`Model set to: `) + chalk2.cyan(selectedModel));
3538
+ }
3539
+ console.log();
3540
+ }
3541
+ }
3542
+ var hookCmd = program.command("hook").description("Manage git hooks");
3543
+ hookCmd.command("install").description("Install post-commit hook (non-blocking review)").action(async () => {
3544
+ if (!await isGitRepo()) {
3545
+ console.error(chalk2.red("Not a git repository"));
3546
+ process.exit(1);
3547
+ }
3548
+ const alreadyInstalled = await isHookInstalled();
3549
+ const hookPath = await installHook();
3550
+ const action = alreadyInstalled ? "updated" : "installed";
3551
+ console.log(chalk2.green(`\u2713 Post-commit hook ${action}: ${hookPath}`));
3552
+ console.log(chalk2.dim("Reviews will run automatically after each commit (non-blocking)"));
3553
+ console.log(
3554
+ chalk2.dim("Hook output: .diffowl/hook.log; latest report: .diffowl/reviews/latest.md")
3555
+ );
3556
+ });
3557
+ hookCmd.command("status").description("Check if the post-commit hook is installed and up to date").action(async () => {
3558
+ const status = await checkHookStale();
3559
+ if (!status.installed) {
3560
+ console.log(chalk2.yellow(`\u2717 ${status.reason ?? "Hook not installed"}`));
3561
+ return;
3562
+ }
3563
+ if (status.stale) {
3564
+ console.log(chalk2.yellow("\u26A0 Hook is installed but stale"));
3565
+ console.log(chalk2.dim(`Reason: ${status.reason}`));
3566
+ console.log(chalk2.dim("Run `diffowl hook install` to update it."));
3567
+ return;
3568
+ }
3569
+ console.log(chalk2.green("\u2713 Hook is installed and up to date"));
3570
+ });
3571
+ hookCmd.command("uninstall").description("Remove the post-commit hook").action(async () => {
3572
+ if (await uninstallHook()) {
3573
+ console.log(chalk2.green("\u2713 Hook removed"));
3574
+ } else {
3575
+ console.log(chalk2.yellow("No diffowl hook found"));
3576
+ }
3577
+ });
3578
+ program.command("hook-run", { hidden: true }).description("Spawn a non-blocking hook review").action(async () => {
3579
+ await runHookReview();
3580
+ });
3581
+ program.command("hook-worker", { hidden: true }).description("Process queued hook reviews").action(async () => {
3582
+ const hookLock = process.env["DIFFOWL_HOOK_LOCK"];
3583
+ if (hookLock) {
3584
+ process.once("exit", () => releaseHookReviewLock(hookLock));
3585
+ }
3586
+ await runPendingHookReviews();
3587
+ });
3588
+ var serverCmd = program.command("server").description("Manage the OpenCode server");
3589
+ serverCmd.command("start").description("Start the OpenCode server").action(async () => {
3590
+ const config = await loadConfigOrExit();
3591
+ const spinner = ora("Starting OpenCode server...").start();
3592
+ try {
3593
+ const url = await ensureServer(config.server.port);
3594
+ spinner.succeed(`Server running at ${url}`);
3595
+ } catch (err) {
3596
+ spinner.fail(err instanceof Error ? err.message : String(err));
3597
+ process.exit(1);
3598
+ }
3599
+ });
3600
+ serverCmd.command("stop").description("Stop the OpenCode server").action(async () => {
3601
+ if (await stopServer()) {
3602
+ console.log(chalk2.green("\u2713 Server stopped"));
3603
+ } else {
3604
+ console.log(chalk2.yellow("No managed server found"));
3605
+ }
3606
+ });
3607
+ serverCmd.command("status").description("Check if the OpenCode server is running").action(async () => {
3608
+ const config = await loadConfigOrExit();
3609
+ const running = await isServerRunning(config.server.port);
3610
+ if (running) {
3611
+ console.log(chalk2.green(`\u2713 Server running on port ${config.server.port}`));
3612
+ } else {
3613
+ console.log(chalk2.yellow(`\u2717 No server on port ${config.server.port}`));
3614
+ }
3615
+ });
3616
+ program.parse();
3617
+ async function loadConfigOrExit() {
3618
+ try {
3619
+ return await loadConfig();
3620
+ } catch (err) {
3621
+ const message = err instanceof Error ? err.message : String(err);
3622
+ console.error(chalk2.red(`Config error: ${message}`));
3623
+ process.exit(1);
3624
+ }
3625
+ }
3626
+ function filterFindingsByConfidence(findings, minConfidence) {
3627
+ const levels = ["low", "medium", "high"];
3628
+ const minIndex = levels.indexOf(minConfidence);
3629
+ const kept = findings.filter((f) => {
3630
+ const idx = levels.indexOf(f.confidence.toLowerCase());
3631
+ return idx >= minIndex;
3632
+ });
3633
+ return { findings: kept, dropped: findings.length - kept.length };
3634
+ }
3635
+ function filterFindingsByChangedFiles(findings, changedFiles) {
3636
+ const kept = [];
3637
+ const suppressed = [];
3638
+ for (const finding of findings) {
3639
+ if (changedFiles.has(finding.file)) {
3640
+ kept.push(finding);
3641
+ } else {
3642
+ suppressed.push(finding);
3643
+ }
3644
+ }
3645
+ return { findings: kept, suppressed };
3646
+ }
3647
+ function buildDocOnlySkipMarkdown(diff) {
3648
+ const lines = [];
3649
+ lines.push("### Summary");
3650
+ lines.push("Documentation-only changes detected. No code review performed.");
3651
+ lines.push("");
3652
+ lines.push("### Changed Files");
3653
+ for (const file of diff.files) {
3654
+ lines.push(`- ${file.path} (+${file.additions}/-${file.deletions})`);
3655
+ }
3656
+ return lines.join("\n");
3657
+ }
3658
+ //# sourceMappingURL=cli.js.map