@diffohq/diffo 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +333 -66
  3. package/dist/client/assets/{abnfDiagram-VCTEODGH--nG4YFZf.js → abnfDiagram-VCTEODGH-DNZRM2Rn.js} +1 -1
  4. package/dist/client/assets/{arc-DUDGa5yC.js → arc-s7daVIwl.js} +1 -1
  5. package/dist/client/assets/architecture-7GRP2DOG-DzM3CzOq.js +1 -0
  6. package/dist/client/assets/{architectureDiagram-5GKGNRK7-DvSKG4lO.js → architectureDiagram-5GKGNRK7-DsxTOcye.js} +1 -1
  7. package/dist/client/assets/{blockDiagram-NRAW4CY4-t9YA0Zds.js → blockDiagram-NRAW4CY4-DqGD_dcu.js} +1 -1
  8. package/dist/client/assets/{c4Diagram-UCG6FXSJ-D0NJEiQf.js → c4Diagram-UCG6FXSJ-CMuJod-h.js} +1 -1
  9. package/dist/client/assets/channel-Cftoq4wT.js +1 -0
  10. package/dist/client/assets/{chunk-75Z2AOVW-Bai5ank7.js → chunk-75Z2AOVW-HRLiZN7c.js} +1 -1
  11. package/dist/client/assets/{chunk-DU6HZSFF-D2kJr6Sd.js → chunk-DU6HZSFF-D6gjp2l1.js} +1 -1
  12. package/dist/client/assets/{chunk-F27PBJKO-Bfqt90G-.js → chunk-F27PBJKO-DRqkoKNg.js} +1 -1
  13. package/dist/client/assets/{chunk-G27WJ6UU-BaH77Llj.js → chunk-G27WJ6UU-QUFgr4za.js} +1 -1
  14. package/dist/client/assets/{chunk-GMAD6QVW-DEk8GHWm.js → chunk-GMAD6QVW-DG-OmEbV.js} +1 -1
  15. package/dist/client/assets/{chunk-JQ64N6SF-DSC3eBmu.js → chunk-JQ64N6SF-CPdpoyCu.js} +1 -1
  16. package/dist/client/assets/{chunk-LCL6LL3I-CbXTQnS7.js → chunk-LCL6LL3I-DoHJ4n3V.js} +1 -1
  17. package/dist/client/assets/{chunk-OBVCFTLP-DTR5L5DZ.js → chunk-OBVCFTLP-DOl2OrIN.js} +1 -1
  18. package/dist/client/assets/{chunk-P2QGCYS3-B4CbyKz8.js → chunk-P2QGCYS3-CBWu7ViW.js} +1 -1
  19. package/dist/client/assets/{chunk-POPQ4Y6H-bUwnKEA6.js → chunk-POPQ4Y6H-Dw_D6UWF.js} +1 -1
  20. package/dist/client/assets/{chunk-PWAF6VOD-zdMmz14g.js → chunk-PWAF6VOD-BHpo38Be.js} +1 -1
  21. package/dist/client/assets/{chunk-R7TYR2AO-Bij6COIm.js → chunk-R7TYR2AO-DgC6z76h.js} +1 -1
  22. package/dist/client/assets/{chunk-RHFEMEQ7-B2_qXJHf.js → chunk-RHFEMEQ7-BabrfzF-.js} +1 -1
  23. package/dist/client/assets/{chunk-SVP7TREG-2A6DuC2c.js → chunk-SVP7TREG-CSTzyRmo.js} +1 -1
  24. package/dist/client/assets/{chunk-TEH6E4GO-ByR-u0fj.js → chunk-TEH6E4GO-M22Cr-Sd.js} +1 -1
  25. package/dist/client/assets/{chunk-XXDRQBXY-D_O-C3As.js → chunk-XXDRQBXY-BHwLEQfa.js} +1 -1
  26. package/dist/client/assets/classDiagram-DTDB5LWJ-uMtyQjAg.js +1 -0
  27. package/dist/client/assets/classDiagram-v2-JRS7N3AN-uMtyQjAg.js +1 -0
  28. package/dist/client/assets/{core-Bnn4HFdG.js → core-B9wYK4yO.js} +1 -1
  29. package/dist/client/assets/{cose-bilkent-JH36ORCC-Z148C14w.js → cose-bilkent-JH36ORCC-BL0lTP0k.js} +1 -1
  30. package/dist/client/assets/{cynefin-OW5HDTMX-_nvdHJ_6.js → cynefin-OW5HDTMX-1zOzoEu-.js} +1 -1
  31. package/dist/client/assets/{cynefinDiagram-5FMLGOSQ-DgjK3dBx.js → cynefinDiagram-5FMLGOSQ-DST_IHb1.js} +1 -1
  32. package/dist/client/assets/{dagre-3AP2YEHR-ZpUDL3As.js → dagre-3AP2YEHR-CoEaLaFu.js} +1 -1
  33. package/dist/client/assets/{diagram-S7CK7UJ4-CkZIS9iX.js → diagram-S7CK7UJ4-BwGXFSb-.js} +1 -1
  34. package/dist/client/assets/{diagram-UQ7AKVKN-CQ18pzfD.js → diagram-UQ7AKVKN-wJMwiRQW.js} +1 -1
  35. package/dist/client/assets/{diagram-VSXAHHWV-BHksZhNT.js → diagram-VSXAHHWV-DRQH2hZ-.js} +1 -1
  36. package/dist/client/assets/{diagram-VX7I27RA-JaMo95UB.js → diagram-VX7I27RA-BWGYUOwn.js} +1 -1
  37. package/dist/client/assets/{diagram-Z3DM3KII-BtbUi7Zp.js → diagram-Z3DM3KII-C2ETOHnp.js} +1 -1
  38. package/dist/client/assets/{dist-frF0Fo17.js → dist-C42IZvNn.js} +1 -1
  39. package/dist/client/assets/{ebnfDiagram-PWID7BFC-CrkTnETC.js → ebnfDiagram-PWID7BFC-CkcgAzr3.js} +1 -1
  40. package/dist/client/assets/{engine-oniguruma-BDMkTjCm.js → engine-oniguruma-D9HVl69n.js} +1 -1
  41. package/dist/client/assets/{erDiagram-SSCWMZ5O-BU_DeMcX.js → erDiagram-SSCWMZ5O-Do6h2U1E.js} +1 -1
  42. package/dist/client/assets/eventmodeling-NTZA5JFV-LK0PWCmm.js +1 -0
  43. package/dist/client/assets/flowDiagram-A5DVABFB-W_CNjdk0.js +1 -0
  44. package/dist/client/assets/{ganttDiagram-EL5Y4UJY-DCoVu2Ob.js → ganttDiagram-EL5Y4UJY-cC8gUuqf.js} +1 -1
  45. package/dist/client/assets/{gitGraph-4MIJSDKK-D_vOLjJd.js → gitGraph-4MIJSDKK-Con-0IRc.js} +1 -1
  46. package/dist/client/assets/{gitGraphDiagram-WWUBYQGX-CbJeUVdZ.js → gitGraphDiagram-WWUBYQGX-Oj6POMRs.js} +1 -1
  47. package/dist/client/assets/index-BPuQYkRJ.js +90 -0
  48. package/dist/client/assets/index-BpeDzG-X.css +1 -0
  49. package/dist/client/assets/{info-A6RAGUB7-CRcr_2Kl.js → info-A6RAGUB7-Cl45HDP4.js} +1 -1
  50. package/dist/client/assets/{infoDiagram-RXCK75RN-B4UffpYj.js → infoDiagram-RXCK75RN-DIVe-qvk.js} +1 -1
  51. package/dist/client/assets/{ishikawaDiagram-5VMMS53U-DO6STczE.js → ishikawaDiagram-5VMMS53U-BB3HsPau.js} +1 -1
  52. package/dist/client/assets/{journeyDiagram-EYS64GPL-CJqu4rjo.js → journeyDiagram-EYS64GPL-Cj8ezyBN.js} +1 -1
  53. package/dist/client/assets/{kanban-definition-3QL26DDD-ugjVwpzB.js → kanban-definition-3QL26DDD-D3dbth5Z.js} +1 -1
  54. package/dist/client/assets/{line-C42aEa5T.js → line-tg3RVOrp.js} +1 -1
  55. package/dist/client/assets/{linear-CAfNWogY.js → linear-CzAWMsuG.js} +1 -1
  56. package/dist/client/assets/{mermaid-parser.core-DIpynd7a.js → mermaid-parser.core-DKC3l_TU.js} +3 -3
  57. package/dist/client/assets/{mermaid.core-b7ipG6MC.js → mermaid.core-B0ktnMCY.js} +4 -4
  58. package/dist/client/assets/{mindmap-definition-FBJOCRG2-Btow11vP.js → mindmap-definition-FBJOCRG2-CSUjR6w9.js} +1 -1
  59. package/dist/client/assets/{packet-AYTQ26CC-DmMiSJ_n.js → packet-AYTQ26CC-NTsh29Fp.js} +1 -1
  60. package/dist/client/assets/{pegDiagram-XKGWAZYB-ZE6QXs-N.js → pegDiagram-XKGWAZYB-BhLsv72R.js} +1 -1
  61. package/dist/client/assets/{pie-WAS4IAKB-CWO1GXQk.js → pie-WAS4IAKB-DENes_Cx.js} +1 -1
  62. package/dist/client/assets/{pieDiagram-E7YTZNPT-CkYClGbv.js → pieDiagram-E7YTZNPT-Ci9YzLDy.js} +1 -1
  63. package/dist/client/assets/{quadrantDiagram-AXDQQJYC-CLah-4Vu.js → quadrantDiagram-AXDQQJYC-D7D5FwMe.js} +1 -1
  64. package/dist/client/assets/{radar-RG4KPBEZ-VI14cwZd.js → radar-RG4KPBEZ-C3OVgpW0.js} +1 -1
  65. package/dist/client/assets/{railroad-74A4TZTK-s1iFKSLC.js → railroad-74A4TZTK-DHWDkPlt.js} +1 -1
  66. package/dist/client/assets/railroad-abnf-HS5TGJTU-XG9NyE2s.js +1 -0
  67. package/dist/client/assets/railroad-ebnf-LZEXJU2U-BePIURFQ.js +1 -0
  68. package/dist/client/assets/railroad-peg-WCYAUIDC-THfR3kY_.js +1 -0
  69. package/dist/client/assets/{railroadDiagram-O6MQD6OU-DSEEuZLE.js → railroadDiagram-O6MQD6OU-ByO5lwcN.js} +1 -1
  70. package/dist/client/assets/{requirementDiagram-EFPCY7ZU-DKPeLCXy.js → requirementDiagram-EFPCY7ZU-UAUf-JbW.js} +1 -1
  71. package/dist/client/assets/{sankeyDiagram-P5KCCOFB-CoRGTY0Z.js → sankeyDiagram-P5KCCOFB-D3dgv1AD.js} +1 -1
  72. package/dist/client/assets/{sequenceDiagram-WJ2MYXX4-vOT2DQAD.js → sequenceDiagram-WJ2MYXX4-DbGvVPa7.js} +1 -1
  73. package/dist/client/assets/{src-COpTHlse.js → src-DQRzrNpT.js} +1 -1
  74. package/dist/client/assets/{stateDiagram-HBIQ2CUA-B1Q5gx1k.js → stateDiagram-HBIQ2CUA-CJznFEjd.js} +1 -1
  75. package/dist/client/assets/stateDiagram-v2-4QOOHH4V-o6PmCsJ0.js +1 -0
  76. package/dist/client/assets/{swimlanes-XN3QIQJK-BHsSTPP_.js → swimlanes-XN3QIQJK-Bp4PtrPm.js} +1 -1
  77. package/dist/client/assets/swimlanesDiagram-VK2B7HYN-W1d9PL3X.js +8 -0
  78. package/dist/client/assets/{timeline-definition-24CTP7MA-gMAK8Huz.js → timeline-definition-24CTP7MA-jqaUS70q.js} +1 -1
  79. package/dist/client/assets/{treeView-Q6P3EWNA-D_ac7x_f.js → treeView-Q6P3EWNA-4uMQTFyy.js} +1 -1
  80. package/dist/client/assets/{treemap-WGGIJYW6-CmKDF5Bk.js → treemap-WGGIJYW6-BEvgQaxX.js} +1 -1
  81. package/dist/client/assets/{vennDiagram-4TSXK5OY-BwoJOmDU.js → vennDiagram-4TSXK5OY-BzSNLT-7.js} +1 -1
  82. package/dist/client/assets/{wardley-WFR3VGLG-DBwnOUBJ.js → wardley-WFR3VGLG-0Vpm3lxj.js} +1 -1
  83. package/dist/client/assets/{wardleyDiagram-VM6X3IG4-DSpTGNPm.js → wardleyDiagram-VM6X3IG4-D9KaADBZ.js} +1 -1
  84. package/dist/client/assets/{xychartDiagram-S5SC5T6Z-I-7pacDu.js → xychartDiagram-S5SC5T6Z-CnrJ3a0c.js} +1 -1
  85. package/dist/client/index.html +2 -2
  86. package/package.json +1 -1
  87. package/plugin.json +1 -1
  88. package/dist/client/assets/architecture-7GRP2DOG-BS3omA-i.js +0 -1
  89. package/dist/client/assets/channel-C2N97jf-.js +0 -1
  90. package/dist/client/assets/classDiagram-DTDB5LWJ-DkiwiYZN.js +0 -1
  91. package/dist/client/assets/classDiagram-v2-JRS7N3AN-DkiwiYZN.js +0 -1
  92. package/dist/client/assets/eventmodeling-NTZA5JFV-C6iBQfJc.js +0 -1
  93. package/dist/client/assets/flowDiagram-A5DVABFB-Dq3MklmQ.js +0 -1
  94. package/dist/client/assets/index-DUY5AD4r.js +0 -90
  95. package/dist/client/assets/index-pWZ6lr0e.css +0 -1
  96. package/dist/client/assets/railroad-abnf-HS5TGJTU-DCYBjW-8.js +0 -1
  97. package/dist/client/assets/railroad-ebnf-LZEXJU2U-Cpg28CUR.js +0 -1
  98. package/dist/client/assets/railroad-peg-WCYAUIDC-D3OHUWgZ.js +0 -1
  99. package/dist/client/assets/stateDiagram-v2-4QOOHH4V-Bq1OmWqW.js +0 -1
  100. package/dist/client/assets/swimlanesDiagram-VK2B7HYN-DACxnx54.js +0 -8
package/dist/cli.mjs CHANGED
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
4
- import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from "node:fs";
4
+ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from "node:fs";
5
5
  import { createServer } from "node:net";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, dirname, extname, join, resolve, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { parseArgs, promisify } from "node:util";
10
+ import { createHash, randomUUID } from "node:crypto";
10
11
  import { readFile } from "node:fs/promises";
11
12
  import { serve } from "@hono/node-server";
12
13
  import { Hono } from "hono";
13
14
  import { stream, streamSSE } from "hono/streaming";
14
- import { createHash, randomUUID } from "node:crypto";
15
15
  //#region src/agentSession.ts
16
16
  /** A row whose leading token is a shell is never the session. Tested on the FIRST
17
17
  * token only: under Claude Code the transient `zsh -c` carries
@@ -94,11 +94,6 @@ function untouchedAgentVoice(thread) {
94
94
  function undeliveredThreadIds(threads) {
95
95
  return threads.filter((t) => (t.state === "sent" || t.state === "addressed") && t.unanswered !== true && t.withheld !== true && t.messages.at(-1)?.author === "reviewer").sort((a, b) => (a.sentAt ?? "").localeCompare(b.sentAt ?? "")).map((t) => t.id);
96
96
  }
97
- const REVIEW_VERDICTS = [
98
- "comment",
99
- "approve",
100
- "request-changes"
101
- ];
102
97
  const EMPTY_REVIEW = {
103
98
  version: 1,
104
99
  threads: []
@@ -128,26 +123,59 @@ function threadsInChangeset(files, threads) {
128
123
  past
129
124
  };
130
125
  }
126
+ /** The line part of a hunk anchor's label: `12`, or `12-20` for a range. */
127
+ function anchorSpan(anchor) {
128
+ return anchor.endLine === void 0 ? `${anchor.line}` : `${anchor.line}-${anchor.endLine}`;
129
+ }
131
130
  function describeAnchor(anchor) {
132
131
  if (anchor.kind === "changeset") return "the whole changeset";
133
132
  if (anchor.kind === "file") return anchor.path;
134
- return `${anchor.path}:${anchor.line} (${anchor.side} side)`;
133
+ return `${anchor.path}:${anchorSpan(anchor)} (${anchor.side} side)`;
135
134
  }
136
135
  //#endregion
137
136
  //#region src/server/prompt.ts
138
- function snapshotHunk(changeset, hunkId) {
137
+ /** A comment can anchor to a long range; the frozen text keeps its head. */
138
+ const ANCHORED_TEXT_CAP = 10;
139
+ function findHunk(changeset, hunkId) {
139
140
  for (const file of changeset.files) {
140
141
  const hunk = file.hunks.find((h) => h.id === hunkId);
141
- if (!hunk) continue;
142
- const marker = {
143
- add: "+",
144
- del: "-",
145
- context: " "
146
- };
147
- return hunk.lines.map((l) => marker[l.kind] + l.text).join("\n");
142
+ if (hunk) return hunk;
148
143
  }
149
144
  return null;
150
145
  }
146
+ /**
147
+ * Freeze what a new thread anchors to: the whole hunk as a diff snapshot, plus
148
+ * — for the anchored line range — where those rows sit in it and their text.
149
+ * Null for non-hunk anchors and for a hunk the changeset no longer has.
150
+ */
151
+ function captureAnchor(changeset, anchor) {
152
+ if (anchor.kind !== "hunk") return null;
153
+ const hunk = findHunk(changeset, anchor.hunkId);
154
+ if (!hunk) return null;
155
+ const marker = {
156
+ add: "+",
157
+ del: "-",
158
+ context: " "
159
+ };
160
+ const rows = hunk.lines.map((l) => marker[l.kind] + l.text);
161
+ const codeContext = rows.join("\n");
162
+ const last = anchor.endLine ?? anchor.line;
163
+ const covered = hunk.lines.map((l, row) => ({
164
+ no: anchor.side === "old" ? l.oldNo : l.newNo,
165
+ row
166
+ })).filter((r) => r.no !== null && r.no >= anchor.line && r.no <= last);
167
+ if (covered.length === 0) return { codeContext };
168
+ const start = covered[0].row;
169
+ const end = covered.at(-1).row;
170
+ return {
171
+ codeContext,
172
+ anchored: {
173
+ start,
174
+ end,
175
+ text: rows.slice(start, Math.min(end + 1, start + ANCHORED_TEXT_CAP)).join("\n")
176
+ }
177
+ };
178
+ }
151
179
  const NPX = `npx -y @diffohq/diffo`;
152
180
  /** This checkout's root — `src/server/prompt.ts` → up two. */
153
181
  const CHECKOUT_ROOT = fileURLToPath(new URL("../..", import.meta.url)).replace(/\/$/, "");
@@ -191,6 +219,7 @@ const INSTALL_SKILL = {
191
219
  const POLL_STANCE = "a tracked background task if your harness has one, the foreground if not — never a detached process";
192
220
  const JOIN_PROMPT = `join the diffo review: run \`${CLI_COMMANDS.poll}\` (${POLL_STANCE}) and follow the JSON payload it prints — each payload carries its own instructions`;
193
221
  function nextStepFor(kind, actionable) {
222
+ if (kind === "cleared") return `Post the guide if this changeset warrants one, then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`;
194
223
  if (kind === "finish" && actionable === 0) return `Nothing to act on — run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}); the reviewer may follow up.`;
195
224
  return `${kind === "finish" ? "The reviewer is done reading — work the whole batch. " : ""}Act on each thread, reply with \`${CLI_COMMANDS.reply}\`, then run \`${CLI_COMMANDS.poll}\` again to keep listening.`;
196
225
  }
@@ -341,20 +370,83 @@ function siblingLines(siblings) {
341
370
  ].join("\n");
342
371
  }
343
372
  const SNAPSHOT_LINE_CAP = 25;
373
+ /** The heading's movement-proof identifier: the first anchored line's text,
374
+ * stripped of its diff marker. The line numbers in `describeAnchor` go stale
375
+ * the moment the code moves; this text is what the agent can still search for. */
376
+ function anchorQuote(thread) {
377
+ const first = thread.anchored?.text.split("\n").map((line) => line.slice(1).trim()).find((line) => line !== "")?.slice(0, 80);
378
+ if (!first) return "";
379
+ return thread.anchor.kind === "hunk" && thread.anchor.endLine !== void 0 ? ` — starts at: "${first}"` : ` — "${first}"`;
380
+ }
381
+ /** When `path:line` must not be trusted against the working tree: an old-side
382
+ * anchor never matches it, and a rotated hunk means the code moved under the
383
+ * numbers. At most one note — removed code can't also have "moved". */
384
+ function anchorNote(thread) {
385
+ if (thread.anchor.kind !== "hunk") return [];
386
+ if (thread.anchor.side === "old") return ["This comments on removed code — the anchored lines are the old version, not the current file."];
387
+ if (thread.codeChanged) return ["The code under this comment changed after the comment was written — its line numbers are stale. Find the code by its anchored lines, not by number."];
388
+ return [];
389
+ }
390
+ /** The `[from, to)` slice of the snapshot to show: the anchored rows centered
391
+ * inside the cap. A range longer than the cap keeps its head. */
392
+ function snapshotWindow(total, anchored) {
393
+ const size = Math.min(total, SNAPSHOT_LINE_CAP);
394
+ const span = anchored.end - anchored.start + 1;
395
+ const pad = Math.floor(Math.max(0, size - span) / 2);
396
+ const from = Math.min(Math.max(0, anchored.start - pad), total - size);
397
+ return {
398
+ from,
399
+ to: from + size
400
+ };
401
+ }
402
+ /** The snapshot as the agent sees it: windowed on the anchored rows, each of
403
+ * them marked with `>`. Threads from before `anchored` existed keep the old
404
+ * head-of-hunk window. */
405
+ function snapshotBlock(thread) {
406
+ const { codeContext, anchored } = thread;
407
+ if (!codeContext) {
408
+ if (!anchored) return [];
409
+ const shown = anchored.text.split("\n");
410
+ const cut = anchored.end - anchored.start + 1 - shown.length;
411
+ return [
412
+ "The commented lines, as they were when the comment was written:",
413
+ "```diff",
414
+ ...shown,
415
+ "```",
416
+ ...cut > 0 ? [`(… and ${cut} more commented lines)`] : []
417
+ ];
418
+ }
419
+ const lines = codeContext.split("\n");
420
+ const where = thread.anchor.kind === "changeset" ? "the file" : `\`${thread.anchor.path}\``;
421
+ if (!anchored) return [
422
+ "The commented change:",
423
+ "```diff",
424
+ ...lines.slice(0, SNAPSHOT_LINE_CAP),
425
+ "```",
426
+ ...lines.length > SNAPSHOT_LINE_CAP ? [`(… and ${lines.length - SNAPSHOT_LINE_CAP} more snapshot lines — read the current ${where} instead)`] : []
427
+ ];
428
+ const { from, to } = snapshotWindow(lines.length, anchored);
429
+ const shown = lines.slice(from, to).map((line, i) => from + i >= anchored.start && from + i <= anchored.end ? `>${line}` : line);
430
+ const cut = lines.length - (to - from);
431
+ return [
432
+ "The commented change (`>` marks the commented lines):",
433
+ "```diff",
434
+ ...shown,
435
+ "```",
436
+ ...cut > 0 ? [`(windowed to the commented lines — ${cut} more snapshot lines around them; read the current ${where} for the full change)`] : []
437
+ ];
438
+ }
344
439
  function threadBlock(thread, index) {
345
440
  const label = startedByAgent(thread) ? " [your comment — the reviewer replied]" : thread.closingNote ? " [their closing note on the whole review]" : thread.intent ? ` [${INTENT_LABEL[thread.intent]}]` : "";
346
441
  const again = thread.unanswered ? " — YOU NEVER ANSWERED THIS" : "";
347
- const parts = [`### Thread ${index + 1}${label} — ${describeAnchor(thread.anchor)}${again}`, `id: ${thread.id}`];
348
- if (thread.codeContext) {
349
- const lines = thread.codeContext.split("\n");
350
- parts.push("The commented change:", "```diff", ...lines.slice(0, SNAPSHOT_LINE_CAP), "```");
351
- if (lines.length > SNAPSHOT_LINE_CAP) {
352
- const where = thread.anchor.kind === "changeset" ? "the file" : `\`${thread.anchor.path}\``;
353
- parts.push(`(… and ${lines.length - SNAPSHOT_LINE_CAP} more snapshot lines — read the current ${where} instead)`);
354
- }
355
- }
356
- parts.push("Messages:", ...thread.messages.map((m) => `- ${m.author}: ${m.text.replace(/\n/g, "\n ")}`));
357
- return parts.join("\n");
442
+ return [
443
+ `### Thread ${index + 1}${label} — ${describeAnchor(thread.anchor)}${anchorQuote(thread)}${again}`,
444
+ `id: ${thread.id}`,
445
+ ...anchorNote(thread),
446
+ ...snapshotBlock(thread),
447
+ "Messages:",
448
+ ...thread.messages.map((m) => `- ${m.author}: ${m.text.replace(/\n/g, "\n ")}`)
449
+ ].join("\n");
358
450
  }
359
451
  function buildThreadPrompt(thread, ctx) {
360
452
  const siblings = siblingLines(ctx.siblings ?? []);
@@ -381,6 +473,23 @@ function buildCoalescedPrompt(threads, ctx) {
381
473
  ""
382
474
  ].join("\n");
383
475
  }
476
+ /**
477
+ * The reviewer started the review over — the previous round landed, and its
478
+ * threads and guide were cleared. Nothing to act on; the one thing owed is
479
+ * orientation for the fresh round, so this restates the guide doctrine the way
480
+ * the open-time nudge does (payloads must stand alone — see POLL_STANCE).
481
+ */
482
+ function buildClearedPrompt(ctx) {
483
+ return [
484
+ `The reviewer cleared the review in \`${ctx.repo.name}\` (branch \`${ctx.repo.branch}\`): the previous round landed, and its threads and guide are gone. What the reviewer sees now is a fresh round.`,
485
+ "",
486
+ ...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
487
+ `There is no feedback to act on. But the fresh round has no guide — if this changeset needs one (${GUIDE.when}): post it — one comment on the whole changeset: ${GUIDE.what}: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset). ${GUIDE.stance}.`,
488
+ "",
489
+ `Then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
490
+ ""
491
+ ].join("\n");
492
+ }
384
493
  function buildFinishPrompt(threads, ctx, coverage) {
385
494
  const repo = ctx.repo;
386
495
  const sent = threads.filter((t) => t.state === "sent");
@@ -396,7 +505,6 @@ function buildFinishPrompt(threads, ctx, coverage) {
396
505
  coverage.skippedFiles.length > 0 ? ` Not marked read: ${coverage.skippedFiles.join(", ")}.` : ""
397
506
  ].join("");
398
507
  const files = coverage.totalFiles !== void 0 && coverage.totalFiles > 0 ? `${coverage.viewedFiles ?? 0}/${coverage.totalFiles} files read, ` : "";
399
- const verdictLine = coverage.verdict === "approve" ? "Verdict: **approved** — the reviewer is happy for this changeset to proceed." : coverage.verdict === "request-changes" ? "Verdict: **changes requested** — do not treat this review as done until the threads below are addressed." : null;
400
508
  const noteLines = coverage.note === void 0 ? [] : closing ? ["Their closing note is Thread 1 below — answer it there, like any other thread."] : ["Their closing note:", ...coverage.note.split("\n").map((l) => `> ${l}`)];
401
509
  const owed = actionable.filter((t) => t.unanswered);
402
510
  const owedLines = owed.length > 0 ? [
@@ -409,7 +517,6 @@ function buildFinishPrompt(threads, ctx, coverage) {
409
517
  const parts = [
410
518
  `A reviewer finished reading your changes in \`${repo.name}\` (branch \`${repo.branch}\`).`,
411
519
  "",
412
- ...verdictLine ? [verdictLine, ""] : [],
413
520
  ...noteLines.length > 0 ? [...noteLines, ""] : [],
414
521
  ...ctx.changeset ? [changesetFrame(ctx.changeset), ""] : [],
415
522
  `Coverage: ${files}${coverage.viewedHunks}/${coverage.totalHunks} hunks read.${skipped}`,
@@ -417,8 +524,10 @@ function buildFinishPrompt(threads, ctx, coverage) {
417
524
  ...owedLines
418
525
  ];
419
526
  if (coverage.skippedFiles.length > 0) parts.push("The reviewer left the not-marked-read files unread. If any of them hide", "a risky or subtle change you made, say so in a comment thread", `(\`${CLI_COMMANDS.comment}\`) — the reviewer replies to take it up, or resolves it.`, "");
420
- if (actionable.length === 0) parts.push("No open review threads — nothing to act on. Run", `\`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE});`, "the reviewer may follow up.", "");
421
- else parts.push(`${actionable.length} review thread${actionable.length === 1 ? "" : "s"} to act on:`, "", ...actionable.map((t, i) => `${threadBlock(t, i)}\n`), replyProtocol(actionable), "");
527
+ if (actionable.length === 0) {
528
+ const fullRead = coverage.totalHunks > 0 && coverage.viewedHunks >= coverage.totalHunks && changed.length === 0 && commented.length === 0 && filtered.length === 0 && coverage.skippedFiles.length === 0;
529
+ parts.push(fullRead && coverage.note === void 0 ? "They read everything and left nothing to address — take it as a green light to proceed." : "No open review threads — nothing to act on.", `Run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE});`, "the reviewer may follow up.", "");
530
+ } else parts.push(`${actionable.length} review thread${actionable.length === 1 ? "" : "s"} to act on:`, "", ...actionable.map((t, i) => `${threadBlock(t, i)}\n`), replyProtocol(actionable), "");
422
531
  return parts.join("\n");
423
532
  }
424
533
  //#endregion
@@ -849,6 +958,57 @@ function parseVerb(verb, rest) {
849
958
  };
850
959
  }
851
960
  //#endregion
961
+ //#region src/devStamp.ts
962
+ /**
963
+ * A fingerprint of the dev checkout's source. The dev CLI runs `src/` straight
964
+ * through tsx, so "which build is this?" isn't answered by the package version —
965
+ * every edit is a new build with the same version string. The stamp is what
966
+ * changes instead: the server reports the stamp of the source it loaded, the
967
+ * next `diffo` open compares it to the source on disk, and a mismatch retires
968
+ * the server exactly like a version bump would.
969
+ *
970
+ * Tests are skipped — they never run inside the server — and `index.html` is
971
+ * included because the client bundle is built from it.
972
+ */
973
+ function computeSrcStamp(checkoutRoot) {
974
+ const hash = createHash("sha256");
975
+ const file = (path, name) => {
976
+ hash.update(name);
977
+ hash.update("\0");
978
+ hash.update(readFileSync(path));
979
+ hash.update("\0");
980
+ };
981
+ const walk = (dir, prefix) => {
982
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
983
+ for (const entry of entries) {
984
+ if (entry.name.endsWith(".test.ts") || entry.name.endsWith(".test.tsx")) continue;
985
+ const path = join(dir, entry.name);
986
+ const name = `${prefix}${entry.name}`;
987
+ if (entry.isDirectory()) walk(path, `${name}/`);
988
+ else if (entry.isFile()) file(path, name);
989
+ }
990
+ };
991
+ walk(join(checkoutRoot, "src"), "src/");
992
+ try {
993
+ file(join(checkoutRoot, "index.html"), "index.html");
994
+ } catch {}
995
+ return hash.digest("hex").slice(0, 16);
996
+ }
997
+ /**
998
+ * This process's stamp, captured at import — for the server that means the
999
+ * source it actually loaded, not whatever is on disk by the time someone asks.
1000
+ * Null outside dev (a published build has no `src/` to stamp, and its version
1001
+ * string already does this job).
1002
+ */
1003
+ const SRC_STAMP = (() => {
1004
+ if (!IS_DEV) return null;
1005
+ try {
1006
+ return computeSrcStamp(CHECKOUT_ROOT);
1007
+ } catch {
1008
+ return null;
1009
+ }
1010
+ })();
1011
+ //#endregion
852
1012
  //#region src/server/git.ts
853
1013
  const MAX_BUFFER = 52428800;
854
1014
  const SNIFF_BYTES = 8192;
@@ -1507,7 +1667,7 @@ const VERSION = (() => {
1507
1667
  dir = resolve(dir, "..");
1508
1668
  try {
1509
1669
  const pkg = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"));
1510
- if (pkg.name === "diffo" && typeof pkg.version === "string") return pkg.version;
1670
+ if (pkg.name === "@diffohq/diffo" && typeof pkg.version === "string") return pkg.version;
1511
1671
  } catch {}
1512
1672
  }
1513
1673
  return "0.0.0";
@@ -1635,7 +1795,8 @@ var DeliveryQueue = class DeliveryQueue {
1635
1795
  if (!bucket) {
1636
1796
  bucket = {
1637
1797
  threads: /* @__PURE__ */ new Set(),
1638
- finish: null
1798
+ finish: null,
1799
+ cleared: false
1639
1800
  };
1640
1801
  this.buckets.set(this.scope, bucket);
1641
1802
  }
@@ -1764,6 +1925,14 @@ var DeliveryQueue = class DeliveryQueue {
1764
1925
  this.bucket().finish = coverage;
1765
1926
  this.wake();
1766
1927
  }
1928
+ /** The reviewer cleared the review. A boolean, not a queue: clearing twice
1929
+ * before a poll still owes exactly one heads-up. */
1930
+ enqueueCleared() {
1931
+ this.bucket().cleared = true;
1932
+ const queued = this.waiter === null;
1933
+ this.wake();
1934
+ if (queued) this.notify();
1935
+ }
1767
1936
  drop(threadId) {
1768
1937
  for (const bucket of this.buckets.values()) bucket.threads.delete(threadId);
1769
1938
  this.deliveredAt.delete(threadId);
@@ -1823,7 +1992,7 @@ var DeliveryQueue = class DeliveryQueue {
1823
1992
  }
1824
1993
  hasPending() {
1825
1994
  const bucket = this.buckets.get(this.scope);
1826
- return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null);
1995
+ return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null || bucket.cleared);
1827
1996
  }
1828
1997
  wake() {
1829
1998
  if (this.waiter === null) return;
@@ -1845,11 +2014,19 @@ var DeliveryQueue = class DeliveryQueue {
1845
2014
  kind: "threads",
1846
2015
  threadIds: [...bucket.threads]
1847
2016
  };
2017
+ if (bucket.cleared) return { kind: "cleared" };
1848
2018
  return null;
1849
2019
  }
1850
2020
  /** The poll response for this snapshot was fully written — NOW it counts as
1851
2021
  * delivered. Clears exactly what the snapshot covered. */
1852
2022
  confirm(snapshot, deliveredThreadIds) {
2023
+ if (snapshot.kind === "cleared") {
2024
+ const bucket = this.buckets.get(this.scope);
2025
+ if (bucket) bucket.cleared = false;
2026
+ this.armGrace();
2027
+ this.notify();
2028
+ return;
2029
+ }
1853
2030
  for (const bucket of this.buckets.values()) if (snapshot.kind === "finish") {
1854
2031
  if (bucket.finish === snapshot.coverage) bucket.finish = null;
1855
2032
  for (const id of snapshot.absorbedThreadIds) bucket.threads.delete(id);
@@ -1981,6 +2158,15 @@ var IdleMonitor = class {
1981
2158
  * committed. Stamp `landed` — the UI's cue to offer a fresh start. A stash
1982
2159
  * empties the diff without moving HEAD, so it never stamps; a branch switch
1983
2160
  * is rescoped to its own review before this runs.
2161
+ * - The commit can also go unwitnessed: it lands and the next round starts
2162
+ * while no server watches, so no recompute ever sees the empty diff. The
2163
+ * evidence survives anyway — HEAD advanced past `seenHead` AND every hunk
2164
+ * the review pointed at (thread anchors, the last finish) is gone from the
2165
+ * current changeset. Both together stamp even over a non-empty diff; either
2166
+ * alone does not (an unrelated commit leaves the reviewed hunks in place, an
2167
+ * agent rewriting every hunk between recomputes leaves HEAD at `seenHead`).
2168
+ * A review that never anchored to a hunk has no such evidence and waits for
2169
+ * the witnessed path.
1984
2170
  * - A landed commit that left HEAD's history (reset, or an amend rewriting it)
1985
2171
  * drops the marker — and an amend re-stamps in the same pass, because
1986
2172
  * `seenHead` is still the pre-landing base and IS an ancestor of the
@@ -1993,7 +2179,7 @@ var IdleMonitor = class {
1993
2179
  * empty diff hangs over a non-empty review it stays frozen at the base — the
1994
2180
  * fixed point that makes "amended" and "reset back" distinguishable above.
1995
2181
  */
1996
- function maintainLanded(review, hasFiles, git) {
2182
+ function maintainLanded(review, hasFiles, currentHunkIds, git) {
1997
2183
  const head = git.head();
1998
2184
  if (head === null) return null;
1999
2185
  const state = review.get();
@@ -2005,7 +2191,7 @@ function maintainLanded(review, hasFiles, git) {
2005
2191
  outcome = "cleared";
2006
2192
  }
2007
2193
  }
2008
- if (!review.get().landed && !hasFiles && !reviewEmpty && state.seenHead !== void 0 && state.seenHead !== head && git.isAncestor(state.seenHead, head) === true) {
2194
+ if (!review.get().landed && (!hasFiles || reviewedHunksAllLanded(state, currentHunkIds)) && !reviewEmpty && state.seenHead !== void 0 && state.seenHead !== head && git.isAncestor(state.seenHead, head) === true) {
2009
2195
  review.markLanded({
2010
2196
  sha: head,
2011
2197
  subject: git.subject(head),
@@ -2016,6 +2202,18 @@ function maintainLanded(review, hasFiles, git) {
2016
2202
  if (hasFiles || reviewEmpty) review.noteHead(head);
2017
2203
  return outcome;
2018
2204
  }
2205
+ /** The unwitnessed-landing evidence: the review pointed at hunks, and none of
2206
+ * them survive in the current changeset. Vacuous truth deliberately fails —
2207
+ * a review of only file- and changeset-anchored threads names no hunks, so it
2208
+ * cannot testify that they landed. */
2209
+ function reviewedHunksAllLanded(state, currentHunkIds) {
2210
+ const referenced = /* @__PURE__ */ new Set();
2211
+ for (const thread of state.threads) if (thread.anchor.kind === "hunk") referenced.add(thread.anchor.hunkId);
2212
+ for (const id of state.lastFinish?.hunkIds ?? []) referenced.add(id);
2213
+ if (referenced.size === 0) return false;
2214
+ for (const id of referenced) if (currentHunkIds.has(id)) return false;
2215
+ return true;
2216
+ }
2019
2217
  //#endregion
2020
2218
  //#region src/server/review.ts
2021
2219
  const STATES = [
@@ -2068,14 +2266,15 @@ var ReviewStore = class {
2068
2266
  this.listeners.add(listener);
2069
2267
  return () => this.listeners.delete(listener);
2070
2268
  }
2071
- createThread(anchor, text, codeContext, intent, author = "reviewer") {
2269
+ createThread(anchor, text, capture, intent, author = "reviewer") {
2072
2270
  const now = (/* @__PURE__ */ new Date()).toISOString();
2073
2271
  return this.insert({
2074
2272
  id: randomUUID(),
2075
2273
  anchor,
2076
2274
  state: "open",
2077
2275
  ...intent ? { intent } : {},
2078
- codeContext,
2276
+ codeContext: capture?.codeContext ?? null,
2277
+ ...capture?.anchored ? { anchored: capture.anchored } : {},
2079
2278
  codeChanged: false,
2080
2279
  messages: [{
2081
2280
  id: randomUUID(),
@@ -2454,7 +2653,6 @@ function normalizeCoverage(value) {
2454
2653
  skippedFiles: fileList(c.skippedFiles) ?? [],
2455
2654
  ...changedFiles !== void 0 ? { changedFiles } : {},
2456
2655
  ...commentedUnread !== void 0 ? { commentedUnread } : {},
2457
- ...REVIEW_VERDICTS.includes(c.verdict) ? { verdict: c.verdict } : {},
2458
2656
  ...typeof c.note === "string" ? { note: c.note } : {}
2459
2657
  };
2460
2658
  }
@@ -2489,12 +2687,14 @@ function parseAnchor(value) {
2489
2687
  if (typeof anchor.hunkId !== "string" || typeof anchor.path !== "string") return null;
2490
2688
  if (anchor.side !== "old" && anchor.side !== "new") return null;
2491
2689
  if (typeof anchor.line !== "number") return null;
2690
+ const endLine = typeof anchor.endLine === "number" && Number.isInteger(anchor.endLine) && anchor.endLine > anchor.line ? anchor.endLine : void 0;
2492
2691
  return {
2493
2692
  kind: "hunk",
2494
2693
  hunkId: anchor.hunkId,
2495
2694
  path: anchor.path,
2496
2695
  side: anchor.side,
2497
- line: anchor.line
2696
+ line: anchor.line,
2697
+ ...endLine !== void 0 ? { endLine } : {}
2498
2698
  };
2499
2699
  }
2500
2700
  if (anchor.kind === "file") return typeof anchor.path === "string" ? {
@@ -2503,12 +2703,25 @@ function parseAnchor(value) {
2503
2703
  } : null;
2504
2704
  return anchor.kind === "changeset" ? { kind: "changeset" } : null;
2505
2705
  }
2706
+ /** Dropped when malformed — the thread is still fine without it. */
2707
+ function normalizeAnchored(value) {
2708
+ if (typeof value !== "object" || value === null) return null;
2709
+ const a = value;
2710
+ if (typeof a.start !== "number" || typeof a.end !== "number" || typeof a.text !== "string") return null;
2711
+ if (!Number.isInteger(a.start) || !Number.isInteger(a.end) || a.start < 0 || a.end < a.start) return null;
2712
+ return {
2713
+ start: a.start,
2714
+ end: a.end,
2715
+ text: a.text
2716
+ };
2717
+ }
2506
2718
  function normalizeThread(value, now) {
2507
2719
  if (typeof value !== "object" || value === null) return null;
2508
2720
  const t = value;
2509
2721
  if (typeof t.id !== "string" || !STATES.includes(t.state)) return null;
2510
2722
  const anchor = parseAnchor(t.anchor);
2511
2723
  if (!anchor) return null;
2724
+ const anchored = normalizeAnchored(t.anchored);
2512
2725
  if (!Array.isArray(t.messages)) return null;
2513
2726
  const messages = [];
2514
2727
  for (const m of t.messages) {
@@ -2530,6 +2743,7 @@ function normalizeThread(value, now) {
2530
2743
  state: t.state,
2531
2744
  ...THREAD_INTENTS.includes(t.intent) ? { intent: t.intent } : {},
2532
2745
  codeContext: typeof t.codeContext === "string" ? t.codeContext : null,
2746
+ ...anchored ? { anchored } : {},
2533
2747
  codeChanged: t.codeChanged === true,
2534
2748
  ...t.unanswered === true ? { unanswered: true } : {},
2535
2749
  ...t.closingNote === true ? { closingNote: true } : {},
@@ -3007,7 +3221,8 @@ function createApp(ctx, store, review, queue) {
3007
3221
  repo: resolve(ctx.root),
3008
3222
  app: "diffo",
3009
3223
  version: VERSION,
3010
- pid: process.pid
3224
+ pid: process.pid,
3225
+ ...SRC_STAMP === null ? {} : { srcStamp: SRC_STAMP }
3011
3226
  }));
3012
3227
  app.post("/api/shutdown", (c) => {
3013
3228
  setImmediate(() => ctx.onShutdownRequest?.());
@@ -3065,14 +3280,14 @@ function createApp(ctx, store, review, queue) {
3065
3280
  const file = typeof body?.file === "string" && body.file.trim() !== "" ? body.file.trim() : null;
3066
3281
  const line = typeof body?.line === "number" && Number.isInteger(body.line) && body.line >= 1 ? body.line : null;
3067
3282
  const anchor = agentAnchor(file, line);
3068
- const codeContext = anchor.kind === "hunk" && store ? snapshotHunk(store.get(), anchor.hunkId) : null;
3069
- return c.json(review.createThread(anchor, text, codeContext, void 0, "agent"));
3283
+ const capture = store ? captureAnchor(store.get(), anchor) : null;
3284
+ return c.json(review.createThread(anchor, text, capture, void 0, "agent"));
3070
3285
  }
3071
3286
  const anchor = parseAnchor(body?.anchor);
3072
3287
  if (!anchor) return c.json({ error: "need {anchor, text}" }, 400);
3073
3288
  const intent = THREAD_INTENTS.includes(body?.intent) ? body.intent : void 0;
3074
- const codeContext = anchor.kind === "hunk" && store ? snapshotHunk(store.get(), anchor.hunkId) : null;
3075
- return c.json(review.createThread(anchor, text, codeContext, intent));
3289
+ const capture = store ? captureAnchor(store.get(), anchor) : null;
3290
+ return c.json(review.createThread(anchor, text, capture, intent));
3076
3291
  });
3077
3292
  app.post("/api/review/threads/:id/messages", async (c) => {
3078
3293
  if (!review) return c.json({ error: "review unavailable" }, 503);
@@ -3117,8 +3332,11 @@ function createApp(ctx, store, review, queue) {
3117
3332
  });
3118
3333
  app.delete("/api/review/threads", (c) => {
3119
3334
  if (!review) return c.json({ error: "review unavailable" }, 503);
3335
+ const before = review.get();
3336
+ const hadRound = before.threads.length > 0 || before.lastFinish !== void 0;
3120
3337
  const removed = review.reset();
3121
3338
  for (const id of removed) queue?.drop(id);
3339
+ if (hadRound && (store?.get().files.length ?? 0) > 0) queue?.enqueueCleared();
3122
3340
  return c.json({ removed: removed.length });
3123
3341
  });
3124
3342
  app.delete("/api/review/landed", (c) => {
@@ -3157,7 +3375,6 @@ function createApp(ctx, store, review, queue) {
3157
3375
  const n = Number(value);
3158
3376
  return Number.isFinite(n) ? n : void 0;
3159
3377
  };
3160
- const verdict = REVIEW_VERDICTS.includes(src.verdict) ? src.verdict : void 0;
3161
3378
  const note = typeof src.note === "string" && src.note.trim() !== "" ? src.note.trim().slice(0, 4e3) : void 0;
3162
3379
  const fileList = (value) => Array.isArray(value) ? value.filter((f) => typeof f === "string") : void 0;
3163
3380
  const changedFiles = fileList(src.changedFiles);
@@ -3170,7 +3387,6 @@ function createApp(ctx, store, review, queue) {
3170
3387
  skippedFiles: fileList(src.skippedFiles) ?? [],
3171
3388
  ...changedFiles !== void 0 ? { changedFiles } : {},
3172
3389
  ...commentedUnread !== void 0 ? { commentedUnread } : {},
3173
- ...verdict !== void 0 ? { verdict } : {},
3174
3390
  ...note !== void 0 ? { note } : {}
3175
3391
  };
3176
3392
  };
@@ -3263,12 +3479,21 @@ function createApp(ctx, store, review, queue) {
3263
3479
  });
3264
3480
  const pollPayload = (snapshot) => {
3265
3481
  const threads = review?.get().threads ?? [];
3482
+ if (snapshot.kind === "cleared") return {
3483
+ status: "feedback",
3484
+ kind: "cleared",
3485
+ threadIds: [],
3486
+ prompt: buildClearedPrompt({
3487
+ repo: repoInfo(),
3488
+ changeset: store?.get() ?? null
3489
+ })
3490
+ };
3266
3491
  if (snapshot.kind === "finish") {
3267
3492
  const batch = activeThreads(threads);
3268
3493
  return {
3269
3494
  status: "feedback",
3270
3495
  kind: "finish",
3271
- threadIds: batch.filter((t) => t.state === "sent").map((t) => t.id),
3496
+ threadIds: batch.filter((t) => t.state === "sent" && t.messages.at(-1)?.author === "reviewer").map((t) => t.id),
3272
3497
  prompt: buildFinishPrompt(batch, {
3273
3498
  repo: repoInfo(),
3274
3499
  changeset: store?.get() ?? null
@@ -3549,8 +3774,8 @@ function startServer(options) {
3549
3774
  isAncestor: (ancestor, descendant) => isAncestor(options.root, ancestor, descendant),
3550
3775
  subject: (sha) => getCommitSubject(options.root, sha)
3551
3776
  };
3552
- const checkLanded = (hasFiles) => {
3553
- if (maintainLanded(review, hasFiles, landedGit) === "stamped") {
3777
+ const checkLanded = (hasFiles, hunkIds) => {
3778
+ if (maintainLanded(review, hasFiles, hunkIds, landedGit) === "stamped") {
3554
3779
  const landed = review.get().landed;
3555
3780
  if (landed) log(`changeset landed in ${landed.sha.slice(0, 7)} — offering a fresh review`);
3556
3781
  }
@@ -3561,11 +3786,13 @@ function startServer(options) {
3561
3786
  review.rescope(changeset.repo.branch);
3562
3787
  queue.rescope(changeset.repo.branch);
3563
3788
  if (review.scope.branch !== before) log(`branch ${before || "detached"} → ${changeset.repo.branch || "detached"}`);
3564
- review.reconcile(new Set(changeset.files.flatMap((f) => f.hunks.map((h) => h.id))));
3565
- checkLanded(changeset.files.length > 0);
3789
+ const hunkIds = new Set(changeset.files.flatMap((f) => f.hunks.map((h) => h.id)));
3790
+ review.reconcile(hunkIds);
3791
+ checkLanded(changeset.files.length > 0, hunkIds);
3566
3792
  });
3567
- review.reconcile(new Set(store.get().files.flatMap((f) => f.hunks.map((h) => h.id))));
3568
- checkLanded(store.get().files.length > 0);
3793
+ const startupHunkIds = new Set(store.get().files.flatMap((f) => f.hunks.map((h) => h.id)));
3794
+ review.reconcile(startupHunkIds);
3795
+ checkLanded(store.get().files.length > 0, startupHunkIds);
3569
3796
  const idleTimeoutMs = resolveIdleTimeoutMs(process.env);
3570
3797
  let idle;
3571
3798
  if (idleTimeoutMs !== null) {
@@ -3635,9 +3862,17 @@ function reviewUrl(port) {
3635
3862
  //#endregion
3636
3863
  //#region src/serverLifecycle.ts
3637
3864
  const MAX_LOG_BYTES = 2097152;
3638
- function assessRunningServer(health, repoPath, version) {
3865
+ /**
3866
+ * `srcStamp` is the dev-build refinement of the version check: a dev server's
3867
+ * version never changes across source edits, so an expected stamp (non-null)
3868
+ * makes a server started from other source a 'replace' too. Callers that must
3869
+ * not churn a running review (poll, reply) simply pass none.
3870
+ */
3871
+ function assessRunningServer(health, repoPath, version, srcStamp = null) {
3639
3872
  if (health?.ok !== true || health.repo !== repoPath) return "foreign";
3640
- return health.app === "diffo" && health.version === version ? "reuse" : "replace";
3873
+ if (health.app !== "diffo" || health.version !== version) return "replace";
3874
+ if (srcStamp !== null && health.srcStamp !== srcStamp) return "replace";
3875
+ return "reuse";
3641
3876
  }
3642
3877
  /**
3643
3878
  * argv for the daemon: the same runtime flags executing this CLI, the same entry
@@ -3711,17 +3946,17 @@ async function retireServer(port, pid, reportedPid = null) {
3711
3946
  * their own copy and write the whole blob back, so the slower watcher erases the
3712
3947
  * reviewer's threads, and clearing the row would hand the claim to a newcomer.
3713
3948
  */
3714
- async function settleExistingServer(db, repoPath, version, onStatus = () => {}) {
3949
+ async function settleExistingServer(db, repoPath, version, onStatus = () => {}, srcStamp = null) {
3715
3950
  const record = db.getServer(repoPath);
3716
3951
  if (!record) return null;
3717
3952
  const health = await fetchHealth(record.port);
3718
- const verdict = assessRunningServer(health, repoPath, version);
3953
+ const verdict = assessRunningServer(health, repoPath, version, srcStamp);
3719
3954
  if (verdict === "reuse") return {
3720
3955
  port: record.port,
3721
3956
  pid: health?.pid ?? record.pid ?? null
3722
3957
  };
3723
3958
  if (verdict === "replace") {
3724
- onStatus(`replacing the diffo server from another build (${health?.version ?? "pre-handshake"} → ${version})`);
3959
+ onStatus(health?.version === version ? `replacing the dev server — the checkout's source changed since it started` : `replacing the diffo server from another build (${health?.version ?? "pre-handshake"} → ${version})`);
3725
3960
  if (!await retireServer(record.port, record.pid ?? null, health?.pid ?? null)) throw new Error(`a diffo server from another build is stuck on port ${record.port}` + (record.pid ? ` (pid ${record.pid})` : "") + ` and would not step aside — stop it, then re-run`);
3726
3961
  }
3727
3962
  db.removeServer(repoPath, record.port);
@@ -3781,7 +4016,7 @@ async function ensureServer(opts, deps = {}) {
3781
4016
  const status = opts.onStatus ?? (() => {});
3782
4017
  const db = new DiffoDb(opts.dbPath);
3783
4018
  try {
3784
- const existing = await settleExistingServer(db, opts.repoPath, opts.version, status);
4019
+ const existing = await settleExistingServer(db, opts.repoPath, opts.version, status, opts.srcStamp ?? null);
3785
4020
  if (existing) return existing.port;
3786
4021
  status("no diffo server for this repo — starting one");
3787
4022
  mkdirSync(dirname(opts.logPath), { recursive: true });
@@ -3792,7 +4027,7 @@ async function ensureServer(opts, deps = {}) {
3792
4027
  if (!await waitUntil(async () => {
3793
4028
  const row = db.getServer(opts.repoPath);
3794
4029
  if (!row) return false;
3795
- if (assessRunningServer(await fetchHealth(row.port, 500), opts.repoPath, opts.version) !== "reuse") return false;
4030
+ if (assessRunningServer(await fetchHealth(row.port, 500), opts.repoPath, opts.version, opts.srcStamp ?? null) !== "reuse") return false;
3796
4031
  port = row.port;
3797
4032
  return true;
3798
4033
  }, opts.spawnTimeoutMs ?? 8e3) || port === null) {
@@ -4201,10 +4436,15 @@ if (command.kind === "setup") {
4201
4436
  const root = findRepoRoot(process.cwd());
4202
4437
  if (!root) fail("not inside a git repository");
4203
4438
  const repoPath = resolve(root);
4439
+ /**
4440
+ * Only the open path settles with the source stamp: opening a review is the
4441
+ * moment "run the current source" is the promise, while poll/reply/comment must
4442
+ * never restart the server under a review in progress.
4443
+ */
4204
4444
  async function discoverServer() {
4205
4445
  const db = new DiffoDb();
4206
4446
  try {
4207
- return await settleExistingServer(db, repoPath, VERSION, (line) => process.stderr.write(`diffo: ${line}\n`));
4447
+ return await settleExistingServer(db, repoPath, VERSION, (line) => process.stderr.write(`diffo: ${line}\n`), SRC_STAMP);
4208
4448
  } catch (err) {
4209
4449
  fail(err.message);
4210
4450
  } finally {
@@ -4218,7 +4458,7 @@ function sessionHeaders() {
4218
4458
  ...pid === null ? {} : { "x-diffo-session-pid": String(pid) }
4219
4459
  };
4220
4460
  }
4221
- async function requireServer(port, base) {
4461
+ async function requireServer(port, base, srcStamp = null) {
4222
4462
  try {
4223
4463
  return await ensureServer({
4224
4464
  repoPath,
@@ -4228,6 +4468,7 @@ async function requireServer(port, base) {
4228
4468
  execArgv: process.execArgv,
4229
4469
  port,
4230
4470
  base,
4471
+ srcStamp,
4231
4472
  logPath: process.env.DIFFO_SERVER_LOG || defaultLogPath(repoPath),
4232
4473
  onStatus: (line) => process.stderr.write(`diffo: ${line}\n`)
4233
4474
  });
@@ -4343,7 +4584,7 @@ if (command.kind === "status") {
4343
4584
  db.close();
4344
4585
  }
4345
4586
  const health = record ? await fetchHealth(record.port) : null;
4346
- const verdict = record ? assessRunningServer(health, repoPath, VERSION) : "foreign";
4587
+ const verdict = record ? assessRunningServer(health, repoPath, VERSION, SRC_STAMP) : "foreign";
4347
4588
  if (!record || verdict === "foreign") {
4348
4589
  if (command.json) console.log(JSON.stringify({ running: false }));
4349
4590
  else console.log(record ? "no diffo server is watching this repo (its last registration is stale)" : "no diffo server is watching this repo");
@@ -4362,7 +4603,7 @@ if (command.kind === "status") {
4362
4603
  process.exit(0);
4363
4604
  }
4364
4605
  await printChangesetSummary(record.port);
4365
- console.log(`server: port ${record.port}${pid ? ` · pid ${pid}` : ""} · v${health?.version ?? "pre-handshake"}` + (verdict === "replace" ? ` (this CLI is v${VERSION} — the next \`diffo\` replaces it)` : ""));
4606
+ console.log(`server: port ${record.port}${pid ? ` · pid ${pid}` : ""} · v${health?.version ?? "pre-handshake"}` + (verdict === "replace" ? health?.version === VERSION ? ` (the checkout's source changed — the next \`diffo\` replaces it)` : ` (this CLI is v${VERSION} — the next \`diffo\` replaces it)` : ""));
4366
4607
  console.log(`→ ${url}`);
4367
4608
  process.exit(0);
4368
4609
  }
@@ -4472,6 +4713,32 @@ async function printAgentNextStep(port) {
4472
4713
  if (nudge) console.log(`first: ${nudge}`);
4473
4714
  console.log("next: run `diffo poll` to receive the reviewer's feedback (`diffo help agent` prints the whole loop)");
4474
4715
  }
4716
+ /**
4717
+ * Dev builds serve the client from the checkout's `dist/client`, which nothing
4718
+ * rebuilds on its own — so opening a review after a source edit showed last
4719
+ * build's UI over this build's server. Rebuild it here, before the server is
4720
+ * settled: the server reads client files from disk per request, so even a
4721
+ * reused server serves the fresh bundle. `.dev-stamp` (written after a
4722
+ * successful build, into a directory vite has just emptied) makes the no-change
4723
+ * open free. The foreground path is exempt — that is `pnpm dev:server`'s tsx
4724
+ * watch loop, where the client is vite's dev server, not this bundle.
4725
+ */
4726
+ function rebuildDevClient(foreground) {
4727
+ if (SRC_STAMP === null || foreground) return;
4728
+ const stampFile = join(CHECKOUT_ROOT, "dist", "client", ".dev-stamp");
4729
+ try {
4730
+ if (readFileSync(stampFile, "utf-8").trim() === SRC_STAMP) return;
4731
+ } catch {}
4732
+ process.stderr.write(`diffo: dev checkout changed — rebuilding the client bundle\n`);
4733
+ const vite = join(CHECKOUT_ROOT, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
4734
+ const built = spawnSync(vite, ["build"], {
4735
+ cwd: CHECKOUT_ROOT,
4736
+ encoding: "utf-8"
4737
+ });
4738
+ if (built.status !== 0) fail(`the client rebuild failed — fix the build, then re-run\n${(built.stderr || built.stdout || String(built.error ?? "")).trim().slice(-2e3)}`);
4739
+ writeFileSync(stampFile, `${SRC_STAMP}\n`);
4740
+ }
4741
+ rebuildDevClient(command.foreground);
4475
4742
  const existing = await discoverServer();
4476
4743
  const takeOverPort = existing !== null && command.foreground && command.port !== void 0 && command.port !== existing.port ? command.port : null;
4477
4744
  if (existing !== null && takeOverPort === null) {
@@ -4496,7 +4763,7 @@ if (existing !== null && takeOverPort !== null) {
4496
4763
  db.close();
4497
4764
  }
4498
4765
  if (!command.foreground) {
4499
- const daemonPort = await requireServer(command.port, command.spec.kind === "branch" ? command.spec.base : void 0);
4766
+ const daemonPort = await requireServer(command.port, command.spec.kind === "branch" ? command.spec.base : void 0, SRC_STAMP);
4500
4767
  const url = reviewUrl(daemonPort);
4501
4768
  await printChangesetSummary(daemonPort);
4502
4769
  console.log(`→ ${url}`);