diffowl 0.3.2 → 0.3.3

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 CHANGED
@@ -129,7 +129,8 @@ async function loadConfigFromRoot(root) {
129
129
  }
130
130
  async function saveConfig(config) {
131
131
  const configPath = findConfigPath();
132
- const content = stringify(DiffOwlConfigSchema.parse(config), { lineWidth: 0 });
132
+ const { model: _legacyModel, ...projectConfig } = DiffOwlConfigSchema.parse(config);
133
+ const content = stringify(projectConfig, { lineWidth: 0 });
133
134
  await writeFile(configPath, content, "utf-8");
134
135
  return configPath;
135
136
  }
@@ -152,14 +153,191 @@ function configExists() {
152
153
  return existsSync(findConfigPath());
153
154
  }
154
155
 
156
+ // src/model-preference.ts
157
+ import { mkdir as mkdir2, readFile as readFile2, rename, rm, writeFile as writeFile2 } from "fs/promises";
158
+ import { join as join3 } from "path";
159
+ import { parse as parse2, stringify as stringify2 } from "yaml";
160
+ import { z as z2 } from "zod";
161
+
162
+ // src/git/state-root.ts
163
+ import { existsSync as existsSync2 } from "fs";
164
+ import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve } from "path";
165
+ import { execa } from "execa";
166
+ var sharedDiffOwlDirPromise;
167
+ var warnedStateMove = false;
168
+ async function getSharedDiffOwlDir() {
169
+ if (!sharedDiffOwlDirPromise) {
170
+ sharedDiffOwlDirPromise = resolveSharedDiffOwlDir();
171
+ }
172
+ const resolution = sharedDiffOwlDirPromise;
173
+ try {
174
+ return await resolution;
175
+ } catch (error) {
176
+ if (sharedDiffOwlDirPromise === resolution) {
177
+ sharedDiffOwlDirPromise = void 0;
178
+ }
179
+ throw error;
180
+ }
181
+ }
182
+ async function resolveSharedDiffOwlDir() {
183
+ const projectRoot = getProjectRoot();
184
+ const localDir = getDiffOwlDir();
185
+ let insideWorkTree;
186
+ try {
187
+ ({ stdout: insideWorkTree } = await execa("git", ["rev-parse", "--is-inside-work-tree"], {
188
+ cwd: projectRoot
189
+ }));
190
+ } catch (error) {
191
+ if (isRecoverableGitLookupError(error)) {
192
+ return localDir;
193
+ }
194
+ throw error;
195
+ }
196
+ if (insideWorkTree.trim() !== "true") {
197
+ return localDir;
198
+ }
199
+ let toplevelRaw;
200
+ let commonRaw;
201
+ try {
202
+ [{ stdout: toplevelRaw }, { stdout: commonRaw }] = await Promise.all([
203
+ gitRevParse(projectRoot, ["--show-toplevel"]),
204
+ gitRevParse(projectRoot, ["--git-common-dir"])
205
+ ]);
206
+ } catch (error) {
207
+ if (isRecoverableGitLookupError(error)) {
208
+ return localDir;
209
+ }
210
+ throw error;
211
+ }
212
+ const toplevel = resolveGitPath(projectRoot, toplevelRaw);
213
+ const commonDir = resolveGitPath(projectRoot, commonRaw);
214
+ const rel = relative(toplevel, projectRoot);
215
+ if (rel.startsWith("..")) {
216
+ return localDir;
217
+ }
218
+ let sharedDiffOwlDir;
219
+ if (basename(commonDir) !== ".git") {
220
+ sharedDiffOwlDir = join2(commonDir, "diffowl", rel, ".diffowl");
221
+ } else {
222
+ sharedDiffOwlDir = join2(dirname2(commonDir), rel, ".diffowl");
223
+ }
224
+ warnIfIgnoringLocalState(localDir, sharedDiffOwlDir);
225
+ return sharedDiffOwlDir;
226
+ }
227
+ function warnIfIgnoringLocalState(localDir, sharedDir) {
228
+ if (warnedStateMove || localDir === sharedDir) {
229
+ return;
230
+ }
231
+ const localDb = join2(localDir, "state.db");
232
+ if (!existsSync2(localDb)) {
233
+ return;
234
+ }
235
+ warnedStateMove = true;
236
+ console.warn(
237
+ `DiffOwl state moved: using ${join2(
238
+ sharedDir,
239
+ "state.db"
240
+ )}; ignoring checkout-local ${localDb}. Delete the checkout-local database if this worktree should use only the shared state.`
241
+ );
242
+ }
243
+ async function gitRevParse(projectRoot, args) {
244
+ try {
245
+ return await execa("git", ["rev-parse", "--path-format=absolute", ...args], {
246
+ cwd: projectRoot
247
+ });
248
+ } catch (error) {
249
+ if (!isUnsupportedGitOptionError(error)) {
250
+ throw error;
251
+ }
252
+ return await execa("git", ["rev-parse", ...args], { cwd: projectRoot });
253
+ }
254
+ }
255
+ function resolveGitPath(projectRoot, raw) {
256
+ const trimmed = raw.trim();
257
+ return isAbsolute(trimmed) ? trimmed : resolve(projectRoot, trimmed);
258
+ }
259
+ function isRecoverableGitLookupError(error) {
260
+ if (!error || typeof error !== "object") {
261
+ return false;
262
+ }
263
+ const err = error;
264
+ return err.code === "ENOENT" || err.exitCode === 128;
265
+ }
266
+ function isUnsupportedGitOptionError(error) {
267
+ if (!error || typeof error !== "object") {
268
+ return false;
269
+ }
270
+ const err = error;
271
+ if (err.exitCode !== 129 && err.exitCode !== 128) {
272
+ return false;
273
+ }
274
+ const stderr = err.stderr ?? "";
275
+ return stderr.includes("path-format") || stderr.includes("unknown option");
276
+ }
277
+
278
+ // src/model-preference.ts
279
+ var ModelPreferenceSchema = z2.object({ model: ModelSchema }).strict();
280
+ var PREFERENCES_FILENAME = "preferences.yml";
281
+ async function loadModelPreference() {
282
+ const path = join3(await getSharedDiffOwlDir(), PREFERENCES_FILENAME);
283
+ try {
284
+ return ModelPreferenceSchema.parse(parse2(await readFile2(path, "utf8"))).model;
285
+ } catch (err) {
286
+ if (isMissingFile(err)) return void 0;
287
+ const message = err instanceof Error ? err.message : String(err);
288
+ throw new Error(`Failed to load ${path}: ${message}`);
289
+ }
290
+ }
291
+ async function saveModelPreference(model) {
292
+ const dir = await getSharedDiffOwlDir();
293
+ const path = join3(dir, PREFERENCES_FILENAME);
294
+ const temporaryPath = `${path}.${process.pid}.tmp`;
295
+ const preference = ModelPreferenceSchema.parse({ model });
296
+ await mkdir2(dir, { recursive: true });
297
+ await writeFile2(temporaryPath, stringify2(preference), { encoding: "utf8", mode: 384 });
298
+ await rename(temporaryPath, path);
299
+ return path;
300
+ }
301
+ async function resetModelPreference() {
302
+ await rm(join3(await getSharedDiffOwlDir(), PREFERENCES_FILENAME), { force: true });
303
+ }
304
+ function isMissingFile(err) {
305
+ return err instanceof Error && "code" in err && err.code === "ENOENT";
306
+ }
307
+
308
+ // src/effective-config.ts
309
+ var MissingModelError = class extends Error {
310
+ constructor() {
311
+ super("No model selected. Run `diffowl model <provider/model>`.");
312
+ }
313
+ };
314
+ async function loadEffectiveConfig(commandModel, env = process.env) {
315
+ const config = await loadConfig();
316
+ const environmentModel = env["DIFFOWL_MODEL"]?.trim() || void 0;
317
+ if (commandModel !== void 0) {
318
+ config.model = parseModel(commandModel);
319
+ return { config, modelSource: "command" };
320
+ }
321
+ if (environmentModel !== void 0) {
322
+ config.model = parseModel(environmentModel);
323
+ return { config, modelSource: "environment" };
324
+ }
325
+ const localModel = await loadModelPreference();
326
+ if (localModel !== void 0) {
327
+ config.model = localModel;
328
+ return { config, modelSource: "local" };
329
+ }
330
+ throw new MissingModelError();
331
+ }
332
+
155
333
  // src/opencode/client.ts
156
334
  import { createOpencodeClient as createOpencodeClient2 } from "@opencode-ai/sdk";
157
335
 
158
336
  // src/opencode/server.ts
159
- import { execa } from "execa";
160
- import { existsSync as existsSync2 } from "fs";
161
- import { readFile as readFile2, writeFile as writeFile2, unlink } from "fs/promises";
162
- import { join as join2 } from "path";
337
+ import { execa as execa2 } from "execa";
338
+ import { existsSync as existsSync3 } from "fs";
339
+ import { readFile as readFile3, writeFile as writeFile3, unlink } from "fs/promises";
340
+ import { join as join4 } from "path";
163
341
  var HEALTH_TIMEOUT_MS = 2e3;
164
342
  var STARTUP_WAIT_MS = 3e3;
165
343
  var MAX_RETRIES = 10;
@@ -188,7 +366,7 @@ async function getServerHealth(port) {
188
366
  }
189
367
  async function getInstalledOpencodeVersion() {
190
368
  try {
191
- const { stdout } = await execa("opencode", ["--version"], { timeout: 5e3 });
369
+ const { stdout } = await execa2("opencode", ["--version"], { timeout: 5e3 });
192
370
  const trimmed = stdout.trim();
193
371
  if (!trimmed) {
194
372
  return null;
@@ -243,10 +421,10 @@ async function checkOpencodeInstalled() {
243
421
  const isWin = process.platform === "win32";
244
422
  const checkCmd = isWin ? "where" : "which";
245
423
  try {
246
- await execa(checkCmd, ["opencode"]);
424
+ await execa2(checkCmd, ["opencode"]);
247
425
  } catch {
248
426
  try {
249
- await execa("opencode", ["--version"], { timeout: 5e3 });
427
+ await execa2("opencode", ["--version"], { timeout: 5e3 });
250
428
  } catch {
251
429
  throw new Error(
252
430
  "opencode not found. Install it: npm i -g opencode-ai\nSee: https://opencode.ai/docs/"
@@ -256,9 +434,9 @@ async function checkOpencodeInstalled() {
256
434
  }
257
435
  async function spawnServer(port) {
258
436
  const dir = await ensureDiffOwlDir();
259
- const pidFile = join2(dir, "server.pid");
437
+ const pidFile = join4(dir, "server.pid");
260
438
  await checkOpencodeInstalled();
261
- const subprocess = execa("opencode", ["serve", "--port", String(port)], {
439
+ const subprocess = execa2("opencode", ["serve", "--port", String(port)], {
262
440
  detached: true,
263
441
  stdio: "ignore",
264
442
  cleanup: false
@@ -266,7 +444,7 @@ async function spawnServer(port) {
266
444
  void subprocess.catch(() => {
267
445
  });
268
446
  if (subprocess.pid) {
269
- await writeFile2(pidFile, String(subprocess.pid), "utf-8");
447
+ await writeFile3(pidFile, String(subprocess.pid), "utf-8");
270
448
  }
271
449
  subprocess.unref();
272
450
  }
@@ -293,13 +471,13 @@ async function stopServer(port) {
293
471
  }
294
472
  async function stopManagedServer() {
295
473
  const dir = getDiffOwlDir();
296
- const pidFile = join2(dir, "server.pid");
297
- if (!existsSync2(pidFile)) {
474
+ const pidFile = join4(dir, "server.pid");
475
+ if (!existsSync3(pidFile)) {
298
476
  return false;
299
477
  }
300
478
  let pid;
301
479
  try {
302
- pid = parseInt(await readFile2(pidFile, "utf-8"), 10);
480
+ pid = parseInt(await readFile3(pidFile, "utf-8"), 10);
303
481
  } catch {
304
482
  try {
305
483
  await unlink(pidFile);
@@ -353,8 +531,8 @@ async function stopUnhealthyServerListener(port) {
353
531
  return true;
354
532
  }
355
533
  async function cleanupPidFile() {
356
- const pidFile = join2(getDiffOwlDir(), "server.pid");
357
- if (!existsSync2(pidFile)) {
534
+ const pidFile = join4(getDiffOwlDir(), "server.pid");
535
+ if (!existsSync3(pidFile)) {
358
536
  return;
359
537
  }
360
538
  try {
@@ -379,7 +557,7 @@ async function findListenerPids(port) {
379
557
  return findListenerPidsWindows(port);
380
558
  }
381
559
  try {
382
- const { stdout } = await execa("lsof", ["-tiTCP:" + String(port), "-sTCP:LISTEN"], {
560
+ const { stdout } = await execa2("lsof", ["-tiTCP:" + String(port), "-sTCP:LISTEN"], {
383
561
  timeout: 5e3
384
562
  });
385
563
  return parsePids(stdout);
@@ -389,7 +567,7 @@ async function findListenerPids(port) {
389
567
  }
390
568
  async function findListenerPidsWindows(port) {
391
569
  try {
392
- const { stdout } = await execa("netstat", ["-ano"], { timeout: 5e3 });
570
+ const { stdout } = await execa2("netstat", ["-ano"], { timeout: 5e3 });
393
571
  const portToken = `:${port}`;
394
572
  const lines = stdout.split(/\r?\n/);
395
573
  const pids = /* @__PURE__ */ new Set();
@@ -431,7 +609,7 @@ async function isOpencodeProcess(pid) {
431
609
  try {
432
610
  if (isWin) {
433
611
  try {
434
- const { stdout: stdout3 } = await execa("powershell", [
612
+ const { stdout: stdout3 } = await execa2("powershell", [
435
613
  "-NoProfile",
436
614
  "-Command",
437
615
  `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`
@@ -442,7 +620,7 @@ async function isOpencodeProcess(pid) {
442
620
  } catch {
443
621
  }
444
622
  try {
445
- const { stdout: stdout3 } = await execa("wmic", [
623
+ const { stdout: stdout3 } = await execa2("wmic", [
446
624
  "process",
447
625
  "where",
448
626
  `ProcessId=${pid}`,
@@ -454,10 +632,10 @@ async function isOpencodeProcess(pid) {
454
632
  }
455
633
  } catch {
456
634
  }
457
- const { stdout: stdout2 } = await execa("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
635
+ const { stdout: stdout2 } = await execa2("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
458
636
  return stdout2.toLowerCase().includes("opencode");
459
637
  }
460
- const { stdout } = await execa("ps", ["-p", String(pid), "-o", "command="]);
638
+ const { stdout } = await execa2("ps", ["-p", String(pid), "-o", "command="]);
461
639
  return stdout.toLowerCase().includes("opencode");
462
640
  } catch {
463
641
  return false;
@@ -548,7 +726,7 @@ Do not force these passes onto unrelated changes; skip surfaces that are not pre
548
726
  - Data filtering/loss: Look for data silently dropped, hidden, duplicated, parsed with a fallback, or reported inconsistently.
549
727
  `;
550
728
  function buildReviewPrompt(target, customRules, include, exclude, localContext, depth = "default") {
551
- const modeInstruction = target.kind === "staged" ? "Review the currently staged changes." : target.kind === "commit" ? "Review the selected commit." : "Review the last commit.";
729
+ const modeInstruction = target.kind === "staged" ? "Review the currently staged changes." : target.kind === "commit" ? "Review the selected commit." : target.kind === "base" ? "Review the committed branch changes since the merge base." : "Review the last commit.";
552
730
  let prompt = `${modeInstruction}
553
731
 
554
732
  DiffOwl has already collected the diff and likely-relevant local context below. Use this context first.
@@ -606,39 +784,39 @@ function reviewDepthInstruction(depth) {
606
784
  }
607
785
 
608
786
  // src/opencode/review-parser.ts
609
- import { z as z2 } from "zod";
610
- var ReviewSeveritySchema = z2.preprocess(
787
+ import { z as z3 } from "zod";
788
+ var ReviewSeveritySchema = z3.preprocess(
611
789
  (value) => typeof value === "string" ? value.toLowerCase() : value,
612
- z2.enum(["error", "warning", "info"])
790
+ z3.enum(["error", "warning", "info"])
613
791
  );
614
- var ReviewConfidenceSchema2 = z2.preprocess(
792
+ var ReviewConfidenceSchema2 = z3.preprocess(
615
793
  (value) => typeof value === "string" ? value.toLowerCase() : value,
616
794
  ReviewConfidenceSchema
617
795
  ).catch("low");
618
- var ReviewFindingLineSchema = z2.preprocess(
796
+ var ReviewFindingLineSchema = z3.preprocess(
619
797
  (value) => typeof value === "string" ? Number(value) : value,
620
- z2.number().int().positive()
798
+ z3.number().int().positive()
621
799
  );
622
- var ReviewFindingPathSchema = z2.preprocess((value) => {
800
+ var ReviewFindingPathSchema = z3.preprocess((value) => {
623
801
  if (typeof value !== "string") return value;
624
802
  const normalized = value.trim().replaceAll("\\", "/").replace(/^(?:\.\/)+/, "");
625
803
  if (normalized === "" || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized) || normalized.split("/").includes("..")) {
626
804
  return void 0;
627
805
  }
628
806
  return normalized;
629
- }, z2.string().min(1));
630
- var ReviewFindingSchema = z2.object({
807
+ }, z3.string().min(1));
808
+ var ReviewFindingSchema = z3.object({
631
809
  severity: ReviewSeveritySchema,
632
810
  file: ReviewFindingPathSchema,
633
811
  line: ReviewFindingLineSchema,
634
- evidence: z2.string().nullish(),
635
- title: z2.string().trim().min(1),
636
- body: z2.string().trim().min(1),
812
+ evidence: z3.string().nullish(),
813
+ title: z3.string().trim().min(1),
814
+ body: z3.string().trim().min(1),
637
815
  confidence: ReviewConfidenceSchema2
638
816
  });
639
- var ReviewJsonSchema = z2.object({
640
- summary: z2.string(),
641
- findings: z2.array(z2.unknown())
817
+ var ReviewJsonSchema = z3.object({
818
+ summary: z3.string(),
819
+ findings: z3.array(z3.unknown())
642
820
  });
643
821
  function parseStructuredReview(raw) {
644
822
  const marker = "FINAL_REVIEW_JSON";
@@ -977,19 +1155,19 @@ async function withTimeout(promise, ms) {
977
1155
  }
978
1156
 
979
1157
  // src/opencode/provider-payload.ts
980
- import { z as z3 } from "zod";
981
- var ProviderModelSchema = z3.object({
982
- id: z3.string(),
983
- status: z3.string().nullish().transform((value) => value ?? void 0),
984
- reasoning: z3.boolean().nullish().transform((value) => value ?? void 0),
985
- capabilities: z3.object({
986
- reasoning: z3.boolean().nullish().transform((value) => value ?? void 0)
1158
+ import { z as z4 } from "zod";
1159
+ var ProviderModelSchema = z4.object({
1160
+ id: z4.string(),
1161
+ status: z4.string().nullish().transform((value) => value ?? void 0),
1162
+ reasoning: z4.boolean().nullish().transform((value) => value ?? void 0),
1163
+ capabilities: z4.object({
1164
+ reasoning: z4.boolean().nullish().transform((value) => value ?? void 0)
987
1165
  }).nullish().transform((value) => value ?? void 0),
988
- variants: z3.record(z3.string(), z3.unknown()).nullish().transform((value) => value ?? void 0)
1166
+ variants: z4.record(z4.string(), z4.unknown()).nullish().transform((value) => value ?? void 0)
989
1167
  }).passthrough();
990
- var ProviderSchema = z3.object({
991
- id: z3.string(),
992
- models: z3.record(z3.string(), z3.unknown()).nullish().transform((models) => {
1168
+ var ProviderSchema = z4.object({
1169
+ id: z4.string(),
1170
+ models: z4.record(z4.string(), z4.unknown()).nullish().transform((models) => {
993
1171
  if (!models) return void 0;
994
1172
  return Object.fromEntries(
995
1173
  Object.entries(models).flatMap(([key, model]) => {
@@ -999,9 +1177,9 @@ var ProviderSchema = z3.object({
999
1177
  );
1000
1178
  })
1001
1179
  }).passthrough();
1002
- var ProviderPayloadSchema = z3.object({
1003
- connected: z3.array(z3.string()).nullish().transform((value) => value ?? []),
1004
- all: z3.array(z3.unknown()).nullish().transform(
1180
+ var ProviderPayloadSchema = z4.object({
1181
+ connected: z4.array(z4.string()).nullish().transform((value) => value ?? []),
1182
+ all: z4.array(z4.unknown()).nullish().transform(
1005
1183
  (providers) => (providers ?? []).flatMap((provider) => {
1006
1184
  const parsed = ProviderSchema.safeParse(provider);
1007
1185
  return parsed.success ? [parsed.data] : [];
@@ -1057,19 +1235,19 @@ function listAvailableModels(payload) {
1057
1235
  }
1058
1236
 
1059
1237
  // src/review/usage.ts
1060
- import { z as z4 } from "zod";
1061
- var ReviewUsageTokensSchema = z4.object({
1062
- input: z4.number(),
1063
- output: z4.number(),
1064
- reasoning: z4.number(),
1065
- cache: z4.object({
1066
- read: z4.number(),
1067
- write: z4.number()
1238
+ import { z as z5 } from "zod";
1239
+ var ReviewUsageTokensSchema = z5.object({
1240
+ input: z5.number(),
1241
+ output: z5.number(),
1242
+ reasoning: z5.number(),
1243
+ cache: z5.object({
1244
+ read: z5.number(),
1245
+ write: z5.number()
1068
1246
  })
1069
1247
  });
1070
- var ReviewUsageSchema = z4.object({
1248
+ var ReviewUsageSchema = z5.object({
1071
1249
  tokens: ReviewUsageTokensSchema,
1072
- cost: z4.number().nullable()
1250
+ cost: z5.number().nullable()
1073
1251
  });
1074
1252
  function parseAssistantUsage(info) {
1075
1253
  if (!info || typeof info !== "object") return void 0;
@@ -1649,13 +1827,13 @@ function getOpenCodeFailureGuidance(message) {
1649
1827
  }
1650
1828
 
1651
1829
  // src/eval/command.ts
1652
- import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
1653
- import { join as join13 } from "path";
1830
+ import { readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
1831
+ import { join as join14 } from "path";
1654
1832
  import chalk2 from "chalk";
1655
1833
  import ora from "ora";
1656
1834
 
1657
1835
  // src/eval/command-types.ts
1658
- import { isAbsolute, join as join3 } from "path";
1836
+ import { isAbsolute as isAbsolute2, join as join5 } from "path";
1659
1837
  function parseEvalOutputFormat(value) {
1660
1838
  if (value === void 0 || value === "text") {
1661
1839
  return "text";
@@ -1694,40 +1872,39 @@ function resolveEvalOutDir(cwd, out, timestamp) {
1694
1872
  if (out) {
1695
1873
  return joinPath(cwd, out);
1696
1874
  }
1697
- return joinPath(cwd, join3("eval", "results", timestamp));
1875
+ return joinPath(cwd, join5("eval", "results", timestamp));
1698
1876
  }
1699
1877
  function joinPath(cwd, target) {
1700
- return isAbsolute(target) ? target : join3(cwd, target);
1878
+ return isAbsolute2(target) ? target : join5(cwd, target);
1701
1879
  }
1702
1880
 
1703
1881
  // src/eval/corpus.ts
1704
1882
  import { createHash } from "crypto";
1705
- import { cp, readdir, readFile as readFile3, stat } from "fs/promises";
1706
- import { basename, join as join4, relative, sep } from "path";
1707
- import { execa as execa2 } from "execa";
1883
+ import { cp, readdir, readFile as readFile4, stat } from "fs/promises";
1884
+ import { basename as basename2, join as join6, relative as relative2, sep } from "path";
1885
+ import { execa as execa3 } from "execa";
1708
1886
 
1709
1887
  // src/eval/case-types.ts
1710
- import { z as z5 } from "zod";
1711
- var EvalCaseCategorySchema = z5.enum(["bug", "clean", "mixed"]);
1712
- var EvalCaseLanguageSchema = z5.enum(["typescript"]);
1713
- var EvalCaseTargetSchema = z5.enum(["staged", "commit"]);
1714
- var EvalSeveritySchema = z5.enum(["error", "warning", "info"]);
1715
- var EvalExpectedFindingSchema = z5.object({
1716
- file: z5.string().trim().min(1),
1717
- line: z5.number().int().positive(),
1718
- line_tolerance: z5.number().int().nonnegative().default(2),
1719
- category: z5.string().trim().min(1).optional(),
1888
+ import { z as z6 } from "zod";
1889
+ var EvalCaseCategorySchema = z6.enum(["bug", "clean", "mixed"]);
1890
+ var EvalCaseLanguageSchema = z6.enum(["typescript"]);
1891
+ var EvalCaseTargetSchema = z6.enum(["staged", "commit"]);
1892
+ var EvalSeveritySchema = z6.enum(["error", "warning", "info"]);
1893
+ var EvalExpectedFindingSchema = z6.object({
1894
+ file: z6.string().trim().min(1),
1895
+ line: z6.number().int().positive(),
1896
+ line_tolerance: z6.number().int().nonnegative().default(2),
1720
1897
  min_severity: EvalSeveritySchema.default("warning"),
1721
- must_detect: z5.boolean().default(true)
1722
- });
1723
- var EvalCaseJsonSchema = z5.object({
1724
- id: z5.string().trim().min(1),
1898
+ must_detect: z6.boolean().default(true)
1899
+ }).strict();
1900
+ var EvalCaseJsonSchema = z6.object({
1901
+ id: z6.string().trim().min(1),
1725
1902
  category: EvalCaseCategorySchema,
1726
1903
  language: EvalCaseLanguageSchema,
1727
- description: z5.string().trim().min(1),
1904
+ description: z6.string().trim().min(1),
1728
1905
  target: EvalCaseTargetSchema.default("commit"),
1729
- expected: z5.array(EvalExpectedFindingSchema).default([]),
1730
- tags: z5.array(z5.string().trim().min(1)).default([])
1906
+ expected: z6.array(EvalExpectedFindingSchema).default([]),
1907
+ tags: z6.array(z6.string().trim().min(1)).default([])
1731
1908
  });
1732
1909
  function parseEvalCaseJson(raw) {
1733
1910
  return EvalCaseJsonSchema.parse(raw);
@@ -1746,13 +1923,13 @@ function validateEvalCaseSemantics(caseJson) {
1746
1923
 
1747
1924
  // src/eval/corpus.ts
1748
1925
  async function loadEvalCase(caseDir) {
1749
- const caseJsonPath = join4(caseDir, "case.json");
1750
- const baseDir = join4(caseDir, "base");
1751
- const patchPath = join4(caseDir, "change.patch");
1752
- const id = basename(caseDir);
1926
+ const caseJsonPath = join6(caseDir, "case.json");
1927
+ const baseDir = join6(caseDir, "base");
1928
+ const patchPath = join6(caseDir, "change.patch");
1929
+ const id = basename2(caseDir);
1753
1930
  let raw;
1754
1931
  try {
1755
- raw = JSON.parse(await readFile3(caseJsonPath, "utf8"));
1932
+ raw = JSON.parse(await readFile4(caseJsonPath, "utf8"));
1756
1933
  } catch (error) {
1757
1934
  throw new Error(`Failed to read case.json for "${id}": ${describeError2(error)}`);
1758
1935
  }
@@ -1788,7 +1965,7 @@ async function copyCaseBase(evalCase, workDir) {
1788
1965
  }
1789
1966
  async function applyCasePatch(evalCase, workDir) {
1790
1967
  try {
1791
- await execa2("git", ["apply", "--whitespace=nowarn", evalCase.patchPath], {
1968
+ await execa3("git", ["apply", "--whitespace=nowarn", evalCase.patchPath], {
1792
1969
  cwd: workDir,
1793
1970
  reject: false
1794
1971
  }).then((result) => {
@@ -1801,8 +1978,8 @@ async function applyCasePatch(evalCase, workDir) {
1801
1978
  }
1802
1979
  }
1803
1980
  async function hashCase(caseDir) {
1804
- const caseJson = await readFile3(join4(caseDir, "case.json"));
1805
- const patch = await readFile3(join4(caseDir, "change.patch"));
1981
+ const caseJson = await readFile4(join6(caseDir, "case.json"));
1982
+ const patch = await readFile4(join6(caseDir, "change.patch"));
1806
1983
  return {
1807
1984
  caseJsonHash: hashBuffer(caseJson),
1808
1985
  patchHash: hashBuffer(patch)
@@ -1813,7 +1990,7 @@ async function hashCorpus(corpusDir) {
1813
1990
  const lines = [];
1814
1991
  const posixFiles = files.map((filePath) => filePath.split(sep).join("/")).sort();
1815
1992
  for (const filePath of posixFiles) {
1816
- const content = await readFile3(join4(corpusDir, filePath));
1993
+ const content = await readFile4(join6(corpusDir, filePath));
1817
1994
  lines.push(`${filePath}:${hashBuffer(content)}`);
1818
1995
  }
1819
1996
  return hashText(lines.join("\n"));
@@ -1823,27 +2000,27 @@ async function listCaseDirectories(corpusDir) {
1823
2000
  const caseDirs = [];
1824
2001
  for (const entry of entries) {
1825
2002
  if (!entry.isDirectory()) continue;
1826
- const caseDir = join4(corpusDir, entry.name);
2003
+ const caseDir = join6(corpusDir, entry.name);
1827
2004
  try {
1828
- await stat(join4(caseDir, "case.json"));
2005
+ await stat(join6(caseDir, "case.json"));
1829
2006
  caseDirs.push(caseDir);
1830
2007
  } catch {
1831
2008
  continue;
1832
2009
  }
1833
2010
  }
1834
- return caseDirs.sort((left, right) => basename(left).localeCompare(basename(right)));
2011
+ return caseDirs.sort((left, right) => basename2(left).localeCompare(basename2(right)));
1835
2012
  }
1836
2013
  async function listFilesRecursive(rootDir, currentDir = rootDir) {
1837
2014
  const entries = await readdir(currentDir, { withFileTypes: true });
1838
2015
  const files = [];
1839
2016
  for (const entry of entries) {
1840
- const absolutePath = join4(currentDir, entry.name);
2017
+ const absolutePath = join6(currentDir, entry.name);
1841
2018
  if (entry.isDirectory()) {
1842
2019
  files.push(...await listFilesRecursive(rootDir, absolutePath));
1843
2020
  continue;
1844
2021
  }
1845
2022
  if (entry.isFile()) {
1846
- files.push(relative(rootDir, absolutePath));
2023
+ files.push(relative2(rootDir, absolutePath));
1847
2024
  }
1848
2025
  }
1849
2026
  return files;
@@ -1889,36 +2066,84 @@ function describeError2(error) {
1889
2066
  }
1890
2067
 
1891
2068
  // src/eval/gates-types.ts
1892
- import { z as z6 } from "zod";
1893
- var EvalGateThresholdsSchema = z6.object({
1894
- min_precision: z6.number().min(0).max(1).optional(),
1895
- min_recall_must_detect: z6.number().min(0).max(1).optional(),
1896
- max_repeated_fp_rate: z6.number().min(0).max(1).optional(),
1897
- min_empty_on_clean_rate: z6.number().min(0).max(1).optional()
2069
+ import { z as z7 } from "zod";
2070
+ var EvalGateThresholdsSchema = z7.object({
2071
+ min_precision: z7.number().min(0).max(1).optional(),
2072
+ min_recall_must_detect: z7.number().min(0).max(1).optional(),
2073
+ max_repeated_fp_rate: z7.number().min(0).max(1).optional(),
2074
+ min_empty_on_clean_rate: z7.number().min(0).max(1).optional()
1898
2075
  });
1899
- var EvalGateResultSchema = z6.object({
1900
- passed: z6.boolean(),
1901
- failures: z6.array(z6.string())
2076
+ var EvalGateResultSchema = z7.object({
2077
+ passed: z7.boolean(),
2078
+ failures: z7.array(z7.string())
1902
2079
  });
1903
2080
  function parseEvalGateThresholds(raw) {
1904
2081
  return EvalGateThresholdsSchema.parse(raw);
1905
2082
  }
1906
2083
 
1907
2084
  // src/eval/manifest.ts
1908
- import { readFile as readFile5 } from "fs/promises";
1909
- import { join as join11 } from "path";
2085
+ import { readFile as readFile6 } from "fs/promises";
2086
+ import { join as join12 } from "path";
1910
2087
 
1911
2088
  // src/eval/runner.ts
1912
2089
  import { performance as performance2 } from "perf_hooks";
1913
2090
 
1914
2091
  // src/review/context.ts
1915
- import { basename as basename4, dirname as dirname2, extname as extname5, join as join7 } from "path";
2092
+ import { basename as basename5, dirname as dirname3, extname as extname5, join as join9 } from "path";
1916
2093
  import picomatch from "picomatch";
1917
2094
 
1918
2095
  // src/git/diff.ts
1919
- import { execa as execa3 } from "execa";
1920
- import { basename as basename2, extname } from "path";
2096
+ import { execa as execa4 } from "execa";
2097
+ import { basename as basename3, extname } from "path";
1921
2098
  var MAX_DIFF_OUTPUT_BYTES = 2 * 1024 * 1024;
2099
+ async function getBranchDiff(baseRef, cwd) {
2100
+ const selectedBaseRef = baseRef ?? await resolveDefaultBranchRef(cwd);
2101
+ const baseCommit = await resolveCommitRef(selectedBaseRef, cwd);
2102
+ const headCommit = await resolveCommitRef("HEAD", cwd);
2103
+ const { stdout } = await execa4("git", ["merge-base", baseCommit, headCommit], cwd ? { cwd } : {});
2104
+ const mergeBaseCommit = stdout.trim();
2105
+ const raw = await collectGitDiff(
2106
+ [
2107
+ "-c",
2108
+ "diff.noprefix=false",
2109
+ "-c",
2110
+ "diff.mnemonicprefix=false",
2111
+ "diff",
2112
+ "--stat",
2113
+ "--patch",
2114
+ `${mergeBaseCommit}..${headCommit}`
2115
+ ],
2116
+ cwd
2117
+ );
2118
+ return {
2119
+ baseRef: selectedBaseRef,
2120
+ baseCommit,
2121
+ mergeBaseCommit,
2122
+ headCommit,
2123
+ diff: parseDiff(raw.stdout, raw.diagnostics)
2124
+ };
2125
+ }
2126
+ async function resolveDefaultBranchRef(cwd) {
2127
+ try {
2128
+ const { stdout } = await execa4(
2129
+ "git",
2130
+ ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
2131
+ cwd ? { cwd } : {}
2132
+ );
2133
+ if (stdout.trim() !== "") return stdout.trim();
2134
+ } catch {
2135
+ }
2136
+ for (const ref of ["main", "master"]) {
2137
+ try {
2138
+ await resolveCommitRef(ref, cwd);
2139
+ return ref;
2140
+ } catch {
2141
+ }
2142
+ }
2143
+ throw new Error(
2144
+ "Could not detect a default branch. Set origin/HEAD or pass an explicit base ref."
2145
+ );
2146
+ }
1922
2147
  async function getResolvedCommitDiff(commit, cwd) {
1923
2148
  const raw = await collectGitDiff(
1924
2149
  [
@@ -1943,7 +2168,7 @@ async function resolveCommitRef(ref, cwd) {
1943
2168
  throw new Error("Commit ref must not be empty.");
1944
2169
  }
1945
2170
  try {
1946
- const { stdout } = await execa3(
2171
+ const { stdout } = await execa4(
1947
2172
  "git",
1948
2173
  ["rev-parse", "--verify", "--quiet", "--end-of-options", `${trimmed}^{commit}`],
1949
2174
  cwd ? { cwd } : {}
@@ -1971,7 +2196,7 @@ async function getStagedDiff(cwd) {
1971
2196
  }
1972
2197
  async function collectGitDiff(args, cwd) {
1973
2198
  try {
1974
- const { stdout } = await execa3("git", args, {
2199
+ const { stdout } = await execa4("git", args, {
1975
2200
  maxBuffer: MAX_DIFF_OUTPUT_BYTES,
1976
2201
  ...cwd ? { cwd } : {}
1977
2202
  });
@@ -1990,7 +2215,7 @@ async function collectGitDiff(args, cwd) {
1990
2215
  }
1991
2216
  async function isGitRepo() {
1992
2217
  try {
1993
- await execa3("git", ["rev-parse", "--is-inside-work-tree"]);
2218
+ await execa4("git", ["rev-parse", "--is-inside-work-tree"]);
1994
2219
  return true;
1995
2220
  } catch {
1996
2221
  return false;
@@ -1998,7 +2223,7 @@ async function isGitRepo() {
1998
2223
  }
1999
2224
  async function hasCommits() {
2000
2225
  try {
2001
- await execa3("git", ["rev-parse", "HEAD"]);
2226
+ await execa4("git", ["rev-parse", "HEAD"]);
2002
2227
  return true;
2003
2228
  } catch {
2004
2229
  return false;
@@ -2217,7 +2442,7 @@ var DOC_BASENAME_PATTERNS = [
2217
2442
  /^TODO/i
2218
2443
  ];
2219
2444
  function isDocFile(path) {
2220
- const base = basename2(path);
2445
+ const base = basename3(path);
2221
2446
  const extension = extname(base).toLowerCase();
2222
2447
  if (DOC_EXTENSIONS.has(extension)) return true;
2223
2448
  if (extension) return false;
@@ -2232,7 +2457,7 @@ import { extname as extname2 } from "path";
2232
2457
 
2233
2458
  // src/review/ast/typescript.ts
2234
2459
  import { createRequire } from "module";
2235
- import { join as join5 } from "path";
2460
+ import { join as join7 } from "path";
2236
2461
  import { pathToFileURL } from "url";
2237
2462
  var MAX_AST_SYMBOL_CHARS = 8e3;
2238
2463
  var cachedTs = null;
@@ -2249,7 +2474,7 @@ var typescriptAstParser = {
2249
2474
  function tryLoadUserTypescript() {
2250
2475
  if (cachedTs !== null) return cachedTs;
2251
2476
  try {
2252
- const require2 = createRequire(pathToFileURL(join5(process.cwd(), "package.json")));
2477
+ const require2 = createRequire(pathToFileURL(join7(process.cwd(), "package.json")));
2253
2478
  cachedTs = require2("typescript");
2254
2479
  } catch {
2255
2480
  try {
@@ -2424,7 +2649,7 @@ function isCodePath(path) {
2424
2649
  }
2425
2650
 
2426
2651
  // src/review/context-references.ts
2427
- import { basename as basename3, extname as extname3 } from "path";
2652
+ import { basename as basename4, extname as extname3 } from "path";
2428
2653
  var MAX_REFERENCES_PER_TERM = 8;
2429
2654
  var MAX_REFERENCE_TERMS = 8;
2430
2655
  var MAX_REFERENCE_LINE_CHARS = 220;
@@ -2439,7 +2664,7 @@ async function buildReferenceContexts(source, changedFiles, skippedFiles, diagno
2439
2664
  ...skippedFiles.map((file) => file.path)
2440
2665
  ]);
2441
2666
  for (const file of changedFiles) {
2442
- terms.add(basename3(file.file.path, extname3(file.file.path)));
2667
+ terms.add(basename4(file.file.path, extname3(file.file.path)));
2443
2668
  for (const symbol of file.symbols.slice(0, 4)) {
2444
2669
  terms.add(symbol);
2445
2670
  }
@@ -2542,19 +2767,19 @@ function truncateSnippet(snippet) {
2542
2767
  }
2543
2768
 
2544
2769
  // src/review/context-source.ts
2545
- import { readFile as readFile4, stat as stat2 } from "fs/promises";
2546
- import { join as join6 } from "path";
2547
- import { execa as execa4 } from "execa";
2770
+ import { readFile as readFile5, stat as stat2 } from "fs/promises";
2771
+ import { join as join8 } from "path";
2772
+ import { execa as execa5 } from "execa";
2548
2773
  var REFERENCE_SEARCH_TIMEOUT_MS = 5e3;
2549
2774
  function createFilesystemContextSource(root) {
2550
2775
  return {
2551
2776
  async read(path, maxBytes) {
2552
2777
  try {
2553
- const absolutePath = join6(root, path);
2778
+ const absolutePath = join8(root, path);
2554
2779
  const info = await stat2(absolutePath);
2555
2780
  if (!info.isFile()) return { status: "skipped", reason: "not a regular file" };
2556
2781
  if (info.size > maxBytes) return tooLarge(info.size, maxBytes);
2557
- return { status: "loaded", content: await readFile4(absolutePath, "utf-8") };
2782
+ return { status: "loaded", content: await readFile5(absolutePath, "utf-8") };
2558
2783
  } catch (err) {
2559
2784
  return { status: "skipped", reason: formatReadError(err) };
2560
2785
  }
@@ -2570,12 +2795,12 @@ function createGitContextSource(root, target) {
2570
2795
  async read(path, maxBytes) {
2571
2796
  const object = `${treeish}${path}`;
2572
2797
  try {
2573
- const { stdout: sizeOutput } = await execa4("git", ["cat-file", "-s", object], {
2798
+ const { stdout: sizeOutput } = await execa5("git", ["cat-file", "-s", object], {
2574
2799
  cwd: root
2575
2800
  });
2576
2801
  const size = Number(sizeOutput.trim());
2577
2802
  if (Number.isFinite(size) && size > maxBytes) return tooLarge(size, maxBytes);
2578
- const { stdout } = await execa4("git", ["show", object], {
2803
+ const { stdout } = await execa5("git", ["show", object], {
2579
2804
  cwd: root,
2580
2805
  maxBuffer: maxBytes,
2581
2806
  stripFinalNewline: false
@@ -2602,7 +2827,7 @@ async function runGitGrep(root, args, terms, commit) {
2602
2827
  if (commit) args.push(commit);
2603
2828
  args.push("--");
2604
2829
  try {
2605
- const { stdout } = await execa4("git", args, {
2830
+ const { stdout } = await execa5("git", args, {
2606
2831
  cwd: root,
2607
2832
  timeout: REFERENCE_SEARCH_TIMEOUT_MS
2608
2833
  });
@@ -2858,6 +3083,7 @@ async function loadReviewSnapshot(root, target) {
2858
3083
  return {
2859
3084
  root,
2860
3085
  target,
3086
+ targetCommit: null,
2861
3087
  diff: await getStagedDiff(root),
2862
3088
  source: createGitContextSource(root, { kind: "staged" })
2863
3089
  };
@@ -2866,6 +3092,7 @@ async function loadReviewSnapshot(root, target) {
2866
3092
  return {
2867
3093
  root,
2868
3094
  target,
3095
+ targetCommit: sha,
2869
3096
  diff: await getResolvedCommitDiff(sha, root),
2870
3097
  source: createGitContextSource(root, { kind: "commit", sha })
2871
3098
  };
@@ -2875,10 +3102,21 @@ async function loadReviewSnapshot(root, target) {
2875
3102
  return {
2876
3103
  root,
2877
3104
  target,
3105
+ targetCommit: sha,
2878
3106
  diff: await getResolvedCommitDiff(sha, root),
2879
3107
  source: createGitContextSource(root, { kind: "commit", sha })
2880
3108
  };
2881
3109
  }
3110
+ case "base": {
3111
+ const branch = await getBranchDiff(target.ref, root);
3112
+ return {
3113
+ root,
3114
+ target: { kind: "base", ref: branch.baseRef },
3115
+ targetCommit: branch.headCommit,
3116
+ diff: branch.diff,
3117
+ source: createGitContextSource(root, { kind: "commit", sha: branch.headCommit })
3118
+ };
3119
+ }
2882
3120
  }
2883
3121
  }
2884
3122
  async function buildReviewContextFromDiff(snapshot, config, depth = config.context.depth) {
@@ -2981,7 +3219,7 @@ async function buildRelatedFileContexts(source, files) {
2981
3219
  return related;
2982
3220
  }
2983
3221
  function shouldReviewFile(path, config) {
2984
- if (LOCKFILE_EXCLUDES.has(basename4(path))) return false;
3222
+ if (LOCKFILE_EXCLUDES.has(basename5(path))) return false;
2985
3223
  const include = config.include.length > 0 ? config.include : ["**/*"];
2986
3224
  if (!include.some((pattern) => picomatch.isMatch(path, pattern))) {
2987
3225
  return false;
@@ -3100,14 +3338,14 @@ function getChangedLinesByFile(rawDiff) {
3100
3338
  return changed;
3101
3339
  }
3102
3340
  function testCandidates(path) {
3103
- const dir = dirname2(path);
3341
+ const dir = dirname3(path);
3104
3342
  const ext = extname5(path);
3105
- const base = basename4(path, ext);
3343
+ const base = basename5(path, ext);
3106
3344
  return [
3107
- join7(dir, `${base}.test${ext}`),
3108
- join7(dir, `${base}.spec${ext}`),
3109
- join7(dir, "__tests__", `${base}.test${ext}`),
3110
- join7(dir, "__tests__", `${base}.spec${ext}`)
3345
+ join9(dir, `${base}.test${ext}`),
3346
+ join9(dir, `${base}.spec${ext}`),
3347
+ join9(dir, "__tests__", `${base}.test${ext}`),
3348
+ join9(dir, "__tests__", `${base}.spec${ext}`)
3111
3349
  ];
3112
3350
  }
3113
3351
  function truncateText3(text, maxChars) {
@@ -3154,80 +3392,11 @@ function filterFindingsByChangedFiles(findings, changedFiles) {
3154
3392
 
3155
3393
  // src/review/formatter.ts
3156
3394
  import chalk from "chalk";
3157
- import { writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
3395
+ import { rename as rename2, unlink as unlink2, writeFile as writeFile4, mkdir as mkdir3 } from "fs/promises";
3396
+ import { randomUUID } from "crypto";
3158
3397
  import { existsSync as existsSync4 } from "fs";
3159
- import { join as join9 } from "path";
3160
- import { parse as parse2, stringify as stringify2 } from "yaml";
3161
-
3162
- // src/git/state-root.ts
3163
- import { existsSync as existsSync3 } from "fs";
3164
- import { basename as basename5, dirname as dirname3, join as join8, relative as relative2, resolve } from "path";
3165
- import { execa as execa5 } from "execa";
3166
- var sharedDiffOwlDirPromise;
3167
- var warnedStateMove = false;
3168
- async function getSharedDiffOwlDir() {
3169
- if (!sharedDiffOwlDirPromise) {
3170
- sharedDiffOwlDirPromise = resolveSharedDiffOwlDir();
3171
- }
3172
- return sharedDiffOwlDirPromise;
3173
- }
3174
- async function resolveSharedDiffOwlDir() {
3175
- const projectRoot = getProjectRoot();
3176
- const localDir = getDiffOwlDir();
3177
- let insideWorkTree;
3178
- try {
3179
- ({ stdout: insideWorkTree } = await execa5("git", ["rev-parse", "--is-inside-work-tree"], {
3180
- cwd: projectRoot
3181
- }));
3182
- } catch {
3183
- return localDir;
3184
- }
3185
- if (insideWorkTree.trim() !== "true") {
3186
- return localDir;
3187
- }
3188
- let toplevelRaw;
3189
- let commonRaw;
3190
- try {
3191
- [{ stdout: toplevelRaw }, { stdout: commonRaw }] = await Promise.all([
3192
- execa5("git", ["rev-parse", "--show-toplevel"], { cwd: projectRoot }),
3193
- execa5("git", ["rev-parse", "--git-common-dir"], { cwd: projectRoot })
3194
- ]);
3195
- } catch {
3196
- return localDir;
3197
- }
3198
- const toplevel = resolve(projectRoot, toplevelRaw.trim());
3199
- const commonDir = resolve(projectRoot, commonRaw.trim());
3200
- const rel = relative2(toplevel, projectRoot);
3201
- if (rel.startsWith("..")) {
3202
- return localDir;
3203
- }
3204
- let sharedDiffOwlDir;
3205
- if (basename5(commonDir) !== ".git") {
3206
- sharedDiffOwlDir = join8(commonDir, "diffowl", rel, ".diffowl");
3207
- } else {
3208
- sharedDiffOwlDir = join8(dirname3(commonDir), rel, ".diffowl");
3209
- }
3210
- warnIfIgnoringLocalState(localDir, sharedDiffOwlDir);
3211
- return sharedDiffOwlDir;
3212
- }
3213
- function warnIfIgnoringLocalState(localDir, sharedDir) {
3214
- if (warnedStateMove || localDir === sharedDir) {
3215
- return;
3216
- }
3217
- const localDb = join8(localDir, "state.db");
3218
- if (!existsSync3(localDb)) {
3219
- return;
3220
- }
3221
- warnedStateMove = true;
3222
- console.warn(
3223
- `DiffOwl state moved: using ${join8(
3224
- sharedDir,
3225
- "state.db"
3226
- )}; ignoring checkout-local ${localDb} (delete it, or see plan 025).`
3227
- );
3228
- }
3229
-
3230
- // src/review/formatter.ts
3398
+ import { join as join10 } from "path";
3399
+ import { parse as parse3, stringify as stringify3 } from "yaml";
3231
3400
  var REPORT_SCHEMA_VERSION = 1;
3232
3401
  function formatFindingHeading(index, finding) {
3233
3402
  const ordinal = `Finding ${index + 1}`;
@@ -3294,32 +3463,52 @@ function renderMarkdown(report) {
3294
3463
  }
3295
3464
  lines.push("");
3296
3465
  lines.push("### Status");
3297
- lines.push(report.findings.length > 0 ? "Open" : "Resolved");
3466
+ lines.push(resolveMarkdownReviewStatus(report.findings));
3298
3467
  return lines.join("\n");
3299
3468
  }
3469
+ function resolveMarkdownReviewStatus(findings) {
3470
+ if (findings.length === 0) {
3471
+ return "Resolved";
3472
+ }
3473
+ if (findings.some((finding) => finding.severity === "error" || finding.severity === "warning")) {
3474
+ return "Open";
3475
+ }
3476
+ return "Advisory";
3477
+ }
3300
3478
  async function writeMarkdownReport(review, metadata) {
3301
- const dir = join9(await getSharedDiffOwlDir(), "reviews");
3479
+ const dir = join10(await getSharedDiffOwlDir(), "reviews");
3302
3480
  if (!existsSync4(dir)) {
3303
- await mkdir2(dir, { recursive: true });
3481
+ await mkdir3(dir, { recursive: true });
3304
3482
  }
3305
3483
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3306
3484
  const filename = `review-${timestamp}.md`;
3307
- const filepath = join9(dir, filename);
3485
+ const filepath = join10(dir, filename);
3308
3486
  const content = `${metadata ? renderReviewFrontmatter(metadata) : ""}# DiffOwl Review
3309
3487
  _${(/* @__PURE__ */ new Date()).toLocaleString()}_
3310
3488
 
3311
3489
  ${review}
3312
3490
  `;
3313
- await writeFile3(filepath, content, "utf-8");
3314
- const latestPath = join9(dir, "latest.md");
3315
- await writeFile3(latestPath, content, "utf-8");
3491
+ await writeFileAtomic(filepath, content);
3492
+ const latestPath = join10(dir, "latest.md");
3493
+ await writeFileAtomic(latestPath, content);
3316
3494
  return filepath;
3317
3495
  }
3496
+ async function writeFileAtomic(filepath, content) {
3497
+ const tempPath = `${filepath}.${process.pid}.${randomUUID()}.tmp`;
3498
+ await writeFile4(tempPath, content, "utf-8");
3499
+ try {
3500
+ await rename2(tempPath, filepath);
3501
+ } catch (error) {
3502
+ await unlink2(tempPath).catch(() => {
3503
+ });
3504
+ throw error;
3505
+ }
3506
+ }
3318
3507
  function parseReviewMetadata(content) {
3319
3508
  if (!content.startsWith("---\n")) return void 0;
3320
3509
  const end = content.indexOf("\n---\n", 4);
3321
3510
  if (end === -1) return void 0;
3322
- const parsed = parse2(content.slice(4, end));
3511
+ const parsed = parse3(content.slice(4, end));
3323
3512
  if (!parsed || typeof parsed !== "object") return void 0;
3324
3513
  const diffowl = parsed.diffowl;
3325
3514
  if (!diffowl || typeof diffowl !== "object") return void 0;
@@ -3344,7 +3533,7 @@ function parseReviewMetadata(content) {
3344
3533
  }
3345
3534
  function renderReviewFrontmatter(metadata) {
3346
3535
  return `---
3347
- ${stringify2({ diffowl: metadata }, { lineWidth: 0 })}---
3536
+ ${stringify3({ diffowl: metadata }, { lineWidth: 0 })}---
3348
3537
 
3349
3538
  `;
3350
3539
  }
@@ -3387,38 +3576,6 @@ function printFooter(report, reportPath) {
3387
3576
  }
3388
3577
  console.log();
3389
3578
  }
3390
- function colorizeMarkdown(text) {
3391
- const lines = text.split("\n");
3392
- const colorizedLines = [];
3393
- let inCodeBlock = false;
3394
- for (const line of lines) {
3395
- if (line.trim().startsWith("```")) {
3396
- inCodeBlock = !inCodeBlock;
3397
- colorizedLines.push(chalk.dim(line));
3398
- continue;
3399
- }
3400
- if (inCodeBlock) {
3401
- colorizedLines.push(line);
3402
- } else {
3403
- const colorized = line.replace(
3404
- /\*\*\[(ERROR|WARNING|INFO)\]([^*]*)\*\*/g,
3405
- (_match, label, rest) => `${colorizeSeverity(label)}${chalk.bold(rest)}`
3406
- ).replace(/^### (.*)/g, (_match, title) => chalk.bold.underline(title)).replace(/\*\*([^*]+)\*\*/g, (_match, content) => chalk.bold(content));
3407
- colorizedLines.push(colorized);
3408
- }
3409
- }
3410
- return colorizedLines.join("\n");
3411
- }
3412
- function colorizeSeverity(label) {
3413
- switch (label) {
3414
- case "ERROR":
3415
- return chalk.red.bold(`[${label}]`);
3416
- case "WARNING":
3417
- return chalk.yellow.bold(`[${label}]`);
3418
- default:
3419
- return chalk.blue.bold(`[${label}]`);
3420
- }
3421
- }
3422
3579
  function formatMarkdownCodeSpan(text) {
3423
3580
  const trimmed = text.trim();
3424
3581
  let maxRun = 0;
@@ -3541,14 +3698,14 @@ ${content.replaceAll("```", "'''")}
3541
3698
  }
3542
3699
 
3543
3700
  // src/eval/repo.ts
3544
- import { mkdtemp, rm } from "fs/promises";
3701
+ import { mkdtemp, rm as rm2 } from "fs/promises";
3545
3702
  import { tmpdir } from "os";
3546
- import { join as join10 } from "path";
3703
+ import { join as join11 } from "path";
3547
3704
  import { execa as execa6 } from "execa";
3548
3705
  var CLEANUP_RETRIES = 5;
3549
3706
  var CLEANUP_RETRY_DELAY_MS = 100;
3550
3707
  async function materializeEvalCaseRepo(evalCase) {
3551
- const workDir = await mkdtemp(join10(tmpdir(), `diffowl-eval-${evalCase.id}-`));
3708
+ const workDir = await mkdtemp(join11(tmpdir(), `diffowl-eval-${evalCase.id}-`));
3552
3709
  try {
3553
3710
  await copyCaseBase(evalCase, workDir);
3554
3711
  await initEvalGitRepo(workDir);
@@ -3562,7 +3719,7 @@ async function materializeEvalCaseRepo(evalCase) {
3562
3719
  }
3563
3720
  }
3564
3721
  async function cleanupMaterializedRepo(workDir) {
3565
- await rm(workDir, {
3722
+ await rm2(workDir, {
3566
3723
  recursive: true,
3567
3724
  force: true,
3568
3725
  maxRetries: CLEANUP_RETRIES,
@@ -3776,7 +3933,7 @@ async function buildEvalManifest(input) {
3776
3933
  };
3777
3934
  }
3778
3935
  async function readDiffOwlVersion(rootDir = process.cwd()) {
3779
- const packageJson = JSON.parse(await readFile5(join11(rootDir, "package.json"), "utf8"));
3936
+ const packageJson = JSON.parse(await readFile6(join12(rootDir, "package.json"), "utf8"));
3780
3937
  if (!packageJson.version) {
3781
3938
  throw new Error("package.json is missing a version field.");
3782
3939
  }
@@ -3784,18 +3941,18 @@ async function readDiffOwlVersion(rootDir = process.cwd()) {
3784
3941
  }
3785
3942
 
3786
3943
  // src/eval/report.ts
3787
- import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
3788
- import { join as join12 } from "path";
3944
+ import { mkdir as mkdir4, writeFile as writeFile5 } from "fs/promises";
3945
+ import { join as join13 } from "path";
3789
3946
 
3790
3947
  // src/eval/delta.ts
3791
- import { z as z7 } from "zod";
3792
- var EvalModeDeltaMetricSchema = z7.object({
3793
- diffowl: z7.number().nullable(),
3794
- baseline: z7.number().nullable(),
3795
- delta: z7.number().nullable()
3948
+ import { z as z8 } from "zod";
3949
+ var EvalModeDeltaMetricSchema = z8.object({
3950
+ diffowl: z8.number().nullable(),
3951
+ baseline: z8.number().nullable(),
3952
+ delta: z8.number().nullable()
3796
3953
  });
3797
- var EvalCaseModeDeltaSchema = z7.object({
3798
- caseId: z7.string(),
3954
+ var EvalCaseModeDeltaSchema = z8.object({
3955
+ caseId: z8.string(),
3799
3956
  precision: EvalModeDeltaMetricSchema,
3800
3957
  recall: EvalModeDeltaMetricSchema,
3801
3958
  fBeta: EvalModeDeltaMetricSchema,
@@ -3803,15 +3960,15 @@ var EvalCaseModeDeltaSchema = z7.object({
3803
3960
  latencyP50: EvalModeDeltaMetricSchema,
3804
3961
  usageMeanCost: EvalModeDeltaMetricSchema
3805
3962
  });
3806
- var EvalCorpusModeDeltaSchema = z7.object({
3807
- caseCount: z7.number(),
3963
+ var EvalCorpusModeDeltaSchema = z8.object({
3964
+ caseCount: z8.number(),
3808
3965
  precision: EvalModeDeltaMetricSchema,
3809
3966
  recall: EvalModeDeltaMetricSchema,
3810
3967
  fBeta: EvalModeDeltaMetricSchema,
3811
3968
  repeatedFpRate: EvalModeDeltaMetricSchema,
3812
3969
  latencyP50: EvalModeDeltaMetricSchema,
3813
3970
  usageMeanCost: EvalModeDeltaMetricSchema,
3814
- cases: z7.array(EvalCaseModeDeltaSchema)
3971
+ cases: z8.array(EvalCaseModeDeltaSchema)
3815
3972
  });
3816
3973
  function summaryMean(summary) {
3817
3974
  return summary?.mean ?? null;
@@ -3958,70 +4115,70 @@ function evaluateEvalGates(doc, thresholds) {
3958
4115
  }
3959
4116
 
3960
4117
  // src/eval/metrics-types.ts
3961
- import { z as z8 } from "zod";
3962
- var StatSummarySchema = z8.object({
3963
- mean: z8.number(),
3964
- stddev: z8.number(),
3965
- values: z8.array(z8.number())
4118
+ import { z as z9 } from "zod";
4119
+ var StatSummarySchema = z9.object({
4120
+ mean: z9.number(),
4121
+ stddev: z9.number(),
4122
+ values: z9.array(z9.number())
3966
4123
  });
3967
4124
  var DEFAULT_EVAL_METRICS_OPTIONS = {
3968
4125
  beta: 1
3969
4126
  };
3970
- var EvalTrialMetricsSchema = z8.object({
3971
- trial: z8.number(),
3972
- precision: z8.number(),
3973
- recall: z8.number(),
3974
- fBeta: z8.number(),
3975
- durationMs: z8.number(),
3976
- usageCost: z8.number().nullable(),
3977
- totalTokens: z8.number().nullable(),
3978
- emptyOnClean: z8.boolean()
4127
+ var EvalTrialMetricsSchema = z9.object({
4128
+ trial: z9.number(),
4129
+ precision: z9.number(),
4130
+ recall: z9.number(),
4131
+ fBeta: z9.number(),
4132
+ durationMs: z9.number(),
4133
+ usageCost: z9.number().nullable(),
4134
+ totalTokens: z9.number().nullable(),
4135
+ emptyOnClean: z9.boolean()
3979
4136
  });
3980
- var EvalLatencyMetricsSchema = z8.object({
3981
- p50: z8.number().nullable(),
3982
- p95: z8.number().nullable(),
3983
- values: z8.array(z8.number())
4137
+ var EvalLatencyMetricsSchema = z9.object({
4138
+ p50: z9.number().nullable(),
4139
+ p95: z9.number().nullable(),
4140
+ values: z9.array(z9.number())
3984
4141
  });
3985
- var EvalUsageMetricsSchema = z8.object({
3986
- meanCost: z8.number().nullable(),
3987
- totalCost: z8.number().nullable(),
3988
- meanTokens: z8.number().nullable(),
3989
- coverage: z8.number()
4142
+ var EvalUsageMetricsSchema = z9.object({
4143
+ meanCost: z9.number().nullable(),
4144
+ totalCost: z9.number().nullable(),
4145
+ meanTokens: z9.number().nullable(),
4146
+ coverage: z9.number()
3990
4147
  });
3991
- var EvalCaseMetricsSchema = z8.object({
3992
- caseId: z8.string(),
4148
+ var EvalCaseMetricsSchema = z9.object({
4149
+ caseId: z9.string(),
3993
4150
  category: EvalCaseCategorySchema,
3994
- tags: z8.array(z8.string()),
3995
- trialCount: z8.number(),
4151
+ tags: z9.array(z9.string()),
4152
+ trialCount: z9.number(),
3996
4153
  precision: StatSummarySchema.nullable(),
3997
4154
  recall: StatSummarySchema.nullable(),
3998
4155
  fBeta: StatSummarySchema.nullable(),
3999
- repeatedFpRate: z8.number(),
4000
- emptyOnCleanRate: z8.number().nullable(),
4156
+ repeatedFpRate: z9.number(),
4157
+ emptyOnCleanRate: z9.number().nullable(),
4001
4158
  latencyMs: EvalLatencyMetricsSchema,
4002
4159
  usage: EvalUsageMetricsSchema,
4003
- trials: z8.array(EvalTrialMetricsSchema)
4160
+ trials: z9.array(EvalTrialMetricsSchema)
4004
4161
  });
4005
- var EvalCategoryMetricsSchema = z8.object({
4162
+ var EvalCategoryMetricsSchema = z9.object({
4006
4163
  category: EvalCaseCategorySchema,
4007
- caseCount: z8.number(),
4164
+ caseCount: z9.number(),
4008
4165
  precision: StatSummarySchema.nullable(),
4009
4166
  recall: StatSummarySchema.nullable(),
4010
4167
  fBeta: StatSummarySchema.nullable(),
4011
- repeatedFpRate: z8.number().nullable(),
4012
- emptyOnCleanRate: z8.number().nullable()
4168
+ repeatedFpRate: z9.number().nullable(),
4169
+ emptyOnCleanRate: z9.number().nullable()
4013
4170
  });
4014
- var EvalCorpusMetricsSchema = z8.object({
4015
- caseCount: z8.number(),
4016
- trialCount: z8.number(),
4171
+ var EvalCorpusMetricsSchema = z9.object({
4172
+ caseCount: z9.number(),
4173
+ trialCount: z9.number(),
4017
4174
  precision: StatSummarySchema.nullable(),
4018
4175
  recall: StatSummarySchema.nullable(),
4019
4176
  fBeta: StatSummarySchema.nullable(),
4020
- repeatedFpRate: z8.number().nullable(),
4021
- emptyOnCleanRate: z8.number().nullable(),
4177
+ repeatedFpRate: z9.number().nullable(),
4178
+ emptyOnCleanRate: z9.number().nullable(),
4022
4179
  latencyMs: EvalLatencyMetricsSchema,
4023
4180
  usage: EvalUsageMetricsSchema,
4024
- byCategory: z8.array(EvalCategoryMetricsSchema)
4181
+ byCategory: z9.array(EvalCategoryMetricsSchema)
4025
4182
  });
4026
4183
 
4027
4184
  // src/eval/metrics.ts
@@ -4189,147 +4346,146 @@ function computeCorpusMetrics(caseMetrics) {
4189
4346
  }
4190
4347
 
4191
4348
  // src/eval/report-types.ts
4192
- import { z as z13 } from "zod";
4349
+ import { z as z14 } from "zod";
4193
4350
 
4194
4351
  // src/eval/manifest-types.ts
4195
- import { z as z9 } from "zod";
4196
- var EvalReportModeSchema = z9.enum(["diffowl", "baseline", "both"]);
4197
- var EvalManifestCaseSchema = z9.object({
4198
- id: z9.string(),
4199
- case_json_hash: z9.string(),
4200
- patch_hash: z9.string()
4352
+ import { z as z10 } from "zod";
4353
+ var EvalReportModeSchema = z10.enum(["diffowl", "baseline", "both"]);
4354
+ var EvalManifestCaseSchema = z10.object({
4355
+ id: z10.string(),
4356
+ case_json_hash: z10.string(),
4357
+ patch_hash: z10.string()
4201
4358
  });
4202
- var EvalRunManifestSchema = z9.object({
4203
- corpus_version: z9.string(),
4204
- cases: z9.array(EvalManifestCaseSchema),
4205
- model: z9.string(),
4359
+ var EvalRunManifestSchema = z10.object({
4360
+ corpus_version: z10.string(),
4361
+ cases: z10.array(EvalManifestCaseSchema),
4362
+ model: z10.string(),
4206
4363
  reasoning: ReasoningEffortSchema,
4207
4364
  depth: ReviewContextDepthSchema,
4208
4365
  min_confidence: ReviewConfidenceSchema,
4209
- trials: z9.number().int().positive(),
4366
+ trials: z10.number().int().positive(),
4210
4367
  mode: EvalReportModeSchema,
4211
- diffowl_version: z9.string(),
4212
- node_version: z9.string(),
4213
- opencode_version: z9.string().nullable(),
4214
- started_at: z9.string(),
4215
- finished_at: z9.string()
4368
+ diffowl_version: z10.string(),
4369
+ node_version: z10.string(),
4370
+ opencode_version: z10.string().nullable(),
4371
+ started_at: z10.string(),
4372
+ finished_at: z10.string()
4216
4373
  });
4217
4374
 
4218
4375
  // src/eval/runner-types.ts
4219
- import { z as z11 } from "zod";
4376
+ import { z as z12 } from "zod";
4220
4377
 
4221
4378
  // src/review/types.ts
4222
- import { z as z10 } from "zod";
4223
- var ReviewSeveritySchema2 = z10.enum(["error", "warning", "info"]);
4224
- var DurableFindingMetadataSchema = z10.object({
4225
- id: z10.string(),
4226
- classification: z10.enum(["new", "existing", "regressed"]),
4227
- status: z10.enum(["open", "deferred", "dismissed", "fixed", "regressed"]),
4228
- lifecycleSuppressed: z10.boolean().optional()
4379
+ import { z as z11 } from "zod";
4380
+ var ReviewSeveritySchema2 = z11.enum(["error", "warning", "info"]);
4381
+ var DurableFindingMetadataSchema = z11.object({
4382
+ id: z11.string(),
4383
+ classification: z11.enum(["new", "existing", "regressed"]),
4384
+ status: z11.enum(["open", "deferred", "dismissed", "fixed", "regressed"]),
4385
+ lifecycleSuppressed: z11.boolean().optional()
4229
4386
  });
4230
- var ReviewFindingSchema2 = z10.object({
4387
+ var ReviewFindingSchema2 = z11.object({
4231
4388
  severity: ReviewSeveritySchema2,
4232
- file: z10.string(),
4233
- line: z10.number(),
4234
- evidence: z10.string().optional(),
4235
- title: z10.string(),
4236
- body: z10.string(),
4389
+ file: z11.string(),
4390
+ line: z11.number(),
4391
+ evidence: z11.string().optional(),
4392
+ title: z11.string(),
4393
+ body: z11.string(),
4237
4394
  confidence: ReviewConfidenceSchema,
4238
4395
  durable: DurableFindingMetadataSchema.optional()
4239
4396
  });
4240
- var ReviewTimingSchema = z10.object({
4241
- phase: z10.string(),
4242
- label: z10.string(),
4243
- ms: z10.number()
4397
+ var ReviewTimingSchema = z11.object({
4398
+ phase: z11.string(),
4399
+ label: z11.string(),
4400
+ ms: z11.number()
4244
4401
  });
4245
4402
 
4246
4403
  // src/eval/runner-types.ts
4247
- var EvalRunModeSchema = z11.enum(["diffowl", "baseline"]);
4248
- var EvalTrialResultSchema = z11.object({
4249
- caseId: z11.string(),
4250
- trial: z11.number(),
4404
+ var EvalRunModeSchema = z12.enum(["diffowl", "baseline"]);
4405
+ var EvalTrialResultSchema = z12.object({
4406
+ caseId: z12.string(),
4407
+ trial: z12.number(),
4251
4408
  mode: EvalRunModeSchema,
4252
- findings: z11.array(ReviewFindingSchema2),
4253
- timings: z11.array(ReviewTimingSchema),
4409
+ findings: z12.array(ReviewFindingSchema2),
4410
+ timings: z12.array(ReviewTimingSchema),
4254
4411
  usage: ReviewUsageSchema.optional(),
4255
- sessionId: z11.string(),
4256
- summary: z11.string(),
4257
- diagnostics: z11.array(z11.string()),
4258
- durationMs: z11.number(),
4259
- error: z11.string().optional()
4412
+ sessionId: z12.string(),
4413
+ summary: z12.string(),
4414
+ diagnostics: z12.array(z12.string()),
4415
+ durationMs: z12.number(),
4416
+ error: z12.string().optional()
4260
4417
  });
4261
- var EvalCaseRunResultSchema = z11.object({
4262
- caseId: z11.string(),
4418
+ var EvalCaseRunResultSchema = z12.object({
4419
+ caseId: z12.string(),
4263
4420
  mode: EvalRunModeSchema,
4264
- trials: z11.array(EvalTrialResultSchema)
4421
+ trials: z12.array(EvalTrialResultSchema)
4265
4422
  });
4266
4423
 
4267
4424
  // src/eval/score-types.ts
4268
- import { z as z12 } from "zod";
4425
+ import { z as z13 } from "zod";
4269
4426
  var DEFAULT_EVAL_SCORE_OPTIONS = {
4270
- categoryMatch: false,
4271
4427
  fnMode: "must_detect",
4272
4428
  repeatedFpThreshold: 2
4273
4429
  };
4274
- var EvalMatchSchema = z12.object({
4275
- expectedIndex: z12.number(),
4276
- reportedIndex: z12.number(),
4277
- lineDistance: z12.number()
4430
+ var EvalMatchSchema = z13.object({
4431
+ expectedIndex: z13.number(),
4432
+ reportedIndex: z13.number(),
4433
+ lineDistance: z13.number()
4278
4434
  });
4279
- var EvalTrialScoreSchema = z12.object({
4280
- caseId: z12.string(),
4281
- trial: z12.number(),
4282
- truePositives: z12.array(EvalMatchSchema),
4283
- falsePositives: z12.array(ReviewFindingSchema2),
4284
- falseNegatives: z12.array(EvalExpectedFindingSchema),
4285
- redundancies: z12.array(ReviewFindingSchema2),
4286
- counts: z12.object({
4287
- tp: z12.number(),
4288
- fp: z12.number(),
4289
- fn: z12.number(),
4290
- redundancy: z12.number()
4435
+ var EvalTrialScoreSchema = z13.object({
4436
+ caseId: z13.string(),
4437
+ trial: z13.number(),
4438
+ truePositives: z13.array(EvalMatchSchema),
4439
+ falsePositives: z13.array(ReviewFindingSchema2),
4440
+ falseNegatives: z13.array(EvalExpectedFindingSchema),
4441
+ redundancies: z13.array(ReviewFindingSchema2),
4442
+ counts: z13.object({
4443
+ tp: z13.number(),
4444
+ fp: z13.number(),
4445
+ fn: z13.number(),
4446
+ redundancy: z13.number()
4291
4447
  })
4292
4448
  });
4293
- var RepeatedFalsePositiveSchema = z12.object({
4294
- fingerprint: z12.string(),
4295
- trialCount: z12.number(),
4449
+ var RepeatedFalsePositiveSchema = z13.object({
4450
+ fingerprint: z13.string(),
4451
+ trialCount: z13.number(),
4296
4452
  example: ReviewFindingSchema2
4297
4453
  });
4298
- var EvalCaseScoreSchema = z12.object({
4299
- caseId: z12.string(),
4454
+ var EvalCaseScoreSchema = z13.object({
4455
+ caseId: z13.string(),
4300
4456
  category: EvalCaseCategorySchema,
4301
- tags: z12.array(z12.string()),
4302
- trials: z12.array(EvalTrialScoreSchema),
4303
- repeatedFalsePositives: z12.array(RepeatedFalsePositiveSchema)
4457
+ tags: z13.array(z13.string()),
4458
+ trials: z13.array(EvalTrialScoreSchema),
4459
+ repeatedFalsePositives: z13.array(RepeatedFalsePositiveSchema)
4304
4460
  });
4305
4461
 
4306
4462
  // src/eval/report-types.ts
4307
4463
  var EVAL_RESULTS_SCHEMA_VERSION = 1;
4308
- var EvalCaseModeResultV1Schema = z13.object({
4464
+ var EvalCaseModeResultV1Schema = z14.object({
4309
4465
  run: EvalCaseRunResultSchema,
4310
4466
  score: EvalCaseScoreSchema,
4311
4467
  metrics: EvalCaseMetricsSchema
4312
4468
  });
4313
- var EvalCaseResultV1Schema = z13.object({
4314
- id: z13.string(),
4469
+ var EvalCaseResultV1Schema = z14.object({
4470
+ id: z14.string(),
4315
4471
  category: EvalCaseCategorySchema,
4316
- tags: z13.array(z13.string()),
4317
- expected: z13.array(EvalExpectedFindingSchema),
4318
- case_json_hash: z13.string(),
4319
- patch_hash: z13.string(),
4472
+ tags: z14.array(z14.string()),
4473
+ expected: z14.array(EvalExpectedFindingSchema),
4474
+ case_json_hash: z14.string(),
4475
+ patch_hash: z14.string(),
4320
4476
  diffowl: EvalCaseModeResultV1Schema.optional(),
4321
4477
  baseline: EvalCaseModeResultV1Schema.optional(),
4322
4478
  delta: EvalCaseModeDeltaSchema.optional()
4323
4479
  });
4324
- var EvalResultsAggregateV1Schema = z13.object({
4480
+ var EvalResultsAggregateV1Schema = z14.object({
4325
4481
  diffowl: EvalCorpusMetricsSchema.optional(),
4326
4482
  baseline: EvalCorpusMetricsSchema.optional(),
4327
4483
  delta: EvalCorpusModeDeltaSchema.optional()
4328
4484
  });
4329
- var EvalResultsDocumentV1Schema = z13.object({
4330
- schema_version: z13.literal(EVAL_RESULTS_SCHEMA_VERSION),
4485
+ var EvalResultsDocumentV1Schema = z14.object({
4486
+ schema_version: z14.literal(EVAL_RESULTS_SCHEMA_VERSION),
4331
4487
  manifest: EvalRunManifestSchema,
4332
- cases: z13.array(EvalCaseResultV1Schema),
4488
+ cases: z14.array(EvalCaseResultV1Schema),
4333
4489
  aggregate: EvalResultsAggregateV1Schema,
4334
4490
  gates: EvalGateResultSchema.optional()
4335
4491
  });
@@ -4383,20 +4539,11 @@ function severityRank(severity) {
4383
4539
  }
4384
4540
  function resolveScoreOptions(options) {
4385
4541
  return {
4386
- categoryMatch: options?.categoryMatch ?? DEFAULT_EVAL_SCORE_OPTIONS.categoryMatch,
4387
4542
  fnMode: options?.fnMode ?? DEFAULT_EVAL_SCORE_OPTIONS.fnMode,
4388
4543
  repeatedFpThreshold: options?.repeatedFpThreshold ?? DEFAULT_EVAL_SCORE_OPTIONS.repeatedFpThreshold
4389
4544
  };
4390
4545
  }
4391
- function categoryMatches(expected, reported) {
4392
- if (!expected.category) {
4393
- return true;
4394
- }
4395
- const needle = expected.category.toLowerCase();
4396
- const haystack = `${reported.title} ${reported.body}`.toLowerCase();
4397
- return haystack.includes(needle);
4398
- }
4399
- function findingMatchesExpected(expected, reported, options) {
4546
+ function findingMatchesExpected(expected, reported) {
4400
4547
  if (reported.file !== expected.file) {
4401
4548
  return false;
4402
4549
  }
@@ -4407,13 +4554,9 @@ function findingMatchesExpected(expected, reported, options) {
4407
4554
  if (severityRank(reported.severity) < severityRank(expected.min_severity)) {
4408
4555
  return false;
4409
4556
  }
4410
- const resolved = resolveScoreOptions(options);
4411
- if (resolved.categoryMatch && !categoryMatches(expected, reported)) {
4412
- return false;
4413
- }
4414
4557
  return true;
4415
4558
  }
4416
- function buildMatchCandidates(expected, reported, options) {
4559
+ function buildMatchCandidates(expected, reported) {
4417
4560
  const candidates = [];
4418
4561
  for (let expectedIndex = 0; expectedIndex < expected.length; expectedIndex++) {
4419
4562
  const expectedEntry = expected[expectedIndex];
@@ -4425,7 +4568,7 @@ function buildMatchCandidates(expected, reported, options) {
4425
4568
  if (!reportedFinding) {
4426
4569
  continue;
4427
4570
  }
4428
- if (!findingMatchesExpected(expectedEntry, reportedFinding, options)) {
4571
+ if (!findingMatchesExpected(expectedEntry, reportedFinding)) {
4429
4572
  continue;
4430
4573
  }
4431
4574
  candidates.push({
@@ -4473,7 +4616,7 @@ function scoreEvalTrial(evalCase, trial, options) {
4473
4616
  const resolved = resolveScoreOptions(options);
4474
4617
  const reported = trial.findings;
4475
4618
  const expected = evalCase.expected;
4476
- const candidates = buildMatchCandidates(expected, reported, resolved);
4619
+ const candidates = buildMatchCandidates(expected, reported);
4477
4620
  const truePositives = assignTruePositives(candidates, reported);
4478
4621
  const matchedExpected = new Set(truePositives.map((match) => match.expectedIndex));
4479
4622
  const matchedReported = new Set(truePositives.map((match) => match.reportedIndex));
@@ -4487,7 +4630,7 @@ function scoreEvalTrial(evalCase, trial, options) {
4487
4630
  continue;
4488
4631
  }
4489
4632
  const matchesAssignedExpected = expected.some(
4490
- (entry, expectedIndex) => matchedExpected.has(expectedIndex) && findingMatchesExpected(entry, reportedFinding, resolved)
4633
+ (entry, expectedIndex) => matchedExpected.has(expectedIndex) && findingMatchesExpected(entry, reportedFinding)
4491
4634
  );
4492
4635
  if (matchesAssignedExpected) {
4493
4636
  redundancies.push(reportedFinding);
@@ -4497,7 +4640,7 @@ function scoreEvalTrial(evalCase, trial, options) {
4497
4640
  if (!finding || matchedReported.has(reportedIndex)) {
4498
4641
  return false;
4499
4642
  }
4500
- return !expected.some((entry) => findingMatchesExpected(entry, finding, resolved));
4643
+ return !expected.some((entry) => findingMatchesExpected(entry, finding));
4501
4644
  });
4502
4645
  const falseNegatives = expected.filter(
4503
4646
  (entry, expectedIndex) => !matchedExpected.has(expectedIndex) && expectedCountsAsFalseNegative(entry, resolved.fnMode)
@@ -4659,6 +4802,25 @@ function renderEvalSummary(document) {
4659
4802
  const { manifest, aggregate } = document;
4660
4803
  lines.push("# DiffOwl Eval Summary");
4661
4804
  lines.push("");
4805
+ lines.push("## At a Glance");
4806
+ lines.push("");
4807
+ lines.push(`- Model: ${manifest.model}`);
4808
+ lines.push(`- Corpus: \`${manifest.corpus_version}\``);
4809
+ lines.push(`- Scope: ${document.cases.length} cases, ${manifest.trials} trial${manifest.trials === 1 ? "" : "s"}, ${manifest.mode} mode`);
4810
+ if (aggregate.diffowl) {
4811
+ lines.push(`- DiffOwl precision: ${formatStatSummary(aggregate.diffowl.precision)}`);
4812
+ lines.push(`- DiffOwl recall: ${formatStatSummary(aggregate.diffowl.recall)}`);
4813
+ lines.push(`- Repeated false-positive rate: ${formatNullableNumber(aggregate.diffowl.repeatedFpRate)}`);
4814
+ }
4815
+ if (aggregate.delta) {
4816
+ lines.push(`- F-beta delta vs baseline: ${formatDelta(aggregate.delta.fBeta.delta)}`);
4817
+ }
4818
+ if (document.gates) {
4819
+ lines.push(`- Gate check: ${document.gates.passed ? "passed" : "recorded with observations"}`);
4820
+ }
4821
+ lines.push("");
4822
+ lines.push("## Run Details");
4823
+ lines.push("");
4662
4824
  lines.push(`- Corpus version: \`${manifest.corpus_version}\``);
4663
4825
  lines.push(`- Mode: ${manifest.mode}`);
4664
4826
  lines.push(`- Model: ${manifest.model}`);
@@ -4686,7 +4848,7 @@ function renderEvalSummary(document) {
4686
4848
  `| Empty-on-clean rate | ${formatNullableNumber(aggregate.diffowl?.emptyOnCleanRate)} | ${formatNullableNumber(aggregate.baseline?.emptyOnCleanRate)} | n/a |`
4687
4849
  );
4688
4850
  lines.push(
4689
- `| Latency p50 (ms) | ${formatNullableNumber(aggregate.diffowl?.latencyMs.p50)} | ${formatNullableNumber(aggregate.baseline?.latencyMs.p50)} | ${formatDelta(aggregate.delta?.latencyP50.delta)} |`
4851
+ `| Latency p50 (ms) | ${formatNullableNumber(aggregate.delta?.latencyP50.diffowl ?? aggregate.diffowl?.latencyMs.p50)} | ${formatNullableNumber(aggregate.delta?.latencyP50.baseline ?? aggregate.baseline?.latencyMs.p50)} | ${formatDelta(aggregate.delta?.latencyP50.delta)} |`
4690
4852
  );
4691
4853
  lines.push(
4692
4854
  `| Usage mean cost | ${formatNullableNumber(aggregate.diffowl?.usage.meanCost)} | ${formatNullableNumber(aggregate.baseline?.usage.meanCost)} | ${formatDelta(aggregate.delta?.usageMeanCost.delta)} |`
@@ -4738,9 +4900,9 @@ function renderEvalSummary(document) {
4738
4900
  }
4739
4901
  lines.push("");
4740
4902
  if (document.gates) {
4741
- lines.push("## Gates");
4903
+ lines.push("## Gate Observations");
4742
4904
  lines.push("");
4743
- lines.push(document.gates.passed ? "Status: **passed**" : "Status: **failed**");
4905
+ lines.push(document.gates.passed ? "Status: **passed**" : "Status: **recorded**");
4744
4906
  if (document.gates.failures.length > 0) {
4745
4907
  lines.push("");
4746
4908
  for (const failure of document.gates.failures) {
@@ -4755,13 +4917,45 @@ function renderEvalResultsJson(document) {
4755
4917
  return `${JSON.stringify(document, null, 2)}
4756
4918
  `;
4757
4919
  }
4920
+ function renderEvalMetricsJson(document) {
4921
+ return `${JSON.stringify(toEvalMetricsDocument(document), null, 2)}
4922
+ `;
4923
+ }
4758
4924
  async function writeEvalResults(outDir, document) {
4759
- await mkdir3(outDir, { recursive: true });
4760
- const jsonPath = join12(outDir, "eval-results.json");
4761
- const summaryPath = join12(outDir, "eval-summary.md");
4762
- await writeFile4(jsonPath, renderEvalResultsJson(document), "utf8");
4763
- await writeFile4(summaryPath, renderEvalSummary(document), "utf8");
4764
- return { jsonPath, summaryPath };
4925
+ await mkdir4(outDir, { recursive: true });
4926
+ const jsonPath = join13(outDir, "eval-results.json");
4927
+ const metricsPath = join13(outDir, "eval-metrics.json");
4928
+ const summaryPath = join13(outDir, "eval-summary.md");
4929
+ await writeFile5(jsonPath, renderEvalResultsJson(document), "utf8");
4930
+ await writeFile5(metricsPath, renderEvalMetricsJson(document), "utf8");
4931
+ await writeFile5(summaryPath, renderEvalSummary(document), "utf8");
4932
+ return { jsonPath, metricsPath, summaryPath };
4933
+ }
4934
+ function toEvalMetricsDocument(document) {
4935
+ return {
4936
+ schema_version: document.schema_version,
4937
+ manifest: document.manifest,
4938
+ aggregate: document.aggregate,
4939
+ gates: document.gates,
4940
+ cases: document.cases.map((entry) => ({
4941
+ id: entry.id,
4942
+ category: entry.category,
4943
+ case_json_hash: entry.case_json_hash,
4944
+ patch_hash: entry.patch_hash,
4945
+ diffowl: entry.diffowl ? {
4946
+ metrics: entry.diffowl.metrics,
4947
+ error_count: countErrors(entry.diffowl.run.trials)
4948
+ } : void 0,
4949
+ baseline: entry.baseline ? {
4950
+ metrics: entry.baseline.metrics,
4951
+ error_count: countErrors(entry.baseline.run.trials)
4952
+ } : void 0,
4953
+ delta: entry.delta
4954
+ }))
4955
+ };
4956
+ }
4957
+ function countErrors(trials) {
4958
+ return trials.filter((trial) => trial.error).length;
4765
4959
  }
4766
4960
  function formatNullableNumber(value) {
4767
4961
  if (value === null || value === void 0) {
@@ -4934,7 +5128,7 @@ function renderEvalComparisonSummary(comparison) {
4934
5128
  for (const entry of comparison.aggregate.cases) {
4935
5129
  lines.push(`- **${entry.caseId}** (${entry.category})`);
4936
5130
  if (entry.recall.delta !== null) {
4937
- lines.push(` - Recall delta: ${formatSigned(entry.recall.delta)}`);
5131
+ lines.push(` - Overall recall delta: ${formatSigned(entry.recall.delta)}`);
4938
5132
  }
4939
5133
  if (entry.regressions.length > 0) {
4940
5134
  for (const regression of entry.regressions) {
@@ -5223,7 +5417,7 @@ async function runEvalCommand(rawOptions, dependencies = {}) {
5223
5417
  const gatePassed = document.gates?.passed ?? true;
5224
5418
  let comparison;
5225
5419
  if (options.comparePath) {
5226
- const referenceRaw = JSON.parse(await readFile6(options.comparePath, "utf8"));
5420
+ const referenceRaw = JSON.parse(await readFile7(options.comparePath, "utf8"));
5227
5421
  const reference = parseEvalResultsDocument(referenceRaw);
5228
5422
  comparison = compareEvalResults(reference, document);
5229
5423
  }
@@ -5240,15 +5434,17 @@ async function runEvalCommand(rawOptions, dependencies = {}) {
5240
5434
  const paths = await deps.writeResults(outDir, document);
5241
5435
  if (comparison) {
5242
5436
  const comparisonSummary = renderEvalComparisonSummary(comparison);
5243
- await writeFile5(join13(outDir, "eval-comparison.md"), comparisonSummary, "utf8");
5437
+ await writeFile6(join14(outDir, "eval-comparison.md"), comparisonSummary, "utf8");
5244
5438
  }
5245
5439
  spinner?.succeed(`Eval complete (${cases.length} case${cases.length === 1 ? "" : "s"})`);
5246
- deps.stdoutWrite(`${chalk2.dim("Results:")} ${paths.jsonPath}
5440
+ deps.stdoutWrite(`${chalk2.dim("Metrics:")} ${paths.metricsPath}
5441
+ `);
5442
+ deps.stdoutWrite(`${chalk2.dim("Full report:")} ${paths.jsonPath}
5247
5443
  `);
5248
5444
  deps.stdoutWrite(`${chalk2.dim("Summary:")} ${paths.summaryPath}
5249
5445
  `);
5250
5446
  if (comparison) {
5251
- deps.stdoutWrite(`${chalk2.dim("Comparison:")} ${join13(outDir, "eval-comparison.md")}
5447
+ deps.stdoutWrite(`${chalk2.dim("Comparison:")} ${join14(outDir, "eval-comparison.md")}
5252
5448
  `);
5253
5449
  if (comparison.hasRegressions) {
5254
5450
  deps.stdoutWrite(chalk2.yellow("Regressions detected vs baseline\n"));
@@ -5303,7 +5499,7 @@ async function runEvalCases(cases, options, runnerOptions, deps, spinner) {
5303
5499
  return bundles;
5304
5500
  }
5305
5501
  async function loadEvalGateThresholds(gatePath) {
5306
- const raw = JSON.parse(await readFile6(gatePath, "utf8"));
5502
+ const raw = JSON.parse(await readFile7(gatePath, "utf8"));
5307
5503
  return parseEvalGateThresholds(raw);
5308
5504
  }
5309
5505
  function failEval(deps, format, error) {
@@ -5336,7 +5532,7 @@ function selectModel(models, currentModel, answer, allowKeepCurrent) {
5336
5532
  }
5337
5533
 
5338
5534
  // src/git/hooks.ts
5339
- import { appendFile, chmod, mkdir as mkdir4, readFile as readFile8, readdir as readdir2, unlink as unlink2, writeFile as writeFile7 } from "fs/promises";
5535
+ import { appendFile, chmod, mkdir as mkdir5, readFile as readFile9, readdir as readdir2, unlink as unlink3, writeFile as writeFile8 } from "fs/promises";
5340
5536
  import {
5341
5537
  closeSync,
5342
5538
  existsSync as existsSync5,
@@ -5346,19 +5542,19 @@ import {
5346
5542
  writeFileSync,
5347
5543
  writeSync
5348
5544
  } from "fs";
5349
- import { basename as basename6, dirname as dirname4, join as join14 } from "path";
5545
+ import { basename as basename6, dirname as dirname4, join as join15 } from "path";
5350
5546
  import { fileURLToPath } from "url";
5351
5547
  import { execa as execa7 } from "execa";
5352
- import { z as z14 } from "zod";
5548
+ import { z as z15 } from "zod";
5353
5549
 
5354
5550
  // src/review/retention.ts
5355
- import { readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
5551
+ import { readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
5356
5552
  async function trimHookLog(logFile, maxBytes) {
5357
5553
  if (maxBytes === 0) return;
5358
5554
  try {
5359
- const content = await readFile7(logFile);
5555
+ const content = await readFile8(logFile);
5360
5556
  if (content.length <= maxBytes) return;
5361
- await writeFile6(logFile, content.subarray(content.length - maxBytes));
5557
+ await writeFile7(logFile, content.subarray(content.length - maxBytes));
5362
5558
  } catch {
5363
5559
  }
5364
5560
  }
@@ -5386,24 +5582,24 @@ async function getHooksDir() {
5386
5582
  async function getHookPath() {
5387
5583
  const hooksDir = await getHooksDir();
5388
5584
  if (basename6(hooksDir) === "_" && basename6(dirname4(hooksDir)) === ".husky") {
5389
- return join14(dirname4(hooksDir), "post-commit");
5585
+ return join15(dirname4(hooksDir), "post-commit");
5390
5586
  }
5391
- return join14(hooksDir, "post-commit");
5587
+ return join15(hooksDir, "post-commit");
5392
5588
  }
5393
5589
  async function installHook() {
5394
5590
  const hookPath = await getHookPath();
5395
- await mkdir4(dirname4(hookPath), { recursive: true });
5591
+ await mkdir5(dirname4(hookPath), { recursive: true });
5396
5592
  const command = await resolveHookCommand();
5397
5593
  if (existsSync5(hookPath)) {
5398
- const existing = await readFile8(hookPath, "utf-8");
5594
+ const existing = await readFile9(hookPath, "utf-8");
5399
5595
  const base = existing.includes(HOOK_MARKER) ? removeManagedSection(existing) : existing.trimEnd();
5400
5596
  const hookSection = generateManagedSection(command);
5401
5597
  const updated = base && !isOnlyShebangs(base) ? `${base}
5402
5598
 
5403
5599
  ${hookSection}` : generateHookScript(command);
5404
- await writeFile7(hookPath, updated, "utf-8");
5600
+ await writeFile8(hookPath, updated, "utf-8");
5405
5601
  } else {
5406
- await writeFile7(hookPath, generateHookScript(command), "utf-8");
5602
+ await writeFile8(hookPath, generateHookScript(command), "utf-8");
5407
5603
  }
5408
5604
  await chmod(hookPath, 493);
5409
5605
  return hookPath;
@@ -5411,43 +5607,43 @@ ${hookSection}` : generateHookScript(command);
5411
5607
  async function uninstallHook() {
5412
5608
  const hookPath = await getHookPath();
5413
5609
  if (!existsSync5(hookPath)) return false;
5414
- const content = await readFile8(hookPath, "utf-8");
5610
+ const content = await readFile9(hookPath, "utf-8");
5415
5611
  if (!content.includes(HOOK_MARKER)) return false;
5416
5612
  const cleaned = removeManagedSection(content);
5417
5613
  if (isOnlyShebangs(cleaned) || cleaned === "") {
5418
- await unlink2(hookPath);
5614
+ await unlink3(hookPath);
5419
5615
  } else {
5420
- await writeFile7(hookPath, cleaned + "\n", "utf-8");
5616
+ await writeFile8(hookPath, cleaned + "\n", "utf-8");
5421
5617
  }
5422
5618
  return true;
5423
5619
  }
5424
5620
  async function isHookInstalled() {
5425
5621
  const hookPath = await getHookPath();
5426
5622
  if (!existsSync5(hookPath)) return false;
5427
- const content = await readFile8(hookPath, "utf-8");
5623
+ const content = await readFile9(hookPath, "utf-8");
5428
5624
  return content.includes(HOOK_MARKER);
5429
5625
  }
5430
- var HookFailureSchema = z14.object({
5431
- commit: z14.string().min(1).optional(),
5432
- exitCode: z14.number().int(),
5433
- timestamp: z14.string(),
5434
- message: z14.string().optional()
5626
+ var HookFailureSchema = z15.object({
5627
+ commit: z15.string().min(1).optional(),
5628
+ exitCode: z15.number().int(),
5629
+ timestamp: z15.string(),
5630
+ message: z15.string().optional()
5435
5631
  });
5436
5632
  async function checkRecentHookFailure() {
5437
5633
  const dir = getDiffOwlDir();
5438
5634
  const pending = await listPendingReviews(dir);
5439
5635
  for (const item of pending) {
5440
- const result = await readHookResult(join14(dir, "pending-reviews", `${item.sha}.result.json`));
5636
+ const result = await readHookResult(join15(dir, "pending-reviews", `${item.sha}.result.json`));
5441
5637
  if (result && result.exitCode !== 0 && result.message !== "Review started.") {
5442
5638
  return result;
5443
5639
  }
5444
5640
  }
5445
- const statusPath = join14(dir, "last-hook-status.json");
5641
+ const statusPath = join15(dir, "last-hook-status.json");
5446
5642
  if (!existsSync5(statusPath)) {
5447
5643
  return void 0;
5448
5644
  }
5449
5645
  try {
5450
- const raw = await readFile8(statusPath, "utf-8");
5646
+ const raw = await readFile9(statusPath, "utf-8");
5451
5647
  const parsed = HookFailureSchema.safeParse(JSON.parse(raw));
5452
5648
  if (!parsed.success) return void 0;
5453
5649
  const { commit, exitCode, timestamp, message } = parsed.data;
@@ -5483,18 +5679,18 @@ async function writeHookStatus(exitCode, commit, message, resultPath = process.e
5483
5679
  2
5484
5680
  );
5485
5681
  if (resultPath) {
5486
- await writeFile7(resultPath, content, "utf-8");
5682
+ await writeFile8(resultPath, content, "utf-8");
5487
5683
  return;
5488
5684
  }
5489
- await writeFile7(join14(statusDir, "last-hook-status.json"), content, "utf-8");
5685
+ await writeFile8(join15(statusDir, "last-hook-status.json"), content, "utf-8");
5490
5686
  } catch {
5491
5687
  }
5492
5688
  }
5493
5689
  async function clearHookFailure(dir, commit) {
5494
- const statusPath = join14(dir, "last-hook-status.json");
5690
+ const statusPath = join15(dir, "last-hook-status.json");
5495
5691
  const status = await readHookResult(statusPath);
5496
5692
  if (status?.commit !== commit || status.exitCode === 0) return;
5497
- await unlink2(statusPath).catch(() => {
5693
+ await unlink3(statusPath).catch(() => {
5498
5694
  });
5499
5695
  }
5500
5696
  function formatHookFailure(failure) {
@@ -5527,9 +5723,9 @@ function isHookQueueStopFailure(message) {
5527
5723
  }
5528
5724
  async function runHookReview() {
5529
5725
  const dir = await ensureDiffOwlDir();
5530
- const logFile = join14(dir, "hook.log");
5531
- const latestReport = join14(await getSharedDiffOwlDir(), "reviews", "latest.md");
5532
- const lockFile = join14(dir, "hook-review.lock");
5726
+ const logFile = join15(dir, "hook.log");
5727
+ const latestReport = join15(await getSharedDiffOwlDir(), "reviews", "latest.md");
5728
+ const lockFile = join15(dir, "hook-review.lock");
5533
5729
  const commit = await getHeadCommit();
5534
5730
  await enqueuePendingReview(dir, commit);
5535
5731
  if (!acquireHookReviewLock(lockFile)) {
@@ -5578,7 +5774,7 @@ async function runHookReview() {
5578
5774
  }
5579
5775
  async function runPendingHookReviews() {
5580
5776
  const dir = await ensureDiffOwlDir();
5581
- const logFile = join14(dir, "hook.log");
5777
+ const logFile = join15(dir, "hook.log");
5582
5778
  const cli = fileURLToPath(import.meta.url);
5583
5779
  const attempted = /* @__PURE__ */ new Set();
5584
5780
  while (true) {
@@ -5587,12 +5783,12 @@ async function runPendingHookReviews() {
5587
5783
  if (!next) return;
5588
5784
  attempted.add(next.sha);
5589
5785
  const outFd = openSync(logFile, "a");
5590
- const resultPath = join14(dir, "pending-reviews", `${next.sha}.result.json`);
5786
+ const resultPath = join15(dir, "pending-reviews", `${next.sha}.result.json`);
5591
5787
  try {
5592
5788
  writeSync(outFd, `diffowl: reviewing queued commit ${next.sha}
5593
5789
  `);
5594
5790
  try {
5595
- await unlink2(resultPath);
5791
+ await unlink3(resultPath);
5596
5792
  } catch {
5597
5793
  }
5598
5794
  const env = { ...process.env };
@@ -5633,7 +5829,7 @@ async function runPendingHookReviews() {
5633
5829
  continue;
5634
5830
  }
5635
5831
  try {
5636
- await unlink2(next.path);
5832
+ await unlink3(next.path);
5637
5833
  } catch (error) {
5638
5834
  await appendFile(
5639
5835
  logFile,
@@ -5645,25 +5841,25 @@ async function runPendingHookReviews() {
5645
5841
  continue;
5646
5842
  }
5647
5843
  try {
5648
- await unlink2(resultPath);
5844
+ await unlink3(resultPath);
5649
5845
  } catch {
5650
5846
  }
5651
5847
  await clearHookFailure(dir, next.sha);
5652
5848
  }
5653
5849
  }
5654
5850
  async function enqueuePendingReview(dir, sha) {
5655
- const pendingDir = join14(dir, "pending-reviews");
5656
- await mkdir4(pendingDir, { recursive: true });
5657
- const marker = join14(pendingDir, sha);
5851
+ const pendingDir = join15(dir, "pending-reviews");
5852
+ await mkdir5(pendingDir, { recursive: true });
5853
+ const marker = join15(pendingDir, sha);
5658
5854
  if (existsSync5(marker)) return;
5659
- await writeFile7(
5855
+ await writeFile8(
5660
5856
  marker,
5661
5857
  JSON.stringify({ sha, queuedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
5662
5858
  "utf-8"
5663
5859
  );
5664
5860
  }
5665
5861
  async function listPendingReviews(dir) {
5666
- const pendingDir = join14(dir, "pending-reviews");
5862
+ const pendingDir = join15(dir, "pending-reviews");
5667
5863
  let files;
5668
5864
  try {
5669
5865
  files = await readdir2(pendingDir);
@@ -5672,14 +5868,14 @@ async function listPendingReviews(dir) {
5672
5868
  }
5673
5869
  const markerFiles = new Set(files.filter((file) => !file.endsWith(".result.json")));
5674
5870
  await Promise.all(
5675
- files.filter((file) => file.endsWith(".result.json")).filter((file) => !markerFiles.has(file.slice(0, -".result.json".length))).map((file) => unlink2(join14(pendingDir, file)).catch(() => {
5871
+ files.filter((file) => file.endsWith(".result.json")).filter((file) => !markerFiles.has(file.slice(0, -".result.json".length))).map((file) => unlink3(join15(pendingDir, file)).catch(() => {
5676
5872
  }))
5677
5873
  );
5678
5874
  const pending = await Promise.all(
5679
5875
  [...markerFiles].map(async (file) => {
5680
- const path = join14(pendingDir, file);
5876
+ const path = join15(pendingDir, file);
5681
5877
  try {
5682
- const parsed = JSON.parse(await readFile8(path, "utf-8"));
5878
+ const parsed = JSON.parse(await readFile9(path, "utf-8"));
5683
5879
  if (typeof parsed.sha !== "string" || typeof parsed.queuedAt !== "string") {
5684
5880
  return void 0;
5685
5881
  }
@@ -5697,7 +5893,7 @@ async function getHeadCommit() {
5697
5893
  }
5698
5894
  async function readHookResult(path) {
5699
5895
  try {
5700
- const parsed = HookFailureSchema.safeParse(JSON.parse(await readFile8(path, "utf-8")));
5896
+ const parsed = HookFailureSchema.safeParse(JSON.parse(await readFile9(path, "utf-8")));
5701
5897
  if (!parsed.success) return void 0;
5702
5898
  return {
5703
5899
  ...parsed.data.commit ? { commit: parsed.data.commit } : {},
@@ -5766,7 +5962,7 @@ async function checkHookStale() {
5766
5962
  }
5767
5963
  let content;
5768
5964
  try {
5769
- content = await readFile8(hookPath, "utf-8");
5965
+ content = await readFile9(hookPath, "utf-8");
5770
5966
  } catch (err) {
5771
5967
  const message = err instanceof Error ? err.message : String(err);
5772
5968
  return { installed: true, stale: false, reason: `Cannot read hook file: ${message}` };
@@ -5908,20 +6104,20 @@ function shellQuote(value) {
5908
6104
  }
5909
6105
 
5910
6106
  // src/review/report-path.ts
5911
- import { readFile as readFile9, readdir as readdir3 } from "fs/promises";
5912
- import { basename as basename7, isAbsolute as isAbsolute2, join as join15, resolve as resolve2 } from "path";
6107
+ import { readFile as readFile10, readdir as readdir3 } from "fs/promises";
6108
+ import { basename as basename7, isAbsolute as isAbsolute3, join as join16, resolve as resolve2 } from "path";
5913
6109
  async function resolveReviewReportPath(report) {
5914
- if (isAbsolute2(report)) return report;
6110
+ if (isAbsolute3(report)) return report;
5915
6111
  if (report.includes("/") || report.includes("\\")) {
5916
6112
  return resolve2(report);
5917
6113
  }
5918
- return join15(await getSharedDiffOwlDir(), "reviews", report);
6114
+ return join16(await getSharedDiffOwlDir(), "reviews", report);
5919
6115
  }
5920
6116
  async function listReviewReportPaths() {
5921
- const reviews = join15(await getSharedDiffOwlDir(), "reviews");
6117
+ const reviews = join16(await getSharedDiffOwlDir(), "reviews");
5922
6118
  const entries = await Promise.all([
5923
6119
  listMarkdownFiles(reviews),
5924
- listMarkdownFiles(join15(reviews, "resolved"))
6120
+ listMarkdownFiles(join16(reviews, "resolved"))
5925
6121
  ]);
5926
6122
  return entries.flat().filter((path) => basename7(path) !== "latest.md").sort((a, b) => basename7(b).localeCompare(basename7(a)));
5927
6123
  }
@@ -5936,14 +6132,14 @@ function selectReviewReportPath(paths, answer) {
5936
6132
  async function listMarkdownFiles(dir) {
5937
6133
  let paths;
5938
6134
  try {
5939
- paths = (await readdir3(dir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => join15(dir, entry.name));
6135
+ paths = (await readdir3(dir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => join16(dir, entry.name));
5940
6136
  } catch {
5941
6137
  return [];
5942
6138
  }
5943
6139
  const reports = await Promise.all(
5944
6140
  paths.map(async (path) => {
5945
6141
  try {
5946
- return parseReviewMetadata(await readFile9(path, "utf-8")) ? path : void 0;
6142
+ return parseReviewMetadata(await readFile10(path, "utf-8")) ? path : void 0;
5947
6143
  } catch {
5948
6144
  return void 0;
5949
6145
  }
@@ -5956,8 +6152,8 @@ async function listMarkdownFiles(dir) {
5956
6152
  import { createHash as createHash3 } from "crypto";
5957
6153
 
5958
6154
  // src/state/db.ts
5959
- import { mkdir as mkdir5 } from "fs/promises";
5960
- import { join as join16 } from "path";
6155
+ import { mkdir as mkdir6 } from "fs/promises";
6156
+ import { join as join17 } from "path";
5961
6157
 
5962
6158
  // src/state/migrations/001-initial-schema.ts
5963
6159
  var MIGRATION_001_INITIAL_SCHEMA = `
@@ -6029,6 +6225,36 @@ CREATE TABLE finding_events (
6029
6225
  CREATE INDEX idx_finding_events_finding_id ON finding_events(finding_id);
6030
6226
  `;
6031
6227
 
6228
+ // src/state/migrations/002-base-review-target.ts
6229
+ var MIGRATION_002_BASE_REVIEW_TARGET = `
6230
+ PRAGMA legacy_alter_table = ON;
6231
+
6232
+ ALTER TABLE reviews RENAME TO reviews_old;
6233
+
6234
+ CREATE TABLE reviews (
6235
+ id TEXT PRIMARY KEY,
6236
+ created_at TEXT NOT NULL,
6237
+ target_kind TEXT NOT NULL CHECK (target_kind IN ('staged', 'commit', 'last-commit', 'base')),
6238
+ target_ref TEXT,
6239
+ target_commit TEXT,
6240
+ diff_hash TEXT NOT NULL,
6241
+ model TEXT NOT NULL,
6242
+ reasoning TEXT NOT NULL,
6243
+ depth TEXT NOT NULL,
6244
+ session_id TEXT NOT NULL,
6245
+ summary TEXT NOT NULL,
6246
+ report_path TEXT,
6247
+ diagnostics_json TEXT NOT NULL DEFAULT '[]',
6248
+ timings_json TEXT NOT NULL DEFAULT '[]',
6249
+ skipped_reason TEXT
6250
+ );
6251
+
6252
+ INSERT INTO reviews SELECT * FROM reviews_old;
6253
+ DROP TABLE reviews_old;
6254
+
6255
+ PRAGMA legacy_alter_table = OFF;
6256
+ `;
6257
+
6032
6258
  // src/state/sqlite.ts
6033
6259
  var sqliteModule;
6034
6260
  async function openSqliteDatabase(path) {
@@ -6141,19 +6367,20 @@ function normalizeRow(row) {
6141
6367
  }
6142
6368
 
6143
6369
  // src/state/types.ts
6144
- import { randomUUID } from "crypto";
6145
- var CURRENT_SCHEMA_VERSION = 1;
6370
+ import { randomUUID as randomUUID2 } from "crypto";
6371
+ var CURRENT_SCHEMA_VERSION = 2;
6146
6372
  function createReviewId() {
6147
- return `rev_${randomUUID()}`;
6373
+ return `rev_${randomUUID2()}`;
6148
6374
  }
6149
6375
  function createFindingId() {
6150
- return `fnd_${randomUUID()}`;
6376
+ return `fnd_${randomUUID2()}`;
6151
6377
  }
6152
6378
 
6153
6379
  // src/state/db.ts
6154
6380
  var BUSY_TIMEOUT_MS = 5e3;
6155
6381
  var MIGRATIONS = {
6156
- 1: MIGRATION_001_INITIAL_SCHEMA
6382
+ 1: MIGRATION_001_INITIAL_SCHEMA,
6383
+ 2: MIGRATION_002_BASE_REVIEW_TARGET
6157
6384
  };
6158
6385
  var StateDatabaseError = class extends Error {
6159
6386
  name = "StateDatabaseError";
@@ -6162,10 +6389,10 @@ var InvalidFindingTransitionError = class extends StateDatabaseError {
6162
6389
  name = "InvalidFindingTransitionError";
6163
6390
  };
6164
6391
  function getStateDbPath(diffOwlDir) {
6165
- return join16(diffOwlDir, "state.db");
6392
+ return join17(diffOwlDir, "state.db");
6166
6393
  }
6167
6394
  async function openStateDatabase(diffOwlDir) {
6168
- await mkdir5(diffOwlDir, { recursive: true });
6395
+ await mkdir6(diffOwlDir, { recursive: true });
6169
6396
  const path = getStateDbPath(diffOwlDir);
6170
6397
  const db = await openSqliteDatabase(path);
6171
6398
  try {
@@ -6225,12 +6452,22 @@ function applyMigrations(db, targetVersion, migrations = MIGRATIONS) {
6225
6452
  }
6226
6453
  const migrate = db.transaction(() => {
6227
6454
  db.exec(sql);
6455
+ const violations = db.pragma("foreign_key_check");
6456
+ if (violations.length > 0) {
6457
+ throw new StateDatabaseError(`Migration ${version} introduced foreign key violations`);
6458
+ }
6228
6459
  db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)").run(
6229
6460
  version,
6230
6461
  (/* @__PURE__ */ new Date()).toISOString()
6231
6462
  );
6232
6463
  });
6233
- migrate();
6464
+ const foreignKeysEnabled = db.pragma("foreign_keys", { simple: true }) === 1;
6465
+ if (foreignKeysEnabled) db.pragma("foreign_keys = OFF");
6466
+ try {
6467
+ migrate();
6468
+ } finally {
6469
+ if (foreignKeysEnabled) db.pragma("foreign_keys = ON");
6470
+ }
6234
6471
  }
6235
6472
  }
6236
6473
  function listAppliedMigrationVersions(db) {
@@ -7029,6 +7266,8 @@ function mapReviewTarget(target) {
7029
7266
  return { targetKind: "last-commit", targetRef: null };
7030
7267
  case "commit":
7031
7268
  return { targetKind: "commit", targetRef: target.ref ?? null };
7269
+ case "base":
7270
+ return { targetKind: "base", targetRef: target.ref ?? null };
7032
7271
  }
7033
7272
  }
7034
7273
 
@@ -7048,9 +7287,11 @@ function buildReviewJsonDocument(input) {
7048
7287
  input.persisted.reconcile.observations,
7049
7288
  input.verbose
7050
7289
  );
7051
- const actionableCount = input.persisted.reconcile.observations.filter(
7052
- (item) => !item.suppressed
7290
+ const unsuppressed = input.persisted.reconcile.observations.filter((item) => !item.suppressed);
7291
+ const actionableCount = unsuppressed.filter(
7292
+ (item) => item.observation.severity !== "info"
7053
7293
  ).length;
7294
+ const advisoryCount = unsuppressed.filter((item) => item.observation.severity === "info").length;
7054
7295
  return {
7055
7296
  schema_version: JSON_OUTPUT_SCHEMA_VERSION,
7056
7297
  review: {
@@ -7066,7 +7307,7 @@ function buildReviewJsonDocument(input) {
7066
7307
  depth: input.review.depth,
7067
7308
  session_id: input.review.sessionId,
7068
7309
  summary: input.review.summary,
7069
- status: resolveReviewJsonStatus(input.review, actionableCount),
7310
+ status: resolveReviewJsonStatus(input.review, actionableCount, advisoryCount),
7070
7311
  report_path: input.review.reportPath,
7071
7312
  skipped_reason: input.review.skippedReason
7072
7313
  },
@@ -7105,11 +7346,17 @@ function selectJsonObservations(observations, verbose = false) {
7105
7346
  }
7106
7347
  return observations.filter((item) => !item.suppressed);
7107
7348
  }
7108
- function resolveReviewJsonStatus(review, actionableCount) {
7349
+ function resolveReviewJsonStatus(review, actionableCount, advisoryCount) {
7109
7350
  if (review.skippedReason) {
7110
7351
  return "skipped";
7111
7352
  }
7112
- return actionableCount > 0 ? "open" : "resolved";
7353
+ if (actionableCount > 0) {
7354
+ return "open";
7355
+ }
7356
+ if (advisoryCount > 0) {
7357
+ return "advisory";
7358
+ }
7359
+ return "resolved";
7113
7360
  }
7114
7361
  function mapJsonFinding(item, occurrenceCounts) {
7115
7362
  const { observation, finding, fingerprint, suppressed } = item;
@@ -7232,6 +7479,14 @@ function renderFindingDetailJson(detail) {
7232
7479
  return `${JSON.stringify(detail, null, 2)}
7233
7480
  `;
7234
7481
  }
7482
+ function renderFindingListJson(items) {
7483
+ return `${JSON.stringify(
7484
+ { schema_version: 1, count: items.length, findings: items },
7485
+ null,
7486
+ 2
7487
+ )}
7488
+ `;
7489
+ }
7235
7490
  function computeListLayout(columns) {
7236
7491
  const fixedWidth = fixedPrefixWidth();
7237
7492
  const flexibleWidth = Math.max(
@@ -7539,15 +7794,245 @@ function toFindingDetail(db, finding) {
7539
7794
  };
7540
7795
  }
7541
7796
 
7797
+ // src/review/run.ts
7798
+ var defaultReviewPipelineDeps = {
7799
+ buildReviewContextFromDiff,
7800
+ computeDiffHash,
7801
+ enrichReviewFindingsWithDurableMetadata,
7802
+ ensureServer,
7803
+ filterFindingsByChangedFiles,
7804
+ filterFindingsByConfidence,
7805
+ formatExcludedCandidateSummary,
7806
+ formatLifecycleSuppressedSummary,
7807
+ isServerRunning,
7808
+ loadFindingOccurrenceCounts,
7809
+ loadReviewSnapshot,
7810
+ mapReviewTarget,
7811
+ persistReviewRun,
7812
+ renderMarkdown,
7813
+ renderReviewContext,
7814
+ resolveTargetCommit,
7815
+ runReview,
7816
+ updatePersistedReview,
7817
+ writeMarkdownReport
7818
+ };
7819
+ async function runReviewPipeline(input, deps = defaultReviewPipelineDeps) {
7820
+ const outcome = await runReviewSkipChecks(input, deps);
7821
+ if (outcome.kind !== "continue") {
7822
+ return outcome;
7823
+ }
7824
+ const { snapshot, timings } = outcome;
7825
+ const { diff } = snapshot;
7826
+ const contextStart = performance.now();
7827
+ const reviewContext = await deps.buildReviewContextFromDiff(snapshot, input.config, input.depth);
7828
+ recordReviewTiming(timings, "context-build", "Local review context build", contextStart);
7829
+ const contextRenderStart = performance.now();
7830
+ const localContext = deps.renderReviewContext(reviewContext, { depth: input.depth });
7831
+ recordReviewTiming(timings, "context-render", "Local review context render", contextRenderStart);
7832
+ if (reviewContext.diagnostics.length > 0) {
7833
+ input.onDiagnostics?.(reviewContext.diagnostics);
7834
+ }
7835
+ const serverStart = performance.now();
7836
+ input.onStatus?.("Connecting to OpenCode...");
7837
+ await prepareReviewServer2(input.config, deps);
7838
+ recordReviewTiming(timings, "server-ensure", "OpenCode server ensure", serverStart);
7839
+ const reviewStart = performance.now();
7840
+ input.onStatus?.("Reviewing changes...");
7841
+ const reviewResult = await deps.runReview({
7842
+ target: snapshot.target,
7843
+ directory: input.projectRoot,
7844
+ config: input.config,
7845
+ localContext,
7846
+ depth: input.depth,
7847
+ ...input.signal ? { signal: input.signal } : {},
7848
+ ...input.onProgress ? { onProgress: input.onProgress } : {}
7849
+ });
7850
+ const report = reviewResult.report;
7851
+ recordReviewTiming(timings, "review-run", "OpenCode review run", reviewStart);
7852
+ const diagnostics = report.diagnostics ?? [];
7853
+ const confidenceFilter = deps.filterFindingsByConfidence(report.findings, input.config.min_confidence);
7854
+ report.findings = confidenceFilter.findings;
7855
+ const changedFilesSet = new Set(reviewContext.changedFiles.map((file) => file.file.path));
7856
+ const changedFileFilter = deps.filterFindingsByChangedFiles(report.findings, changedFilesSet);
7857
+ report.findings = changedFileFilter.findings;
7858
+ if (changedFileFilter.suppressed.length > 0 && input.verbose) {
7859
+ report.suppressedFindings = changedFileFilter.suppressed;
7860
+ }
7861
+ if (confidenceFilter.dropped > 0 || changedFileFilter.suppressed.length > 0) {
7862
+ diagnostics.push(deps.formatExcludedCandidateSummary(
7863
+ confidenceFilter.dropped,
7864
+ changedFileFilter.suppressed.length
7865
+ ));
7866
+ }
7867
+ if (diagnostics.length > 0) {
7868
+ report.diagnostics = diagnostics;
7869
+ }
7870
+ const persistStart = performance.now();
7871
+ const persisted = await deps.persistReviewRun(input.diffOwlDir, {
7872
+ ...deps.mapReviewTarget(snapshot.target),
7873
+ targetCommit: snapshot.targetCommit,
7874
+ diffHash: deps.computeDiffHash(diff.raw),
7875
+ model: input.config.model,
7876
+ reasoning: input.config.reasoning.effort,
7877
+ depth: input.depth,
7878
+ sessionId: reviewResult.sessionId,
7879
+ summary: report.summary,
7880
+ diagnostics,
7881
+ timings: [...timings, ...report.timings ?? []],
7882
+ findings: report.findings
7883
+ });
7884
+ recordReviewTiming(timings, "persist-state", "Persist review state", persistStart);
7885
+ report.findings = persisted.actionableFindings;
7886
+ const lifecycleSummary = deps.formatLifecycleSuppressedSummary(persisted.reconcile.suppressedCounts);
7887
+ if (lifecycleSummary) {
7888
+ diagnostics.push(lifecycleSummary);
7889
+ report.diagnostics = diagnostics;
7890
+ }
7891
+ if (input.verbose && persisted.lifecycleSuppressedFindings.length > 0) {
7892
+ report.suppressedFindings = [
7893
+ ...report.suppressedFindings ?? [],
7894
+ ...persisted.lifecycleSuppressedFindings
7895
+ ];
7896
+ }
7897
+ report.findings = deps.enrichReviewFindingsWithDurableMetadata(report.findings, persisted.reconcile);
7898
+ if (report.suppressedFindings) {
7899
+ report.suppressedFindings = deps.enrichReviewFindingsWithDurableMetadata(report.suppressedFindings, persisted.reconcile);
7900
+ }
7901
+ const renderStart = performance.now();
7902
+ const markdown = deps.renderMarkdown(report);
7903
+ recordReviewTiming(timings, "render-report", "Markdown render", renderStart);
7904
+ const writeStart = performance.now();
7905
+ let reportPath;
7906
+ try {
7907
+ reportPath = await deps.writeMarkdownReport(markdown, {
7908
+ schema_version: REPORT_SCHEMA_VERSION,
7909
+ review_id: persisted.reviewId,
7910
+ session_id: reviewResult.sessionId,
7911
+ project_root: input.projectRoot
7912
+ });
7913
+ await deps.updatePersistedReview(input.diffOwlDir, persisted.reviewId, { reportPath, diagnostics });
7914
+ } catch (err) {
7915
+ const message = err instanceof Error ? err.message : String(err);
7916
+ diagnostics.push(`Report write failed: ${message}`);
7917
+ report.diagnostics = diagnostics;
7918
+ await deps.updatePersistedReview(input.diffOwlDir, persisted.reviewId, { reportPath: null, diagnostics });
7919
+ throw err;
7920
+ }
7921
+ recordReviewTiming(timings, "write-report", "Report write", writeStart);
7922
+ return {
7923
+ kind: "completed",
7924
+ report,
7925
+ persisted,
7926
+ reportPath,
7927
+ sessionId: reviewResult.sessionId,
7928
+ suppressed: {
7929
+ outsideChangedFiles: changedFileFilter.suppressed.length,
7930
+ belowConfidence: confidenceFilter.dropped
7931
+ },
7932
+ timings: [...timings, ...report.timings ?? []],
7933
+ usage: reviewResult.usage ?? null
7934
+ };
7935
+ }
7936
+ async function runReviewSkipChecks(input, deps = defaultReviewPipelineDeps) {
7937
+ const timings = [...input.timings];
7938
+ const snapshot = await deps.loadReviewSnapshot(input.projectRoot, input.target);
7939
+ const { diff } = snapshot;
7940
+ const skippedReview = {
7941
+ ...deps.mapReviewTarget(snapshot.target),
7942
+ diffHash: deps.computeDiffHash(diff.raw),
7943
+ model: input.config.model,
7944
+ reasoning: input.config.reasoning.effort,
7945
+ depth: input.depth,
7946
+ sessionId: "",
7947
+ diagnostics: [],
7948
+ timings,
7949
+ findings: []
7950
+ };
7951
+ if ((snapshot.target.kind === "staged" || snapshot.target.kind === "base") && diff.files.length === 0) {
7952
+ if (!input.persistEmptyDiff) {
7953
+ return { kind: "empty-diff", timings };
7954
+ }
7955
+ const summary = snapshot.target.kind === "staged" ? "No staged changes to review." : "No committed branch changes to review.";
7956
+ return {
7957
+ kind: "skipped",
7958
+ reason: "empty-diff",
7959
+ persisted: await deps.persistReviewRun(input.diffOwlDir, {
7960
+ ...skippedReview,
7961
+ targetCommit: snapshot.targetCommit,
7962
+ summary,
7963
+ skippedReason: "empty-diff"
7964
+ }),
7965
+ reportPath: null,
7966
+ timings
7967
+ };
7968
+ }
7969
+ if (!input.config.skip_doc_only || !isDocOnlyDiff(diff)) {
7970
+ return { kind: "continue", snapshot, timings };
7971
+ }
7972
+ const persisted = await deps.persistReviewRun(input.diffOwlDir, {
7973
+ ...skippedReview,
7974
+ targetCommit: snapshot.targetCommit,
7975
+ summary: "Documentation-only changes detected. No code review performed.",
7976
+ skippedReason: "documentation-only"
7977
+ });
7978
+ try {
7979
+ const reportPath = await deps.writeMarkdownReport(buildDocOnlySkipMarkdown(diff));
7980
+ await deps.updatePersistedReview(input.diffOwlDir, persisted.reviewId, { reportPath });
7981
+ return { kind: "skipped", reason: "documentation-only", persisted, reportPath, timings };
7982
+ } catch (err) {
7983
+ const message = err instanceof Error ? err.message : String(err);
7984
+ await deps.updatePersistedReview(input.diffOwlDir, persisted.reviewId, {
7985
+ reportPath: null,
7986
+ diagnostics: [`Report write failed: ${message}`]
7987
+ });
7988
+ throw err;
7989
+ }
7990
+ }
7991
+ function buildDocOnlySkipMarkdown(diff) {
7992
+ return [
7993
+ "### Summary",
7994
+ "Documentation-only changes detected. No code review performed.",
7995
+ "",
7996
+ "### Changed Files",
7997
+ ...diff.files.map((file) => `- ${file.path} (+${file.additions}/-${file.deletions})`)
7998
+ ].join("\n");
7999
+ }
8000
+ async function resolveTargetCommit(target, resolveCommit = resolveCommitRef) {
8001
+ switch (target.kind) {
8002
+ case "staged":
8003
+ return null;
8004
+ case "last-commit":
8005
+ case "base":
8006
+ return resolveCommit("HEAD");
8007
+ case "commit":
8008
+ return resolveCommit(target.ref);
8009
+ }
8010
+ }
8011
+ async function prepareReviewServer2(config, deps = defaultReviewPipelineDeps) {
8012
+ if (config.server.auto_start) {
8013
+ await deps.ensureServer(config.server.port);
8014
+ return;
8015
+ }
8016
+ if (await deps.isServerRunning(config.server.port)) {
8017
+ return;
8018
+ }
8019
+ throw new Error(
8020
+ `OpenCode server is not running on port ${config.server.port}. Start it with \`diffowl server start\` or set server.auto_start: true.`
8021
+ );
8022
+ }
8023
+ function recordReviewTiming(timings, phase, label, start) {
8024
+ timings.push({ phase, label, ms: Math.max(0, Math.round(performance.now() - start)) });
8025
+ }
8026
+
7542
8027
  // src/cli.ts
7543
- import { readFile as readFile10 } from "fs/promises";
8028
+ import { readFile as readFile11 } from "fs/promises";
7544
8029
  import { basename as basename8, dirname as dirname5 } from "path";
7545
8030
  import { execa as execa8 } from "execa";
7546
8031
 
7547
8032
  // package.json
7548
8033
  var package_default = {
7549
8034
  name: "diffowl",
7550
- version: "0.3.2",
8035
+ version: "0.3.3",
7551
8036
  description: "Local AI code review agent powered by OpenCode",
7552
8037
  keywords: [
7553
8038
  "ai",
@@ -7585,7 +8070,6 @@ var package_default = {
7585
8070
  lint: "oxlint . && pnpm run typecheck",
7586
8071
  format: "oxfmt --write .",
7587
8072
  "format:check": "oxfmt --check .",
7588
- "dogfood:0.3": "pnpm run build && node scripts/dogfood-0.3.mjs",
7589
8073
  eval: "pnpm run build && node dist/cli.js eval",
7590
8074
  prepack: "npm run build"
7591
8075
  },
@@ -7617,10 +8101,10 @@ var package_default = {
7617
8101
  // src/cli.ts
7618
8102
  var program = new Command();
7619
8103
  program.name("diffowl").description("Local AI code review agent powered by OpenCode").version(package_default.version);
7620
- 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(
8104
+ program.command("review", { isDefault: true }).description("Review the last commit, staged changes, or committed branch changes").option("--staged", "Review staged changes instead of last commit").option("--commit <ref>", "Review a specific commit ref instead of HEAD").option("--base [ref]", "Review committed branch changes since the merge base").option("--hook", "Running from git hook (non-blocking mode)").option("--depth <depth>", "Review context depth: shallow or default").option(
7621
8105
  "--reasoning <effort>",
7622
8106
  "Reasoning variant: auto, none, minimal, low, medium, high, max, or xhigh"
7623
- ).option("--verbose", "Include suppressed findings and extra review details").option("--format <format>", "Output format: text or json", "text").action(async (options) => {
8107
+ ).option("--model <id>", "Review model override").option("--verbose", "Include suppressed findings and extra review details").option("--format <format>", "Output format: text or json", "text").action(async (options) => {
7624
8108
  const format = resolveReviewOutputFormat(options.format);
7625
8109
  const jsonMode = format === "json";
7626
8110
  const hookCommit = options.hook && options.commit ? String(options.commit) : void 0;
@@ -7635,7 +8119,7 @@ program.command("review", { isDefault: true }).description("Review the last comm
7635
8119
  const timings = [];
7636
8120
  const gitRepoStart = performance.now();
7637
8121
  const isRepo = await isGitRepo();
7638
- recordCliTiming(timings, "git-repo-check", "Git repository check", gitRepoStart);
8122
+ timings.push(createCliTiming("git-repo-check", "Git repository check", gitRepoStart));
7639
8123
  if (!isRepo) {
7640
8124
  await failReview(format, "Not a git repository", { hook: options.hook, hookCommit });
7641
8125
  }
@@ -7643,23 +8127,39 @@ program.command("review", { isDefault: true }).description("Review the last comm
7643
8127
  console.log(chalk4.yellow("No .diffowl.yml found. Running first-time setup...\n"));
7644
8128
  await runInit();
7645
8129
  }
7646
- const config = await loadConfigOrExit();
8130
+ const config = (await loadEffectiveConfigOrExit(options.model)).config;
7647
8131
  const projectRoot = getProjectRoot();
7648
8132
  const diffOwlDir = await getSharedDiffOwlDir();
8133
+ const baseRequested = options.base !== void 0;
7649
8134
  if (options.staged && options.commit) {
7650
8135
  await failReview(format, "Cannot use --staged and --commit together", {
7651
8136
  hook: options.hook,
7652
8137
  hookCommit
7653
8138
  });
7654
8139
  }
7655
- const target = options.staged ? { kind: "staged" } : options.commit ? { kind: "commit", ref: String(options.commit) } : { kind: "last-commit" };
8140
+ if (options.staged && baseRequested) {
8141
+ await failReview(format, "Cannot use --staged and --base together", {
8142
+ hook: options.hook,
8143
+ hookCommit
8144
+ });
8145
+ }
8146
+ if (options.commit && baseRequested) {
8147
+ await failReview(format, "Cannot use --commit and --base together", {
8148
+ hook: options.hook,
8149
+ hookCommit
8150
+ });
8151
+ }
8152
+ const target = options.staged ? { kind: "staged" } : options.commit ? { kind: "commit", ref: String(options.commit) } : baseRequested ? {
8153
+ kind: "base",
8154
+ ...typeof options.base === "string" ? { ref: options.base } : {}
8155
+ } : { kind: "last-commit" };
7656
8156
  const depth = resolveReviewDepth(options.depth, config);
7657
8157
  config.reasoning.effort = resolveReasoningEffort(options.reasoning, config);
7658
8158
  const verbose = Boolean(config.verbose || options.verbose);
7659
8159
  if (target.kind !== "staged") {
7660
8160
  const hasCommitsStart = performance.now();
7661
8161
  const commitsExist = await hasCommits();
7662
- recordCliTiming(timings, "git-commit-check", "Git commit check", hasCommitsStart);
8162
+ timings.push(createCliTiming("git-commit-check", "Git commit check", hasCommitsStart));
7663
8163
  if (!commitsExist) {
7664
8164
  await failReview(format, "No commits found in this repository", {
7665
8165
  hook: options.hook,
@@ -7704,247 +8204,108 @@ program.command("review", { isDefault: true }).description("Review the last comm
7704
8204
  });
7705
8205
  });
7706
8206
  try {
7707
- const snapshot = await loadReviewSnapshot(projectRoot, target);
7708
- const { diff } = snapshot;
7709
- if (target.kind === "staged" && diff.files.length === 0) {
8207
+ const outcome = await runReviewPipeline({
8208
+ target,
8209
+ config,
8210
+ depth,
8211
+ verbose,
8212
+ projectRoot,
8213
+ diffOwlDir,
8214
+ timings,
8215
+ persistEmptyDiff: jsonMode,
8216
+ signal: cancelController.signal,
8217
+ onProgress: (event) => {
8218
+ if (spinner) {
8219
+ spinner.text = formatReviewProgress(event);
8220
+ }
8221
+ },
8222
+ onDiagnostics: (diagnostics) => {
8223
+ if (!spinner) {
8224
+ return;
8225
+ }
8226
+ spinner.warn("Local review context built with warnings.");
8227
+ for (const diagnostic of diagnostics) {
8228
+ console.log(chalk4.yellow(` - ${diagnostic}`));
8229
+ }
8230
+ console.log();
8231
+ spinner.start("Connecting to OpenCode...");
8232
+ },
8233
+ onStatus: (message) => {
8234
+ if (spinner) {
8235
+ spinner.text = message;
8236
+ }
8237
+ }
8238
+ });
8239
+ if (outcome.kind === "empty-diff") {
7710
8240
  spinner?.stop();
7711
- if (jsonMode) {
7712
- const persisted2 = await persistReviewRun(diffOwlDir, {
7713
- ...mapReviewTarget(target),
7714
- targetCommit: null,
7715
- diffHash: computeDiffHash(diff.raw),
7716
- model: config.model,
7717
- reasoning: config.reasoning.effort,
7718
- depth,
7719
- sessionId: "",
7720
- summary: "No staged changes to review.",
7721
- diagnostics: [],
7722
- timings,
7723
- findings: [],
7724
- skippedReason: "empty-diff"
7725
- });
7726
- await emitReviewJsonSuccess({
7727
- diffOwlDir,
7728
- reviewId: persisted2.reviewId,
7729
- persisted: persisted2,
7730
- suppressed: { outsideChangedFiles: 0, belowConfidence: 0 },
7731
- verbose,
7732
- timings
7733
- });
7734
- process.exit(0);
8241
+ console.log(chalk4.yellow("No changes to review"));
8242
+ if (options.hook) {
8243
+ await writeHookStatus(0, hookCommit);
8244
+ }
8245
+ process.exit(0);
8246
+ }
8247
+ if (outcome.kind === "skipped" && outcome.reason === "empty-diff") {
8248
+ spinner?.stop();
8249
+ await emitReviewJsonSuccess({
8250
+ diffOwlDir,
8251
+ reviewId: outcome.persisted.reviewId,
8252
+ persisted: outcome.persisted,
8253
+ suppressed: { outsideChangedFiles: 0, belowConfidence: 0 },
8254
+ verbose,
8255
+ timings: outcome.timings
8256
+ });
8257
+ if (options.hook) {
8258
+ await writeHookStatus(0, hookCommit);
7735
8259
  }
7736
- console.log(chalk4.yellow("No staged changes to review"));
7737
8260
  process.exit(0);
7738
8261
  }
7739
- if (config.skip_doc_only && isDocOnlyDiff(diff)) {
8262
+ if (outcome.kind === "skipped" && outcome.reason === "documentation-only") {
7740
8263
  spinner?.stop();
7741
8264
  if (!jsonMode) {
7742
8265
  console.warn(chalk4.yellow("Documentation-only changes detected. Skipping review."));
7743
8266
  }
7744
- const skipContent = buildDocOnlySkipMarkdown(diff);
7745
- const diffHash2 = computeDiffHash(diff.raw);
7746
- const targetFields2 = mapReviewTarget(target);
7747
- const targetCommit2 = await resolveTargetCommit(target);
7748
- const persisted2 = await persistReviewRun(diffOwlDir, {
7749
- ...targetFields2,
7750
- targetCommit: targetCommit2,
7751
- diffHash: diffHash2,
7752
- model: config.model,
7753
- reasoning: config.reasoning.effort,
7754
- depth,
7755
- sessionId: "",
7756
- summary: "Documentation-only changes detected. No code review performed.",
7757
- diagnostics: [],
7758
- timings,
7759
- findings: [],
7760
- skippedReason: "documentation-only"
7761
- });
7762
- let reportPath2;
7763
- try {
7764
- reportPath2 = await writeMarkdownReport(skipContent);
7765
- await updatePersistedReview(diffOwlDir, persisted2.reviewId, {
7766
- reportPath: reportPath2
7767
- });
7768
- } catch (err) {
7769
- const message = err instanceof Error ? err.message : String(err);
7770
- await updatePersistedReview(diffOwlDir, persisted2.reviewId, {
7771
- reportPath: null,
7772
- diagnostics: [`Report write failed: ${message}`]
7773
- });
7774
- throw err;
7775
- }
7776
8267
  if (jsonMode) {
7777
8268
  await emitReviewJsonSuccess({
7778
8269
  diffOwlDir,
7779
- reviewId: persisted2.reviewId,
7780
- persisted: persisted2,
8270
+ reviewId: outcome.persisted.reviewId,
8271
+ persisted: outcome.persisted,
7781
8272
  suppressed: { outsideChangedFiles: 0, belowConfidence: 0 },
7782
8273
  verbose,
7783
- timings
8274
+ timings: outcome.timings
7784
8275
  });
7785
8276
  } else {
7786
- console.log(chalk4.dim(`Report saved: ${reportPath2}`));
8277
+ console.log(chalk4.dim(`Report saved: ${outcome.reportPath}`));
7787
8278
  }
7788
8279
  if (options.hook) {
7789
8280
  await writeHookStatus(0, hookCommit);
7790
8281
  }
7791
8282
  process.exit(0);
7792
8283
  }
7793
- const contextStart = performance.now();
7794
- const reviewContext = await buildReviewContextFromDiff(snapshot, config, depth);
7795
- recordCliTiming(timings, "context-build", "Local review context build", contextStart);
7796
- const contextRenderStart = performance.now();
7797
- const localContext = renderReviewContext(reviewContext, { depth });
7798
- recordCliTiming(timings, "context-render", "Local review context render", contextRenderStart);
7799
- if (reviewContext.diagnostics.length > 0 && spinner) {
7800
- spinner.warn("Local review context built with warnings.");
7801
- for (const diagnostic of reviewContext.diagnostics) {
7802
- console.log(chalk4.yellow(` - ${diagnostic}`));
7803
- }
7804
- console.log();
7805
- spinner.start("Connecting to OpenCode...");
7806
- }
7807
- if (spinner) {
7808
- spinner.text = "Connecting to OpenCode...";
7809
- }
7810
- const serverStart = performance.now();
7811
- await prepareReviewServer2(config);
7812
- recordCliTiming(timings, "server-ensure", "OpenCode server ensure", serverStart);
7813
- if (spinner) {
7814
- spinner.text = "Reviewing changes...";
8284
+ if (outcome.kind !== "completed") {
8285
+ process.exit(0);
7815
8286
  }
7816
- const reviewStart = performance.now();
7817
- const reviewResult = await runReview({
7818
- target,
7819
- directory: projectRoot,
7820
- config,
7821
- localContext,
7822
- depth,
7823
- signal: cancelController.signal,
7824
- onProgress: (event) => {
7825
- if (spinner) {
7826
- spinner.text = formatReviewProgress(event);
7827
- }
7828
- }
7829
- });
7830
- const report = reviewResult.report;
7831
- recordCliTiming(timings, "review-run", "OpenCode review run", reviewStart);
8287
+ const report = outcome.report;
7832
8288
  spinner?.succeed("Review complete.");
7833
8289
  if (!jsonMode) {
7834
8290
  console.log();
7835
8291
  }
7836
- const diagnostics = report.diagnostics ?? [];
7837
- const confidenceFilter = filterFindingsByConfidence(report.findings, config.min_confidence);
7838
- report.findings = confidenceFilter.findings;
7839
- const changedFilesSet = /* @__PURE__ */ new Set();
7840
- for (const file of reviewContext.changedFiles) {
7841
- changedFilesSet.add(file.file.path);
7842
- }
7843
- const changedFileFilter = filterFindingsByChangedFiles(report.findings, changedFilesSet);
7844
- report.findings = changedFileFilter.findings;
7845
- if (changedFileFilter.suppressed.length > 0) {
7846
- if (verbose) {
7847
- report.suppressedFindings = changedFileFilter.suppressed;
7848
- }
7849
- }
7850
- if (confidenceFilter.dropped > 0 || changedFileFilter.suppressed.length > 0) {
7851
- diagnostics.push(
7852
- formatExcludedCandidateSummary(
7853
- confidenceFilter.dropped,
7854
- changedFileFilter.suppressed.length
7855
- )
7856
- );
7857
- }
7858
- if (diagnostics.length > 0) {
7859
- report.diagnostics = diagnostics;
7860
- }
7861
- const diffHash = computeDiffHash(diff.raw);
7862
- const targetFields = mapReviewTarget(target);
7863
- const targetCommit = await resolveTargetCommit(target);
7864
- const persistStart = performance.now();
7865
- const persisted = await persistReviewRun(diffOwlDir, {
7866
- ...targetFields,
7867
- targetCommit,
7868
- diffHash,
7869
- model: config.model,
7870
- reasoning: config.reasoning.effort,
7871
- depth,
7872
- sessionId: reviewResult.sessionId,
7873
- summary: report.summary,
7874
- diagnostics,
7875
- timings: [...timings, ...report.timings ?? []],
7876
- findings: report.findings
7877
- });
7878
- recordCliTiming(timings, "persist-state", "Persist review state", persistStart);
7879
- report.findings = persisted.actionableFindings;
7880
- const lifecycleSummary = formatLifecycleSuppressedSummary(
7881
- persisted.reconcile.suppressedCounts
7882
- );
7883
- if (lifecycleSummary) {
7884
- diagnostics.push(lifecycleSummary);
7885
- report.diagnostics = diagnostics;
7886
- }
7887
- if (verbose && persisted.lifecycleSuppressedFindings.length > 0) {
7888
- report.suppressedFindings = [
7889
- ...report.suppressedFindings ?? [],
7890
- ...persisted.lifecycleSuppressedFindings
7891
- ];
7892
- }
7893
- report.findings = enrichReviewFindingsWithDurableMetadata(
7894
- report.findings,
7895
- persisted.reconcile
7896
- );
7897
- if (report.suppressedFindings) {
7898
- report.suppressedFindings = enrichReviewFindingsWithDurableMetadata(
7899
- report.suppressedFindings,
7900
- persisted.reconcile
7901
- );
7902
- }
7903
- const renderStart = performance.now();
7904
- const markdown = renderMarkdown(report);
7905
- recordCliTiming(timings, "render-report", "Markdown render", renderStart);
7906
- const writeStart = performance.now();
7907
- let reportPath;
7908
- try {
7909
- reportPath = await writeMarkdownReport(markdown, {
7910
- schema_version: REPORT_SCHEMA_VERSION,
7911
- review_id: persisted.reviewId,
7912
- session_id: reviewResult.sessionId,
7913
- project_root: projectRoot
7914
- });
7915
- await updatePersistedReview(diffOwlDir, persisted.reviewId, {
7916
- reportPath,
7917
- diagnostics
7918
- });
7919
- } catch (err) {
7920
- const message = err instanceof Error ? err.message : String(err);
7921
- diagnostics.push(`Report write failed: ${message}`);
7922
- report.diagnostics = diagnostics;
7923
- await updatePersistedReview(diffOwlDir, persisted.reviewId, {
7924
- reportPath: null,
7925
- diagnostics
7926
- });
7927
- throw err;
7928
- }
7929
- recordCliTiming(timings, "write-report", "Report write", writeStart);
7930
- recordCliTiming(timings, "total", "Total review command", totalStart);
8292
+ const outputTimings = [
8293
+ ...outcome.timings,
8294
+ createCliTiming("total", "Total review command", totalStart)
8295
+ ];
7931
8296
  if (jsonMode) {
7932
8297
  await emitReviewJsonSuccess({
7933
8298
  diffOwlDir,
7934
- reviewId: persisted.reviewId,
7935
- persisted,
7936
- suppressed: {
7937
- outsideChangedFiles: changedFileFilter.suppressed.length,
7938
- belowConfidence: confidenceFilter.dropped
7939
- },
8299
+ reviewId: outcome.persisted.reviewId,
8300
+ persisted: outcome.persisted,
8301
+ suppressed: outcome.suppressed,
7940
8302
  verbose,
7941
- timings: [...timings, ...report.timings ?? []],
7942
- usage: reviewResult.usage ?? null
8303
+ timings: outputTimings,
8304
+ usage: outcome.usage
7943
8305
  });
7944
8306
  } else {
7945
- console.log(colorizeMarkdown(markdown));
7946
- printFooter(report, reportPath);
7947
- printTimingSummary([...timings, ...report.timings ?? []]);
8307
+ printFooter(report, outcome.reportPath);
8308
+ printTimingSummary(outputTimings);
7948
8309
  }
7949
8310
  if (options.hook) {
7950
8311
  await writeHookStatus(0, hookCommit);
@@ -7983,7 +8344,7 @@ program.command("chat").description("Open the OpenCode session for a review").ar
7983
8344
  const reportPath = report ? await resolveReviewReportPath(report) : await selectReviewInteractively();
7984
8345
  let content;
7985
8346
  try {
7986
- content = await readFile10(reportPath, "utf-8");
8347
+ content = await readFile11(reportPath, "utf-8");
7987
8348
  } catch {
7988
8349
  console.error(chalk4.red(`Review report not found: ${reportPath}`));
7989
8350
  process.exit(1);
@@ -8104,8 +8465,8 @@ function resolveReasoningEffort(value, config) {
8104
8465
  process.exit(1);
8105
8466
  }
8106
8467
  }
8107
- function recordCliTiming(timings, phase, label, start) {
8108
- timings.push({ phase, label, ms: performance.now() - start });
8468
+ function createCliTiming(phase, label, start) {
8469
+ return { phase, label, ms: Math.max(0, Math.round(performance.now() - start)) };
8109
8470
  }
8110
8471
  function printTimingSummary(timings) {
8111
8472
  if (timings.length === 0) return;
@@ -8123,47 +8484,58 @@ function formatDuration2(ms) {
8123
8484
  if (ms < 1e3) return `${Math.round(ms)}ms`;
8124
8485
  return `${(ms / 1e3).toFixed(1)}s`;
8125
8486
  }
8126
- async function prepareReviewServer2(config) {
8127
- if (config.server.auto_start) {
8128
- await ensureServer(config.server.port);
8129
- return;
8130
- }
8131
- if (await isServerRunning(config.server.port)) {
8132
- return;
8133
- }
8134
- throw new Error(
8135
- `OpenCode server is not running on port ${config.server.port}. Start it with \`diffowl server start\` or set server.auto_start: true.`
8136
- );
8137
- }
8138
8487
  program.command("init").description("Set up DiffOwl for this project").action(async () => {
8139
8488
  await runInit();
8140
8489
  });
8141
8490
  async function runInit() {
8142
8491
  console.log(chalk4.bold("DiffOwl Setup\n"));
8143
- const config = await loadConfigOrExit();
8492
+ const config = await loadProjectConfigOrExit();
8144
8493
  await selectModelInteractively(config, { allowKeepCurrent: false });
8494
+ console.log(chalk4.green(`\u2713 Config saved to ${await saveConfig(config)}`));
8145
8495
  }
8146
- program.command("model").description("View or change the AI model").argument("[model]", "Model to use (e.g., opencode/big-pickle)").action(async (model) => {
8147
- const config = await loadConfigOrExit();
8148
- if (!model) {
8149
- console.log(chalk4.bold("Current model: ") + chalk4.cyan(config.model));
8150
- await selectModelInteractively(config, { allowKeepCurrent: true });
8496
+ program.command("model").description("View or change the AI model").argument("[model]", "Model to use (e.g., opencode/big-pickle)").option("--reset", "Remove the shared local model preference").action(async (model, options) => {
8497
+ if (model && options.reset) {
8498
+ console.error(chalk4.red("Cannot pass a model and --reset together"));
8499
+ process.exit(1);
8500
+ }
8501
+ if (options.reset) {
8502
+ await resetModelPreference();
8503
+ console.log(chalk4.green("\u2713 Local model preference reset"));
8504
+ console.log(chalk4.dim("Run `diffowl model <provider/model>` to choose another."));
8151
8505
  return;
8152
8506
  }
8153
- let parsedModel;
8507
+ if (model !== void 0) {
8508
+ let parsedModel;
8509
+ try {
8510
+ parsedModel = parseModel(model);
8511
+ } catch {
8512
+ console.error(chalk4.red(`Invalid model: ${model}`));
8513
+ console.error(chalk4.dim("Expected provider/model format, for example opencode/big-pickle"));
8514
+ process.exit(1);
8515
+ }
8516
+ const configPath = await saveModelPreference(parsedModel);
8517
+ console.log(chalk4.green(`\u2713 Model set to ${chalk4.cyan(parsedModel)}`));
8518
+ console.log(chalk4.dim(`Local preference: ${configPath}`));
8519
+ return;
8520
+ }
8521
+ let effective;
8154
8522
  try {
8155
- parsedModel = parseModel(model);
8156
- } catch {
8157
- console.error(chalk4.red(`Invalid model: ${model}`));
8158
- console.error(
8159
- chalk4.dim("Expected provider/model format, for example opencode/big-pickle")
8160
- );
8161
- process.exit(1);
8523
+ effective = await loadEffectiveConfig();
8524
+ } catch (err) {
8525
+ if (!(err instanceof MissingModelError)) {
8526
+ console.error(
8527
+ chalk4.red(`Config error: ${err instanceof Error ? err.message : String(err)}`)
8528
+ );
8529
+ process.exit(1);
8530
+ }
8531
+ const config2 = await loadProjectConfigOrExit();
8532
+ console.log(chalk4.yellow("No model selected."));
8533
+ await selectModelInteractively(config2, { allowKeepCurrent: false });
8534
+ return;
8162
8535
  }
8163
- config.model = parsedModel;
8164
- const configPath = await saveConfig(config);
8165
- console.log(chalk4.green(`\u2713 Model set to ${chalk4.cyan(parsedModel)}`));
8166
- console.log(chalk4.dim(`Config: ${configPath}`));
8536
+ const config = effective.config;
8537
+ console.log(formatEffectiveModel(config.model, effective.modelSource));
8538
+ await selectModelInteractively(config, { allowKeepCurrent: true });
8167
8539
  });
8168
8540
  async function selectModelInteractively(config, options) {
8169
8541
  const spinner = ora2("Querying available models from OpenCode...").start();
@@ -8224,22 +8596,14 @@ async function selectModelInteractively(config, options) {
8224
8596
  rl.close();
8225
8597
  }
8226
8598
  } else {
8227
- console.log(chalk4.yellow("\nNo active/connected providers found in OpenCode."));
8228
- console.log(
8229
- chalk4.dim("Make sure you run ") + chalk4.cyan("opencode") + chalk4.dim(" to authenticate and set up your providers/keys first.")
8230
- );
8231
- console.log(chalk4.dim("Using fallback default model: ") + chalk4.cyan(config.model));
8232
- console.log();
8599
+ console.error(chalk4.red("\nNo active/connected providers found in OpenCode."));
8600
+ console.error(chalk4.dim("Run opencode to configure a provider, then retry."));
8601
+ process.exit(1);
8233
8602
  }
8234
8603
  if (selectedModel !== config.model || !options.allowKeepCurrent) {
8235
8604
  config.model = selectedModel;
8236
- const configPath = await saveConfig(config);
8237
- if (options.allowKeepCurrent) {
8238
- console.log(chalk4.green(`\u2713 Model set to ${chalk4.cyan(selectedModel)}`));
8239
- } else {
8240
- console.log(chalk4.green(`\u2713 Config saved to ${configPath}`));
8241
- console.log(chalk4.dim(`Model set to: `) + chalk4.cyan(selectedModel));
8242
- }
8605
+ await saveModelPreference(selectedModel);
8606
+ console.log(chalk4.green(`\u2713 Model set to ${chalk4.cyan(selectedModel)}`));
8243
8607
  console.log();
8244
8608
  }
8245
8609
  }
@@ -8342,9 +8706,14 @@ serverCmd.command("status").description("Check if the OpenCode server is running
8342
8706
  }
8343
8707
  });
8344
8708
  var findingsCmd = program.command("findings").description("Inspect and manage durable findings");
8345
- findingsCmd.command("list", { isDefault: true }).description("List unresolved findings").action(async () => {
8709
+ findingsCmd.command("list", { isDefault: true }).description("List unresolved findings").option("--format <format>", "Output format: text or json", "text").action(async (options) => {
8346
8710
  await loadConfigOrExit();
8711
+ const format = resolveReviewOutputFormat(options.format);
8347
8712
  const items = await withFindingDatabase(await getSharedDiffOwlDir(), listUnresolvedFindings);
8713
+ if (format === "json") {
8714
+ process.stdout.write(renderFindingListJson(items));
8715
+ return;
8716
+ }
8348
8717
  if (items.length === 0) {
8349
8718
  console.log(chalk4.green("No unresolved findings."));
8350
8719
  return;
@@ -8456,34 +8825,29 @@ function collectValues(value, previous) {
8456
8825
  return [...previous, value];
8457
8826
  }
8458
8827
  async function loadConfigOrExit() {
8828
+ return await loadProjectConfigOrExit();
8829
+ }
8830
+ async function loadEffectiveConfigOrExit(commandModel) {
8459
8831
  try {
8460
- return await loadConfig();
8832
+ return await loadEffectiveConfig(commandModel);
8461
8833
  } catch (err) {
8462
8834
  const message = err instanceof Error ? err.message : String(err);
8463
8835
  console.error(chalk4.red(`Config error: ${message}`));
8464
8836
  process.exit(1);
8465
8837
  }
8466
8838
  }
8467
- function buildDocOnlySkipMarkdown(diff) {
8468
- const lines = [];
8469
- lines.push("### Summary");
8470
- lines.push("Documentation-only changes detected. No code review performed.");
8471
- lines.push("");
8472
- lines.push("### Changed Files");
8473
- for (const file of diff.files) {
8474
- lines.push(`- ${file.path} (+${file.additions}/-${file.deletions})`);
8839
+ async function loadProjectConfigOrExit() {
8840
+ try {
8841
+ return await loadConfig();
8842
+ } catch (err) {
8843
+ const message = err instanceof Error ? err.message : String(err);
8844
+ console.error(chalk4.red(`Config error: ${message}`));
8845
+ process.exit(1);
8475
8846
  }
8476
- return lines.join("\n");
8477
8847
  }
8478
- async function resolveTargetCommit(target) {
8479
- switch (target.kind) {
8480
- case "staged":
8481
- return null;
8482
- case "last-commit":
8483
- return resolveCommitRef("HEAD");
8484
- case "commit":
8485
- return resolveCommitRef(target.ref);
8486
- }
8848
+ function formatEffectiveModel(model, source) {
8849
+ const label = source === "local" ? "local preference" : source;
8850
+ return `${chalk4.bold("Current model: ")} ${chalk4.cyan(model)} ${chalk4.dim(`(${label})`)}`;
8487
8851
  }
8488
8852
  function handleReviewInterrupt(input) {
8489
8853
  input.cancelController.abort();