@diffohq/diffo 0.9.0 → 0.11.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 +19 -29
  2. package/dist/cli.mjs +795 -81
  3. package/dist/client/assets/{abnfDiagram-O67JEVCF-Dh4is2Nj.js → abnfDiagram-O67JEVCF-BP5iCFkM.js} +1 -1
  4. package/dist/client/assets/{arc-CMN0Gb1g.js → arc-C87wOYz0.js} +1 -1
  5. package/dist/client/assets/architecture-7GRP2DOG-CsHR0ILn.js +1 -0
  6. package/dist/client/assets/{architectureDiagram-NJMV4G6O-DxVGDSE1.js → architectureDiagram-NJMV4G6O-Ch5MdO5l.js} +1 -1
  7. package/dist/client/assets/{blockDiagram-BEXU5L5S-T1F9hSwZ.js → blockDiagram-BEXU5L5S-CFc74J_K.js} +1 -1
  8. package/dist/client/assets/{c4Diagram-YGBWAQC7-DC6zwYuX.js → c4Diagram-YGBWAQC7-ClsjpVUP.js} +1 -1
  9. package/dist/client/assets/channel-DQRWLOSk.js +1 -0
  10. package/dist/client/assets/{chunk-3FUC2YCW-D0PAoCRl.js → chunk-3FUC2YCW-N2ZkdF_f.js} +1 -1
  11. package/dist/client/assets/{chunk-5DYCD2WN-NqHzOtt0.js → chunk-5DYCD2WN-5muGfxCV.js} +1 -1
  12. package/dist/client/assets/{chunk-742MDFTN-B9Y5HHB7.js → chunk-742MDFTN-C5vBH3An.js} +1 -1
  13. package/dist/client/assets/{chunk-7INBJB4K-CBOWlj91.js → chunk-7INBJB4K-DPMhysil.js} +1 -1
  14. package/dist/client/assets/{chunk-7M6MHVWA-D2dZ_rQI.js → chunk-7M6MHVWA-D5sd2f7K.js} +1 -1
  15. package/dist/client/assets/{chunk-7PRAP22T-7dilu8x-.js → chunk-7PRAP22T-CI_sV34J.js} +1 -1
  16. package/dist/client/assets/{chunk-GTNCS2PH-at0ENjhB.js → chunk-GTNCS2PH-DfNZcIoa.js} +1 -1
  17. package/dist/client/assets/{chunk-GWA4HPMP-C06n_37U.js → chunk-GWA4HPMP-DQAQRo6A.js} +1 -1
  18. package/dist/client/assets/{chunk-MBY4JIJT-DuowBunG.js → chunk-MBY4JIJT-BaSwxH2J.js} +1 -1
  19. package/dist/client/assets/{chunk-NETBCI7D-ClNA7QFH.js → chunk-NETBCI7D-7ynGMJbY.js} +1 -1
  20. package/dist/client/assets/{chunk-O7XYJQB3-BjcjInBL.js → chunk-O7XYJQB3-B6zthtTM.js} +1 -1
  21. package/dist/client/assets/{chunk-UA2S7LBM-B-PAn1hd.js → chunk-UA2S7LBM-C5XT36wS.js} +1 -1
  22. package/dist/client/assets/{chunk-WEXAMYUT-Br84uK4B.js → chunk-WEXAMYUT-DKSfnXN1.js} +1 -1
  23. package/dist/client/assets/{chunk-XXDRQBXY-BbBZykgX.js → chunk-XXDRQBXY-C_bd9P3S.js} +1 -1
  24. package/dist/client/assets/{chunk-Z7XXMR3K-CQ-grwfe.js → chunk-Z7XXMR3K-DX8Rrfc1.js} +1 -1
  25. package/dist/client/assets/{chunk-ZIGJFQKS-CLwfE-hs.js → chunk-ZIGJFQKS-DYXH6dfg.js} +1 -1
  26. package/dist/client/assets/{classDiagram-v2-NBCMYWYE-DD-bcOHO.js → classDiagram-v2-NBCMYWYE-ppwXe7vx.js} +1 -1
  27. package/dist/client/assets/{core-BADyutHL.js → core-Cg_mWgd2.js} +1 -1
  28. package/dist/client/assets/{cose-bilkent-JH36ORCC-fuMg3J_l.js → cose-bilkent-JH36ORCC-DBThhQUN.js} +1 -1
  29. package/dist/client/assets/{cynefin-OW5HDTMX-DwFXJ_x8.js → cynefin-OW5HDTMX-BzSaDndh.js} +1 -1
  30. package/dist/client/assets/{cynefinDiagram-VND7K2PF-cGL_k8gj.js → cynefinDiagram-VND7K2PF-D7pfZG6I.js} +1 -1
  31. package/dist/client/assets/{dagre-6A5THRUB-BetgCBn9.js → dagre-6A5THRUB-C09fFRAG.js} +1 -1
  32. package/dist/client/assets/{diagram-22UHCM2B-C7qT2Ix2.js → diagram-22UHCM2B-Ch1ZxAj-.js} +1 -1
  33. package/dist/client/assets/{diagram-3UASUU5V-DGUO9DWV.js → diagram-3UASUU5V-BgHiK8FV.js} +1 -1
  34. package/dist/client/assets/{diagram-ATOU4E4O-f520gjx5.js → diagram-ATOU4E4O-Bo77_UMN.js} +1 -1
  35. package/dist/client/assets/{diagram-CDSNMT55-Dhrm4GzB.js → diagram-CDSNMT55--hmHMcni.js} +1 -1
  36. package/dist/client/assets/{diagram-MLGK6HIB-Di0aRiuU.js → diagram-MLGK6HIB-D9F7Il7J.js} +1 -1
  37. package/dist/client/assets/{diagram-MPIPVDR6-BHhVZUxI.js → diagram-MPIPVDR6-D3d07JsR.js} +1 -1
  38. package/dist/client/assets/{dist-Cezvykkn.js → dist-CAR9vYXv.js} +1 -1
  39. package/dist/client/assets/{dist-CbdYocyN.js → dist-Dgznis65.js} +1 -1
  40. package/dist/client/assets/{ebnfDiagram-ZINNZB2B-CQA0eib_.js → ebnfDiagram-ZINNZB2B-Bso144Pp.js} +1 -1
  41. package/dist/client/assets/{elk-276RUBZZ-CZKKVSu0.js → elk-276RUBZZ-DsWB0IFq.js} +1 -1
  42. package/dist/client/assets/{engine-oniguruma-B68c_YAs.js → engine-oniguruma-BDu3jbE4.js} +1 -1
  43. package/dist/client/assets/{erDiagram-OPXOYQCR-DcNnfq_S.js → erDiagram-OPXOYQCR-DFcxAkd4.js} +1 -1
  44. package/dist/client/assets/eventmodeling-NTZA5JFV-Dy8DiEN2.js +1 -0
  45. package/dist/client/assets/flowDiagram-KWPJA3E3-CNCW86am.js +1 -0
  46. package/dist/client/assets/{ganttDiagram-FUAMR5RP-Dteh33ci.js → ganttDiagram-FUAMR5RP-DZ8yDc0-.js} +1 -1
  47. package/dist/client/assets/{gitGraph-4MIJSDKK-DMTE1jTV.js → gitGraph-4MIJSDKK-jziaCjGb.js} +1 -1
  48. package/dist/client/assets/{gitGraphDiagram-X574FWY7-EV_0IPok.js → gitGraphDiagram-X574FWY7-DCRBeaGE.js} +1 -1
  49. package/dist/client/assets/index-C4VoHKyI.css +1 -0
  50. package/dist/client/assets/index-CWGB-mch.js +93 -0
  51. package/dist/client/assets/{info-A6RAGUB7-WkHzxEDS.js → info-A6RAGUB7-CVherEOw.js} +1 -1
  52. package/dist/client/assets/{infoDiagram-VRGFBTTK-BcH5U5G2.js → infoDiagram-VRGFBTTK-0xq1jhrR.js} +1 -1
  53. package/dist/client/assets/{ishikawaDiagram-OU5B5YK6-Z8vZnj1l.js → ishikawaDiagram-OU5B5YK6-BOs1zS14.js} +1 -1
  54. package/dist/client/assets/{journeyDiagram-ZHPQQLJL-Cn-m-UYg.js → journeyDiagram-ZHPQQLJL-D0Ozyf-O.js} +1 -1
  55. package/dist/client/assets/{kanban-definition-PNTS6WVX-DLOrUOML.js → kanban-definition-PNTS6WVX-DRE_uQjW.js} +1 -1
  56. package/dist/client/assets/{line-DhKwT3E3.js → line-DaN3iy15.js} +1 -1
  57. package/dist/client/assets/{linear-DvwuMCFu.js → linear-CRCMQGaX.js} +1 -1
  58. package/dist/client/assets/{mermaid-parser.core-CwcOI9nG.js → mermaid-parser.core-oN6M_WEa.js} +3 -3
  59. package/dist/client/assets/{mermaid.core-BabKb2iQ.js → mermaid.core-YJctE5LN.js} +4 -4
  60. package/dist/client/assets/{mindmap-definition-NLK3R4M7-D7wpNtz6.js → mindmap-definition-NLK3R4M7-C6eqCgee.js} +1 -1
  61. package/dist/client/assets/{packet-AYTQ26CC-BJablqte.js → packet-AYTQ26CC-D09KC4vR.js} +1 -1
  62. package/dist/client/assets/{pegDiagram-GJSIUBJH-EAlXAkUz.js → pegDiagram-GJSIUBJH-CbKH-rBq.js} +1 -1
  63. package/dist/client/assets/{pie-WAS4IAKB-BAAbtIiO.js → pie-WAS4IAKB-whtC2UUK.js} +1 -1
  64. package/dist/client/assets/{pieDiagram-5QR66LMP-b1pYP4ES.js → pieDiagram-5QR66LMP-Dvc9m9wg.js} +1 -1
  65. package/dist/client/assets/{quadrantDiagram-O4NWA36T-B_LJNNdE.js → quadrantDiagram-O4NWA36T-D0G-xgdx.js} +1 -1
  66. package/dist/client/assets/{radar-RG4KPBEZ-BKldNRRC.js → radar-RG4KPBEZ-UErkZb84.js} +1 -1
  67. package/dist/client/assets/{railroad-74A4TZTK-LvULpHpQ.js → railroad-74A4TZTK-CQBJHfzO.js} +1 -1
  68. package/dist/client/assets/railroad-abnf-HS5TGJTU-BedGOWtg.js +1 -0
  69. package/dist/client/assets/railroad-ebnf-LZEXJU2U-DudE8AEK.js +1 -0
  70. package/dist/client/assets/railroad-peg-WCYAUIDC-DlrQn7uw.js +1 -0
  71. package/dist/client/assets/{railroadDiagram-XR7U4H2S-kp6LeaeM.js → railroadDiagram-XR7U4H2S-DVT-lJ4r.js} +1 -1
  72. package/dist/client/assets/{requirementDiagram-PLB6GJNP-Br-OgP6b.js → requirementDiagram-PLB6GJNP-BWJZJJlR.js} +1 -1
  73. package/dist/client/assets/{sankeyDiagram-IPEJSGJF-DSIbAxcK.js → sankeyDiagram-IPEJSGJF-CEh_PdFx.js} +1 -1
  74. package/dist/client/assets/{sequenceDiagram-PO4LG4MO-uVv8u8VI.js → sequenceDiagram-PO4LG4MO-DodtYIMU.js} +1 -1
  75. package/dist/client/assets/{src-Bmfs-TFn.js → src-1v74j3tV.js} +1 -1
  76. package/dist/client/assets/{stateDiagram-v2-GCMORJYK-Crql1z4d.js → stateDiagram-v2-GCMORJYK-kMbRFMI8.js} +1 -1
  77. package/dist/client/assets/{swimlanes-2SLR337P-CnpU7AM-.js → swimlanes-2SLR337P-D6NL4heU.js} +1 -1
  78. package/dist/client/assets/swimlanesDiagram-TC7HE7FX-CC02q6Vu.js +8 -0
  79. package/dist/client/assets/{timeline-definition-EJHVYXUP-CqzGi-Sd.js → timeline-definition-EJHVYXUP-DBiPEcaq.js} +1 -1
  80. package/dist/client/assets/{treeView-Q6P3EWNA-N_EzEUvg.js → treeView-Q6P3EWNA-B4A_X6Ee.js} +1 -1
  81. package/dist/client/assets/{treemap-WGGIJYW6-BYeVgP-v.js → treemap-WGGIJYW6-BKfnTV1e.js} +1 -1
  82. package/dist/client/assets/{usecaseDiagram-POWQR4AR-Dyeq2Zcy.js → usecaseDiagram-POWQR4AR-C-wOJ0sN.js} +1 -1
  83. package/dist/client/assets/{vennDiagram-UO4OBE2U-CuCjJaOo.js → vennDiagram-UO4OBE2U-CgBX6mVG.js} +1 -1
  84. package/dist/client/assets/{wardley-WFR3VGLG-AV0-dO_Z.js → wardley-WFR3VGLG-Ca0IxSXR.js} +1 -1
  85. package/dist/client/assets/{wardleyDiagram-VNRHLVJA-f0dVm2LA.js → wardleyDiagram-VNRHLVJA-BtiwsIrq.js} +1 -1
  86. package/dist/client/assets/{xychartDiagram-PMCCYNJV-BNjSW7P1.js → xychartDiagram-PMCCYNJV-BYZTngs6.js} +1 -1
  87. package/dist/client/favicon.svg +15 -7
  88. package/dist/client/index.html +2 -2
  89. package/package.json +9 -8
  90. package/plugin.json +1 -1
  91. package/dist/client/assets/architecture-7GRP2DOG-DaIFNiR2.js +0 -1
  92. package/dist/client/assets/channel-Ci5ZF5xD.js +0 -1
  93. package/dist/client/assets/eventmodeling-NTZA5JFV-C61pKQjq.js +0 -1
  94. package/dist/client/assets/flowDiagram-KWPJA3E3-BolWtw3C.js +0 -1
  95. package/dist/client/assets/index-B__poZ2b.js +0 -93
  96. package/dist/client/assets/index-CL4K6Y07.css +0 -1
  97. package/dist/client/assets/railroad-abnf-HS5TGJTU-CORGw4Le.js +0 -1
  98. package/dist/client/assets/railroad-ebnf-LZEXJU2U-BXCX4hkl.js +0 -1
  99. package/dist/client/assets/railroad-peg-WCYAUIDC-C6BQUpjn.js +0 -1
  100. package/dist/client/assets/swimlanesDiagram-TC7HE7FX-Crca2YKi.js +0 -8
package/dist/cli.mjs CHANGED
@@ -126,6 +126,29 @@ function detectHarness() {
126
126
  //#endregion
127
127
  //#region src/shared/review.ts
128
128
  const THREAD_INTENTS = ["fix", "question"];
129
+ /** A review comment on GitHub tops out at 65 536 characters; a suggestion that
130
+ * long is a mistake, and the reviewer edits it in a thread card. */
131
+ const PR_COMMENT_MAX = 1e4;
132
+ function parsePrComment(value) {
133
+ if (typeof value !== "string") return void 0;
134
+ const text = value.trim();
135
+ return text ? text.slice(0, PR_COMMENT_MAX) : void 0;
136
+ }
137
+ /**
138
+ * The suggestion the reviewer can still act on: the latest agent message
139
+ * carrying one, while nothing has been decided about it and the thread is
140
+ * open. Earlier suggestions in the same thread are superseded, decided or
141
+ * not — a redraft replaces, it never stacks.
142
+ */
143
+ function liveSuggestion(thread) {
144
+ if (isPublic(thread) || thread.state === "resolved") return null;
145
+ for (let i = thread.messages.length - 1; i >= 0; i--) {
146
+ const m = thread.messages[i];
147
+ if (m.author !== "agent" || !m.prComment) continue;
148
+ return m.prComment.outcome === void 0 ? m : null;
149
+ }
150
+ return null;
151
+ }
129
152
  function parseSuggestedReply(value) {
130
153
  if (typeof value !== "string") return void 0;
131
154
  const line = value.replace(/\s+/g, " ").trim();
@@ -166,6 +189,10 @@ function seenByAgent(thread, index) {
166
189
  function untouchedAgentVoice(thread) {
167
190
  return startedByAgent(thread) && !thread.messages.some((m) => m.author === "reviewer");
168
191
  }
192
+ /** The path a layer file names, without the reserved `:from-to` range. */
193
+ function layerFilePath(file) {
194
+ return (typeof file === "string" ? file : file.path).replace(/:\d+-\d+$/, "");
195
+ }
169
196
  /**
170
197
  * Feedback the reviewer is still owed an answer on — the delivery queue's contents,
171
198
  * derived rather than remembered, so a restart cannot drop an Ask nobody collected.
@@ -199,13 +226,15 @@ const EMPTY_REVIEW = {
199
226
  * Past threads are hidden, never deleted: a stash or a branch switch empties the diff
200
227
  * for a minute and must not destroy a thread.
201
228
  */
202
- function threadsInChangeset(files, threads) {
229
+ function threadsInChangeset(files, threads, layers) {
203
230
  const hunkIds = new Set(files.flatMap((f) => f.hunks.map((h) => h.id)));
204
231
  const paths = new Set(files.flatMap((f) => f.oldPath ? [f.path, f.oldPath] : [f.path]));
232
+ const layerIds = new Set((layers?.items ?? []).map((l) => l.id));
205
233
  const lives = (thread) => {
206
234
  const anchor = thread.anchor;
207
235
  if (anchor.kind === "changeset") return files.length > 0;
208
236
  if (anchor.kind === "file") return paths.has(anchor.path);
237
+ if (anchor.kind === "layer") return layerIds.has(anchor.layerId);
209
238
  return hunkIds.has(anchor.hunkId) || paths.has(anchor.path);
210
239
  };
211
240
  const active = [];
@@ -223,6 +252,7 @@ function anchorSpan(anchor) {
223
252
  function describeAnchor(anchor) {
224
253
  if (anchor.kind === "changeset") return "the whole changeset";
225
254
  if (anchor.kind === "file") return anchor.path;
255
+ if (anchor.kind === "layer") return `layer "${anchor.title}"`;
226
256
  return `${anchor.path}:${anchorSpan(anchor)} (${anchor.side} side)`;
227
257
  }
228
258
  //#endregion
@@ -241,7 +271,19 @@ function findHunk(changeset, hunkId) {
241
271
  * — for the anchored line range — where those rows sit in it and their text.
242
272
  * Null for non-hunk anchors and for a hunk the changeset no longer has.
243
273
  */
244
- function captureAnchor(changeset, anchor) {
274
+ function captureAnchor(changeset, anchor, layers) {
275
+ if (anchor.kind === "layer") {
276
+ const layer = layers?.items.find((l) => l.id === anchor.layerId);
277
+ if (!layer) return null;
278
+ return {
279
+ codeContext: null,
280
+ anchoredLayer: {
281
+ title: layer.title,
282
+ ...layer.summary ? { summary: layer.summary } : {},
283
+ files: layer.files.map(layerFilePath)
284
+ }
285
+ };
286
+ }
245
287
  if (anchor.kind !== "hunk") return null;
246
288
  const hunk = findHunk(changeset, anchor.hunkId);
247
289
  if (!hunk) return null;
@@ -355,6 +397,8 @@ const ACK_NEXT_STEP = {
355
397
  reply: `When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
356
398
  replyMore: "Interim reply posted — the reviewer still sees you working on this thread. Post the follow-up as a plain reply (no --more) BEFORE your next poll: re-polling closes the batch and counts the promise as never kept.",
357
399
  comment: `It's in the review as your comment, labeled as yours — the reviewer replies to take it up, or resolves it. Continue with the review threads, then run \`${CLI_COMMANDS.poll}\`.`,
400
+ replyPrComment: `Reply posted, with the suggested review comment under it. The reviewer adds it to their review, edits it first, or dismisses it — nothing posts on its own, and you will hear what happened when they submit. When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
401
+ commentPrComment: `It's in the review as your comment, with the suggested review comment under it. The reviewer adds it to their review, edits it first, or dismisses it — nothing posts on its own. Continue with the review threads, then run \`${CLI_COMMANDS.poll}\`.`,
358
402
  layers: `The outline is live in the reviewer's Layers tab, resolved against the changeset as it moves. Files you touch later land in a trailing "Since your review" layer until you re-post the whole list. Continue with the review threads, then run \`${CLI_COMMANDS.poll}\`.`,
359
403
  layersSuggested: `The review now offers the outline to the reviewer. Mention it in your handoff too — "say layers and I'll outline it" — and post it with \`${CLI_COMMANDS.layers}\` when they ask. Then run \`${CLI_COMMANDS.poll}\`.`,
360
404
  layersAlready: `This review already carries layers, so there is nothing to suggest — re-post the whole list with \`${CLI_COMMANDS.layers}\` if the outline is stale. Then run \`${CLI_COMMANDS.poll}\`.`,
@@ -372,6 +416,18 @@ const HELP_AGENT_PR = `Reviewing a pull request (\`diffo <PR URL | owner/repo#N
372
416
  label: read what the reviewer wants from the words. When the answer is a
373
417
  fix, put it in a \`\`\`suggestion block in your reply, not an edit to the
374
418
  worktree — the reviewer can post your reply to GitHub from there.
419
+ - Read who each private message is for. A question ("what calls this?")
420
+ wants an answer. A finding stated about the code ("this resets the streak
421
+ on late completions") is a thought on its way to the author: write the
422
+ review comment they would leave, in their voice, and attach it with
423
+ \`--pr-comment "<text>"\` on your reply (or your comment). It shows under
424
+ your reply with Add to review / Edit / Dismiss; your evidence stays in the
425
+ reply, never in the comment. Attach one when they state a finding, when
426
+ their hunch proves right, when they ask you to draft it, or when a plain
427
+ question turns up a bug you can show. Not when they were wrong (say so
428
+ privately), not on pushback against your own reply — redraft the one
429
+ already in the thread instead — and not when asked but you have nothing
430
+ worth saying to the author: say so privately, never invent one.
375
431
  - The guide and layers work as above, built from the description, the commits
376
432
  and the diff; skip the guide when the description already orients.
377
433
  - When the reviewer submits, a poll returns a \`"kind": "submitted"\` notice:
@@ -442,14 +498,56 @@ const LAYERS = {
442
498
  stance: GUIDE.stance,
443
499
  /** A post is the whole list. */
444
500
  replace: "a post replaces the whole list, never merges; ids are kept for titles that match, so a re-post never moves the reviewer's place",
501
+ /** What earns a line in a layer's `decisions`, and the bar for it. The
502
+ * reviewer reads these before the diff — or instead of it — so every entry
503
+ * must be something they could act on, never a retelling of the change. */
504
+ decisions: "a layer may carry up to five \"decisions\", each one short line (under eight words) with a one-sentence \"detail\" and an \"at\" — where it lives: \"path\", \"path:line\" or \"path:from-to\", or a list of up to three such places when the decision spans hunks (the rule and the test that pins it). A decision is where you chose between real alternatives a reviewer might weigh differently (a cache, a data shape, a fallback), where the code now does something the diff does not make obvious (a 400 where there was an empty list, a sort every caller sees), or where something in the repo or the environment shaped the change (no clock on the model, tests you could not run). Only what a reviewer could want done differently or would be surprised by later; nothing about naming or style; no verdicts. Write each line the way you would answer \"what did you decide here?\" to a teammate who has not seen the code: a concrete statement in plain words (\"Unknown ?due= values return 400, not an empty list\", \"Todos store a date, never a time\"), understood in a second by someone who has not read the diff — no project nicknames, no clever phrasing, no mechanism named instead of its effect. Most layers have none — a plumbing step has nothing to decide, so leave the list out",
445
505
  /** The payload, in one line. */
446
- shape: "[{ \"title\": \"…\", \"summary\": \"…\", \"kind\": \"mechanical\" (optional), \"files\": [\"src/a.ts\", { \"path\": \"src/b.ts\", \"note\": \"why this file is in this step\" }] }]"
506
+ shape: "[{ \"title\": \"…\", \"summary\": \"…\", \"kind\": \"mechanical\" (optional), \"files\": [\"src/a.ts\", { \"path\": \"src/b.ts\", \"note\": \"why this file is in this step\" }], \"decisions\": [{ \"text\": \"…\", \"detail\": \"…\", \"at\": \"src/a.ts:12-18\" | [\"src/a.ts:12-18\", \"test/a.test.ts:40\"] }] (optional, at most five) }]"
447
507
  };
448
508
  const AUTHOR_DOCTRINE = {
449
509
  guide: GUIDE,
450
510
  layers: LAYERS,
451
511
  fix: "- `issue` threads want a code change. Address each one, or push back in the thread with your reasoning."
452
512
  };
513
+ /**
514
+ * The one piece of PR doctrine with no analogue for an author: the reviewer's
515
+ * private words are often a review comment in the making, and the agent is the
516
+ * one who can write it while the reviewer is still reading. The comment is
517
+ * addressed to the author in the reviewer's voice; nothing of it posts until the
518
+ * reviewer adds it to their review and submits.
519
+ */
520
+ const PR_COMMENT_DOCTRINE = `## Drafting a review comment for the author
521
+
522
+ The reviewer's private messages are of two kinds. A question to you ("what
523
+ calls this?") wants an answer. A finding stated about the code ("this resets
524
+ the streak on late completions") is a thought on its way to the author: the
525
+ reviewer is thinking out loud at the line, and the next thing they would type
526
+ is a review comment. Write that comment for them, attached to your reply:
527
+
528
+ ${CLI} reply <threadId> --message "<your private answer>" --pr-comment "<the comment>"
529
+
530
+ Attach one when: they state a finding (always, a nit included); their hunch
531
+ phrased as a question turns out right; they ask you to draft it ("write this
532
+ up for the author"); or your answer to a plain question is a bug with a case
533
+ to show. Do not attach one when they were wrong — say so privately — or when
534
+ they are pushing back on your own reply; then redraft the comment already in
535
+ the thread, which replaces it. Asked to suggest a change where you find
536
+ nothing worth saying to the author, say so privately and attach nothing: a
537
+ comment invented to fill the request is the one thing the reviewer cannot
538
+ tell apart from a finding. On a comment of your own (\`${CLI} comment\`), the
539
+ same flag carries a finding you can show, never a hunch.
540
+
541
+ The comment is in the reviewer's voice, to the author: state the finding, why
542
+ it matters, and what would fix it. A \`\`\`suggestion block when the fix is local
543
+ to the anchored lines. Nothing about you, the worktree, tests you ran, or
544
+ Diffo — that evidence goes in --message, where the reviewer reads it. Match
545
+ how this reviewer writes: their own review comments on this pull request are
546
+ quoted under "The reviewer's voice" when there are any.
547
+
548
+ Nothing you attach reaches GitHub. It appears under your reply with Add to
549
+ review / Edit / Dismiss; the reviewer decides, and the submit notice tells you
550
+ what happened to each.`;
453
551
  const PR_DOCTRINE = {
454
552
  guide: {
455
553
  when: "the pull request description leaves a cold reader without the shape of the change — skip when the description already orients",
@@ -461,7 +559,8 @@ const PR_DOCTRINE = {
461
559
  ...LAYERS,
462
560
  order: "the order you would explain it in after reading the description and the commits — the file that explains the rest first, mechanical consequences last; for a feature, follow the request from entry point to effect; for a refactor, contract first, then consumers"
463
561
  },
464
- fix: "- `issue` threads want a fix you cannot push: work it out in the worktree, verify it the cheapest honest way, restore the worktree (`git checkout -- .`), and reply with a ```suggestion block plus what you checked. Never commit or push to the pull request."
562
+ fix: "- `issue` threads want a fix you cannot push: work it out in the worktree, verify it the cheapest honest way, restore the worktree (`git checkout -- .`), and reply with a ```suggestion block plus what you checked. Never commit or push to the pull request.",
563
+ prComment: PR_COMMENT_DOCTRINE
465
564
  };
466
565
  function doctrineFor(changeset) {
467
566
  return changeset?.pr ? PR_DOCTRINE : AUTHOR_DOCTRINE;
@@ -565,7 +664,7 @@ ${intentContract(threads, D).map((line) => ` ${line}`).join("\n")}
565
664
 
566
665
  Change only what these threads ask about — the reviewer is mid-read. Re-read
567
666
  the current file before editing; the code may have moved. Resolving a thread
568
- is the reviewer's call, never yours.`;
667
+ is the reviewer's call, never yours.${D.prComment ? `\n\nA private message that states a finding about the code is a review comment in the making: attach the comment the reviewer would leave the author, in their voice, with \`--pr-comment "<text>"\` on your reply (evidence stays in --message). Not when they were wrong, not on pushback against your reply. \`${CLI} help agent\` has the whole rule.` : ""}`;
569
668
  }
570
669
  function replyProtocol(threads, mode = "full", D = AUTHOR_DOCTRINE) {
571
670
  if (mode === "compact") return compactProtocol(threads, D);
@@ -618,7 +717,64 @@ Rules:
618
717
  - Each "commented change" above was frozen when the comment was written —
619
718
  re-read the current file before editing; the code may have moved since.
620
719
  - Your code edits are detected automatically and the reviewer's diff updates
621
- live. Resolving a thread is the reviewer's call, never yours.`;
720
+ live. Resolving a thread is the reviewer's call, never yours.${D.prComment ? `\n\n${D.prComment}` : ""}`;
721
+ }
722
+ const VOICE_COMMENT_CAP = 5;
723
+ const VOICE_EDIT_CAP = 3;
724
+ const VOICE_TEXT_CAP = 400;
725
+ function voiceQuote(text) {
726
+ const flat = text.replace(/\s+/g, " ").trim();
727
+ return flat.length > VOICE_TEXT_CAP ? `${flat.slice(0, 399)}…` : flat;
728
+ }
729
+ /**
730
+ * The reviewer's own review comments on this pull request, newest first, and
731
+ * for each suggestion of the agent's they edited before adding, both versions.
732
+ * The agent sees "I wrote X, they posted Y" and the next suggestion sounds like
733
+ * the reviewer. Null when there is nothing of theirs yet.
734
+ */
735
+ function voiceLines(threads) {
736
+ const byId = new Map(threads.map((t) => [t.id, t]));
737
+ const comments = threads.filter((t) => isPublic(t) && t.messages[0]?.author === "reviewer").map((t) => ({
738
+ at: t.createdAt,
739
+ line: `- ${describeAnchor(t.anchor)} — "${voiceQuote(t.messages[0].text)}"`
740
+ })).sort((a, b) => b.at.localeCompare(a.at)).slice(0, VOICE_COMMENT_CAP);
741
+ const edits = [];
742
+ for (const t of threads) {
743
+ if (isPublic(t)) continue;
744
+ for (const m of t.messages) {
745
+ const o = m.prComment?.outcome;
746
+ if (m.author !== "agent" || !o || o.kind !== "added" || !o.edited) continue;
747
+ const theirs = byId.get(o.draftThreadId)?.messages[0]?.text;
748
+ if (!theirs) continue;
749
+ edits.push({
750
+ at: o.at,
751
+ lines: [
752
+ `- ${describeAnchor(t.anchor)}`,
753
+ ` - yours: "${voiceQuote(m.prComment.text)}"`,
754
+ ` - theirs: "${voiceQuote(theirs)}"`
755
+ ]
756
+ });
757
+ }
758
+ }
759
+ edits.sort((a, b) => b.at.localeCompare(a.at));
760
+ const pairs = edits.slice(0, VOICE_EDIT_CAP).flatMap((e) => e.lines);
761
+ if (comments.length === 0 && pairs.length === 0) return null;
762
+ return [
763
+ "## The reviewer's voice",
764
+ "",
765
+ ...comments.length > 0 ? ["Their review comments on this pull request so far (posted or drafted):", ...comments.map((c) => c.line)] : [],
766
+ ...pairs.length > 0 ? [
767
+ ...comments.length > 0 ? [""] : [],
768
+ "What you suggested, and what they posted after editing it:",
769
+ ...pairs
770
+ ] : []
771
+ ].join("\n");
772
+ }
773
+ /** The voice block, when the review is a pull request and the reviewer has written anything. */
774
+ function voiceBlock(ctx) {
775
+ if (!ctx.changeset?.pr || !ctx.voice) return [];
776
+ const block = voiceLines(ctx.voice);
777
+ return block ? [block, ""] : [];
622
778
  }
623
779
  const FRAME_FILE_CAP = 40;
624
780
  function fileLine(file) {
@@ -693,8 +849,19 @@ function snapshotWindow(total, anchored) {
693
849
  /** The snapshot as the agent sees it: windowed on the anchored rows, each of
694
850
  * them marked with `>`. Threads from before `anchored` existed keep the old
695
851
  * head-of-hunk window. */
852
+ /** A layer thread's stand-in for the diff snapshot: the step as the reviewer
853
+ * read it, so the agent answers about the outline, not about a line. */
854
+ function layerBlock(layer) {
855
+ return [
856
+ "The layer this comments on, as it was outlined when the comment was written:",
857
+ `- title: ${layer.title}`,
858
+ ...layer.summary ? ["- summary:", ...layer.summary.split("\n").map((l) => ` ${l}`)] : [],
859
+ `- files: ${layer.files.map((f) => `\`${f}\``).join(", ")}`
860
+ ];
861
+ }
696
862
  function snapshotBlock(thread) {
697
863
  const { codeContext, anchored } = thread;
864
+ if (thread.anchor.kind === "layer") return thread.anchoredLayer ? layerBlock(thread.anchoredLayer) : [];
698
865
  if (!codeContext) {
699
866
  if (!anchored) return [];
700
867
  const shown = anchored.text.split("\n");
@@ -708,7 +875,7 @@ function snapshotBlock(thread) {
708
875
  ];
709
876
  }
710
877
  const lines = codeContext.split("\n");
711
- const where = thread.anchor.kind === "changeset" ? "the file" : `\`${thread.anchor.path}\``;
878
+ const where = thread.anchor.kind === "hunk" || thread.anchor.kind === "file" ? `\`${thread.anchor.path}\`` : "the file";
712
879
  if (thread.deliveredThrough) return [`(diff snapshot delivered to you earlier — read the current ${where} instead)`];
713
880
  if (!anchored) return [
714
881
  "The commented change:",
@@ -758,6 +925,7 @@ function buildThreadPrompt(thread, ctx) {
758
925
  threadBlock(thread, 0),
759
926
  "",
760
927
  ...siblings ? [siblings, ""] : [],
928
+ ...voiceBlock(ctx),
761
929
  replyProtocol([thread], ctx.protocol, D),
762
930
  ""
763
931
  ].join("\n");
@@ -773,6 +941,7 @@ function buildCoalescedPrompt(threads, ctx) {
773
941
  ...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
774
942
  ...threads.map((t, i) => `${threadBlock(t, i)}\n`),
775
943
  ...siblings ? [siblings, ""] : [],
944
+ ...voiceBlock(ctx),
776
945
  replyProtocol(threads, ctx.protocol, D),
777
946
  ""
778
947
  ].join("\n");
@@ -781,6 +950,17 @@ function buildCoalescedPrompt(threads, ctx) {
781
950
  * The reviewer submitted the pull-request review on GitHub. Context, not work:
782
951
  * the agent learns the review ended and what it said, and owes nothing.
783
952
  */
953
+ /** What became of the agent's suggested review comments, in one line: the
954
+ * feedback that lets it calibrate, aggregate so it never argues for one. */
955
+ function suggestionsLine(s) {
956
+ const n = (count, word) => `${count} ${word}`;
957
+ const parts = [
958
+ `${n(s.posted, "posted")}${s.edited > 0 ? ` (${s.edited} edited first)` : ""}`,
959
+ ...s.dismissed > 0 ? [n(s.dismissed, "dismissed")] : [],
960
+ ...s.undecided > 0 ? [`${s.undecided} left undecided (they stay private and never post)`] : []
961
+ ];
962
+ return `Of the ${s.total} review comment${s.total === 1 ? "" : "s"} you suggested: ${parts.join(", ")}.`;
963
+ }
784
964
  function buildSubmittedPrompt(ctx, submitted) {
785
965
  const pr = ctx.changeset?.pr;
786
966
  return [
@@ -791,6 +971,7 @@ function buildSubmittedPrompt(ctx, submitted) {
791
971
  ...submitted.body.split("\n").map((l) => `> ${l}`),
792
972
  ""
793
973
  ] : [],
974
+ ...submitted.suggestions && submitted.suggestions.total > 0 ? [suggestionsLine(submitted.suggestions), ""] : [],
794
975
  "Nothing to act on — this is context so you know where the review stands. Mention it to the user in one line.",
795
976
  `Run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}); the reviewer may follow up. If the user is done, \`${CLI_COMMANDS.end}\`.`,
796
977
  ""
@@ -823,6 +1004,8 @@ function buildLayersRequestPrompt(ctx, existing) {
823
1004
  "",
824
1005
  `Order: ${D.layers.order}. ${D.layers.mechanical}. ${D.layers.stance}. Files are whole files, by path relative to the repo root; list every file of the changeset somewhere, or the leftovers land in "Since your review".`,
825
1006
  "",
1007
+ `Decisions: ${D.layers.decisions}.`,
1008
+ "",
826
1009
  `Shape: ${LAYERS.shape}`,
827
1010
  "",
828
1011
  `Post it: \`${CLI_COMMANDS.layers}\`, or pipe the JSON to \`${CLI_COMMANDS.layersStdin}\` when it is long. Nothing else is owed for this item — no reply, no comment.`,
@@ -898,6 +1081,8 @@ const SUMMARY_CAP = 4e3;
898
1081
  /** A note is one line by contract — anything past the first newline is dropped,
899
1082
  * and the line itself is capped so the file header stays a header. */
900
1083
  const NOTE_CAP = 200;
1084
+ const DECISION_TEXT_CAP = 120;
1085
+ const DECISION_DETAIL_CAP = 400;
901
1086
  const REPO_PATH = /^[^\0]+$/;
902
1087
  /**
903
1088
  * Paths are relative to the repo root: no leading slash, no `..` segment, no
@@ -956,6 +1141,93 @@ function oneLine(text, cap) {
956
1141
  const line = text.split("\n", 1)[0].trim();
957
1142
  return line.length > cap ? `${line.slice(0, cap - 1).trimEnd()}…` : line;
958
1143
  }
1144
+ /**
1145
+ * Where a decision points: `path`, `path:line` or `path:from-to`, the same
1146
+ * spelling a file reference in a summary uses. Lines are head-side and
1147
+ * 1-based; a range is normalised so `from` never exceeds `to`.
1148
+ */
1149
+ function parseDecisionAt(raw) {
1150
+ const m = /^(.*?)(?::(\d+)(?:-(\d+))?)?$/.exec(raw.trim());
1151
+ const path = m?.[1] ?? "";
1152
+ const why = badPath(path);
1153
+ if (why) return `"at" path "${raw}" ${why}`;
1154
+ if (m?.[2] === void 0) return { path };
1155
+ const a = Number.parseInt(m[2], 10);
1156
+ const b = m[3] === void 0 ? a : Number.parseInt(m[3], 10);
1157
+ if (a < 1 || b < 1) return `"at" line in "${raw}" must be 1 or more`;
1158
+ const [line, endLine] = a <= b ? [a, b] : [b, a];
1159
+ return endLine === line ? {
1160
+ path,
1161
+ line
1162
+ } : {
1163
+ path,
1164
+ line,
1165
+ endLine
1166
+ };
1167
+ }
1168
+ /** The object form of `at` back as the string the agent would have written;
1169
+ * null for anything that is not that object. */
1170
+ function atToString(raw) {
1171
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
1172
+ const a = raw;
1173
+ if (typeof a.path !== "string") return null;
1174
+ if (a.line === void 0) return a.endLine === void 0 ? a.path : null;
1175
+ if (typeof a.line !== "number" || !Number.isInteger(a.line)) return null;
1176
+ if (a.endLine === void 0) return `${a.path}:${a.line}`;
1177
+ if (typeof a.endLine !== "number" || !Number.isInteger(a.endLine)) return null;
1178
+ return `${a.path}:${a.line}-${a.endLine}`;
1179
+ }
1180
+ function parseDecision(raw, where) {
1181
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
1182
+ ok: false,
1183
+ error: `${where}: each decision is an object { text, detail?, at? }`
1184
+ };
1185
+ const d = raw;
1186
+ const text = typeof d.text === "string" ? oneLine(d.text, DECISION_TEXT_CAP) : "";
1187
+ if (text === "") return {
1188
+ ok: false,
1189
+ error: `${where}: needs a non-empty "text"`
1190
+ };
1191
+ if (d.detail !== void 0 && typeof d.detail !== "string") return {
1192
+ ok: false,
1193
+ error: `${where} ("${text}"): "detail" must be a string`
1194
+ };
1195
+ const detail = typeof d.detail === "string" ? d.detail.trim().slice(0, DECISION_DETAIL_CAP) : void 0;
1196
+ let at;
1197
+ if (d.at !== void 0) {
1198
+ const raws = Array.isArray(d.at) ? d.at : [d.at];
1199
+ if (raws.length === 0) return {
1200
+ ok: false,
1201
+ error: `${where} ("${text}"): "at" must name at least one place`
1202
+ };
1203
+ if (raws.length > 3) return {
1204
+ ok: false,
1205
+ error: `${where} ("${text}"): "at" names ${raws.length} places; keep it to 3 — the layer's files are the file list`
1206
+ };
1207
+ at = [];
1208
+ for (const raw of raws) {
1209
+ const str = typeof raw === "string" ? raw : atToString(raw);
1210
+ if (str === null) return {
1211
+ ok: false,
1212
+ error: `${where} ("${text}"): "at" is a string — "path", "path:line" or "path:from-to" — or a list of up to 3`
1213
+ };
1214
+ const parsed = parseDecisionAt(str);
1215
+ if (typeof parsed === "string") return {
1216
+ ok: false,
1217
+ error: `${where} ("${text}"): ${parsed}`
1218
+ };
1219
+ at.push(parsed);
1220
+ }
1221
+ }
1222
+ return {
1223
+ ok: true,
1224
+ decision: {
1225
+ text,
1226
+ ...detail ? { detail } : {},
1227
+ ...at ? { at } : {}
1228
+ }
1229
+ };
1230
+ }
959
1231
  /** The reason behind `--suggest`, fit for the one line the empty state quotes
960
1232
  * it in. Undefined when there is nothing usable — the suggestion stands alone. */
961
1233
  function parseSuggestReason(raw) {
@@ -967,8 +1239,9 @@ function parseSuggestReason(raw) {
967
1239
  * The whole post, or the first thing wrong with it. Titles and file lists are
968
1240
  * required and non-empty; everything else is optional and normalised — a
969
1241
  * summary trimmed and capped, a note cut to one line, an unknown `kind`
970
- * refused rather than silently dropped (the agent meant something by it). Any
971
- * `id` the agent sends is ignored: the server mints them.
1242
+ * refused rather than silently dropped (the agent meant something by it), a
1243
+ * decisions list checked entry by entry and refused past five. Any `id` the
1244
+ * agent sends is ignored: the server mints them.
972
1245
  */
973
1246
  function parseLayersInput(raw) {
974
1247
  if (!Array.isArray(raw)) return {
@@ -1017,11 +1290,29 @@ function parseLayersInput(raw) {
1017
1290
  ok: false,
1018
1291
  error: `${where} ("${title}"): "kind" can only be "mechanical"; leave it out otherwise`
1019
1292
  };
1293
+ let decisions;
1294
+ if (l.decisions !== void 0) {
1295
+ if (!Array.isArray(l.decisions)) return {
1296
+ ok: false,
1297
+ error: `${where} ("${title}"): "decisions" must be a list`
1298
+ };
1299
+ if (l.decisions.length > 5) return {
1300
+ ok: false,
1301
+ error: `${where} ("${title}"): keep "decisions" to 5 — the ones a reviewer could want done differently, not a retelling of the diff`
1302
+ };
1303
+ decisions = [];
1304
+ for (const [j, raw] of l.decisions.entries()) {
1305
+ const parsed = parseDecision(raw, `${where} ("${title}") decision ${j + 1}`);
1306
+ if (!parsed.ok) return parsed;
1307
+ decisions.push(parsed.decision);
1308
+ }
1309
+ }
1020
1310
  items.push({
1021
1311
  title,
1022
1312
  files,
1023
1313
  ...summary !== "" ? { summary } : {},
1024
- ...l.kind === "mechanical" ? { kind: "mechanical" } : {}
1314
+ ...l.kind === "mechanical" ? { kind: "mechanical" } : {},
1315
+ ...decisions && decisions.length > 0 ? { decisions } : {}
1025
1316
  });
1026
1317
  }
1027
1318
  return {
@@ -1278,7 +1569,7 @@ Examples:
1278
1569
  diffo poll`,
1279
1570
  reply: `diffo reply: post a reply to a review thread
1280
1571
 
1281
- Usage: diffo reply <threadId> --message "<text>" [--suggest-reply "<one line>"]
1572
+ Usage: diffo reply <threadId> --message "<text>" [--suggest-reply "<one line>"] [--pr-comment "<text>"]
1282
1573
  … | diffo reply <threadId> (long replies: pipe on stdin)
1283
1574
 
1284
1575
  Thread ids arrive in poll payloads. Each run posts one message, so don't
@@ -1292,6 +1583,12 @@ ghost text in their reply box, taken with Tab, edited or ignored at will.
1292
1583
  Use it when your message ends in a decision that is theirs to make
1293
1584
  ("want me to extract this?" → --suggest-reply "yes, extract it"); never
1294
1585
  on a message that only reports.
1586
+ --pr-comment, on a pull request only, attaches the review comment the
1587
+ reviewer would leave the author: in their voice, to the author, as GitHub
1588
+ will show it (Markdown; a \`\`\`suggestion block when the fix is local to the
1589
+ anchored lines). It appears under your reply with Add to review / Edit /
1590
+ Dismiss; nothing posts until the reviewer submits. Your evidence goes in
1591
+ --message, never in the comment. \`diffo help agent\` says when to attach one.
1295
1592
  Messages render GitHub-flavored markdown; a \`\`\`mermaid fence renders as a
1296
1593
  diagram in the review.
1297
1594
 
@@ -1301,7 +1598,7 @@ Example:
1301
1598
  diffo reply t-3 --message "fixed: the guard now covers the empty case"`,
1302
1599
  comment: `diffo comment: start a comment thread as the agent
1303
1600
 
1304
- Usage: diffo comment [<file>] [--line <n>] --message "<text>" [--suggest-reply "<one line>"]
1601
+ Usage: diffo comment [<file>] [--line <n>] --message "<text>" [--suggest-reply "<one line>"] [--pr-comment "<text>"]
1305
1602
  … | diffo comment [<file>] [--line <n>] (long comments: pipe on stdin)
1306
1603
 
1307
1604
  Anchors to a line (--line), a file, or, with no file, the whole changeset.
@@ -1313,6 +1610,10 @@ Spend these sparingly; an agent that annotates everything gets skimmed.
1313
1610
  ghost text in their reply box, taken with Tab, edited or ignored at will.
1314
1611
  Use it when the comment proposes something and the call is theirs; the
1315
1612
  reply they take hands the thread to you like any other.
1613
+ --pr-comment, on a pull request only, attaches the review comment the
1614
+ reviewer would leave the author about this: same rules as on \`reply\`. For a
1615
+ finding you can show (a case you ran, a call site the change misses), not
1616
+ for a hunch.
1316
1617
  Messages render GitHub-flavored markdown; a \`\`\`mermaid fence renders as a
1317
1618
  diagram in the review.
1318
1619
 
@@ -1342,6 +1643,8 @@ accepted (the file may land later) and simply shows nothing until it does.
1342
1643
  Files you touch after posting land in a trailing "Since your review" layer
1343
1644
  until you re-post.
1344
1645
 
1646
+ Decisions: ${LAYERS.decisions}.
1647
+
1345
1648
  Shape: ${LAYERS.shape}
1346
1649
 
1347
1650
  Output: {"ok":true,"layers":4,"next_step":"…"}
@@ -1349,7 +1652,7 @@ Output: {"ok":true,"layers":4,"next_step":"…"}
1349
1652
 
1350
1653
  Examples:
1351
1654
  diffo layers --suggest "the parser change explains the rest"
1352
- diffo layers --json '[{"title":"Parser contract","summary":"The contract the rest of the change leans on: bad input now comes back as null, not an exception.","files":["src/parse.ts"]},{"title":"Callers adapted","kind":"mechanical","summary":"Call sites following the new return type.","files":["src/cli.ts","src/api.ts"]}]'`,
1655
+ diffo layers --json '[{"title":"Parser contract","summary":"The contract the rest of the change leans on: bad input now comes back as null, not an exception.","files":["src/parse.ts"],"decisions":[{"text":"Bad input returns null, not a throw","detail":"Callers already branch on null for a missing value; a throw would need a try at every site.","at":"src/parse.ts:41-46"}]},{"title":"Callers adapted","kind":"mechanical","summary":"Call sites following the new return type.","files":["src/cli.ts","src/api.ts"]}]'`,
1353
1656
  end: `diffo end: detach from the review politely
1354
1657
 
1355
1658
  Usage: diffo end
@@ -1762,6 +2065,7 @@ function parseVerb(verb, rest) {
1762
2065
  title: { type: "string" },
1763
2066
  more: { type: "boolean" },
1764
2067
  "suggest-reply": { type: "string" },
2068
+ "pr-comment": { type: "string" },
1765
2069
  json: { type: "boolean" },
1766
2070
  help: {
1767
2071
  type: "boolean",
@@ -1775,6 +2079,7 @@ function parseVerb(verb, rest) {
1775
2079
  };
1776
2080
  const { values, positionals } = parsed.value;
1777
2081
  const suggestReply = values["suggest-reply"];
2082
+ const prComment = values["pr-comment"];
1778
2083
  if (values.help) return {
1779
2084
  kind: "help",
1780
2085
  topic: verb
@@ -1801,6 +2106,16 @@ function parseVerb(verb, rest) {
1801
2106
  message: "--suggest-reply is one line — the reviewer completes it"
1802
2107
  };
1803
2108
  }
2109
+ if (prComment !== void 0) {
2110
+ if (verb !== "reply" && verb !== "comment") return {
2111
+ kind: "error",
2112
+ message: `'${verb}' takes no --pr-comment`
2113
+ };
2114
+ if (!prComment.trim()) return {
2115
+ kind: "error",
2116
+ message: "--pr-comment needs the review comment you are suggesting"
2117
+ };
2118
+ }
1804
2119
  if (values.title !== void 0 && verb !== "poll") return {
1805
2120
  kind: "error",
1806
2121
  message: `'${verb}' takes no --title`
@@ -1850,7 +2165,8 @@ function parseVerb(verb, rest) {
1850
2165
  threadId,
1851
2166
  message: values.message ?? null,
1852
2167
  more: values.more === true,
1853
- suggestReply: suggestReply?.trim() ?? null
2168
+ suggestReply: suggestReply?.trim() ?? null,
2169
+ prComment: prComment?.trim() ?? null
1854
2170
  };
1855
2171
  }
1856
2172
  const file = positionals[0] ?? null;
@@ -1875,7 +2191,8 @@ function parseVerb(verb, rest) {
1875
2191
  file,
1876
2192
  line,
1877
2193
  message: values.message ?? null,
1878
- suggestReply: suggestReply?.trim() ?? null
2194
+ suggestReply: suggestReply?.trim() ?? null,
2195
+ prComment: prComment?.trim() ?? null
1879
2196
  };
1880
2197
  }
1881
2198
  //#endregion
@@ -1959,7 +2276,6 @@ query($owner: String!, $name: String!, $number: Int!) {
1959
2276
  messageHeadline
1960
2277
  committedDate
1961
2278
  author { name user { login avatarUrl } }
1962
- statusCheckRollup { state }
1963
2279
  }
1964
2280
  }
1965
2281
  }
@@ -1974,6 +2290,24 @@ query($owner: String!, $name: String!, $number: Int!) {
1974
2290
  }
1975
2291
  }
1976
2292
  }`;
2293
+ /**
2294
+ * CI status, asked for on its own. Reading it needs the checks and statuses
2295
+ * scopes, which a fine-grained or SAML-limited token may lack while reading
2296
+ * everything else about the pull request; GraphQL answers that with a
2297
+ * FORBIDDEN error on this one field, and `gh` turns any error into a failed
2298
+ * call. Kept out of the PR query so a token that cannot see CI still opens
2299
+ * the review.
2300
+ */
2301
+ const CHECKS_QUERY = `
2302
+ query($owner: String!, $name: String!, $number: Int!) {
2303
+ repository(owner: $owner, name: $name) {
2304
+ pullRequest(number: $number) {
2305
+ commits(last: 1) {
2306
+ nodes { commit { statusCheckRollup { state } } }
2307
+ }
2308
+ }
2309
+ }
2310
+ }`;
1977
2311
  /** The pages after the first of each connection the PR query opens. */
1978
2312
  const REVIEWS_QUERY = `
1979
2313
  query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
@@ -2220,18 +2554,32 @@ var GhClient = class {
2220
2554
  reviewNodes
2221
2555
  };
2222
2556
  }
2557
+ /** CI on the head commit, best effort: a token without the checks scope
2558
+ * gets `unknown` and the review opens anyway. Never throws. */
2559
+ async checks(ref) {
2560
+ try {
2561
+ const rollup = ((await this.graphql(ref.host, CHECKS_QUERY, {
2562
+ owner: ref.owner,
2563
+ name: ref.repo,
2564
+ number: ref.number
2565
+ })).repository?.pullRequest?.commits.nodes.at(-1)?.commit)?.statusCheckRollup;
2566
+ return checkState(rollup?.state);
2567
+ } catch {
2568
+ return "unknown";
2569
+ }
2570
+ }
2223
2571
  async getPr(ref) {
2224
- const { viewer, pr, reviewNodes } = await this.head(ref);
2225
- return prInfo(ref, viewer, pr, reviewNodes);
2572
+ const [{ viewer, pr, reviewNodes }, checks] = await Promise.all([this.head(ref), this.checks(ref)]);
2573
+ return prInfo(ref, viewer, pr, reviewNodes, checks);
2226
2574
  }
2227
2575
  async listThreads(ref) {
2228
2576
  const { pr, reviewNodes } = await this.head(ref);
2229
2577
  return this.conversation(ref, pr, reviewNodes);
2230
2578
  }
2231
2579
  async fetchPr(ref) {
2232
- const { viewer, pr, reviewNodes } = await this.head(ref);
2580
+ const [{ viewer, pr, reviewNodes }, checks] = await Promise.all([this.head(ref), this.checks(ref)]);
2233
2581
  return {
2234
- pr: prInfo(ref, viewer, pr, reviewNodes),
2582
+ pr: prInfo(ref, viewer, pr, reviewNodes, checks),
2235
2583
  threads: await this.conversation(ref, pr, reviewNodes)
2236
2584
  };
2237
2585
  }
@@ -2345,9 +2693,8 @@ var GhClient = class {
2345
2693
  }
2346
2694
  };
2347
2695
  /** The PR query's answer as a PrInfo, with every review page in hand. */
2348
- function prInfo(ref, viewer, pr, reviewNodes) {
2349
- const commitNodes = pr.commits.nodes ?? [];
2350
- const commits = commitNodes.map((n) => {
2696
+ function prInfo(ref, viewer, pr, reviewNodes, checks) {
2697
+ const commits = (pr.commits.nodes ?? []).map((n) => {
2351
2698
  const c = n.commit;
2352
2699
  const author = c.author;
2353
2700
  return {
@@ -2357,7 +2704,6 @@ function prInfo(ref, viewer, pr, reviewNodes) {
2357
2704
  at: String(c.committedDate ?? "")
2358
2705
  };
2359
2706
  });
2360
- const rollup = (commitNodes.at(-1)?.commit)?.statusCheckRollup;
2361
2707
  const reviews = reviewNodes.map((r) => ({
2362
2708
  id: String(r.id),
2363
2709
  author: user(r.author),
@@ -2390,7 +2736,7 @@ function prInfo(ref, viewer, pr, reviewNodes) {
2390
2736
  sha: String(pr.headRefOid)
2391
2737
  },
2392
2738
  commits,
2393
- checks: { state: checkState(rollup?.state) },
2739
+ checks: { state: checks },
2394
2740
  reviews: reviews.filter((r) => r.state !== "PENDING"),
2395
2741
  approvals: [...latest.values()].filter((s) => s === "APPROVED").length,
2396
2742
  changesRequested: [...latest.values()].filter((s) => s === "CHANGES_REQUESTED").length,
@@ -2608,6 +2954,25 @@ function branchExists(root, branch) {
2608
2954
  return err.status !== 1;
2609
2955
  }
2610
2956
  }
2957
+ /** {@link branchExists}, off the event loop, for the daemon's background sweep
2958
+ * over every repo it has ever reviewed. Same lean: true unless git said no. */
2959
+ async function branchExistsAsync(root, branch) {
2960
+ try {
2961
+ await execFileAsync("git", [
2962
+ "show-ref",
2963
+ "--verify",
2964
+ "--quiet",
2965
+ `refs/heads/${branch}`
2966
+ ], {
2967
+ cwd: root,
2968
+ encoding: "utf-8",
2969
+ maxBuffer: MAX_BUFFER
2970
+ });
2971
+ return true;
2972
+ } catch (err) {
2973
+ return err.code !== 1;
2974
+ }
2975
+ }
2611
2976
  /**
2612
2977
  * The name of the linked worktree we're in, or null in the main one. A linked
2613
2978
  * worktree's git dir is `<main>/.git/worktrees/<name>`, while the main worktree's
@@ -3242,7 +3607,17 @@ const SCHEMA_VERSION = 3;
3242
3607
  const REVIEWS_RESHAPED_AT = 2;
3243
3608
  var DiffoDb = class {
3244
3609
  db;
3610
+ closed = false;
3245
3611
  path;
3612
+ /**
3613
+ * Opening is cheap on purpose: the schema, and one SQL statement for the TTL.
3614
+ * Nothing here touches the filesystem beyond the database file, and nothing
3615
+ * asks git. It used to: every open checked every review's branch with a
3616
+ * `git show-ref`, across every repo this machine ever reviewed — 73 git
3617
+ * processes, 1.5s, on a database a few weeks old, and an open constructs
3618
+ * this five times. The checks live in {@link pruneRepo} (one repo, before its
3619
+ * review loads) and {@link maintain} (everything, in the daemon's background).
3620
+ */
3246
3621
  constructor(path = defaultDbPath()) {
3247
3622
  this.path = path;
3248
3623
  mkdirSync(dirname(path), {
@@ -3254,7 +3629,7 @@ var DiffoDb = class {
3254
3629
  chmodSync(path, 384);
3255
3630
  this.db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 3000; PRAGMA journal_size_limit = 4194304;");
3256
3631
  this.ensureSchema();
3257
- this.pruneVanishedRepos();
3632
+ this.pruneExpiredReviews();
3258
3633
  }
3259
3634
  /**
3260
3635
  * Creates what is missing and retires what is unknown. Runs at open, and
@@ -3323,38 +3698,69 @@ var DiffoDb = class {
3323
3698
  }
3324
3699
  }
3325
3700
  /**
3326
- * Rows about a worktree that is gone can never be reached again — the path *is*
3327
- * the key. An unmounted volume looks the same as a deleted worktree, so its rows
3328
- * go too; the `servers` row needs its process dead as well, or a live server's
3329
- * claim could be dropped over a moment of unreachability.
3701
+ * Reviews untouched for `REVIEW_TTL_DAYS` go on every open: one statement,
3702
+ * no git, and the backstop for everything the two targeted sweeps below leave
3703
+ * for later.
3704
+ */
3705
+ pruneExpiredReviews() {
3706
+ const cutoff = (/* @__PURE__ */ new Date(Date.now() - 5184e6)).toISOString();
3707
+ const stale = this.db.prepare("DELETE FROM reviews WHERE updated_at < ?").run(cutoff);
3708
+ if (Number(stale.changes) > 0) this.checkpoint();
3709
+ }
3710
+ /**
3711
+ * Retire this one repo's reviews for branches it no longer has. The server
3712
+ * runs it before loading the review it is about to serve, so a branch
3713
+ * deleted since the last server start is forgotten before a namesake could
3714
+ * inherit its threads. Only this repo's branches are asked about, so the
3715
+ * cost is a handful of `git show-ref`s, once per server start, never per CLI
3716
+ * call. Branch existence is asked of git rather than assumed — when git can't
3717
+ * answer the row is kept, because guessing here deletes threads.
3718
+ */
3719
+ pruneRepo(repoPath) {
3720
+ const rows = this.db.prepare("SELECT DISTINCT branch FROM reviews WHERE repo_path = ?").all(repoPath);
3721
+ let dropped = 0;
3722
+ for (const { branch } of rows) {
3723
+ if (branch === "" || branchExists(repoPath, branch)) continue;
3724
+ dropped += this.dropReviews(repoPath, branch);
3725
+ }
3726
+ return dropped;
3727
+ }
3728
+ /**
3729
+ * The full sweep, for a long-lived process with time on its hands: rows about
3730
+ * repos that are gone, and reviews for branches that are. The daemon runs it
3731
+ * once, a few seconds after it is serving; `diffo clean` runs it on demand.
3732
+ * Git is asked asynchronously and one repo at a time, so a sweep over every
3733
+ * repo this machine ever reviewed never blocks a request. A `close()` midway
3734
+ * ends it quietly between steps.
3735
+ *
3736
+ * Rows about a worktree that is gone can never be reached again — the path
3737
+ * *is* the key. An unmounted volume looks the same as a deleted worktree, so
3738
+ * its rows go too; the `servers` row needs its process dead as well, or a live
3739
+ * server's claim could be dropped over a moment of unreachability.
3330
3740
  */
3331
- pruneVanishedRepos() {
3332
- for (const { repo_path } of this.distinctRepoPaths("reviews")) if (!existsSync(repo_path)) this.db.prepare("DELETE FROM reviews WHERE repo_path = ?").run(repo_path);
3741
+ async maintain() {
3742
+ const report = { reviews: 0 };
3743
+ if (this.closed) return report;
3744
+ for (const { repo_path } of this.distinctRepoPaths("reviews")) if (!existsSync(repo_path)) report.reviews += this.dropReviews(repo_path);
3333
3745
  for (const { repo_path } of this.distinctRepoPaths("repo_ports")) if (!existsSync(repo_path)) this.db.prepare("DELETE FROM repo_ports WHERE repo_path = ?").run(repo_path);
3334
3746
  const worktrees = this.db.prepare("SELECT worktree_path, repo_path FROM worktrees").all();
3335
3747
  for (const { worktree_path, repo_path } of worktrees) if (!existsSync(repo_path)) this.db.prepare("DELETE FROM worktrees WHERE worktree_path = ?").run(worktree_path);
3336
3748
  const servers = this.db.prepare("SELECT repo_path, pid FROM servers").all();
3337
3749
  for (const { repo_path, pid } of servers) if (!existsSync(repo_path) && !pidAlive$1(pid)) this.db.prepare("DELETE FROM servers WHERE repo_path = ?").run(repo_path);
3338
- this.pruneStaleReviews();
3339
- }
3340
- /**
3341
- * Reviews the reviewer can never return to: a branch that no longer exists, or
3342
- * one untouched for `REVIEW_TTL_DAYS`. Branch existence is asked of git rather
3343
- * than assumed — if git can't answer the row is kept, because guessing here
3344
- * deletes threads.
3345
- */
3346
- pruneStaleReviews() {
3347
- const cutoff = (/* @__PURE__ */ new Date(Date.now() - 5184e6)).toISOString();
3348
- const stale = this.db.prepare("DELETE FROM reviews WHERE updated_at < ?").run(cutoff);
3349
- if (Number(stale.changes) > 0) {
3350
- this.checkpoint();
3351
- return;
3352
- }
3353
3750
  const rows = this.db.prepare("SELECT DISTINCT repo_path, branch FROM reviews").all();
3354
3751
  for (const { repo_path, branch } of rows) {
3355
- if (branch === "" || branchExists(repo_path, branch)) continue;
3356
- this.db.prepare("DELETE FROM reviews WHERE repo_path = ? AND branch = ?").run(repo_path, branch);
3752
+ if (branch === "") continue;
3753
+ const exists = await branchExistsAsync(repo_path, branch);
3754
+ if (this.closed) return report;
3755
+ if (!exists) report.reviews += this.dropReviews(repo_path, branch);
3357
3756
  }
3757
+ if (report.reviews > 0) this.checkpoint();
3758
+ return report;
3759
+ }
3760
+ /** Every review of a repo, or only one branch's. Returns the rows dropped. */
3761
+ dropReviews(repoPath, branch) {
3762
+ const result = branch === void 0 ? this.db.prepare("DELETE FROM reviews WHERE repo_path = ?").run(repoPath) : this.db.prepare("DELETE FROM reviews WHERE repo_path = ? AND branch = ?").run(repoPath, branch);
3763
+ return Number(result.changes);
3358
3764
  }
3359
3765
  /** Fold the write-ahead log back into the database file and truncate it. WAL
3360
3766
  * only shrinks at a checkpoint, which a long-lived server never reaches. */
@@ -3452,6 +3858,7 @@ var DiffoDb = class {
3452
3858
  else this.db.prepare("DELETE FROM servers WHERE repo_path = ? AND port = ?").run(repoPath, port);
3453
3859
  }
3454
3860
  close() {
3861
+ this.closed = true;
3455
3862
  this.db.close();
3456
3863
  }
3457
3864
  };
@@ -3517,6 +3924,10 @@ var DeliveryQueue = class DeliveryQueue {
3517
3924
  stalled = false;
3518
3925
  stallTimer = null;
3519
3926
  betweenPolls = false;
3927
+ /** Why the agent is parked between polls: a reply just landed, its poll
3928
+ * ended on its own and the re-poll is expected, or it opened the review and
3929
+ * is still orienting itself before its first poll. */
3930
+ parked = "replied";
3520
3931
  graceTimer = null;
3521
3932
  lastDetach = null;
3522
3933
  since = Date.now();
@@ -3664,7 +4075,7 @@ var DeliveryQueue = class DeliveryQueue {
3664
4075
  if (this.awaitingReply) return this.stalled ? "stalled" : "delivered";
3665
4076
  if (this.outlining() !== null) return "delivered";
3666
4077
  if (this.waiter) return "polling";
3667
- if (this.betweenPolls) return "replied";
4078
+ if (this.betweenPolls) return this.parked;
3668
4079
  return this.lastDetach ?? "no-agent";
3669
4080
  }
3670
4081
  hasListener() {
@@ -3717,9 +4128,10 @@ var DeliveryQueue = class DeliveryQueue {
3717
4128
  if (this.stallTimer !== null) clearTimeout(this.stallTimer);
3718
4129
  this.stallTimer = null;
3719
4130
  }
3720
- armGrace() {
4131
+ armGrace(why = "replied", windowMs = this.replyGraceMs) {
3721
4132
  this.clearGrace();
3722
4133
  this.betweenPolls = true;
4134
+ this.parked = why;
3723
4135
  const drop = () => {
3724
4136
  this.graceTimer = null;
3725
4137
  if (!this.betweenPolls) return;
@@ -3728,7 +4140,7 @@ var DeliveryQueue = class DeliveryQueue {
3728
4140
  };
3729
4141
  const pid = this.owner;
3730
4142
  if (pid === null) {
3731
- const timer = setTimeout(drop, this.replyGraceMs);
4143
+ const timer = setTimeout(drop, windowMs);
3732
4144
  timer.unref?.();
3733
4145
  this.graceTimer = timer;
3734
4146
  return;
@@ -3754,6 +4166,42 @@ var DeliveryQueue = class DeliveryQueue {
3754
4166
  this.graceTimer = null;
3755
4167
  this.betweenPolls = false;
3756
4168
  }
4169
+ /**
4170
+ * A poll ended without `diffo end`: the poll window closed, or the process
4171
+ * died under its harness. Neither means the agent left — the CLI tells the
4172
+ * session to re-run the poll, and it usually does within moments — so the
4173
+ * reviewer keeps an agent instead of being asked to invite one again. The
4174
+ * agent parks in the same grace a reply uses: a named session is watched
4175
+ * until its process is gone (or the cap), an unnamed one gets the fixed
4176
+ * window. Only a session that is already dead reads as gone at once. When
4177
+ * the grace runs out without a re-poll, the reason it fell to waiting is
4178
+ * still the dead poll.
4179
+ */
4180
+ pollDropped() {
4181
+ this.lastDetach = "disconnected";
4182
+ const pid = this.owner;
4183
+ const isAlive = this.liveness.isAlive ?? pidAlive;
4184
+ if (!(pid !== null && !isAlive(pid)) && !this.awaitingReply && this.outlining() === null) this.armGrace("repolling");
4185
+ this.notify();
4186
+ }
4187
+ /**
4188
+ * An agent's CLI opened this review. The agent reads the change and writes
4189
+ * its guide before it ever polls, which can take minutes — and a reviewer who
4190
+ * opened the URL meanwhile must not be told there is no agent and offered an
4191
+ * Invite. So from the open until the first poll the agent is 'working', for
4192
+ * the reason 'arriving': a named session is watched until its process is gone
4193
+ * (or the cap), an anonymous one gets a fixed window. The first poll ends it
4194
+ * the way it ends every grace. An agent already here in any state is left
4195
+ * alone — a second open is not a second agent. Returns whether it took.
4196
+ */
4197
+ agentArrived(pid) {
4198
+ if (this.presence() !== "waiting") return false;
4199
+ this.owner = DeliveryQueue.clean(pid);
4200
+ this.lastDetach = null;
4201
+ this.armGrace("arriving", this.liveness.arriveGraceMs ?? 3e5);
4202
+ this.notify();
4203
+ return true;
4204
+ }
3757
4205
  enqueueThreads(threadIds) {
3758
4206
  const bucket = this.bucket();
3759
4207
  for (const id of threadIds) bucket.threads.add(id);
@@ -3841,8 +4289,7 @@ var DeliveryQueue = class DeliveryQueue {
3841
4289
  onAbort?.(() => {
3842
4290
  if (this.waiter === waiter) {
3843
4291
  this.releaseWaiter(null);
3844
- this.lastDetach = "disconnected";
3845
- this.notify();
4292
+ this.pollDropped();
3846
4293
  }
3847
4294
  });
3848
4295
  });
@@ -4280,7 +4727,7 @@ function threadsFromGithub(pr, imported, files) {
4280
4727
  * body, or a conversation comment.
4281
4728
  */
4282
4729
  function githubPosition(anchor, body, files) {
4283
- if (anchor.kind === "changeset") return null;
4730
+ if (anchor.kind === "changeset" || anchor.kind === "layer") return null;
4284
4731
  if (anchor.kind === "file") return {
4285
4732
  draft: {
4286
4733
  path: anchor.path,
@@ -4361,6 +4808,37 @@ function planPublicLeg(threads, files) {
4361
4808
  resolves
4362
4809
  };
4363
4810
  }
4811
+ /**
4812
+ * What became of the agent's suggested review comments, counted at submit:
4813
+ * a suggestion is `posted` when the draft it became is still in the review
4814
+ * (a discarded draft takes the thread with it), `edited` when the reviewer
4815
+ * changed the words first. The agent hears this once, in the submit notice.
4816
+ */
4817
+ function suggestionsSummary(threads) {
4818
+ const ids = new Set(threads.map((t) => t.id));
4819
+ const s = {
4820
+ total: 0,
4821
+ posted: 0,
4822
+ edited: 0,
4823
+ dismissed: 0,
4824
+ undecided: 0
4825
+ };
4826
+ for (const t of threads) {
4827
+ if (isPublic(t)) continue;
4828
+ for (const m of t.messages) {
4829
+ if (m.author !== "agent" || !m.prComment) continue;
4830
+ s.total++;
4831
+ const o = m.prComment.outcome;
4832
+ if (!o) s.undecided++;
4833
+ else if (o.kind === "dismissed") s.dismissed++;
4834
+ else if (ids.has(o.draftThreadId)) {
4835
+ s.posted++;
4836
+ if (o.edited) s.edited++;
4837
+ } else s.undecided++;
4838
+ }
4839
+ }
4840
+ return s;
4841
+ }
4364
4842
  /** The dialog's rows: what will post, in words. */
4365
4843
  function describeLeg(leg) {
4366
4844
  return {
@@ -4369,7 +4847,8 @@ function describeLeg(leg) {
4369
4847
  anchor: describeAnchor(d.thread.anchor),
4370
4848
  text: d.text,
4371
4849
  downgraded: d.downgraded,
4372
- conversation: d.position === null
4850
+ conversation: d.position === null,
4851
+ ...d.thread.origin ? { fromAgent: { edited: d.thread.origin.edited } } : {}
4373
4852
  })),
4374
4853
  replies: leg.replies.map((r) => ({
4375
4854
  id: r.thread.id,
@@ -4890,21 +5369,67 @@ var ReviewStore = class {
4890
5369
  state: "open",
4891
5370
  ...options.audience === "pr" ? { audience: "pr" } : intent ? { intent } : {},
4892
5371
  ...options.parentId ? { parentId: options.parentId } : {},
5372
+ ...options.audience === "pr" && options.origin ? { origin: options.origin } : {},
4893
5373
  codeContext: capture?.codeContext ?? null,
4894
5374
  ...capture?.anchored ? { anchored: capture.anchored } : {},
5375
+ ...capture?.anchoredLayer ? { anchoredLayer: capture.anchoredLayer } : {},
4895
5376
  codeChanged: false,
4896
5377
  messages: [{
4897
5378
  id: randomUUID(),
4898
5379
  author,
4899
5380
  text,
4900
5381
  at: now,
4901
- ...author === "agent" && options.suggestedReply ? { suggestedReply: options.suggestedReply } : {}
5382
+ ...author === "agent" && options.suggestedReply ? { suggestedReply: options.suggestedReply } : {},
5383
+ ...author === "agent" && options.prComment ? { prComment: { text: options.prComment } } : {}
4902
5384
  }],
4903
5385
  createdAt: now,
4904
5386
  updatedAt: now
4905
5387
  });
4906
5388
  }
4907
5389
  /**
5390
+ * The reviewer's decision on a suggested PR comment: added to their review
5391
+ * (the public draft it became is named) or dismissed. Stamped once — a
5392
+ * decided suggestion is history, and the card shows it as such. Null when
5393
+ * the thread or message is not a live suggestion.
5394
+ */
5395
+ decidePrComment(threadId, messageId, outcome) {
5396
+ const current = this.state.threads.find((t) => t.id === threadId);
5397
+ const message = current?.messages.find((m) => m.id === messageId);
5398
+ if (!current || !message || message.author !== "agent" || !message.prComment) return null;
5399
+ if (message.prComment.outcome !== void 0) return null;
5400
+ return this.setPrCommentOutcome(threadId, messageId, outcome);
5401
+ }
5402
+ /**
5403
+ * The decision taken back: a dismissed suggestion returns live, with its
5404
+ * buttons. An added one does too, but only once the draft it became is gone
5405
+ * (discarded) — while the draft stands, reviving the suggestion would offer
5406
+ * the same comment twice. Null when there is nothing to restore.
5407
+ */
5408
+ restorePrComment(threadId, messageId) {
5409
+ const current = this.state.threads.find((t) => t.id === threadId);
5410
+ const message = current?.messages.find((m) => m.id === messageId);
5411
+ const outcome = message?.prComment?.outcome;
5412
+ if (!current || !message || !outcome) return null;
5413
+ if (outcome.kind === "added" && this.state.threads.some((t) => t.id === outcome.draftThreadId)) return null;
5414
+ return this.setPrCommentOutcome(threadId, messageId, void 0);
5415
+ }
5416
+ setPrCommentOutcome(threadId, messageId, outcome) {
5417
+ return this.update(threadId, (thread) => ({
5418
+ ...thread,
5419
+ messages: thread.messages.map((m) => {
5420
+ if (m.id !== messageId) return m;
5421
+ const { outcome: _was, ...rest } = m.prComment;
5422
+ return {
5423
+ ...m,
5424
+ prComment: outcome ? {
5425
+ ...rest,
5426
+ outcome
5427
+ } : rest
5428
+ };
5429
+ })
5430
+ }));
5431
+ }
5432
+ /**
4908
5433
  * The closing note of a Finish round, as a thread. Anchored to the changeset
4909
5434
  * and opened in the reviewer's voice, so it travels the ordinary path: the
4910
5435
  * agent replies to it with `diffo reply`, the queue counts it as feedback
@@ -4946,14 +5471,15 @@ var ReviewStore = class {
4946
5471
  * the reviewer raced in since — appended at the end, it would render as an
4947
5472
  * answer to a comment it never saw, and the raced comment would stop reading
4948
5473
  * as "waiting on the agent" even though it still is. */
4949
- addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false, suggestedReply) {
5474
+ addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false, suggestedReply, prComment) {
4950
5475
  return this.update(threadId, ({ unanswered: _answered, ...thread }) => {
4951
5476
  const message = {
4952
5477
  id: randomUUID(),
4953
5478
  author,
4954
5479
  text,
4955
5480
  at: (/* @__PURE__ */ new Date()).toISOString(),
4956
- ...author === "agent" && suggestedReply ? { suggestedReply } : {}
5481
+ ...author === "agent" && suggestedReply ? { suggestedReply } : {},
5482
+ ...author === "agent" && prComment ? { prComment: { text: prComment } } : {}
4957
5483
  };
4958
5484
  const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => m.author === "reviewer" && Date.parse(m.at) > seenThroughMs) : -1;
4959
5485
  const { awaitingFollowUp: _promised, ...settled } = thread;
@@ -5069,13 +5595,14 @@ var ReviewStore = class {
5069
5595
  }));
5070
5596
  }
5071
5597
  removeThread(threadId) {
5072
- const threads = this.state.threads.filter((t) => t.id !== threadId);
5073
- if (threads.length === this.state.threads.length) return false;
5598
+ const removed = this.state.threads.find((t) => t.id === threadId);
5599
+ if (!removed) return false;
5074
5600
  this.state = {
5075
5601
  ...this.state,
5076
- threads
5602
+ threads: this.state.threads.filter((t) => t.id !== threadId)
5077
5603
  };
5078
5604
  this.commit();
5605
+ if (removed.origin) this.restorePrComment(removed.origin.threadId, removed.origin.messageId);
5079
5606
  return true;
5080
5607
  }
5081
5608
  /**
@@ -5118,7 +5645,8 @@ var ReviewStore = class {
5118
5645
  const { layersSuggested: _suggested, ...rest } = this.state;
5119
5646
  this.state = {
5120
5647
  ...rest,
5121
- layers
5648
+ layers,
5649
+ threads: reconcileLayerThreads(rest.threads, layers)
5122
5650
  };
5123
5651
  this.commit();
5124
5652
  return layers;
@@ -5601,9 +6129,32 @@ function migrateLegacySuggestion(value, now) {
5601
6129
  updatedAt: at
5602
6130
  };
5603
6131
  }
6132
+ /**
6133
+ * A re-post dropped a layer (its title is gone), so the threads on it have
6134
+ * nothing left to sit under — the same fate as a hunk whose id rotated: a
6135
+ * `sent` thread was overtaken and reads as addressed; an open one stays as it
6136
+ * is and the UI files it with the threads the changeset left behind.
6137
+ */
6138
+ function reconcileLayerThreads(threads, layers) {
6139
+ const ids = new Set(layers.items.map((l) => l.id));
6140
+ return threads.map((thread) => thread.anchor.kind === "layer" && thread.state === "sent" && !ids.has(thread.anchor.layerId) ? {
6141
+ ...thread,
6142
+ state: "addressed",
6143
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6144
+ } : thread);
6145
+ }
5604
6146
  function parseAnchor(value) {
5605
6147
  if (typeof value !== "object" || value === null) return null;
5606
6148
  const anchor = value;
6149
+ if (anchor.kind === "layer") {
6150
+ if (typeof anchor.layerId !== "string" || anchor.layerId === "") return null;
6151
+ if (typeof anchor.title !== "string" || anchor.title.trim() === "") return null;
6152
+ return {
6153
+ kind: "layer",
6154
+ layerId: anchor.layerId,
6155
+ title: anchor.title.trim()
6156
+ };
6157
+ }
5607
6158
  if (anchor.kind === "hunk") {
5608
6159
  if (typeof anchor.hunkId !== "string" || typeof anchor.path !== "string") return null;
5609
6160
  if (anchor.side !== "old" && anchor.side !== "new") return null;
@@ -5636,6 +6187,53 @@ function normalizeAnchored(value) {
5636
6187
  text: a.text
5637
6188
  };
5638
6189
  }
6190
+ /** Dropped when malformed — the thread keeps its anchor's title regardless. */
6191
+ function normalizeAnchoredLayer(value) {
6192
+ if (typeof value !== "object" || value === null) return null;
6193
+ const a = value;
6194
+ if (typeof a.title !== "string" || !Array.isArray(a.files)) return null;
6195
+ if (!a.files.every((f) => typeof f === "string")) return null;
6196
+ return {
6197
+ title: a.title,
6198
+ ...typeof a.summary === "string" && a.summary !== "" ? { summary: a.summary } : {},
6199
+ files: a.files
6200
+ };
6201
+ }
6202
+ function normalizePrComment(value) {
6203
+ if (typeof value !== "object" || value === null) return null;
6204
+ const v = value;
6205
+ if (typeof v.text !== "string" || v.text.trim() === "") return null;
6206
+ const o = v.outcome;
6207
+ let outcome;
6208
+ if (typeof o === "object" && o !== null) {
6209
+ const r = o;
6210
+ const at = typeof r.at === "string" ? r.at : (/* @__PURE__ */ new Date()).toISOString();
6211
+ if (r.kind === "dismissed") outcome = {
6212
+ kind: "dismissed",
6213
+ at
6214
+ };
6215
+ else if (r.kind === "added" && typeof r.draftThreadId === "string") outcome = {
6216
+ kind: "added",
6217
+ draftThreadId: r.draftThreadId,
6218
+ edited: r.edited === true,
6219
+ at
6220
+ };
6221
+ }
6222
+ return {
6223
+ text: v.text,
6224
+ ...outcome ? { outcome } : {}
6225
+ };
6226
+ }
6227
+ function normalizeOrigin(value) {
6228
+ if (typeof value !== "object" || value === null) return null;
6229
+ const v = value;
6230
+ if (typeof v.threadId !== "string" || typeof v.messageId !== "string") return null;
6231
+ return {
6232
+ threadId: v.threadId,
6233
+ messageId: v.messageId,
6234
+ edited: v.edited === true
6235
+ };
6236
+ }
5639
6237
  function normalizeThread(value, now) {
5640
6238
  if (typeof value !== "object" || value === null) return null;
5641
6239
  const t = value;
@@ -5643,6 +6241,7 @@ function normalizeThread(value, now) {
5643
6241
  const anchor = parseAnchor(t.anchor);
5644
6242
  if (!anchor) return null;
5645
6243
  const anchored = normalizeAnchored(t.anchored);
6244
+ const anchoredLayer = normalizeAnchoredLayer(t.anchoredLayer);
5646
6245
  if (!Array.isArray(t.messages)) return null;
5647
6246
  const messages = [];
5648
6247
  for (const m of t.messages) {
@@ -5659,6 +6258,7 @@ function normalizeThread(value, now) {
5659
6258
  at: typeof msg.at === "string" ? msg.at : now,
5660
6259
  ...typeof msg.durationMs === "number" ? { durationMs: msg.durationMs } : {},
5661
6260
  ...msg.author === "agent" && typeof msg.suggestedReply === "string" && msg.suggestedReply ? { suggestedReply: msg.suggestedReply } : {},
6261
+ ...msg.author === "agent" && normalizePrComment(msg.prComment) ? { prComment: normalizePrComment(msg.prComment) } : {},
5662
6262
  ...typeof msg.editedAt === "string" ? { editedAt: msg.editedAt } : {},
5663
6263
  ...github ? { github } : {}
5664
6264
  });
@@ -5672,10 +6272,12 @@ function normalizeThread(value, now) {
5672
6272
  ...t.audience === "pr" ? { audience: "pr" } : {},
5673
6273
  ...typeof t.parentId === "string" && t.parentId !== "" ? { parentId: t.parentId } : {},
5674
6274
  ...github ? { github } : {},
6275
+ ...t.audience === "pr" && normalizeOrigin(t.origin) ? { origin: normalizeOrigin(t.origin) } : {},
5675
6276
  ...queued ? { queued } : {},
5676
6277
  ...THREAD_INTENTS.includes(t.intent) ? { intent: t.intent } : {},
5677
6278
  codeContext: typeof t.codeContext === "string" ? t.codeContext : null,
5678
6279
  ...anchored ? { anchored } : {},
6280
+ ...anchoredLayer ? { anchoredLayer } : {},
5679
6281
  codeChanged: t.codeChanged === true,
5680
6282
  ...t.unanswered === true ? { unanswered: true } : {},
5681
6283
  ...t.awaitingFollowUp === true && t.unanswered !== true ? { awaitingFollowUp: true } : {},
@@ -6489,6 +7091,9 @@ const POLL_HEARTBEAT_MS = 15e3;
6489
7091
  * aborts, and a listening poll blocks the daemon's idle reap.
6490
7092
  */
6491
7093
  const POLL_MAX_MS = 18e5;
7094
+ /** How long after listening the daemon starts the machine-wide database sweep:
7095
+ * past the first page load, which is what the sweep must never delay. */
7096
+ const MAINTENANCE_DELAY_MS = 5e3;
6492
7097
  function createApp(ctx, store, review, queue) {
6493
7098
  const app = new Hono();
6494
7099
  if (review && queue) queue.onBatchClosed((closed) => {
@@ -6548,15 +7153,27 @@ function createApp(ctx, store, review, queue) {
6548
7153
  "light",
6549
7154
  "dark"
6550
7155
  ]);
7156
+ const CRITTER_VALUES = /* @__PURE__ */ new Set(["on", "off"]);
7157
+ const stored = (key, values) => {
7158
+ const value = ctx.uiSettings?.get(key) ?? null;
7159
+ return value !== null && values.has(value) ? value : null;
7160
+ };
6551
7161
  app.get("/api/settings", (c) => {
6552
- const theme = ctx.uiSettings?.get("theme") ?? null;
6553
- return c.json({ theme: theme !== null && THEME_VALUES.has(theme) ? theme : null });
7162
+ return c.json({
7163
+ theme: stored("theme", THEME_VALUES),
7164
+ critter: stored("critter", CRITTER_VALUES)
7165
+ });
6554
7166
  });
6555
7167
  app.put("/api/settings", async (c) => {
6556
7168
  if (!ctx.uiSettings) return c.json({ error: "settings unavailable" }, 503);
6557
- const theme = (await c.req.json().catch(() => null))?.theme;
6558
- if (typeof theme !== "string" || !THEME_VALUES.has(theme)) return c.json({ error: "need {theme: \"system\" | \"light\" | \"dark\"}" }, 400);
6559
- ctx.uiSettings.set("theme", theme);
7169
+ const body = await c.req.json().catch(() => null);
7170
+ const theme = body?.theme;
7171
+ const critter = body?.critter;
7172
+ const themeOk = typeof theme === "string" && THEME_VALUES.has(theme);
7173
+ const critterOk = typeof critter === "string" && CRITTER_VALUES.has(critter);
7174
+ if (theme !== void 0 && !themeOk || critter !== void 0 && !critterOk || !(themeOk || critterOk)) return c.json({ error: "need {theme?: \"system\" | \"light\" | \"dark\", critter?: \"on\" | \"off\"}, at least one" }, 400);
7175
+ if (themeOk) ctx.uiSettings.set("theme", theme);
7176
+ if (critterOk) ctx.uiSettings.set("critter", critter);
6560
7177
  return c.json({ ok: true });
6561
7178
  });
6562
7179
  const reviewKind = () => reviewKindOf(ctx.spec, ctx.pr !== void 0);
@@ -6593,6 +7210,7 @@ function createApp(ctx, store, review, queue) {
6593
7210
  const promptCtx = (...excludeThreadIds) => ({
6594
7211
  repo: repoInfo(),
6595
7212
  changeset: store?.get() ?? null,
7213
+ ...ctx.pr && review ? { voice: review.get().threads } : {},
6596
7214
  siblings: review?.get().threads.filter((t) => !excludeThreadIds.includes(t.id) && (t.state === "open" || t.state === "sent")) ?? []
6597
7215
  });
6598
7216
  const deliverThreads = (threadIds) => {
@@ -6636,20 +7254,67 @@ function createApp(ctx, store, review, queue) {
6636
7254
  const file = typeof body?.file === "string" && body.file.trim() !== "" ? body.file.trim() : null;
6637
7255
  const line = typeof body?.line === "number" && Number.isInteger(body.line) && body.line >= 1 ? body.line : null;
6638
7256
  const anchor = agentAnchor(file, line);
7257
+ const prComment = parsePrComment(body?.prComment);
7258
+ if (body?.prComment !== void 0 && !ctx.pr) return c.json({ error: "a suggested PR comment needs a pull request under review" }, 400);
6639
7259
  const capture = store ? captureAnchor(store.get(), anchor) : null;
6640
- return c.json(review.createThread(anchor, text, capture, void 0, "agent", { suggestedReply: parseSuggestedReply(body?.suggestedReply) }));
7260
+ return c.json(review.createThread(anchor, text, capture, void 0, "agent", {
7261
+ suggestedReply: parseSuggestedReply(body?.suggestedReply),
7262
+ ...prComment ? { prComment } : {}
7263
+ }));
6641
7264
  }
6642
7265
  const anchor = parseAnchor(body?.anchor);
6643
7266
  if (!anchor) return c.json({ error: "need {anchor, text}" }, 400);
7267
+ const layer = anchor.kind === "layer" ? review.get().layers?.items.find((l) => l.id === anchor.layerId) : void 0;
7268
+ if (anchor.kind === "layer") {
7269
+ if (!layer) return c.json({ error: "that layer is not in the outline" }, 400);
7270
+ anchor.title = layer.title;
7271
+ }
6644
7272
  const intent = THREAD_INTENTS.includes(body?.intent) ? body.intent : void 0;
6645
7273
  const audience = body?.audience === "pr" ? "pr" : void 0;
6646
7274
  if (audience && !ctx.pr) return c.json({ error: "public comments need a pull request under review" }, 400);
7275
+ if (audience && anchor.kind === "layer") return c.json({ error: "a comment on a layer is for the agent, not the pull request" }, 400);
6647
7276
  const parentId = typeof body?.parentId === "string" && review.get().threads.some((t) => t.id === body.parentId) ? body.parentId : void 0;
6648
- const capture = store ? captureAnchor(store.get(), anchor) : null;
6649
- return c.json(review.createThread(anchor, text, capture, intent, "reviewer", {
7277
+ let origin;
7278
+ if (body?.origin !== void 0) {
7279
+ const o = body.origin;
7280
+ const source = audience && typeof o?.threadId === "string" ? review.get().threads.find((t) => t.id === o.threadId) : void 0;
7281
+ const message = source && typeof o?.messageId === "string" ? source.messages.find((m) => m.id === o.messageId) : void 0;
7282
+ if (!source || !message || liveSuggestion(source)?.id !== message.id) return c.json({ error: "that suggested comment is no longer live" }, 409);
7283
+ origin = {
7284
+ threadId: source.id,
7285
+ messageId: message.id,
7286
+ edited: message.prComment.text.trim() !== text
7287
+ };
7288
+ }
7289
+ const capture = store ? captureAnchor(store.get(), anchor, review.get().layers) : null;
7290
+ const created = review.createThread(anchor, text, capture, intent, "reviewer", {
6650
7291
  ...audience ? { audience } : {},
6651
- ...parentId ? { parentId } : {}
6652
- }));
7292
+ ...parentId ? { parentId } : {},
7293
+ ...origin ? { origin } : {}
7294
+ });
7295
+ if (origin) review.decidePrComment(origin.threadId, origin.messageId, {
7296
+ kind: "added",
7297
+ draftThreadId: created.id,
7298
+ edited: origin.edited,
7299
+ at: (/* @__PURE__ */ new Date()).toISOString()
7300
+ });
7301
+ return c.json(created);
7302
+ });
7303
+ app.post("/api/review/threads/:id/messages/:messageId/pr-comment", async (c) => {
7304
+ if (!review) return c.json({ error: "review unavailable" }, 503);
7305
+ const body = await c.req.json().catch(() => null);
7306
+ const id = c.req.param("id");
7307
+ const messageId = c.req.param("messageId");
7308
+ if (body?.outcome === "restored") {
7309
+ const thread = review.restorePrComment(id, messageId);
7310
+ return thread ? c.json(thread) : c.json({ error: "nothing to restore there" }, 404);
7311
+ }
7312
+ if (body?.outcome !== "dismissed") return c.json({ error: "outcome must be \"dismissed\" or \"restored\"" }, 400);
7313
+ const thread = review.decidePrComment(id, messageId, {
7314
+ kind: "dismissed",
7315
+ at: (/* @__PURE__ */ new Date()).toISOString()
7316
+ });
7317
+ return thread ? c.json(thread) : c.json({ error: "no live suggested comment there" }, 404);
6653
7318
  });
6654
7319
  app.post("/api/review/layers", async (c) => {
6655
7320
  if (!review) return c.json({ error: "review unavailable" }, 503);
@@ -6686,9 +7351,11 @@ function createApp(ctx, store, review, queue) {
6686
7351
  if (!review.get().threads.some((t) => t.id === id)) return c.json({ error: "no such thread" }, 404);
6687
7352
  queue?.noteSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
6688
7353
  const more = body?.more === true;
7354
+ const prComment = parsePrComment(body?.prComment);
7355
+ if (body?.prComment !== void 0 && !ctx.pr) return c.json({ error: "a suggested PR comment needs a pull request under review" }, 400);
6689
7356
  const waitedMs = queue?.agentReplied(id, more) ?? null;
6690
7357
  const seenThroughMs = waitedMs === null ? void 0 : Date.now() - waitedMs;
6691
- const thread = review.addMessage(id, "agent", text, false, seenThroughMs, more, parseSuggestedReply(body?.suggestedReply));
7358
+ const thread = review.addMessage(id, "agent", text, false, seenThroughMs, more, parseSuggestedReply(body?.suggestedReply), prComment);
6692
7359
  if (!thread) return c.json({ error: "no such thread" }, 404);
6693
7360
  if (waitedMs !== null) review.annotateAgentReplies([thread.id], waitedMs);
6694
7361
  return c.json({
@@ -6828,7 +7495,7 @@ function createApp(ctx, store, review, queue) {
6828
7495
  ...note !== void 0 ? { note } : {}
6829
7496
  };
6830
7497
  };
6831
- const activeThreads = (threads) => threadsInChangeset(store?.get().files ?? [], threads).active.filter((t) => !isPublic(t));
7498
+ const activeThreads = (threads) => threadsInChangeset(store?.get().files ?? [], threads, review?.get().layers).active.filter((t) => !isPublic(t));
6832
7499
  const flushableIds = (threads) => new Set(activeThreads(threads).filter((t) => !untouchedAgentVoice(t)).map((t) => t.id));
6833
7500
  const projectFinish = (threads) => {
6834
7501
  const flushable = flushableIds(threads);
@@ -6890,6 +7557,7 @@ function createApp(ctx, store, review, queue) {
6890
7557
  }, coverage),
6891
7558
  ...leg ? { public: {
6892
7559
  ...describeLeg(leg),
7560
+ undecidedSuggestions: before.filter((t) => liveSuggestion(t) !== null).length,
6893
7561
  canApprove: pr ? !pr.viewer.isAuthor : true,
6894
7562
  pendingReview: (review.get().pr?.pendingReviewId ?? pr?.viewer.pendingReviewId ?? null) !== null
6895
7563
  } } : {}
@@ -6915,7 +7583,8 @@ function createApp(ctx, store, review, queue) {
6915
7583
  event,
6916
7584
  comments: publicOutcome.posted,
6917
7585
  body: coverage.note ?? "",
6918
- ...publicOutcome.url ? { url: publicOutcome.url } : {}
7586
+ ...publicOutcome.url ? { url: publicOutcome.url } : {},
7587
+ suggestions: suggestionsSummary(review.get().threads)
6919
7588
  });
6920
7589
  ctx.pr.refresh?.();
6921
7590
  }
@@ -7098,6 +7767,16 @@ function createApp(ctx, store, review, queue) {
7098
7767
  }
7099
7768
  });
7100
7769
  });
7770
+ app.post("/api/agent/arrive", (c) => {
7771
+ if (!queue) return c.json({ error: "agent queue unavailable" }, 503);
7772
+ if (!c.req.header("x-diffo-agent") && !c.req.header("x-diffo-session-pid")) return c.json({ error: "agent arrivals must send the x-diffo-agent header" }, 403);
7773
+ const arrived = queue.agentArrived(parseSessionPid(c.req.header("x-diffo-session-pid")));
7774
+ return c.json({
7775
+ ok: true,
7776
+ arrived,
7777
+ presence: queue.presence()
7778
+ });
7779
+ });
7101
7780
  app.post("/api/agent/end", (c) => {
7102
7781
  if (!queue) return c.json({ error: "agent queue unavailable" }, 503);
7103
7782
  const sessionPid = parseSessionPid(c.req.header("x-diffo-session-pid"));
@@ -7271,11 +7950,13 @@ function startServer(options) {
7271
7950
  throw new RepoAlreadyServedError(holder);
7272
7951
  }
7273
7952
  db.setPreferredPort(resolve(options.root), options.port);
7953
+ const log = (message) => console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false })}] ${message}`);
7954
+ const retired = db.pruneRepo(resolve(options.root));
7955
+ if (retired > 0) log(`retired ${retired} ${retired === 1 ? "review" : "reviews"} for branches this repo no longer has`);
7274
7956
  const review = new ReviewStore(options.root, db, options.spec);
7275
7957
  const queue = new DeliveryQueue();
7276
7958
  queue.rescope(store.get().repo.branch);
7277
7959
  rehydrateQueue(review, queue);
7278
- const log = (message) => console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false })}] ${message}`);
7279
7960
  const telemetry = options.telemetry ?? new Telemetry({
7280
7961
  store: {
7281
7962
  get: (key) => db.getUiSetting(key),
@@ -7358,6 +8039,12 @@ function startServer(options) {
7358
8039
  port: options.port,
7359
8040
  hostname: "127.0.0.1"
7360
8041
  });
8042
+ const maintenance = setTimeout(() => {
8043
+ db.maintain().then((report) => {
8044
+ if (report.reviews > 0) log(`retired ${report.reviews} ${report.reviews === 1 ? "review" : "reviews"} whose repo or branch is gone`);
8045
+ }).catch((err) => log(`database sweep failed: ${err.message}`));
8046
+ }, MAINTENANCE_DELAY_MS);
8047
+ maintenance.unref();
7361
8048
  const deregister = () => {
7362
8049
  try {
7363
8050
  db.removeServer(review.repoPath, options.port);
@@ -7381,6 +8068,7 @@ function startServer(options) {
7381
8068
  queue,
7382
8069
  telemetry,
7383
8070
  stopWatching: async () => {
8071
+ clearTimeout(maintenance);
7384
8072
  idle?.stop();
7385
8073
  puller?.stop();
7386
8074
  unsubscribeReconcile();
@@ -7986,6 +8674,7 @@ if (command.kind === "setup") {
7986
8674
  if (command.kind === "clean") {
7987
8675
  const db = new DiffoDb();
7988
8676
  try {
8677
+ await db.maintain();
7989
8678
  const entries = sweepWorktrees(db, void 0, {
7990
8679
  force: command.force,
7991
8680
  all: command.all
@@ -8118,6 +8807,23 @@ async function announceOpen(port) {
8118
8807
  });
8119
8808
  } catch {}
8120
8809
  }
8810
+ /**
8811
+ * An agent opened the review (piped stdout, like `printAgentNextStep`): tell
8812
+ * the server it is here before the URL prints, so the reviewer who opens the
8813
+ * page while the guide is being written sees an agent orienting itself, not
8814
+ * "no agent" and an Invite. A human's terminal open says nothing — the
8815
+ * reviewer IS the opener, and the chip must keep offering the invite.
8816
+ */
8817
+ async function announceArrival(port) {
8818
+ if (process.stdout.isTTY) return;
8819
+ try {
8820
+ await fetch(apiUrl(port, "/api/agent/arrive"), {
8821
+ method: "POST",
8822
+ headers: sessionHeaders(),
8823
+ signal: AbortSignal.timeout(2e3)
8824
+ });
8825
+ } catch {}
8826
+ }
8121
8827
  async function fetchChangesetInfo(port) {
8122
8828
  try {
8123
8829
  return await (await fetch(apiUrl(port, "/api/changeset"), { signal: AbortSignal.timeout(2e3) })).json();
@@ -8309,16 +9015,19 @@ if (command.kind === "reply") {
8309
9015
  author: "agent",
8310
9016
  text: message,
8311
9017
  ...command.more ? { more: true } : {},
8312
- ...command.suggestReply ? { suggestedReply: command.suggestReply } : {}
9018
+ ...command.suggestReply ? { suggestedReply: command.suggestReply } : {},
9019
+ ...command.prComment ? { prComment: command.prComment } : {}
8313
9020
  });
8314
9021
  if (status === 404) fail(`no thread with id '${command.threadId}'`);
9022
+ if (status === 400) fail(`reply refused: ${body?.error ?? "bad request"}`);
8315
9023
  if (status !== 200) fail(`reply failed (${status})`);
8316
9024
  const thread = body.thread;
8317
9025
  console.log(JSON.stringify({
8318
9026
  ok: true,
8319
9027
  threadId: thread.id,
8320
9028
  state: thread.state,
8321
- next_step: command.more ? ACK_NEXT_STEP.replyMore : ACK_NEXT_STEP.reply
9029
+ ...command.prComment ? { prComment: "suggested" } : {},
9030
+ next_step: command.more ? ACK_NEXT_STEP.replyMore : command.prComment ? ACK_NEXT_STEP.replyPrComment : ACK_NEXT_STEP.reply
8322
9031
  }));
8323
9032
  process.exit(0);
8324
9033
  }
@@ -8331,13 +9040,16 @@ if (command.kind === "comment") {
8331
9040
  file: command.file,
8332
9041
  line: command.line,
8333
9042
  text: message,
8334
- ...command.suggestReply ? { suggestedReply: command.suggestReply } : {}
9043
+ ...command.suggestReply ? { suggestedReply: command.suggestReply } : {},
9044
+ ...command.prComment ? { prComment: command.prComment } : {}
8335
9045
  });
9046
+ if (status === 400) fail(`comment refused: ${body?.error ?? "bad request"}`);
8336
9047
  if (status !== 200) fail(`comment failed (${status})`);
8337
9048
  console.log(JSON.stringify({
8338
9049
  ok: true,
8339
9050
  threadId: body.id,
8340
- next_step: ACK_NEXT_STEP.comment
9051
+ ...command.prComment ? { prComment: "suggested" } : {},
9052
+ next_step: command.prComment ? ACK_NEXT_STEP.commentPrComment : ACK_NEXT_STEP.comment
8341
9053
  }));
8342
9054
  process.exit(0);
8343
9055
  }
@@ -8500,6 +9212,7 @@ if (existing !== null && takeOverPort === null) {
8500
9212
  console.log(prOpen ? "diffo is already watching this pull request" : "diffo is already watching this repo");
8501
9213
  await warnSpecMismatch(existing.port, spec);
8502
9214
  await announceOpen(existing.port);
9215
+ await announceArrival(existing.port);
8503
9216
  console.log(`→ ${url}`);
8504
9217
  await printAgentNextStep(existing.port, prOpen?.pr);
8505
9218
  process.exit(0);
@@ -8520,6 +9233,7 @@ if (existing !== null && takeOverPort !== null) {
8520
9233
  if (!command.foreground) {
8521
9234
  const daemonPort = await requireServer(command.port, spec.kind === "branch" ? spec.base : void 0, SRC_STAMP, prOpen ? command.target : void 0);
8522
9235
  await announceOpen(daemonPort);
9236
+ await announceArrival(daemonPort);
8523
9237
  const url = reviewUrl(daemonPort);
8524
9238
  await printChangesetSummary(daemonPort);
8525
9239
  console.log(`→ ${url}`);