diffowl 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -12,6 +12,19 @@ import { existsSync } from "fs";
12
12
  import { join, dirname } from "path";
13
13
  import { parse, stringify } from "yaml";
14
14
  import { z, ZodError } from "zod";
15
+ var ReviewConfidenceSchema = z.enum(["low", "medium", "high"]);
16
+ var ReviewContextDepthSchema = z.enum(["shallow", "default"]);
17
+ var ReasoningEffortSchema = z.enum([
18
+ "auto",
19
+ "none",
20
+ "minimal",
21
+ "low",
22
+ "medium",
23
+ "high",
24
+ "max",
25
+ "xhigh"
26
+ ]);
27
+ var ModelSchema = z.string().trim().min(1, "model must not be empty").regex(/^[^/\s]+\/\S+$/, "model must use provider/model format");
15
28
  var DEFAULT_CONFIG = {
16
29
  model: "opencode-go/big-pickle",
17
30
  server: {
@@ -19,17 +32,17 @@ var DEFAULT_CONFIG = {
19
32
  auto_start: true
20
33
  },
21
34
  context: {
22
- depth: "default"
35
+ depth: ReviewContextDepthSchema.enum.default
23
36
  },
24
37
  reasoning: {
25
- effort: "auto"
38
+ effort: ReasoningEffortSchema.enum.auto
26
39
  },
27
40
  retention: {
28
41
  hook_log_kb: 1024
29
42
  },
30
43
  timeout: 300,
31
44
  // 5 minutes
32
- min_confidence: "medium",
45
+ min_confidence: ReviewConfidenceSchema.enum.medium,
33
46
  include: ["**/*"],
34
47
  exclude: [
35
48
  "**/*.test.*",
@@ -45,19 +58,6 @@ var DEFAULT_CONFIG = {
45
58
  verbose: false
46
59
  };
47
60
  var CONFIG_FILENAME = ".diffowl.yml";
48
- var ReviewConfidenceSchema = z.enum(["low", "medium", "high"]);
49
- var ReviewContextDepthSchema = z.enum(["shallow", "default"]);
50
- var ReasoningEffortSchema = z.enum([
51
- "auto",
52
- "none",
53
- "minimal",
54
- "low",
55
- "medium",
56
- "high",
57
- "max",
58
- "xhigh"
59
- ]);
60
- var ModelSchema = z.string().trim().min(1, "model must not be empty").regex(/^[^/\s]+\/\S+$/, "model must use provider/model format");
61
61
  var stringArraySchema = z.array(z.string().trim().min(1));
62
62
  var DiffOwlConfigSchema = z.object({
63
63
  model: ModelSchema.default(DEFAULT_CONFIG.model),
@@ -114,7 +114,7 @@ function findConfigPath() {
114
114
  async function loadConfig() {
115
115
  const configPath = findConfigPath();
116
116
  if (!existsSync(configPath)) {
117
- return { ...DEFAULT_CONFIG };
117
+ return parseConfigInput({});
118
118
  }
119
119
  try {
120
120
  const raw = await readFile(configPath, "utf-8");
@@ -366,8 +366,8 @@ Required review passes:
366
366
  - Performance and boundedness: Look for unbounded scans, large-file/diff cliffs, slow hook behavior, and expensive operations in common paths.
367
367
  - Data filtering/loss: Look for data silently dropped, hidden, duplicated, parsed with a fallback, or reported inconsistently.
368
368
  `;
369
- function buildReviewPrompt(mode, customRules, include, exclude, localContext, depth = "default") {
370
- const modeInstruction = mode === "staged" ? "Review the currently staged changes." : mode === "commit" ? "Review the selected commit." : "Review the last commit.";
369
+ function buildReviewPrompt(target, customRules, include, exclude, localContext, depth = "default") {
370
+ const modeInstruction = target.kind === "staged" ? "Review the currently staged changes." : target.kind === "commit" ? "Review the selected commit." : "Review the last commit.";
371
371
  let prompt = `${modeInstruction}
372
372
 
373
373
  DiffOwl has already collected the diff and likely-relevant local context below. Use this context first.
@@ -425,7 +425,7 @@ var ReviewSeveritySchema = z2.preprocess(
425
425
  );
426
426
  var ReviewConfidenceSchema2 = z2.preprocess(
427
427
  (value) => typeof value === "string" ? value.toLowerCase() : value,
428
- z2.enum(["low", "medium", "high"])
428
+ ReviewConfidenceSchema
429
429
  ).catch("low");
430
430
  var ReviewFindingLineSchema = z2.preprocess(
431
431
  (value) => typeof value === "string" ? Number(value) : value,
@@ -553,19 +553,20 @@ function createReviewSettlementCoordinator(options) {
553
553
  let reconciliationRunning = false;
554
554
  let timeoutRequested = false;
555
555
  let lastReconciliationError;
556
- const settle = (outcome, value) => {
556
+ const settle = (outcome) => {
557
557
  if (settled) return;
558
558
  settled = true;
559
559
  clearTimeout(safetyTimeout);
560
560
  clearInterval(reconciliationInterval);
561
561
  options.onAbort();
562
- if (outcome === "resolve") {
563
- options.resolve(value);
562
+ if (outcome.kind === "resolve") {
563
+ options.resolve(outcome.text);
564
564
  } else {
565
- options.reject(value);
565
+ options.reject(outcome.error);
566
566
  }
567
567
  };
568
568
  const acceptText = (text) => {
569
+ if (settled) return false;
569
570
  if (text.length > fullResponse.length) {
570
571
  fullResponse = text;
571
572
  options.onText?.(fullResponse);
@@ -576,7 +577,7 @@ function createReviewSettlementCoordinator(options) {
576
577
  if (lengthDelta > 500 || endsWithBrace) {
577
578
  lastCheckedLength = fullResponse.length;
578
579
  if (looksLikeCompleteStructuredReview(fullResponse)) {
579
- settle("resolve", fullResponse);
580
+ settle({ kind: "resolve", text: fullResponse });
580
581
  return true;
581
582
  }
582
583
  }
@@ -587,27 +588,30 @@ function createReviewSettlementCoordinator(options) {
587
588
  reconciliationRunning = true;
588
589
  try {
589
590
  const result = await options.reconcile();
590
- if (result?.error) {
591
- settle("reject", result.error);
592
- return;
593
- }
594
- if (result?.reconciliationError) {
595
- lastReconciliationError = result.reconciliationError;
596
- } else if (result) {
597
- lastReconciliationError = void 0;
598
- }
599
- if (result?.text && acceptText(result.text)) {
600
- return;
591
+ switch (result.kind) {
592
+ case "review-error":
593
+ settle({ kind: "reject", error: result.error });
594
+ return;
595
+ case "transport-error":
596
+ lastReconciliationError = result.error;
597
+ break;
598
+ case "text":
599
+ lastReconciliationError = void 0;
600
+ if (acceptText(result.text)) return;
601
+ break;
602
+ case "empty":
603
+ lastReconciliationError = void 0;
604
+ break;
601
605
  }
602
606
  if (isTimeout || timeoutRequested) {
603
607
  const suffix = lastReconciliationError ? ` Last session reconciliation error: ${lastReconciliationError.message}` : "";
604
- settle(
605
- "reject",
606
- new Error(
608
+ settle({
609
+ kind: "reject",
610
+ error: new Error(
607
611
  `Review timed out.${suffix}`,
608
612
  lastReconciliationError ? { cause: lastReconciliationError } : void 0
609
613
  )
610
- );
614
+ });
611
615
  }
612
616
  } finally {
613
617
  reconciliationRunning = false;
@@ -625,14 +629,14 @@ function createReviewSettlementCoordinator(options) {
625
629
  acceptText,
626
630
  finish: () => {
627
631
  if (settled || acceptText(fullResponse)) return;
628
- settle(
629
- "reject",
630
- new Error("OpenCode event stream ended before a complete review was received.")
631
- );
632
+ settle({
633
+ kind: "reject",
634
+ error: new Error("OpenCode event stream ended before a complete review was received.")
635
+ });
632
636
  },
633
637
  isSettled: () => settled,
634
- reject: (error) => settle("reject", error),
635
- resolve: (text) => settle("resolve", text)
638
+ reject: (error) => settle({ kind: "reject", error }),
639
+ resolve: (text) => settle({ kind: "resolve", text })
636
640
  };
637
641
  }
638
642
 
@@ -706,7 +710,7 @@ async function replyWithAvailableEndpoint(client, permission, response) {
706
710
  });
707
711
  }
708
712
  }
709
- function extractPermissionRequest(payload, sessionId) {
713
+ function extractPermissionRequest(payload, expectedSessionId) {
710
714
  if (!payload || typeof payload !== "object") {
711
715
  return void 0;
712
716
  }
@@ -715,7 +719,8 @@ function extractPermissionRequest(payload, sessionId) {
715
719
  return void 0;
716
720
  }
717
721
  const properties = event.properties;
718
- if (properties["sessionID"] !== sessionId) {
722
+ const sessionId = properties["sessionID"];
723
+ if (typeof sessionId !== "string" || expectedSessionId !== void 0 && sessionId !== expectedSessionId) {
719
724
  return void 0;
720
725
  }
721
726
  if (event.type === "permission.updated") {
@@ -832,14 +837,96 @@ function listAvailableModels(payload) {
832
837
  }
833
838
 
834
839
  // src/opencode/client.ts
835
- function extractEventPayload(event) {
840
+ function normalizeOpenCodeEvent(event, expectedSessionId) {
836
841
  if (!event || typeof event !== "object") return void 0;
837
842
  const payload = event.payload;
838
843
  if (!payload || typeof payload !== "object") return void 0;
839
- return payload;
844
+ const raw = payload;
845
+ if (typeof raw.type !== "string" || !raw.properties || typeof raw.properties !== "object") {
846
+ return void 0;
847
+ }
848
+ const permission = extractPermissionRequest(payload, expectedSessionId);
849
+ if (permission) return { type: "permission", request: permission };
850
+ const properties = raw.properties;
851
+ const sessionId = properties["sessionID"];
852
+ if (expectedSessionId !== void 0 && typeof sessionId === "string" && sessionId !== expectedSessionId) {
853
+ return void 0;
854
+ }
855
+ if (raw.type === "session.error" && typeof sessionId === "string") {
856
+ return {
857
+ type: "session-error",
858
+ sessionId,
859
+ error: new Error(`OpenCode session failed: ${describeSessionError(properties["error"])}`)
860
+ };
861
+ }
862
+ if (raw.type === "message.part.updated") {
863
+ return normalizeMessagePart(properties["part"], expectedSessionId);
864
+ }
865
+ if (raw.type === "message.updated") {
866
+ return normalizeAssistantMessage(properties["info"], expectedSessionId);
867
+ }
868
+ if (raw.type === "session.status" && typeof sessionId === "string") {
869
+ const status = properties["status"];
870
+ if (!status || typeof status !== "object") return void 0;
871
+ const statusType = status.type;
872
+ if (typeof statusType !== "string") return void 0;
873
+ const message = status.message;
874
+ return {
875
+ type: "session-status",
876
+ sessionId,
877
+ status: statusType,
878
+ ...typeof message === "string" ? { message } : {}
879
+ };
880
+ }
881
+ if (raw.type === "session.idle" && typeof sessionId === "string") {
882
+ return { type: "session-idle", sessionId };
883
+ }
884
+ return void 0;
885
+ }
886
+ function normalizeMessagePart(part, expectedSessionId) {
887
+ if (!part || typeof part !== "object") return void 0;
888
+ const value = part;
889
+ const sessionId = value["sessionID"];
890
+ if (typeof sessionId !== "string" || expectedSessionId !== void 0 && sessionId !== expectedSessionId) {
891
+ return void 0;
892
+ }
893
+ if (value["type"] === "tool" && typeof value["tool"] === "string") {
894
+ const state = value["state"] && typeof value["state"] === "object" ? value["state"] : void 0;
895
+ const status = typeof state?.["status"] === "string" ? state["status"] : "unknown";
896
+ const title = typeof state?.["title"] === "string" ? state["title"] : value["tool"];
897
+ return {
898
+ type: "tool-part",
899
+ sessionId,
900
+ tool: value["tool"],
901
+ status,
902
+ title
903
+ };
904
+ }
905
+ if (value["type"] === "text" && typeof value["messageID"] === "string" && typeof value["text"] === "string" && value["text"] !== "") {
906
+ return {
907
+ type: "text-part",
908
+ sessionId,
909
+ messageId: value["messageID"],
910
+ text: value["text"]
911
+ };
912
+ }
913
+ return void 0;
914
+ }
915
+ function normalizeAssistantMessage(info, expectedSessionId) {
916
+ if (!info || typeof info !== "object") return void 0;
917
+ const value = info;
918
+ if (value["role"] !== "assistant" || typeof value["sessionID"] !== "string" || typeof value["id"] !== "string" || expectedSessionId !== void 0 && value["sessionID"] !== expectedSessionId) {
919
+ return void 0;
920
+ }
921
+ return {
922
+ type: "assistant-message",
923
+ sessionId: value["sessionID"],
924
+ messageId: value["id"],
925
+ ...value["error"] ? { error: new Error(describeSessionError(value["error"]) || "Review failed") } : {}
926
+ };
840
927
  }
841
928
  async function runReview(options) {
842
- const { mode, config, localContext, depth, onProgress } = options;
929
+ const { target, config, localContext, depth, onProgress } = options;
843
930
  const port = config.server.port;
844
931
  const directoryOptions = opencodeDirectoryOptions();
845
932
  const timings = [];
@@ -869,7 +956,7 @@ async function runReview(options) {
869
956
  recordTiming(timings, onProgress, "tool-policy", "OpenCode tool policy", toolPolicyStart);
870
957
  const promptStart = performance.now();
871
958
  const prompt = buildReviewPrompt(
872
- mode,
959
+ target,
873
960
  config.rules,
874
961
  config.include,
875
962
  config.exclude,
@@ -920,69 +1007,59 @@ async function runReview(options) {
920
1007
  try {
921
1008
  for await (const event of sseResult.stream) {
922
1009
  if (settlement.isSettled()) break;
923
- const payload = extractEventPayload(event);
924
- if (!payload) continue;
925
- const permission = extractPermissionRequest(payload, sessionId);
926
- if (permission) {
927
- void replyToPermissionRequest(client, permission, onProgress).catch((err) => {
928
- onProgress?.({
929
- type: "session",
930
- message: `OpenCode permission reply failed: ${err instanceof Error ? err.message : String(err)}`,
931
- sessionId
932
- });
933
- });
934
- continue;
935
- }
936
- const sessionError = extractSessionError(payload, sessionId);
937
- if (sessionError) {
938
- settlement.reject(sessionError);
939
- break;
940
- }
941
- if (payload.type === "message.part.updated" && payload.properties?.part?.sessionID === sessionId) {
942
- const part = payload.properties.part;
943
- if (part.type === "tool" && typeof part.tool === "string") {
944
- const state = typeof part.state?.status === "string" ? part.state.status : "unknown";
945
- const title = typeof part.state?.title === "string" ? part.state.title : part.tool;
1010
+ const normalized = normalizeOpenCodeEvent(event, sessionId);
1011
+ if (!normalized) continue;
1012
+ switch (normalized.type) {
1013
+ case "permission":
1014
+ void replyToPermissionRequest(client, normalized.request, onProgress).catch(
1015
+ (err) => {
1016
+ onProgress?.({
1017
+ type: "session",
1018
+ message: `OpenCode permission reply failed: ${err instanceof Error ? err.message : String(err)}`,
1019
+ sessionId
1020
+ });
1021
+ }
1022
+ );
1023
+ break;
1024
+ case "session-error":
1025
+ settlement.reject(normalized.error);
1026
+ break;
1027
+ case "tool-part":
946
1028
  onProgress?.({
947
1029
  type: "tool",
948
- message: `${title} (${state})`,
949
- tool: part.tool,
950
- status: state
1030
+ message: `${normalized.title} (${normalized.status})`,
1031
+ tool: normalized.tool,
1032
+ status: normalized.status
951
1033
  });
952
- }
953
- if (part.type === "text" && typeof part.messageID === "string" && typeof part.text === "string" && part.text) {
954
- textPartsByMessageId.set(part.messageID, part.text);
955
- if (assistantMessageIds.has(part.messageID) && settlement.acceptText(part.text)) {
1034
+ break;
1035
+ case "text-part":
1036
+ textPartsByMessageId.set(normalized.messageId, normalized.text);
1037
+ if (assistantMessageIds.has(normalized.messageId) && settlement.acceptText(normalized.text)) {
956
1038
  break;
957
1039
  }
958
- }
959
- }
960
- if (payload.type === "message.updated" && payload.properties?.info?.sessionID === sessionId) {
961
- const msg = payload.properties?.info;
962
- if (msg?.role === "assistant" && typeof msg.id === "string") {
963
- assistantMessageIds.add(msg.id);
964
- const text = textPartsByMessageId.get(msg.id);
1040
+ break;
1041
+ case "assistant-message": {
1042
+ assistantMessageIds.add(normalized.messageId);
1043
+ const text = textPartsByMessageId.get(normalized.messageId);
965
1044
  if (text && settlement.acceptText(text)) {
966
1045
  break;
967
1046
  }
968
- if (msg.error) {
969
- const message = typeof msg.error.data?.message === "string" ? msg.error.data.message : "Review failed";
970
- settlement.reject(new Error(message));
971
- break;
1047
+ if (normalized.error) {
1048
+ settlement.reject(normalized.error);
972
1049
  }
1050
+ break;
973
1051
  }
1052
+ case "session-status":
1053
+ const message = normalized.status === "retry" ? `OpenCode retrying: ${normalized.message ?? "unknown error"}` : `OpenCode session ${normalized.status}.`;
1054
+ onProgress?.({ type: "session", message, sessionId });
1055
+ break;
1056
+ case "session-idle":
1057
+ if (fullResponse.length === 0) break;
1058
+ onProgress?.({ type: "idle", message: "OpenCode session is idle." });
1059
+ settlement.finish();
1060
+ break;
974
1061
  }
975
- if (payload.type === "session.status" && payload.properties?.sessionID === sessionId) {
976
- const status = payload.properties.status;
977
- if (!status || typeof status.type !== "string") continue;
978
- const message = status.type === "retry" ? `OpenCode retrying: ${typeof status.message === "string" ? status.message : "unknown error"}` : `OpenCode session ${status.type}.`;
979
- onProgress?.({ type: "session", message, sessionId });
980
- }
981
- if (payload.type === "session.idle" && payload.properties?.sessionID === sessionId && fullResponse.length > 0) {
982
- onProgress?.({ type: "idle", message: "OpenCode session is idle." });
983
- settlement.finish();
984
- break;
985
- }
1062
+ if (settlement.isSettled()) break;
986
1063
  }
987
1064
  if (!settlement.isSettled()) {
988
1065
  settlement.finish();
@@ -1031,18 +1108,10 @@ async function runReview(options) {
1031
1108
  sessionId
1032
1109
  };
1033
1110
  }
1034
- function extractSessionError(payload, sessionId) {
1035
- if (!payload || typeof payload !== "object") return void 0;
1036
- const event = payload;
1037
- if (event.type !== "session.error" || event.properties?.sessionID !== sessionId) {
1038
- return void 0;
1039
- }
1040
- return new Error(`OpenCode session failed: ${describeSessionError(event.properties.error)}`);
1041
- }
1042
1111
  function extractSessionMessageResult(response) {
1043
- if (!response || typeof response !== "object") return void 0;
1112
+ if (!response || typeof response !== "object") return { kind: "empty" };
1044
1113
  const data = response.data;
1045
- if (!Array.isArray(data)) return void 0;
1114
+ if (!Array.isArray(data)) return { kind: "empty" };
1046
1115
  for (let index = data.length - 1; index >= 0; index--) {
1047
1116
  const message = data[index];
1048
1117
  if (!message || typeof message !== "object") continue;
@@ -1052,7 +1121,10 @@ function extractSessionMessageResult(response) {
1052
1121
  }
1053
1122
  const error = info.error;
1054
1123
  if (error) {
1055
- return { error: new Error(`OpenCode session failed: ${describeSessionError(error)}`) };
1124
+ return {
1125
+ kind: "review-error",
1126
+ error: new Error(`OpenCode session failed: ${describeSessionError(error)}`)
1127
+ };
1056
1128
  }
1057
1129
  const parts = message.parts;
1058
1130
  if (!Array.isArray(parts)) continue;
@@ -1061,9 +1133,9 @@ function extractSessionMessageResult(response) {
1061
1133
  part && typeof part === "object" && part.type === "text" && typeof part.text === "string"
1062
1134
  )
1063
1135
  ).map((part) => part.text).join("");
1064
- if (text) return { text };
1136
+ if (text) return { kind: "text", text };
1065
1137
  }
1066
- return void 0;
1138
+ return { kind: "empty" };
1067
1139
  }
1068
1140
  async function reconcileSessionMessages(client, directoryOptions, sessionId) {
1069
1141
  try {
@@ -1080,7 +1152,8 @@ async function reconcileSessionMessages(client, directoryOptions, sessionId) {
1080
1152
  return extractSessionMessageResult(response);
1081
1153
  } catch (error) {
1082
1154
  return {
1083
- reconciliationError: error instanceof Error ? error : new Error(`Session reconciliation failed: ${String(error)}`)
1155
+ kind: "transport-error",
1156
+ error: error instanceof Error ? error : new Error(`Session reconciliation failed: ${String(error)}`)
1084
1157
  };
1085
1158
  }
1086
1159
  }
@@ -1298,7 +1371,7 @@ async function installHook() {
1298
1371
  const command = await resolveHookCommand();
1299
1372
  if (existsSync3(hookPath)) {
1300
1373
  const existing = await readFile4(hookPath, "utf-8");
1301
- const base = existing.includes(HOOK_MARKER) || existing.includes("# commitdog-managed") ? removeManagedSection(existing) : existing.trimEnd();
1374
+ const base = existing.includes(HOOK_MARKER) ? removeManagedSection(existing) : existing.trimEnd();
1302
1375
  const hookSection = generateManagedSection(command);
1303
1376
  const updated = base && !isOnlyShebangs(base) ? `${base}
1304
1377
 
@@ -1315,7 +1388,7 @@ async function uninstallHook() {
1315
1388
  const hookPath = join3(hooksDir, "post-commit");
1316
1389
  if (!existsSync3(hookPath)) return false;
1317
1390
  const content = await readFile4(hookPath, "utf-8");
1318
- if (!content.includes(HOOK_MARKER) && !content.includes("# commitdog-managed")) return false;
1391
+ if (!content.includes(HOOK_MARKER)) return false;
1319
1392
  const cleaned = removeManagedSection(content);
1320
1393
  if (isOnlyShebangs(cleaned) || cleaned === "") {
1321
1394
  await unlink2(hookPath);
@@ -1579,8 +1652,12 @@ function isHookReviewLockActive(lockFile) {
1579
1652
  try {
1580
1653
  const pid = Number.parseInt(readFileSync(lockFile, "utf-8"), 10);
1581
1654
  if (!Number.isInteger(pid) || pid <= 0) return false;
1582
- process.kill(pid, 0);
1583
- return true;
1655
+ try {
1656
+ process.kill(pid, 0);
1657
+ return true;
1658
+ } catch (err) {
1659
+ return err.code === "EPERM";
1660
+ }
1584
1661
  } catch {
1585
1662
  return false;
1586
1663
  }
@@ -1675,7 +1752,6 @@ async function resolveCommand(command) {
1675
1752
  function removeManagedSection(content) {
1676
1753
  let lines = content.split("\n");
1677
1754
  lines = removeSectionByMarkers(lines, "# diffowl-managed", "# end-diffowl");
1678
- lines = removeSectionByMarkers(lines, "# commitdog-managed", "# end-commitdog");
1679
1755
  return lines.join("\n").trim();
1680
1756
  }
1681
1757
  function removeSectionByMarkers(lines, startMarker, endMarker) {
@@ -1821,12 +1897,13 @@ async function hasCommits() {
1821
1897
  }
1822
1898
  }
1823
1899
  function parseDiff(raw, diagnostics = []) {
1824
- const files = [];
1900
+ const drafts = [];
1825
1901
  const lines = raw.split(/\r?\n/).map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
1826
1902
  for (const line of lines) {
1827
1903
  const gitDiffPaths = parseGitDiffLine(line);
1828
1904
  if (gitDiffPaths) {
1829
- files.push({
1905
+ drafts.push({
1906
+ sourcePath: gitDiffPaths.pathA,
1830
1907
  path: gitDiffPaths.pathB,
1831
1908
  status: "modified",
1832
1909
  additions: 0,
@@ -1836,7 +1913,8 @@ function parseDiff(raw, diagnostics = []) {
1836
1913
  }
1837
1914
  const combinedPath = parseCombinedDiffLine(line);
1838
1915
  if (combinedPath) {
1839
- files.push({
1916
+ drafts.push({
1917
+ sourcePath: combinedPath,
1840
1918
  path: combinedPath,
1841
1919
  status: "modified",
1842
1920
  additions: 0,
@@ -1844,11 +1922,10 @@ function parseDiff(raw, diagnostics = []) {
1844
1922
  });
1845
1923
  continue;
1846
1924
  }
1847
- const lastFile = files[files.length - 1];
1925
+ const lastFile = drafts[drafts.length - 1];
1848
1926
  if (lastFile) {
1849
1927
  if (line.startsWith("rename to ")) {
1850
- const target = unescapePath(line.slice("rename to ".length));
1851
- lastFile.path = target;
1928
+ lastFile.path = unescapePath(line.slice("rename to ".length));
1852
1929
  lastFile.status = "renamed";
1853
1930
  continue;
1854
1931
  }
@@ -1867,9 +1944,20 @@ function parseDiff(raw, diagnostics = []) {
1867
1944
  }
1868
1945
  }
1869
1946
  }
1870
- const summary = files.map((f) => `${statusSymbol(f.status)} ${f.path} (+${f.additions}/-${f.deletions})`).join("\n");
1947
+ const files = drafts.map(finalizeDiffFile);
1948
+ const summary = files.map((file) => {
1949
+ const path = file.status === "renamed" ? `${file.oldPath} -> ${file.path}` : file.path;
1950
+ return `${statusSymbol(file.status)} ${path} (+${file.additions}/-${file.deletions})`;
1951
+ }).join("\n");
1871
1952
  return { files, raw, summary, ...diagnostics.length > 0 ? { diagnostics } : {} };
1872
1953
  }
1954
+ function finalizeDiffFile(draft) {
1955
+ const { sourcePath, path, status, additions, deletions } = draft;
1956
+ if (status === "renamed") {
1957
+ return { oldPath: sourcePath, path, status, additions, deletions };
1958
+ }
1959
+ return { path, status, additions, deletions };
1960
+ }
1873
1961
  function isMaxBufferError(err) {
1874
1962
  return err !== null && typeof err === "object" && err.isMaxBuffer === true && "stdout" in err && typeof err.stdout === "string";
1875
1963
  }
@@ -2347,7 +2435,7 @@ function renderReviewContext(context, options = {}) {
2347
2435
  const lines = [];
2348
2436
  lines.push("## Local Review Context");
2349
2437
  lines.push("");
2350
- lines.push(`Mode: ${context.mode}`);
2438
+ lines.push(`Mode: ${context.target.kind}`);
2351
2439
  lines.push(`Review depth: ${depth}`);
2352
2440
  lines.push("");
2353
2441
  lines.push("### Changed Files");
@@ -2413,22 +2501,30 @@ function renderReviewContext(context, options = {}) {
2413
2501
  lines.push("");
2414
2502
  }
2415
2503
  }
2416
- if (fileContext.content && fileContext.astSymbols.length === 0 && fileContext.shouldRenderContent) {
2417
- lines.push(
2418
- fence(
2419
- shallow ? truncateText2(fileContext.content, MAX_QUICK_FILE_CHARS).text : fileContext.content,
2420
- languageForPath(fileContext.file.path)
2421
- )
2422
- );
2423
- if (fileContext.truncated) {
2424
- lines.push("_File content truncated._");
2425
- }
2426
- } else if (fileContext.content && fileContext.astSymbols.length === 0) {
2427
- lines.push("_Full file content omitted because the diff already shows the changed hunks._");
2428
- } else if (fileContext.content) {
2429
- lines.push("_Full file content omitted because changed AST symbols are shown._");
2504
+ if (fileContext.content.status === "skipped") {
2505
+ lines.push(`_File content skipped: ${fileContext.content.reason}._`);
2430
2506
  } else {
2431
- lines.push(`_File content skipped: ${fileContext.skippedReason ?? "unavailable"}._`);
2507
+ switch (fileContext.content.render) {
2508
+ case "full":
2509
+ lines.push(
2510
+ fence(
2511
+ shallow ? truncateText2(fileContext.content.text, MAX_QUICK_FILE_CHARS).text : fileContext.content.text,
2512
+ languageForPath(fileContext.file.path)
2513
+ )
2514
+ );
2515
+ if (fileContext.content.truncated) {
2516
+ lines.push("_File content truncated._");
2517
+ }
2518
+ break;
2519
+ case "diff-only":
2520
+ lines.push(
2521
+ "_Full file content omitted because the diff already shows the changed hunks._"
2522
+ );
2523
+ break;
2524
+ case "ast-symbols":
2525
+ lines.push("_Full file content omitted because changed AST symbols are shown._");
2526
+ break;
2527
+ }
2432
2528
  }
2433
2529
  lines.push("");
2434
2530
  }
@@ -2536,8 +2632,18 @@ var MAX_INLINE_FILE_LINES = 80;
2536
2632
  var MAX_CONTEXT_FILE_BYTES = 512 * 1024;
2537
2633
  var MIN_CHANGED_RATIO_FOR_INLINE_CONTENT = 0.4;
2538
2634
  var LOCKFILE_EXCLUDES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb"];
2539
- async function buildReviewContext(mode, config, depth = config.context.depth, diff) {
2540
- const diffResult = diff ?? await loadDiffForMode(mode);
2635
+ async function loadReviewDiff(target) {
2636
+ switch (target.kind) {
2637
+ case "staged":
2638
+ return getStagedDiff();
2639
+ case "commit":
2640
+ return getCommitDiff(target.ref);
2641
+ case "last-commit":
2642
+ return getLastCommitDiff();
2643
+ }
2644
+ }
2645
+ async function buildReviewContextFromDiff(snapshot, config, depth = config.context.depth) {
2646
+ const { target, diff: diffResult } = snapshot;
2541
2647
  const reviewableFiles = diffResult.files.filter((file) => shouldReviewFile(file.path, config));
2542
2648
  const skippedFiles = diffResult.files.filter((file) => !shouldReviewFile(file.path, config));
2543
2649
  const changedLines = getChangedLinesByFile(diffResult.raw);
@@ -2553,7 +2659,7 @@ async function buildReviewContext(mode, config, depth = config.context.depth, di
2553
2659
  const relatedFiles = depth === "shallow" ? [] : await buildRelatedFileContexts(reviewableFiles);
2554
2660
  const references = depth === "shallow" ? [] : await buildReferenceContexts(changedFiles, skippedFiles, diagnostics);
2555
2661
  return {
2556
- mode,
2662
+ target,
2557
2663
  depth,
2558
2664
  diff: diffResult,
2559
2665
  changedFiles,
@@ -2563,15 +2669,6 @@ async function buildReviewContext(mode, config, depth = config.context.depth, di
2563
2669
  diagnostics
2564
2670
  };
2565
2671
  }
2566
- async function loadDiffForMode(mode) {
2567
- if (mode === "staged") {
2568
- return getStagedDiff();
2569
- }
2570
- if (mode === "commit") {
2571
- throw new Error("Commit review context requires an explicit diff.");
2572
- }
2573
- return getLastCommitDiff();
2574
- }
2575
2672
  async function buildChangedFileContext(file, changedLines) {
2576
2673
  if (file.status === "deleted") {
2577
2674
  return {
@@ -2581,15 +2678,13 @@ async function buildChangedFileContext(file, changedLines) {
2581
2678
  symbols: [],
2582
2679
  changedLines,
2583
2680
  astSymbols: [],
2584
- truncated: false,
2585
- shouldRenderContent: false,
2586
- skippedReason: "deleted file"
2681
+ content: { status: "skipped", reason: "deleted file" }
2587
2682
  },
2588
2683
  diagnostics: []
2589
2684
  };
2590
2685
  }
2591
2686
  const contentResult = await readTextFile(file.path, MAX_FILE_CHARS);
2592
- if (!contentResult.content) {
2687
+ if (contentResult.status === "skipped") {
2593
2688
  return {
2594
2689
  fileContext: {
2595
2690
  file,
@@ -2597,9 +2692,7 @@ async function buildChangedFileContext(file, changedLines) {
2597
2692
  symbols: [],
2598
2693
  changedLines,
2599
2694
  astSymbols: [],
2600
- truncated: false,
2601
- shouldRenderContent: false,
2602
- skippedReason: contentResult.reason
2695
+ content: { status: "skipped", reason: contentResult.reason }
2603
2696
  },
2604
2697
  diagnostics: []
2605
2698
  };
@@ -2615,9 +2708,12 @@ async function buildChangedFileContext(file, changedLines) {
2615
2708
  ),
2616
2709
  changedLines,
2617
2710
  astSymbols: astResult.symbols,
2618
- content: contentResult.content,
2619
- truncated: contentResult.truncated,
2620
- shouldRenderContent: shouldRenderFullFileContent(file, contentResult.content)
2711
+ content: {
2712
+ status: "loaded",
2713
+ text: contentResult.content,
2714
+ truncated: contentResult.truncated,
2715
+ render: astResult.symbols.length > 0 ? "ast-symbols" : shouldRenderFullFileContent(file, contentResult.content) ? "full" : "diff-only"
2716
+ }
2621
2717
  },
2622
2718
  diagnostics: astResult.diagnostics ?? []
2623
2719
  };
@@ -2631,7 +2727,7 @@ async function buildRelatedFileContexts(files) {
2631
2727
  if (seen.has(candidate) || !existsSync4(candidate)) continue;
2632
2728
  seen.add(candidate);
2633
2729
  const result = await readTextFile(candidate, MAX_RELATED_FILE_CHARS);
2634
- if (!result.content) continue;
2730
+ if (result.status === "skipped") continue;
2635
2731
  related.push({
2636
2732
  path: candidate,
2637
2733
  reason: `Likely test file for ${file.path}`,
@@ -2654,23 +2750,23 @@ async function readTextFile(path, maxChars) {
2654
2750
  try {
2655
2751
  const info = await stat2(path);
2656
2752
  if (!info.isFile()) {
2657
- return { truncated: false, reason: "not a regular file" };
2753
+ return { status: "skipped", reason: "not a regular file" };
2658
2754
  }
2659
2755
  if (info.size > MAX_CONTEXT_FILE_BYTES) {
2660
2756
  return {
2661
- truncated: false,
2757
+ status: "skipped",
2662
2758
  reason: `file too large for context (${formatBytes2(info.size)} > ${formatBytes2(MAX_CONTEXT_FILE_BYTES)})`
2663
2759
  };
2664
2760
  }
2665
2761
  const raw = await readFile6(path, "utf-8");
2666
2762
  if (raw.includes("\0")) {
2667
- return { truncated: false, reason: "binary file" };
2763
+ return { status: "skipped", reason: "binary file" };
2668
2764
  }
2669
2765
  const result = truncateText3(raw, maxChars);
2670
- return { content: result.text, truncated: result.truncated };
2766
+ return { status: "loaded", content: result.text, truncated: result.truncated };
2671
2767
  } catch (err) {
2672
2768
  return {
2673
- truncated: false,
2769
+ status: "skipped",
2674
2770
  reason: err instanceof Error ? err.message : String(err)
2675
2771
  };
2676
2772
  }
@@ -3033,7 +3129,7 @@ import { execa as execa5 } from "execa";
3033
3129
  // package.json
3034
3130
  var package_default = {
3035
3131
  name: "diffowl",
3036
- version: "0.1.0",
3132
+ version: "0.2.0",
3037
3133
  description: "Local AI code review agent powered by OpenCode",
3038
3134
  keywords: [
3039
3135
  "ai",
@@ -3052,7 +3148,7 @@ var package_default = {
3052
3148
  url: "git+https://github.com/gutierrezje/diffowl.git"
3053
3149
  },
3054
3150
  bin: {
3055
- diffowl: "./dist/cli.js"
3151
+ diffowl: "dist/cli.js"
3056
3152
  },
3057
3153
  files: [
3058
3154
  "dist"
@@ -3147,11 +3243,11 @@ program.command("review", { isDefault: true }).description("Review the last comm
3147
3243
  console.error(chalk2.red("Cannot use --staged and --commit together"));
3148
3244
  process.exit(1);
3149
3245
  }
3150
- const mode = options.staged ? "staged" : options.commit ? "commit" : "last-commit";
3246
+ const target = options.staged ? { kind: "staged" } : options.commit ? { kind: "commit", ref: String(options.commit) } : { kind: "last-commit" };
3151
3247
  const depth = resolveReviewDepth(options.depth, config);
3152
3248
  config.reasoning.effort = resolveReasoningEffort(options.reasoning, config);
3153
3249
  const verbose = Boolean(config.verbose || options.verbose);
3154
- if (mode !== "staged") {
3250
+ if (target.kind !== "staged") {
3155
3251
  const hasCommitsStart = performance.now();
3156
3252
  const commitsExist = await hasCommits();
3157
3253
  recordCliTiming(timings, "git-commit-check", "Git commit check", hasCommitsStart);
@@ -3166,17 +3262,6 @@ program.command("review", { isDefault: true }).description("Review the last comm
3166
3262
  console.log(chalk2.yellow(`\u26A0 ${formatHookFailure(hookFailure)}`));
3167
3263
  console.log();
3168
3264
  }
3169
- const diff = mode === "staged" ? await getStagedDiff() : mode === "commit" ? await getCommitDiff(String(options.commit)) : await getLastCommitDiff();
3170
- if (config.skip_doc_only && isDocOnlyDiff(diff)) {
3171
- console.warn(chalk2.yellow("Documentation-only changes detected. Skipping review."));
3172
- const skipContent = buildDocOnlySkipMarkdown(diff);
3173
- const reportPath = await writeMarkdownReport(skipContent);
3174
- console.log(chalk2.dim(`Report saved: ${reportPath}`));
3175
- if (options.hook) {
3176
- await writeHookStatus(0, hookCommit);
3177
- }
3178
- process.exit(0);
3179
- }
3180
3265
  const spinner = ora({
3181
3266
  text: "Building local review context...",
3182
3267
  color: "cyan",
@@ -3199,14 +3284,26 @@ program.command("review", { isDefault: true }).description("Review the last comm
3199
3284
  process.exit(146);
3200
3285
  });
3201
3286
  try {
3202
- const contextStart = performance.now();
3203
- const reviewContext = await buildReviewContext(mode, config, depth, diff);
3204
- recordCliTiming(timings, "context-build", "Local review context build", contextStart);
3205
- if (mode === "staged" && reviewContext.diff.files.length === 0) {
3287
+ const diff = await loadReviewDiff(target);
3288
+ if (target.kind === "staged" && diff.files.length === 0) {
3206
3289
  spinner.stop();
3207
3290
  console.log(chalk2.yellow("No staged changes to review"));
3208
3291
  process.exit(0);
3209
3292
  }
3293
+ if (config.skip_doc_only && isDocOnlyDiff(diff)) {
3294
+ spinner.stop();
3295
+ console.warn(chalk2.yellow("Documentation-only changes detected. Skipping review."));
3296
+ const skipContent = buildDocOnlySkipMarkdown(diff);
3297
+ const reportPath2 = await writeMarkdownReport(skipContent);
3298
+ console.log(chalk2.dim(`Report saved: ${reportPath2}`));
3299
+ if (options.hook) {
3300
+ await writeHookStatus(0, hookCommit);
3301
+ }
3302
+ process.exit(0);
3303
+ }
3304
+ const contextStart = performance.now();
3305
+ const reviewContext = await buildReviewContextFromDiff({ target, diff }, config, depth);
3306
+ recordCliTiming(timings, "context-build", "Local review context build", contextStart);
3210
3307
  const contextRenderStart = performance.now();
3211
3308
  const localContext = renderReviewContext(reviewContext, { depth });
3212
3309
  recordCliTiming(timings, "context-render", "Local review context render", contextRenderStart);
@@ -3225,7 +3322,7 @@ program.command("review", { isDefault: true }).description("Review the last comm
3225
3322
  spinner.text = "Reviewing changes...";
3226
3323
  const reviewStart = performance.now();
3227
3324
  const reviewResult = await runReview({
3228
- mode,
3325
+ target,
3229
3326
  config,
3230
3327
  localContext,
3231
3328
  depth,