diffprism 0.27.0 → 0.30.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.
@@ -116,6 +116,46 @@ function listCommits(options) {
116
116
  return [];
117
117
  }
118
118
  }
119
+ function detectWorktree(options) {
120
+ const cwd = options?.cwd ?? process.cwd();
121
+ try {
122
+ const gitDir = execSync("git rev-parse --git-dir", {
123
+ cwd,
124
+ encoding: "utf-8",
125
+ stdio: ["pipe", "pipe", "pipe"]
126
+ }).trim();
127
+ const gitCommonDir = execSync("git rev-parse --git-common-dir", {
128
+ cwd,
129
+ encoding: "utf-8",
130
+ stdio: ["pipe", "pipe", "pipe"]
131
+ }).trim();
132
+ const resolvedGitDir = path.resolve(cwd, gitDir);
133
+ const resolvedCommonDir = path.resolve(cwd, gitCommonDir);
134
+ const isWorktree = resolvedGitDir !== resolvedCommonDir;
135
+ if (!isWorktree) {
136
+ return { isWorktree: false };
137
+ }
138
+ const worktreePath = execSync("git rev-parse --show-toplevel", {
139
+ cwd,
140
+ encoding: "utf-8",
141
+ stdio: ["pipe", "pipe", "pipe"]
142
+ }).trim();
143
+ const mainWorktreePath = path.dirname(resolvedCommonDir);
144
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
145
+ cwd,
146
+ encoding: "utf-8",
147
+ stdio: ["pipe", "pipe", "pipe"]
148
+ }).trim();
149
+ return {
150
+ isWorktree: true,
151
+ worktreePath,
152
+ mainWorktreePath,
153
+ branch: branch === "HEAD" ? void 0 : branch
154
+ };
155
+ } catch {
156
+ return { isWorktree: false };
157
+ }
158
+ }
119
159
  function getUntrackedDiffs(cwd) {
120
160
  let untrackedList;
121
161
  try {
@@ -398,17 +438,138 @@ function getWorkingCopyDiff(options) {
398
438
  }
399
439
 
400
440
  // packages/analysis/src/deterministic.ts
441
+ var MECHANICAL_CONFIG_PATTERNS = [
442
+ /\.config\./,
443
+ /\.eslintrc/,
444
+ /\.prettierrc/,
445
+ /tsconfig.*\.json$/,
446
+ /\.gitignore$/,
447
+ /\.lock$/
448
+ ];
449
+ var API_SURFACE_PATTERNS = [
450
+ /\/api\//,
451
+ /\/routes\//
452
+ ];
453
+ function isFormattingOnly(file) {
454
+ if (file.hunks.length === 0) return false;
455
+ for (const hunk of file.hunks) {
456
+ const adds = hunk.changes.filter((c) => c.type === "add").map((c) => c.content.replace(/\s/g, ""));
457
+ const deletes = hunk.changes.filter((c) => c.type === "delete").map((c) => c.content.replace(/\s/g, ""));
458
+ if (adds.length === 0 || deletes.length === 0) return false;
459
+ const deleteBag = [...deletes];
460
+ for (const add of adds) {
461
+ const idx = deleteBag.indexOf(add);
462
+ if (idx === -1) return false;
463
+ deleteBag.splice(idx, 1);
464
+ }
465
+ if (deleteBag.length > 0) return false;
466
+ }
467
+ return true;
468
+ }
469
+ function isImportOnly(file) {
470
+ if (file.hunks.length === 0) return false;
471
+ const importPattern = /^\s*(import\s|export\s.*from\s|const\s+\w+\s*=\s*require\(|require\()/;
472
+ for (const hunk of file.hunks) {
473
+ for (const change of hunk.changes) {
474
+ if (change.type === "context") continue;
475
+ const trimmed = change.content.trim();
476
+ if (trimmed === "") continue;
477
+ if (!importPattern.test(trimmed)) return false;
478
+ }
479
+ }
480
+ return true;
481
+ }
482
+ function isMechanicalConfigFile(path7) {
483
+ return MECHANICAL_CONFIG_PATTERNS.some((re) => re.test(path7));
484
+ }
485
+ function isApiSurface(file) {
486
+ if (API_SURFACE_PATTERNS.some((re) => re.test(file.path))) return true;
487
+ const basename = file.path.slice(file.path.lastIndexOf("/") + 1);
488
+ if ((basename === "index.ts" || basename === "index.js") && file.additions >= 10) {
489
+ return true;
490
+ }
491
+ return false;
492
+ }
401
493
  function categorizeFiles(files) {
402
- const notable = files.map((f) => ({
403
- file: f.path,
404
- description: `${f.status} (${f.language || "unknown"}) +${f.additions} -${f.deletions}`,
405
- reason: "Uncategorized in M0 \u2014 placed in notable by default"
406
- }));
407
- return {
408
- critical: [],
409
- notable,
410
- mechanical: []
411
- };
494
+ const critical = [];
495
+ const notable = [];
496
+ const mechanical = [];
497
+ const securityFlags = detectSecurityPatterns(files);
498
+ const complexityScores = computeComplexityScores(files);
499
+ const securityByFile = /* @__PURE__ */ new Map();
500
+ for (const flag of securityFlags) {
501
+ const existing = securityByFile.get(flag.file) || [];
502
+ existing.push(flag);
503
+ securityByFile.set(flag.file, existing);
504
+ }
505
+ const complexityByFile = /* @__PURE__ */ new Map();
506
+ for (const score of complexityScores) {
507
+ complexityByFile.set(score.path, score);
508
+ }
509
+ for (const file of files) {
510
+ const description = `${file.status} (${file.language || "unknown"}) +${file.additions} -${file.deletions}`;
511
+ const fileSecurityFlags = securityByFile.get(file.path);
512
+ const fileComplexity = complexityByFile.get(file.path);
513
+ const criticalReasons = [];
514
+ if (fileSecurityFlags && fileSecurityFlags.length > 0) {
515
+ const patterns = fileSecurityFlags.map((f) => f.pattern);
516
+ const unique = [...new Set(patterns)];
517
+ criticalReasons.push(`security patterns detected: ${unique.join(", ")}`);
518
+ }
519
+ if (fileComplexity && fileComplexity.score >= 8) {
520
+ criticalReasons.push(`high complexity score (${fileComplexity.score}/10)`);
521
+ }
522
+ if (isApiSurface(file)) {
523
+ criticalReasons.push("modifies public API surface");
524
+ }
525
+ if (criticalReasons.length > 0) {
526
+ critical.push({
527
+ file: file.path,
528
+ description,
529
+ reason: `Critical: ${criticalReasons.join("; ")}`
530
+ });
531
+ continue;
532
+ }
533
+ const isPureRename = file.status === "renamed" && file.additions === 0 && file.deletions === 0;
534
+ if (isPureRename) {
535
+ mechanical.push({
536
+ file: file.path,
537
+ description,
538
+ reason: "Mechanical: pure rename with no content changes"
539
+ });
540
+ continue;
541
+ }
542
+ if (isFormattingOnly(file)) {
543
+ mechanical.push({
544
+ file: file.path,
545
+ description,
546
+ reason: "Mechanical: formatting/whitespace-only changes"
547
+ });
548
+ continue;
549
+ }
550
+ if (isMechanicalConfigFile(file.path)) {
551
+ mechanical.push({
552
+ file: file.path,
553
+ description,
554
+ reason: "Mechanical: config file change"
555
+ });
556
+ continue;
557
+ }
558
+ if (file.hunks.length > 0 && isImportOnly(file)) {
559
+ mechanical.push({
560
+ file: file.path,
561
+ description,
562
+ reason: "Mechanical: import/require-only changes"
563
+ });
564
+ continue;
565
+ }
566
+ notable.push({
567
+ file: file.path,
568
+ description,
569
+ reason: "Notable: requires review"
570
+ });
571
+ }
572
+ return { critical, notable, mechanical };
412
573
  }
413
574
  function computeFileStats(files) {
414
575
  return files.map((f) => ({
@@ -584,15 +745,15 @@ var CONFIG_PATTERNS = [
584
745
  /vite\.config/,
585
746
  /vitest\.config/
586
747
  ];
587
- function isTestFile(path6) {
588
- return TEST_PATTERNS.some((re) => re.test(path6));
748
+ function isTestFile(path7) {
749
+ return TEST_PATTERNS.some((re) => re.test(path7));
589
750
  }
590
- function isNonCodeFile(path6) {
591
- const ext = path6.slice(path6.lastIndexOf("."));
751
+ function isNonCodeFile(path7) {
752
+ const ext = path7.slice(path7.lastIndexOf("."));
592
753
  return NON_CODE_EXTENSIONS.has(ext);
593
754
  }
594
- function isConfigFile(path6) {
595
- return CONFIG_PATTERNS.some((re) => re.test(path6));
755
+ function isConfigFile(path7) {
756
+ return CONFIG_PATTERNS.some((re) => re.test(path7));
596
757
  }
597
758
  function detectTestCoverageGaps(files) {
598
759
  const filePaths = new Set(files.map((f) => f.path));
@@ -764,20 +925,222 @@ function analyze(diffSet) {
764
925
  };
765
926
  }
766
927
 
928
+ // packages/core/src/watch-file.ts
929
+ import fs from "fs";
930
+ import path3 from "path";
931
+ import { execSync as execSync2 } from "child_process";
932
+ function findGitRoot(cwd) {
933
+ const root = execSync2("git rev-parse --show-toplevel", {
934
+ cwd: cwd ?? process.cwd(),
935
+ encoding: "utf-8"
936
+ }).trim();
937
+ return root;
938
+ }
939
+ function watchFilePath(cwd) {
940
+ const gitRoot = findGitRoot(cwd);
941
+ return path3.join(gitRoot, ".diffprism", "watch.json");
942
+ }
943
+ function isPidAlive(pid) {
944
+ try {
945
+ process.kill(pid, 0);
946
+ return true;
947
+ } catch {
948
+ return false;
949
+ }
950
+ }
951
+ function writeWatchFile(cwd, info) {
952
+ const filePath = watchFilePath(cwd);
953
+ const dir = path3.dirname(filePath);
954
+ if (!fs.existsSync(dir)) {
955
+ fs.mkdirSync(dir, { recursive: true });
956
+ }
957
+ fs.writeFileSync(filePath, JSON.stringify(info, null, 2) + "\n");
958
+ }
959
+ function readWatchFile(cwd) {
960
+ const filePath = watchFilePath(cwd);
961
+ if (!fs.existsSync(filePath)) {
962
+ return null;
963
+ }
964
+ try {
965
+ const raw = fs.readFileSync(filePath, "utf-8");
966
+ const info = JSON.parse(raw);
967
+ if (!isPidAlive(info.pid)) {
968
+ fs.unlinkSync(filePath);
969
+ return null;
970
+ }
971
+ return info;
972
+ } catch {
973
+ return null;
974
+ }
975
+ }
976
+ function removeWatchFile(cwd) {
977
+ try {
978
+ const filePath = watchFilePath(cwd);
979
+ if (fs.existsSync(filePath)) {
980
+ fs.unlinkSync(filePath);
981
+ }
982
+ } catch {
983
+ }
984
+ }
985
+ function reviewResultPath(cwd) {
986
+ const gitRoot = findGitRoot(cwd);
987
+ return path3.join(gitRoot, ".diffprism", "last-review.json");
988
+ }
989
+ function writeReviewResult(cwd, result) {
990
+ const filePath = reviewResultPath(cwd);
991
+ const dir = path3.dirname(filePath);
992
+ if (!fs.existsSync(dir)) {
993
+ fs.mkdirSync(dir, { recursive: true });
994
+ }
995
+ const data = {
996
+ result,
997
+ timestamp: Date.now(),
998
+ consumed: false
999
+ };
1000
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
1001
+ }
1002
+ function readReviewResult(cwd) {
1003
+ try {
1004
+ const filePath = reviewResultPath(cwd);
1005
+ if (!fs.existsSync(filePath)) {
1006
+ return null;
1007
+ }
1008
+ const raw = fs.readFileSync(filePath, "utf-8");
1009
+ const data = JSON.parse(raw);
1010
+ if (data.consumed) {
1011
+ return null;
1012
+ }
1013
+ return data;
1014
+ } catch {
1015
+ return null;
1016
+ }
1017
+ }
1018
+ function consumeReviewResult(cwd) {
1019
+ try {
1020
+ const filePath = reviewResultPath(cwd);
1021
+ if (!fs.existsSync(filePath)) {
1022
+ return;
1023
+ }
1024
+ const raw = fs.readFileSync(filePath, "utf-8");
1025
+ const data = JSON.parse(raw);
1026
+ data.consumed = true;
1027
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
1028
+ } catch {
1029
+ }
1030
+ }
1031
+
767
1032
  // packages/core/src/pipeline.ts
768
1033
  import getPort from "get-port";
769
1034
  import open from "open";
770
1035
 
771
- // packages/core/src/ws-bridge.ts
1036
+ // packages/core/src/watch-bridge.ts
1037
+ import http from "http";
772
1038
  import { WebSocketServer, WebSocket } from "ws";
773
- function createWsBridge(port) {
774
- const wss2 = new WebSocketServer({ port });
1039
+ function createWatchBridge(port, callbacks) {
775
1040
  let client = null;
776
- let resultResolve = null;
777
- let resultReject = null;
778
- let pendingInit = null;
779
1041
  let initPayload = null;
1042
+ let pendingInit = null;
780
1043
  let closeTimer = null;
1044
+ let submitCallback = null;
1045
+ let resultReject = null;
1046
+ const httpServer = http.createServer(async (req, res) => {
1047
+ res.setHeader("Access-Control-Allow-Origin", "*");
1048
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1049
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
1050
+ if (req.method === "OPTIONS") {
1051
+ res.writeHead(204);
1052
+ res.end();
1053
+ return;
1054
+ }
1055
+ if (req.method === "GET" && req.url === "/api/status") {
1056
+ res.writeHead(200, { "Content-Type": "application/json" });
1057
+ res.end(JSON.stringify({ running: true, pid: process.pid }));
1058
+ return;
1059
+ }
1060
+ if (req.method === "POST" && req.url === "/api/context") {
1061
+ let body = "";
1062
+ req.on("data", (chunk) => {
1063
+ body += chunk.toString();
1064
+ });
1065
+ req.on("end", () => {
1066
+ try {
1067
+ const payload = JSON.parse(body);
1068
+ callbacks.onContextUpdate(payload);
1069
+ sendToClient({ type: "context:update", payload });
1070
+ res.writeHead(200, { "Content-Type": "application/json" });
1071
+ res.end(JSON.stringify({ ok: true }));
1072
+ } catch {
1073
+ res.writeHead(400, { "Content-Type": "application/json" });
1074
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
1075
+ }
1076
+ });
1077
+ return;
1078
+ }
1079
+ if (req.method === "POST" && req.url === "/api/refresh") {
1080
+ callbacks.onRefreshRequest();
1081
+ res.writeHead(200, { "Content-Type": "application/json" });
1082
+ res.end(JSON.stringify({ ok: true }));
1083
+ return;
1084
+ }
1085
+ const pathname = (req.url ?? "").split("?")[0];
1086
+ if (req.method === "GET" && (pathname === "/api/refs" || /^\/api\/reviews\/[^/]+\/refs$/.test(pathname))) {
1087
+ if (callbacks.onRefsRequest) {
1088
+ const refsPayload = await callbacks.onRefsRequest();
1089
+ if (refsPayload) {
1090
+ res.writeHead(200, { "Content-Type": "application/json" });
1091
+ res.end(JSON.stringify(refsPayload));
1092
+ } else {
1093
+ res.writeHead(500, { "Content-Type": "application/json" });
1094
+ res.end(JSON.stringify({ error: "Failed to list git refs" }));
1095
+ }
1096
+ } else {
1097
+ res.writeHead(404);
1098
+ res.end("Not found");
1099
+ }
1100
+ return;
1101
+ }
1102
+ if (req.method === "POST" && (pathname === "/api/compare" || /^\/api\/reviews\/[^/]+\/compare$/.test(pathname))) {
1103
+ if (callbacks.onCompareRequest) {
1104
+ let body = "";
1105
+ req.on("data", (chunk) => {
1106
+ body += chunk.toString();
1107
+ });
1108
+ req.on("end", async () => {
1109
+ try {
1110
+ const { ref } = JSON.parse(body);
1111
+ if (!ref) {
1112
+ res.writeHead(400, { "Content-Type": "application/json" });
1113
+ res.end(JSON.stringify({ error: "Missing ref" }));
1114
+ return;
1115
+ }
1116
+ const success = await callbacks.onCompareRequest(ref);
1117
+ if (success) {
1118
+ res.writeHead(200, { "Content-Type": "application/json" });
1119
+ res.end(JSON.stringify({ ok: true }));
1120
+ } else {
1121
+ res.writeHead(400, { "Content-Type": "application/json" });
1122
+ res.end(JSON.stringify({ error: "Failed to compute diff" }));
1123
+ }
1124
+ } catch {
1125
+ res.writeHead(400, { "Content-Type": "application/json" });
1126
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
1127
+ }
1128
+ });
1129
+ } else {
1130
+ res.writeHead(404);
1131
+ res.end("Not found");
1132
+ }
1133
+ return;
1134
+ }
1135
+ res.writeHead(404);
1136
+ res.end("Not found");
1137
+ });
1138
+ const wss2 = new WebSocketServer({ server: httpServer });
1139
+ function sendToClient(msg) {
1140
+ if (client && client.readyState === WebSocket.OPEN) {
1141
+ client.send(JSON.stringify(msg));
1142
+ }
1143
+ }
781
1144
  wss2.on("connection", (ws) => {
782
1145
  if (closeTimer) {
783
1146
  clearTimeout(closeTimer);
@@ -786,20 +1149,16 @@ function createWsBridge(port) {
786
1149
  client = ws;
787
1150
  const payload = pendingInit ?? initPayload;
788
1151
  if (payload) {
789
- const msg = {
790
- type: "review:init",
791
- payload
792
- };
793
- ws.send(JSON.stringify(msg));
1152
+ sendToClient({ type: "review:init", payload });
794
1153
  pendingInit = null;
795
1154
  }
796
1155
  ws.on("message", (data) => {
797
1156
  try {
798
1157
  const msg = JSON.parse(data.toString());
799
- if (msg.type === "review:submit" && resultResolve) {
800
- resultResolve(msg.payload);
801
- resultResolve = null;
802
- resultReject = null;
1158
+ if (msg.type === "review:submit" && submitCallback) {
1159
+ submitCallback(msg.payload);
1160
+ } else if (msg.type === "diff:change_ref" && callbacks.onDiffRefChange) {
1161
+ callbacks.onDiffRefChange(msg.payload.diffRef);
803
1162
  }
804
1163
  } catch {
805
1164
  }
@@ -808,56 +1167,184 @@ function createWsBridge(port) {
808
1167
  client = null;
809
1168
  if (resultReject) {
810
1169
  closeTimer = setTimeout(() => {
1170
+ closeTimer = null;
811
1171
  if (resultReject) {
812
1172
  resultReject(new Error("Browser closed before review was submitted"));
813
- resultResolve = null;
814
1173
  resultReject = null;
1174
+ submitCallback = null;
815
1175
  }
816
1176
  }, 2e3);
1177
+ } else {
1178
+ closeTimer = setTimeout(() => {
1179
+ closeTimer = null;
1180
+ }, 2e3);
817
1181
  }
818
1182
  });
819
1183
  });
820
- return {
821
- port,
822
- sendInit(payload) {
823
- initPayload = payload;
824
- if (client && client.readyState === WebSocket.OPEN) {
825
- const msg = {
826
- type: "review:init",
827
- payload
828
- };
829
- client.send(JSON.stringify(msg));
830
- } else {
831
- pendingInit = payload;
832
- }
833
- },
834
- waitForResult() {
835
- return new Promise((resolve, reject) => {
836
- resultResolve = resolve;
837
- resultReject = reject;
838
- });
839
- },
840
- close() {
841
- for (const ws of wss2.clients) {
842
- ws.close();
843
- }
844
- wss2.close();
845
- }
846
- };
847
- }
848
-
849
- // packages/core/src/review-manager.ts
850
- var sessions = /* @__PURE__ */ new Map();
851
- var idCounter = 0;
852
- function createSession(options) {
853
- const id = `review-${Date.now()}-${++idCounter}`;
854
- const session = {
855
- id,
856
- options,
857
- status: "pending",
858
- createdAt: Date.now()
859
- };
860
- sessions.set(id, session);
1184
+ return new Promise((resolve, reject) => {
1185
+ httpServer.on("error", reject);
1186
+ httpServer.listen(port, () => {
1187
+ resolve({
1188
+ port,
1189
+ sendInit(payload) {
1190
+ initPayload = payload;
1191
+ if (client && client.readyState === WebSocket.OPEN) {
1192
+ sendToClient({ type: "review:init", payload });
1193
+ } else {
1194
+ pendingInit = payload;
1195
+ }
1196
+ },
1197
+ storeInitPayload(payload) {
1198
+ initPayload = payload;
1199
+ },
1200
+ sendDiffUpdate(payload) {
1201
+ sendToClient({ type: "diff:update", payload });
1202
+ },
1203
+ sendContextUpdate(payload) {
1204
+ sendToClient({ type: "context:update", payload });
1205
+ },
1206
+ sendDiffError(payload) {
1207
+ sendToClient({ type: "diff:error", payload });
1208
+ },
1209
+ onSubmit(callback) {
1210
+ submitCallback = callback;
1211
+ },
1212
+ waitForResult() {
1213
+ return new Promise((resolve2, reject2) => {
1214
+ submitCallback = resolve2;
1215
+ resultReject = reject2;
1216
+ });
1217
+ },
1218
+ triggerRefresh() {
1219
+ callbacks.onRefreshRequest();
1220
+ },
1221
+ async close() {
1222
+ if (closeTimer) {
1223
+ clearTimeout(closeTimer);
1224
+ }
1225
+ for (const ws of wss2.clients) {
1226
+ ws.close();
1227
+ }
1228
+ wss2.close();
1229
+ await new Promise((resolve2) => {
1230
+ httpServer.close(() => resolve2());
1231
+ });
1232
+ }
1233
+ });
1234
+ });
1235
+ });
1236
+ }
1237
+
1238
+ // packages/core/src/diff-utils.ts
1239
+ import { createHash } from "crypto";
1240
+ function hashDiff(rawDiff) {
1241
+ return createHash("sha256").update(rawDiff).digest("hex");
1242
+ }
1243
+ function fileKey(file) {
1244
+ return file.stage ? `${file.stage}:${file.path}` : file.path;
1245
+ }
1246
+ function detectChangedFiles(oldDiffSet, newDiffSet) {
1247
+ if (!oldDiffSet) {
1248
+ return newDiffSet.files.map(fileKey);
1249
+ }
1250
+ const oldFiles = new Map(
1251
+ oldDiffSet.files.map((f) => [fileKey(f), f])
1252
+ );
1253
+ const changed = [];
1254
+ for (const newFile of newDiffSet.files) {
1255
+ const key = fileKey(newFile);
1256
+ const oldFile = oldFiles.get(key);
1257
+ if (!oldFile) {
1258
+ changed.push(key);
1259
+ } else if (oldFile.additions !== newFile.additions || oldFile.deletions !== newFile.deletions) {
1260
+ changed.push(key);
1261
+ }
1262
+ }
1263
+ for (const oldFile of oldDiffSet.files) {
1264
+ if (!newDiffSet.files.some((f) => fileKey(f) === fileKey(oldFile))) {
1265
+ changed.push(fileKey(oldFile));
1266
+ }
1267
+ }
1268
+ return changed;
1269
+ }
1270
+
1271
+ // packages/core/src/diff-poller.ts
1272
+ function createDiffPoller(options) {
1273
+ let { diffRef } = options;
1274
+ const { cwd, pollInterval, onDiffChanged, onError, silent } = options;
1275
+ let lastDiffHash = null;
1276
+ let lastDiffSet = null;
1277
+ let refreshRequested = false;
1278
+ let interval = null;
1279
+ let running = false;
1280
+ function poll() {
1281
+ if (!running) return;
1282
+ try {
1283
+ const { diffSet: newDiffSet, rawDiff: newRawDiff } = getDiff(diffRef, { cwd });
1284
+ const newHash = hashDiff(newRawDiff);
1285
+ if (newHash !== lastDiffHash || refreshRequested) {
1286
+ refreshRequested = false;
1287
+ const newBriefing = analyze(newDiffSet);
1288
+ const changedFiles = detectChangedFiles(lastDiffSet, newDiffSet);
1289
+ lastDiffHash = newHash;
1290
+ lastDiffSet = newDiffSet;
1291
+ const updatePayload = {
1292
+ diffSet: newDiffSet,
1293
+ rawDiff: newRawDiff,
1294
+ briefing: newBriefing,
1295
+ changedFiles,
1296
+ timestamp: Date.now()
1297
+ };
1298
+ onDiffChanged(updatePayload);
1299
+ }
1300
+ } catch (err) {
1301
+ if (onError && err instanceof Error) {
1302
+ onError(err);
1303
+ }
1304
+ }
1305
+ }
1306
+ return {
1307
+ start() {
1308
+ if (running) return;
1309
+ running = true;
1310
+ try {
1311
+ const { diffSet: initialDiffSet, rawDiff: initialRawDiff } = getDiff(diffRef, { cwd });
1312
+ lastDiffHash = hashDiff(initialRawDiff);
1313
+ lastDiffSet = initialDiffSet;
1314
+ } catch {
1315
+ }
1316
+ interval = setInterval(poll, pollInterval);
1317
+ },
1318
+ stop() {
1319
+ running = false;
1320
+ if (interval) {
1321
+ clearInterval(interval);
1322
+ interval = null;
1323
+ }
1324
+ },
1325
+ setDiffRef(newRef) {
1326
+ diffRef = newRef;
1327
+ lastDiffHash = null;
1328
+ lastDiffSet = null;
1329
+ },
1330
+ refresh() {
1331
+ refreshRequested = true;
1332
+ }
1333
+ };
1334
+ }
1335
+
1336
+ // packages/core/src/review-manager.ts
1337
+ var sessions = /* @__PURE__ */ new Map();
1338
+ var idCounter = 0;
1339
+ function createSession(options) {
1340
+ const id = `review-${Date.now()}-${++idCounter}`;
1341
+ const session = {
1342
+ id,
1343
+ options,
1344
+ status: "pending",
1345
+ createdAt: Date.now()
1346
+ };
1347
+ sessions.set(id, session);
861
1348
  return session;
862
1349
  }
863
1350
  function updateSession(id, update) {
@@ -868,9 +1355,9 @@ function updateSession(id, update) {
868
1355
  }
869
1356
 
870
1357
  // packages/core/src/ui-server.ts
871
- import http from "http";
872
- import fs from "fs";
873
- import path3 from "path";
1358
+ import http2 from "http";
1359
+ import fs2 from "fs";
1360
+ import path4 from "path";
874
1361
  import { fileURLToPath } from "url";
875
1362
  var MIME_TYPES = {
876
1363
  ".html": "text/html",
@@ -885,14 +1372,14 @@ var MIME_TYPES = {
885
1372
  };
886
1373
  function resolveUiDist() {
887
1374
  const thisFile = fileURLToPath(import.meta.url);
888
- const thisDir = path3.dirname(thisFile);
889
- const publishedUiDist = path3.resolve(thisDir, "..", "ui-dist");
890
- if (fs.existsSync(path3.join(publishedUiDist, "index.html"))) {
1375
+ const thisDir = path4.dirname(thisFile);
1376
+ const publishedUiDist = path4.resolve(thisDir, "..", "ui-dist");
1377
+ if (fs2.existsSync(path4.join(publishedUiDist, "index.html"))) {
891
1378
  return publishedUiDist;
892
1379
  }
893
- const workspaceRoot = path3.resolve(thisDir, "..", "..", "..");
894
- const devUiDist = path3.join(workspaceRoot, "packages", "ui", "dist");
895
- if (fs.existsSync(path3.join(devUiDist, "index.html"))) {
1380
+ const workspaceRoot = path4.resolve(thisDir, "..", "..", "..");
1381
+ const devUiDist = path4.join(workspaceRoot, "packages", "ui", "dist");
1382
+ if (fs2.existsSync(path4.join(devUiDist, "index.html"))) {
896
1383
  return devUiDist;
897
1384
  }
898
1385
  throw new Error(
@@ -901,10 +1388,10 @@ function resolveUiDist() {
901
1388
  }
902
1389
  function resolveUiRoot() {
903
1390
  const thisFile = fileURLToPath(import.meta.url);
904
- const thisDir = path3.dirname(thisFile);
905
- const workspaceRoot = path3.resolve(thisDir, "..", "..", "..");
906
- const uiRoot = path3.join(workspaceRoot, "packages", "ui");
907
- if (fs.existsSync(path3.join(uiRoot, "index.html"))) {
1391
+ const thisDir = path4.dirname(thisFile);
1392
+ const workspaceRoot = path4.resolve(thisDir, "..", "..", "..");
1393
+ const uiRoot = path4.join(workspaceRoot, "packages", "ui");
1394
+ if (fs2.existsSync(path4.join(uiRoot, "index.html"))) {
908
1395
  return uiRoot;
909
1396
  }
910
1397
  throw new Error(
@@ -922,16 +1409,16 @@ async function startViteDevServer(uiRoot, port, silent) {
922
1409
  return vite;
923
1410
  }
924
1411
  function createStaticServer(distPath, port) {
925
- const server = http.createServer((req, res) => {
1412
+ const server = http2.createServer((req, res) => {
926
1413
  const urlPath = req.url?.split("?")[0] ?? "/";
927
- let filePath = path3.join(distPath, urlPath === "/" ? "index.html" : urlPath);
928
- if (!fs.existsSync(filePath)) {
929
- filePath = path3.join(distPath, "index.html");
1414
+ let filePath = path4.join(distPath, urlPath === "/" ? "index.html" : urlPath);
1415
+ if (!fs2.existsSync(filePath)) {
1416
+ filePath = path4.join(distPath, "index.html");
930
1417
  }
931
- const ext = path3.extname(filePath);
1418
+ const ext = path4.extname(filePath);
932
1419
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
933
1420
  try {
934
- const content = fs.readFileSync(filePath);
1421
+ const content = fs2.readFileSync(filePath);
935
1422
  res.writeHead(200, { "Content-Type": contentType });
936
1423
  res.end(content);
937
1424
  } catch {
@@ -963,392 +1450,228 @@ async function startReview(options) {
963
1450
  const briefing = analyze(diffSet);
964
1451
  const session = createSession(options);
965
1452
  updateSession(session.id, { status: "in_progress" });
966
- const [wsPort, httpPort] = await Promise.all([
1453
+ const worktreeInfo = detectWorktree({ cwd });
1454
+ const metadata = {
1455
+ title,
1456
+ description,
1457
+ reasoning,
1458
+ currentBranch,
1459
+ worktree: worktreeInfo.isWorktree ? {
1460
+ isWorktree: true,
1461
+ worktreePath: worktreeInfo.worktreePath,
1462
+ mainWorktreePath: worktreeInfo.mainWorktreePath
1463
+ } : void 0
1464
+ };
1465
+ let poller = null;
1466
+ const [bridgePort, httpPort] = await Promise.all([
967
1467
  getPort(),
968
1468
  getPort()
969
1469
  ]);
970
- const bridge = createWsBridge(wsPort);
971
- let httpServer = null;
972
- let viteServer = null;
973
- try {
974
- if (dev) {
975
- const uiRoot = resolveUiRoot();
976
- viteServer = await startViteDevServer(uiRoot, httpPort, !!silent);
977
- } else {
978
- const uiDist = resolveUiDist();
979
- httpServer = await createStaticServer(uiDist, httpPort);
980
- }
981
- const url = `http://localhost:${httpPort}?wsPort=${wsPort}&reviewId=${session.id}`;
982
- if (!silent) {
983
- console.log(`
984
- DiffPrism Review: ${title ?? briefing.summary}`);
985
- console.log(`Opening browser at ${url}
986
- `);
987
- }
988
- await open(url);
989
- const initPayload = {
1470
+ function handleDiffRefChange(newRef) {
1471
+ const { diffSet: newDiffSet, rawDiff: newRawDiff } = getDiff(newRef, { cwd });
1472
+ const newBriefing = analyze(newDiffSet);
1473
+ bridge.sendDiffUpdate({
1474
+ diffSet: newDiffSet,
1475
+ rawDiff: newRawDiff,
1476
+ briefing: newBriefing,
1477
+ changedFiles: newDiffSet.files.map((f) => f.path),
1478
+ timestamp: Date.now()
1479
+ });
1480
+ bridge.storeInitPayload({
990
1481
  reviewId: session.id,
991
- diffSet,
992
- rawDiff,
993
- briefing,
994
- metadata: { title, description, reasoning, currentBranch }
995
- };
996
- bridge.sendInit(initPayload);
997
- const result = await bridge.waitForResult();
998
- updateSession(session.id, { status: "completed", result });
999
- return result;
1000
- } finally {
1001
- bridge.close();
1002
- if (viteServer) {
1003
- await viteServer.close();
1004
- }
1005
- if (httpServer) {
1006
- httpServer.close();
1007
- }
1008
- }
1009
- }
1010
-
1011
- // packages/core/src/watch-file.ts
1012
- import fs2 from "fs";
1013
- import path4 from "path";
1014
- import { execSync as execSync2 } from "child_process";
1015
- function findGitRoot(cwd) {
1016
- const root = execSync2("git rev-parse --show-toplevel", {
1017
- cwd: cwd ?? process.cwd(),
1018
- encoding: "utf-8"
1019
- }).trim();
1020
- return root;
1021
- }
1022
- function watchFilePath(cwd) {
1023
- const gitRoot = findGitRoot(cwd);
1024
- return path4.join(gitRoot, ".diffprism", "watch.json");
1025
- }
1026
- function isPidAlive(pid) {
1027
- try {
1028
- process.kill(pid, 0);
1029
- return true;
1030
- } catch {
1031
- return false;
1032
- }
1033
- }
1034
- function writeWatchFile(cwd, info) {
1035
- const filePath = watchFilePath(cwd);
1036
- const dir = path4.dirname(filePath);
1037
- if (!fs2.existsSync(dir)) {
1038
- fs2.mkdirSync(dir, { recursive: true });
1039
- }
1040
- fs2.writeFileSync(filePath, JSON.stringify(info, null, 2) + "\n");
1041
- }
1042
- function readWatchFile(cwd) {
1043
- const filePath = watchFilePath(cwd);
1044
- if (!fs2.existsSync(filePath)) {
1045
- return null;
1046
- }
1047
- try {
1048
- const raw = fs2.readFileSync(filePath, "utf-8");
1049
- const info = JSON.parse(raw);
1050
- if (!isPidAlive(info.pid)) {
1051
- fs2.unlinkSync(filePath);
1052
- return null;
1053
- }
1054
- return info;
1055
- } catch {
1056
- return null;
1057
- }
1058
- }
1059
- function removeWatchFile(cwd) {
1060
- try {
1061
- const filePath = watchFilePath(cwd);
1062
- if (fs2.existsSync(filePath)) {
1063
- fs2.unlinkSync(filePath);
1064
- }
1065
- } catch {
1066
- }
1067
- }
1068
- function reviewResultPath(cwd) {
1069
- const gitRoot = findGitRoot(cwd);
1070
- return path4.join(gitRoot, ".diffprism", "last-review.json");
1071
- }
1072
- function writeReviewResult(cwd, result) {
1073
- const filePath = reviewResultPath(cwd);
1074
- const dir = path4.dirname(filePath);
1075
- if (!fs2.existsSync(dir)) {
1076
- fs2.mkdirSync(dir, { recursive: true });
1077
- }
1078
- const data = {
1079
- result,
1080
- timestamp: Date.now(),
1081
- consumed: false
1082
- };
1083
- fs2.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
1084
- }
1085
- function readReviewResult(cwd) {
1086
- try {
1087
- const filePath = reviewResultPath(cwd);
1088
- if (!fs2.existsSync(filePath)) {
1089
- return null;
1090
- }
1091
- const raw = fs2.readFileSync(filePath, "utf-8");
1092
- const data = JSON.parse(raw);
1093
- if (data.consumed) {
1094
- return null;
1095
- }
1096
- return data;
1097
- } catch {
1098
- return null;
1099
- }
1100
- }
1101
- function consumeReviewResult(cwd) {
1102
- try {
1103
- const filePath = reviewResultPath(cwd);
1104
- if (!fs2.existsSync(filePath)) {
1105
- return;
1106
- }
1107
- const raw = fs2.readFileSync(filePath, "utf-8");
1108
- const data = JSON.parse(raw);
1109
- data.consumed = true;
1110
- fs2.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
1111
- } catch {
1112
- }
1113
- }
1114
-
1115
- // packages/core/src/server-file.ts
1116
- import fs3 from "fs";
1117
- import path5 from "path";
1118
- import os from "os";
1119
- function serverDir() {
1120
- return path5.join(os.homedir(), ".diffprism");
1121
- }
1122
- function serverFilePath() {
1123
- return path5.join(serverDir(), "server.json");
1124
- }
1125
- function isPidAlive2(pid) {
1126
- try {
1127
- process.kill(pid, 0);
1128
- return true;
1129
- } catch {
1130
- return false;
1131
- }
1132
- }
1133
- function writeServerFile(info) {
1134
- const dir = serverDir();
1135
- if (!fs3.existsSync(dir)) {
1136
- fs3.mkdirSync(dir, { recursive: true });
1137
- }
1138
- fs3.writeFileSync(serverFilePath(), JSON.stringify(info, null, 2) + "\n");
1139
- }
1140
- function readServerFile() {
1141
- const filePath = serverFilePath();
1142
- if (!fs3.existsSync(filePath)) {
1143
- return null;
1144
- }
1145
- try {
1146
- const raw = fs3.readFileSync(filePath, "utf-8");
1147
- const info = JSON.parse(raw);
1148
- if (!isPidAlive2(info.pid)) {
1149
- fs3.unlinkSync(filePath);
1150
- return null;
1151
- }
1152
- return info;
1153
- } catch {
1154
- return null;
1155
- }
1156
- }
1157
- function removeServerFile() {
1158
- try {
1159
- const filePath = serverFilePath();
1160
- if (fs3.existsSync(filePath)) {
1161
- fs3.unlinkSync(filePath);
1162
- }
1163
- } catch {
1164
- }
1165
- }
1166
- async function isServerAlive() {
1167
- const info = readServerFile();
1168
- if (!info) {
1169
- return null;
1170
- }
1171
- try {
1172
- const response = await fetch(`http://localhost:${info.httpPort}/api/status`, {
1173
- signal: AbortSignal.timeout(2e3)
1482
+ diffSet: newDiffSet,
1483
+ rawDiff: newRawDiff,
1484
+ briefing: newBriefing,
1485
+ metadata,
1486
+ watchMode: true
1174
1487
  });
1175
- if (response.ok) {
1176
- return info;
1177
- }
1178
- return null;
1179
- } catch {
1180
- removeServerFile();
1181
- return null;
1182
- }
1183
- }
1184
-
1185
- // packages/core/src/watch.ts
1186
- import getPort2 from "get-port";
1187
- import open2 from "open";
1188
-
1189
- // packages/core/src/watch-bridge.ts
1190
- import http2 from "http";
1191
- import { WebSocketServer as WebSocketServer2, WebSocket as WebSocket2 } from "ws";
1192
- function createWatchBridge(port, callbacks) {
1193
- let client = null;
1194
- let initPayload = null;
1195
- let pendingInit = null;
1196
- let closeTimer = null;
1197
- let submitCallback = null;
1198
- const httpServer = http2.createServer((req, res) => {
1199
- res.setHeader("Access-Control-Allow-Origin", "*");
1200
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1201
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
1202
- if (req.method === "OPTIONS") {
1203
- res.writeHead(204);
1204
- res.end();
1205
- return;
1206
- }
1207
- if (req.method === "GET" && req.url === "/api/status") {
1208
- res.writeHead(200, { "Content-Type": "application/json" });
1209
- res.end(JSON.stringify({ running: true, pid: process.pid }));
1210
- return;
1211
- }
1212
- if (req.method === "POST" && req.url === "/api/context") {
1213
- let body = "";
1214
- req.on("data", (chunk) => {
1215
- body += chunk.toString();
1216
- });
1217
- req.on("end", () => {
1218
- try {
1219
- const payload = JSON.parse(body);
1220
- callbacks.onContextUpdate(payload);
1221
- sendToClient({ type: "context:update", payload });
1222
- res.writeHead(200, { "Content-Type": "application/json" });
1223
- res.end(JSON.stringify({ ok: true }));
1224
- } catch {
1225
- res.writeHead(400, { "Content-Type": "application/json" });
1226
- res.end(JSON.stringify({ error: "Invalid JSON" }));
1227
- }
1228
- });
1229
- return;
1230
- }
1231
- if (req.method === "POST" && req.url === "/api/refresh") {
1232
- callbacks.onRefreshRequest();
1233
- res.writeHead(200, { "Content-Type": "application/json" });
1234
- res.end(JSON.stringify({ ok: true }));
1235
- return;
1236
- }
1237
- res.writeHead(404);
1238
- res.end("Not found");
1239
- });
1240
- const wss2 = new WebSocketServer2({ server: httpServer });
1241
- function sendToClient(msg) {
1242
- if (client && client.readyState === WebSocket2.OPEN) {
1243
- client.send(JSON.stringify(msg));
1244
- }
1488
+ poller?.setDiffRef(newRef);
1245
1489
  }
1246
- wss2.on("connection", (ws) => {
1247
- if (closeTimer) {
1248
- clearTimeout(closeTimer);
1249
- closeTimer = null;
1250
- }
1251
- client = ws;
1252
- const payload = pendingInit ?? initPayload;
1253
- if (payload) {
1254
- sendToClient({ type: "review:init", payload });
1255
- pendingInit = null;
1256
- }
1257
- ws.on("message", (data) => {
1490
+ const bridge = await createWatchBridge(bridgePort, {
1491
+ onRefreshRequest: () => {
1492
+ poller?.refresh();
1493
+ },
1494
+ onContextUpdate: (payload) => {
1495
+ if (payload.reasoning !== void 0) metadata.reasoning = payload.reasoning;
1496
+ if (payload.title !== void 0) metadata.title = payload.title;
1497
+ if (payload.description !== void 0) metadata.description = payload.description;
1498
+ },
1499
+ onDiffRefChange: (newRef) => {
1258
1500
  try {
1259
- const msg = JSON.parse(data.toString());
1260
- if (msg.type === "review:submit" && submitCallback) {
1261
- submitCallback(msg.payload);
1262
- }
1263
- } catch {
1501
+ handleDiffRefChange(newRef);
1502
+ } catch (err) {
1503
+ bridge.sendDiffError({
1504
+ error: err instanceof Error ? err.message : String(err)
1505
+ });
1264
1506
  }
1265
- });
1266
- ws.on("close", () => {
1267
- client = null;
1268
- closeTimer = setTimeout(() => {
1269
- closeTimer = null;
1270
- }, 2e3);
1271
- });
1272
- });
1273
- return new Promise((resolve, reject) => {
1274
- httpServer.on("error", reject);
1275
- httpServer.listen(port, () => {
1276
- resolve({
1277
- port,
1278
- sendInit(payload) {
1279
- initPayload = payload;
1280
- if (client && client.readyState === WebSocket2.OPEN) {
1281
- sendToClient({ type: "review:init", payload });
1282
- } else {
1283
- pendingInit = payload;
1284
- }
1285
- },
1286
- storeInitPayload(payload) {
1287
- initPayload = payload;
1288
- },
1289
- sendDiffUpdate(payload) {
1290
- sendToClient({ type: "diff:update", payload });
1291
- },
1292
- sendContextUpdate(payload) {
1293
- sendToClient({ type: "context:update", payload });
1294
- },
1295
- onSubmit(callback) {
1296
- submitCallback = callback;
1297
- },
1298
- triggerRefresh() {
1299
- callbacks.onRefreshRequest();
1300
- },
1301
- async close() {
1302
- if (closeTimer) {
1303
- clearTimeout(closeTimer);
1304
- }
1305
- for (const ws of wss2.clients) {
1306
- ws.close();
1307
- }
1308
- wss2.close();
1309
- await new Promise((resolve2) => {
1310
- httpServer.close(() => resolve2());
1311
- });
1507
+ },
1508
+ onRefsRequest: async () => {
1509
+ try {
1510
+ const resolvedCwd = cwd ?? process.cwd();
1511
+ const branches = listBranches({ cwd: resolvedCwd });
1512
+ const commits = listCommits({ cwd: resolvedCwd });
1513
+ const branch = getCurrentBranch({ cwd: resolvedCwd });
1514
+ return { branches, commits, currentBranch: branch };
1515
+ } catch {
1516
+ return null;
1517
+ }
1518
+ },
1519
+ onCompareRequest: async (ref) => {
1520
+ try {
1521
+ handleDiffRefChange(ref);
1522
+ return true;
1523
+ } catch {
1524
+ return false;
1525
+ }
1526
+ }
1527
+ });
1528
+ let httpServer = null;
1529
+ let viteServer = null;
1530
+ try {
1531
+ if (dev) {
1532
+ const uiRoot = resolveUiRoot();
1533
+ viteServer = await startViteDevServer(uiRoot, httpPort, !!silent);
1534
+ } else {
1535
+ const uiDist = resolveUiDist();
1536
+ httpServer = await createStaticServer(uiDist, httpPort);
1537
+ }
1538
+ writeWatchFile(cwd, {
1539
+ wsPort: bridgePort,
1540
+ uiPort: httpPort,
1541
+ pid: process.pid,
1542
+ cwd: cwd ?? process.cwd(),
1543
+ diffRef,
1544
+ startedAt: Date.now()
1545
+ });
1546
+ const url = `http://localhost:${httpPort}?wsPort=${bridgePort}&httpPort=${bridgePort}&reviewId=${session.id}`;
1547
+ if (!silent) {
1548
+ console.log(`
1549
+ DiffPrism Review: ${title ?? briefing.summary}`);
1550
+ console.log(`Opening browser at ${url}
1551
+ `);
1552
+ }
1553
+ await open(url);
1554
+ const initPayload = {
1555
+ reviewId: session.id,
1556
+ diffSet,
1557
+ rawDiff,
1558
+ briefing,
1559
+ metadata,
1560
+ watchMode: true
1561
+ };
1562
+ bridge.sendInit(initPayload);
1563
+ poller = createDiffPoller({
1564
+ diffRef,
1565
+ cwd: cwd ?? process.cwd(),
1566
+ pollInterval: 1e3,
1567
+ onDiffChanged: (updatePayload) => {
1568
+ bridge.storeInitPayload({
1569
+ reviewId: session.id,
1570
+ diffSet: updatePayload.diffSet,
1571
+ rawDiff: updatePayload.rawDiff,
1572
+ briefing: updatePayload.briefing,
1573
+ metadata,
1574
+ watchMode: true
1575
+ });
1576
+ bridge.sendDiffUpdate(updatePayload);
1577
+ if (!silent && updatePayload.changedFiles.length > 0) {
1578
+ console.log(
1579
+ `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Diff updated: ${updatePayload.changedFiles.length} file(s) changed`
1580
+ );
1312
1581
  }
1313
- });
1582
+ }
1314
1583
  });
1315
- });
1584
+ poller.start();
1585
+ const result = await bridge.waitForResult();
1586
+ poller.stop();
1587
+ updateSession(session.id, { status: "completed", result });
1588
+ return result;
1589
+ } finally {
1590
+ poller?.stop();
1591
+ await bridge.close();
1592
+ removeWatchFile(cwd);
1593
+ if (viteServer) {
1594
+ await viteServer.close();
1595
+ }
1596
+ if (httpServer) {
1597
+ httpServer.close();
1598
+ }
1599
+ }
1316
1600
  }
1317
1601
 
1318
- // packages/core/src/diff-utils.ts
1319
- import { createHash } from "crypto";
1320
- function hashDiff(rawDiff) {
1321
- return createHash("sha256").update(rawDiff).digest("hex");
1602
+ // packages/core/src/server-file.ts
1603
+ import fs3 from "fs";
1604
+ import path5 from "path";
1605
+ import os from "os";
1606
+ function serverDir() {
1607
+ return path5.join(os.homedir(), ".diffprism");
1322
1608
  }
1323
- function fileKey(file) {
1324
- return file.stage ? `${file.stage}:${file.path}` : file.path;
1609
+ function serverFilePath() {
1610
+ return path5.join(serverDir(), "server.json");
1325
1611
  }
1326
- function detectChangedFiles(oldDiffSet, newDiffSet) {
1327
- if (!oldDiffSet) {
1328
- return newDiffSet.files.map(fileKey);
1612
+ function isPidAlive2(pid) {
1613
+ try {
1614
+ process.kill(pid, 0);
1615
+ return true;
1616
+ } catch {
1617
+ return false;
1329
1618
  }
1330
- const oldFiles = new Map(
1331
- oldDiffSet.files.map((f) => [fileKey(f), f])
1332
- );
1333
- const changed = [];
1334
- for (const newFile of newDiffSet.files) {
1335
- const key = fileKey(newFile);
1336
- const oldFile = oldFiles.get(key);
1337
- if (!oldFile) {
1338
- changed.push(key);
1339
- } else if (oldFile.additions !== newFile.additions || oldFile.deletions !== newFile.deletions) {
1340
- changed.push(key);
1619
+ }
1620
+ function writeServerFile(info) {
1621
+ const dir = serverDir();
1622
+ if (!fs3.existsSync(dir)) {
1623
+ fs3.mkdirSync(dir, { recursive: true });
1624
+ }
1625
+ fs3.writeFileSync(serverFilePath(), JSON.stringify(info, null, 2) + "\n");
1626
+ }
1627
+ function readServerFile() {
1628
+ const filePath = serverFilePath();
1629
+ if (!fs3.existsSync(filePath)) {
1630
+ return null;
1631
+ }
1632
+ try {
1633
+ const raw = fs3.readFileSync(filePath, "utf-8");
1634
+ const info = JSON.parse(raw);
1635
+ if (!isPidAlive2(info.pid)) {
1636
+ fs3.unlinkSync(filePath);
1637
+ return null;
1341
1638
  }
1639
+ return info;
1640
+ } catch {
1641
+ return null;
1342
1642
  }
1343
- for (const oldFile of oldDiffSet.files) {
1344
- if (!newDiffSet.files.some((f) => fileKey(f) === fileKey(oldFile))) {
1345
- changed.push(fileKey(oldFile));
1643
+ }
1644
+ function removeServerFile() {
1645
+ try {
1646
+ const filePath = serverFilePath();
1647
+ if (fs3.existsSync(filePath)) {
1648
+ fs3.unlinkSync(filePath);
1346
1649
  }
1650
+ } catch {
1651
+ }
1652
+ }
1653
+ async function isServerAlive() {
1654
+ const info = readServerFile();
1655
+ if (!info) {
1656
+ return null;
1657
+ }
1658
+ try {
1659
+ const response = await fetch(`http://localhost:${info.httpPort}/api/status`, {
1660
+ signal: AbortSignal.timeout(2e3)
1661
+ });
1662
+ if (response.ok) {
1663
+ return info;
1664
+ }
1665
+ return null;
1666
+ } catch {
1667
+ removeServerFile();
1668
+ return null;
1347
1669
  }
1348
- return changed;
1349
1670
  }
1350
1671
 
1351
1672
  // packages/core/src/watch.ts
1673
+ import getPort2 from "get-port";
1674
+ import open2 from "open";
1352
1675
  async function startWatch(options) {
1353
1676
  const {
1354
1677
  diffRef,
@@ -1363,8 +1686,6 @@ async function startWatch(options) {
1363
1686
  const { diffSet: initialDiffSet, rawDiff: initialRawDiff } = getDiff(diffRef, { cwd });
1364
1687
  const currentBranch = getCurrentBranch({ cwd });
1365
1688
  const initialBriefing = analyze(initialDiffSet);
1366
- let lastDiffHash = hashDiff(initialRawDiff);
1367
- let lastDiffSet = initialDiffSet;
1368
1689
  const metadata = {
1369
1690
  title,
1370
1691
  description,
@@ -1375,15 +1696,63 @@ async function startWatch(options) {
1375
1696
  getPort2(),
1376
1697
  getPort2()
1377
1698
  ]);
1378
- let refreshRequested = false;
1699
+ const reviewId = "watch-session";
1700
+ function handleDiffRefChange(newRef) {
1701
+ const { diffSet: newDiffSet, rawDiff: newRawDiff } = getDiff(newRef, { cwd });
1702
+ const newBriefing = analyze(newDiffSet);
1703
+ bridge.sendDiffUpdate({
1704
+ diffSet: newDiffSet,
1705
+ rawDiff: newRawDiff,
1706
+ briefing: newBriefing,
1707
+ changedFiles: newDiffSet.files.map((f) => f.path),
1708
+ timestamp: Date.now()
1709
+ });
1710
+ bridge.storeInitPayload({
1711
+ reviewId,
1712
+ diffSet: newDiffSet,
1713
+ rawDiff: newRawDiff,
1714
+ briefing: newBriefing,
1715
+ metadata,
1716
+ watchMode: true
1717
+ });
1718
+ poller.setDiffRef(newRef);
1719
+ }
1379
1720
  const bridge = await createWatchBridge(bridgePort, {
1380
1721
  onRefreshRequest: () => {
1381
- refreshRequested = true;
1722
+ poller.refresh();
1382
1723
  },
1383
1724
  onContextUpdate: (payload) => {
1384
1725
  if (payload.reasoning !== void 0) metadata.reasoning = payload.reasoning;
1385
1726
  if (payload.title !== void 0) metadata.title = payload.title;
1386
1727
  if (payload.description !== void 0) metadata.description = payload.description;
1728
+ },
1729
+ onDiffRefChange: (newRef) => {
1730
+ try {
1731
+ handleDiffRefChange(newRef);
1732
+ } catch (err) {
1733
+ bridge.sendDiffError({
1734
+ error: err instanceof Error ? err.message : String(err)
1735
+ });
1736
+ }
1737
+ },
1738
+ onRefsRequest: async () => {
1739
+ try {
1740
+ const resolvedCwd = cwd ?? process.cwd();
1741
+ const branches = listBranches({ cwd: resolvedCwd });
1742
+ const commits = listCommits({ cwd: resolvedCwd });
1743
+ const branch = getCurrentBranch({ cwd: resolvedCwd });
1744
+ return { branches, commits, currentBranch: branch };
1745
+ } catch {
1746
+ return null;
1747
+ }
1748
+ },
1749
+ onCompareRequest: async (ref) => {
1750
+ try {
1751
+ handleDiffRefChange(ref);
1752
+ return true;
1753
+ } catch {
1754
+ return false;
1755
+ }
1387
1756
  }
1388
1757
  });
1389
1758
  let httpServer = null;
@@ -1403,8 +1772,7 @@ async function startWatch(options) {
1403
1772
  diffRef,
1404
1773
  startedAt: Date.now()
1405
1774
  });
1406
- const reviewId = "watch-session";
1407
- const url = `http://localhost:${uiPort}?wsPort=${bridgePort}&reviewId=${reviewId}`;
1775
+ const url = `http://localhost:${uiPort}?wsPort=${bridgePort}&httpPort=${bridgePort}&reviewId=${reviewId}`;
1408
1776
  if (!silent) {
1409
1777
  console.log(`
1410
1778
  DiffPrism Watch: ${title ?? `watching ${diffRef}`}`);
@@ -1434,46 +1802,30 @@ Review submitted: ${result.decision}`);
1434
1802
  }
1435
1803
  writeReviewResult(cwd, result);
1436
1804
  });
1437
- let pollRunning = true;
1438
- const pollLoop = setInterval(() => {
1439
- if (!pollRunning) return;
1440
- try {
1441
- const { diffSet: newDiffSet, rawDiff: newRawDiff } = getDiff(diffRef, { cwd });
1442
- const newHash = hashDiff(newRawDiff);
1443
- if (newHash !== lastDiffHash || refreshRequested) {
1444
- refreshRequested = false;
1445
- const newBriefing = analyze(newDiffSet);
1446
- const changedFiles = detectChangedFiles(lastDiffSet, newDiffSet);
1447
- lastDiffHash = newHash;
1448
- lastDiffSet = newDiffSet;
1449
- bridge.storeInitPayload({
1450
- reviewId,
1451
- diffSet: newDiffSet,
1452
- rawDiff: newRawDiff,
1453
- briefing: newBriefing,
1454
- metadata,
1455
- watchMode: true
1456
- });
1457
- const updatePayload = {
1458
- diffSet: newDiffSet,
1459
- rawDiff: newRawDiff,
1460
- briefing: newBriefing,
1461
- changedFiles,
1462
- timestamp: Date.now()
1463
- };
1464
- bridge.sendDiffUpdate(updatePayload);
1465
- if (!silent && changedFiles.length > 0) {
1466
- console.log(
1467
- `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Diff updated: ${changedFiles.length} file(s) changed`
1468
- );
1469
- }
1805
+ const poller = createDiffPoller({
1806
+ diffRef,
1807
+ cwd: cwd ?? process.cwd(),
1808
+ pollInterval,
1809
+ onDiffChanged: (updatePayload) => {
1810
+ bridge.storeInitPayload({
1811
+ reviewId,
1812
+ diffSet: updatePayload.diffSet,
1813
+ rawDiff: updatePayload.rawDiff,
1814
+ briefing: updatePayload.briefing,
1815
+ metadata,
1816
+ watchMode: true
1817
+ });
1818
+ bridge.sendDiffUpdate(updatePayload);
1819
+ if (!silent && updatePayload.changedFiles.length > 0) {
1820
+ console.log(
1821
+ `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Diff updated: ${updatePayload.changedFiles.length} file(s) changed`
1822
+ );
1470
1823
  }
1471
- } catch {
1472
1824
  }
1473
- }, pollInterval);
1825
+ });
1826
+ poller.start();
1474
1827
  async function stop() {
1475
- pollRunning = false;
1476
- clearInterval(pollLoop);
1828
+ poller.stop();
1477
1829
  await bridge.close();
1478
1830
  if (viteServer) {
1479
1831
  await viteServer.close();
@@ -1497,7 +1849,7 @@ import http3 from "http";
1497
1849
  import { randomUUID } from "crypto";
1498
1850
  import getPort3 from "get-port";
1499
1851
  import open3 from "open";
1500
- import { WebSocketServer as WebSocketServer3, WebSocket as WebSocket3 } from "ws";
1852
+ import { WebSocketServer as WebSocketServer2, WebSocket as WebSocket2 } from "ws";
1501
1853
  var SUBMITTED_TTL_MS = 5 * 60 * 1e3;
1502
1854
  var ABANDONED_TTL_MS = 60 * 60 * 1e3;
1503
1855
  var CLEANUP_INTERVAL_MS = 60 * 1e3;
@@ -1563,7 +1915,7 @@ function broadcastToAll(msg) {
1563
1915
  if (!wss) return;
1564
1916
  const data = JSON.stringify(msg);
1565
1917
  for (const client of wss.clients) {
1566
- if (client.readyState === WebSocket3.OPEN) {
1918
+ if (client.readyState === WebSocket2.OPEN) {
1567
1919
  client.send(data);
1568
1920
  }
1569
1921
  }
@@ -1572,7 +1924,7 @@ function sendToSessionClients(sessionId, msg) {
1572
1924
  if (!wss) return;
1573
1925
  const data = JSON.stringify(msg);
1574
1926
  for (const [client, sid] of clientSessions.entries()) {
1575
- if (sid === sessionId && client.readyState === WebSocket3.OPEN) {
1927
+ if (sid === sessionId && client.readyState === WebSocket2.OPEN) {
1576
1928
  client.send(data);
1577
1929
  }
1578
1930
  }
@@ -1596,7 +1948,7 @@ function broadcastSessionRemoved(sessionId) {
1596
1948
  }
1597
1949
  function hasViewersForSession(sessionId) {
1598
1950
  for (const [client, sid] of clientSessions.entries()) {
1599
- if (sid === sessionId && client.readyState === WebSocket3.OPEN) {
1951
+ if (sid === sessionId && client.readyState === WebSocket2.OPEN) {
1600
1952
  return true;
1601
1953
  }
1602
1954
  }
@@ -1606,54 +1958,40 @@ function startSessionWatcher(sessionId) {
1606
1958
  if (sessionWatchers.has(sessionId)) return;
1607
1959
  const session = sessions2.get(sessionId);
1608
1960
  if (!session?.diffRef) return;
1609
- const interval = setInterval(() => {
1610
- const s = sessions2.get(sessionId);
1611
- if (!s?.diffRef) {
1612
- stopSessionWatcher(sessionId);
1613
- return;
1614
- }
1615
- try {
1616
- const { diffSet: newDiffSet, rawDiff: newRawDiff } = getDiff(s.diffRef, {
1617
- cwd: s.projectPath
1618
- });
1619
- const newHash = hashDiff(newRawDiff);
1620
- if (newHash !== s.lastDiffHash) {
1621
- const newBriefing = analyze(newDiffSet);
1622
- const changedFiles = detectChangedFiles(s.lastDiffSet ?? null, newDiffSet);
1623
- s.payload = {
1624
- ...s.payload,
1625
- diffSet: newDiffSet,
1626
- rawDiff: newRawDiff,
1627
- briefing: newBriefing
1628
- };
1629
- s.lastDiffHash = newHash;
1630
- s.lastDiffSet = newDiffSet;
1631
- if (hasViewersForSession(sessionId)) {
1632
- sendToSessionClients(sessionId, {
1633
- type: "diff:update",
1634
- payload: {
1635
- diffSet: newDiffSet,
1636
- rawDiff: newRawDiff,
1637
- briefing: newBriefing,
1638
- changedFiles,
1639
- timestamp: Date.now()
1640
- }
1641
- });
1642
- s.hasNewChanges = false;
1643
- } else {
1644
- s.hasNewChanges = true;
1645
- broadcastSessionList();
1646
- }
1961
+ const poller = createDiffPoller({
1962
+ diffRef: session.diffRef,
1963
+ cwd: session.projectPath,
1964
+ pollInterval: serverPollInterval,
1965
+ onDiffChanged: (updatePayload) => {
1966
+ const s = sessions2.get(sessionId);
1967
+ if (!s) return;
1968
+ s.payload = {
1969
+ ...s.payload,
1970
+ diffSet: updatePayload.diffSet,
1971
+ rawDiff: updatePayload.rawDiff,
1972
+ briefing: updatePayload.briefing
1973
+ };
1974
+ s.lastDiffHash = hashDiff(updatePayload.rawDiff);
1975
+ s.lastDiffSet = updatePayload.diffSet;
1976
+ if (hasViewersForSession(sessionId)) {
1977
+ sendToSessionClients(sessionId, {
1978
+ type: "diff:update",
1979
+ payload: updatePayload
1980
+ });
1981
+ s.hasNewChanges = false;
1982
+ } else {
1983
+ s.hasNewChanges = true;
1984
+ broadcastSessionList();
1647
1985
  }
1648
- } catch {
1649
1986
  }
1650
- }, serverPollInterval);
1651
- sessionWatchers.set(sessionId, interval);
1987
+ });
1988
+ poller.start();
1989
+ sessionWatchers.set(sessionId, poller);
1652
1990
  }
1653
1991
  function stopSessionWatcher(sessionId) {
1654
- const interval = sessionWatchers.get(sessionId);
1655
- if (interval) {
1656
- clearInterval(interval);
1992
+ const poller = sessionWatchers.get(sessionId);
1993
+ if (poller) {
1994
+ poller.stop();
1657
1995
  sessionWatchers.delete(sessionId);
1658
1996
  }
1659
1997
  }
@@ -1665,15 +2003,15 @@ function startAllWatchers() {
1665
2003
  }
1666
2004
  }
1667
2005
  function stopAllWatchers() {
1668
- for (const [id, interval] of sessionWatchers.entries()) {
1669
- clearInterval(interval);
2006
+ for (const [, poller] of sessionWatchers.entries()) {
2007
+ poller.stop();
1670
2008
  }
1671
2009
  sessionWatchers.clear();
1672
2010
  }
1673
2011
  function hasConnectedClients() {
1674
2012
  if (!wss) return false;
1675
2013
  for (const client of wss.clients) {
1676
- if (client.readyState === WebSocket3.OPEN) return true;
2014
+ if (client.readyState === WebSocket2.OPEN) return true;
1677
2015
  }
1678
2016
  return false;
1679
2017
  }
@@ -1730,6 +2068,7 @@ async function handleApiRequest(req, res) {
1730
2068
  existingSession.lastDiffHash = diffRef ? hashDiff(payload.rawDiff) : void 0;
1731
2069
  existingSession.lastDiffSet = diffRef ? payload.diffSet : void 0;
1732
2070
  existingSession.hasNewChanges = false;
2071
+ existingSession.annotations = [];
1733
2072
  if (diffRef && hasConnectedClients()) {
1734
2073
  startSessionWatcher(sessionId);
1735
2074
  }
@@ -1758,7 +2097,8 @@ async function handleApiRequest(req, res) {
1758
2097
  diffRef,
1759
2098
  lastDiffHash: diffRef ? hashDiff(payload.rawDiff) : void 0,
1760
2099
  lastDiffSet: diffRef ? payload.diffSet : void 0,
1761
- hasNewChanges: false
2100
+ hasNewChanges: false,
2101
+ annotations: []
1762
2102
  };
1763
2103
  sessions2.set(sessionId, session);
1764
2104
  if (diffRef && hasConnectedClients()) {
@@ -1857,6 +2197,65 @@ async function handleApiRequest(req, res) {
1857
2197
  }
1858
2198
  return true;
1859
2199
  }
2200
+ const postAnnotationParams = matchRoute(method, url, "POST", "/api/reviews/:id/annotations");
2201
+ if (postAnnotationParams) {
2202
+ const session = sessions2.get(postAnnotationParams.id);
2203
+ if (!session) {
2204
+ jsonResponse(res, 404, { error: "Session not found" });
2205
+ return true;
2206
+ }
2207
+ try {
2208
+ const body = await readBody(req);
2209
+ const { file, line, body: annotationBody, type, confidence, category, source } = JSON.parse(body);
2210
+ const annotation = {
2211
+ id: randomUUID(),
2212
+ sessionId: session.id,
2213
+ file,
2214
+ line,
2215
+ body: annotationBody,
2216
+ type,
2217
+ confidence: confidence ?? 1,
2218
+ category: category ?? "other",
2219
+ source,
2220
+ createdAt: Date.now()
2221
+ };
2222
+ session.annotations.push(annotation);
2223
+ sendToSessionClients(session.id, {
2224
+ type: "annotation:added",
2225
+ payload: annotation
2226
+ });
2227
+ jsonResponse(res, 200, { annotationId: annotation.id });
2228
+ } catch {
2229
+ jsonResponse(res, 400, { error: "Invalid request body" });
2230
+ }
2231
+ return true;
2232
+ }
2233
+ const getAnnotationsParams = matchRoute(method, url, "GET", "/api/reviews/:id/annotations");
2234
+ if (getAnnotationsParams) {
2235
+ const session = sessions2.get(getAnnotationsParams.id);
2236
+ if (!session) {
2237
+ jsonResponse(res, 404, { error: "Session not found" });
2238
+ return true;
2239
+ }
2240
+ jsonResponse(res, 200, { annotations: session.annotations });
2241
+ return true;
2242
+ }
2243
+ const dismissAnnotationParams = matchRoute(method, url, "POST", "/api/reviews/:id/annotations/:annotationId/dismiss");
2244
+ if (dismissAnnotationParams) {
2245
+ const session = sessions2.get(dismissAnnotationParams.id);
2246
+ if (!session) {
2247
+ jsonResponse(res, 404, { error: "Session not found" });
2248
+ return true;
2249
+ }
2250
+ const annotation = session.annotations.find((a) => a.id === dismissAnnotationParams.annotationId);
2251
+ if (!annotation) {
2252
+ jsonResponse(res, 404, { error: "Annotation not found" });
2253
+ return true;
2254
+ }
2255
+ annotation.dismissed = true;
2256
+ jsonResponse(res, 200, { ok: true });
2257
+ return true;
2258
+ }
1860
2259
  const deleteParams = matchRoute(method, url, "DELETE", "/api/reviews/:id");
1861
2260
  if (deleteParams) {
1862
2261
  stopSessionWatcher(deleteParams.id);
@@ -1968,7 +2367,7 @@ async function startGlobalServer(options = {}) {
1968
2367
  res.end("Not found");
1969
2368
  }
1970
2369
  });
1971
- wss = new WebSocketServer3({ port: wsPort });
2370
+ wss = new WebSocketServer2({ port: wsPort });
1972
2371
  wss.on("connection", (ws, req) => {
1973
2372
  startAllWatchers();
1974
2373
  const url = new URL(req.url ?? "/", `http://localhost:${wsPort}`);
@@ -2046,6 +2445,49 @@ async function startGlobalServer(options = {}) {
2046
2445
  closedSession.status = "submitted";
2047
2446
  }
2048
2447
  broadcastSessionRemoved(closedId);
2448
+ } else if (msg.type === "diff:change_ref") {
2449
+ const sid = clientSessions.get(ws);
2450
+ if (sid) {
2451
+ const session = sessions2.get(sid);
2452
+ if (session) {
2453
+ const newRef = msg.payload.diffRef;
2454
+ try {
2455
+ const { diffSet: newDiffSet, rawDiff: newRawDiff } = getDiff(newRef, {
2456
+ cwd: session.projectPath
2457
+ });
2458
+ const newBriefing = analyze(newDiffSet);
2459
+ session.payload = {
2460
+ ...session.payload,
2461
+ diffSet: newDiffSet,
2462
+ rawDiff: newRawDiff,
2463
+ briefing: newBriefing
2464
+ };
2465
+ session.diffRef = newRef;
2466
+ session.lastDiffHash = hashDiff(newRawDiff);
2467
+ session.lastDiffSet = newDiffSet;
2468
+ stopSessionWatcher(sid);
2469
+ startSessionWatcher(sid);
2470
+ sendToSessionClients(sid, {
2471
+ type: "diff:update",
2472
+ payload: {
2473
+ diffSet: newDiffSet,
2474
+ rawDiff: newRawDiff,
2475
+ briefing: newBriefing,
2476
+ changedFiles: newDiffSet.files.map((f) => f.path),
2477
+ timestamp: Date.now()
2478
+ }
2479
+ });
2480
+ } catch (err) {
2481
+ const errorMsg = {
2482
+ type: "diff:error",
2483
+ payload: {
2484
+ error: err instanceof Error ? err.message : String(err)
2485
+ }
2486
+ };
2487
+ ws.send(JSON.stringify(errorMsg));
2488
+ }
2489
+ }
2490
+ }
2049
2491
  }
2050
2492
  } catch {
2051
2493
  }
@@ -2126,14 +2568,20 @@ Waiting for reviews...
2126
2568
  return { httpPort, wsPort, stop };
2127
2569
  }
2128
2570
 
2571
+ // packages/core/src/review-history.ts
2572
+ import fs4 from "fs";
2573
+ import path6 from "path";
2574
+ import { randomUUID as randomUUID2 } from "crypto";
2575
+
2129
2576
  export {
2130
2577
  getCurrentBranch,
2578
+ detectWorktree,
2131
2579
  getDiff,
2132
2580
  analyze,
2133
- startReview,
2134
2581
  readWatchFile,
2135
2582
  readReviewResult,
2136
2583
  consumeReviewResult,
2584
+ startReview,
2137
2585
  startWatch,
2138
2586
  readServerFile,
2139
2587
  isServerAlive,