@diffohq/diffo 0.10.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 +635 -45
  3. package/dist/client/assets/{abnfDiagram-O67JEVCF-DBlSJW53.js → abnfDiagram-O67JEVCF-BP5iCFkM.js} +1 -1
  4. package/dist/client/assets/{arc-BZZ_ET2N.js → arc-C87wOYz0.js} +1 -1
  5. package/dist/client/assets/architecture-7GRP2DOG-CsHR0ILn.js +1 -0
  6. package/dist/client/assets/{architectureDiagram-NJMV4G6O-CmS7e2K4.js → architectureDiagram-NJMV4G6O-Ch5MdO5l.js} +1 -1
  7. package/dist/client/assets/{blockDiagram-BEXU5L5S-Kj8H2ODF.js → blockDiagram-BEXU5L5S-CFc74J_K.js} +1 -1
  8. package/dist/client/assets/{c4Diagram-YGBWAQC7-CixbFJxb.js → c4Diagram-YGBWAQC7-ClsjpVUP.js} +1 -1
  9. package/dist/client/assets/channel-DQRWLOSk.js +1 -0
  10. package/dist/client/assets/{chunk-3FUC2YCW-CRdo0Gw5.js → chunk-3FUC2YCW-N2ZkdF_f.js} +1 -1
  11. package/dist/client/assets/{chunk-5DYCD2WN-Bw9n6qPh.js → chunk-5DYCD2WN-5muGfxCV.js} +1 -1
  12. package/dist/client/assets/{chunk-742MDFTN-BsvOSyDz.js → chunk-742MDFTN-C5vBH3An.js} +1 -1
  13. package/dist/client/assets/{chunk-7INBJB4K-ie7EoCoW.js → chunk-7INBJB4K-DPMhysil.js} +1 -1
  14. package/dist/client/assets/{chunk-7M6MHVWA-DJlbYDLH.js → chunk-7M6MHVWA-D5sd2f7K.js} +1 -1
  15. package/dist/client/assets/{chunk-7PRAP22T-Hs5y39V8.js → chunk-7PRAP22T-CI_sV34J.js} +1 -1
  16. package/dist/client/assets/{chunk-GTNCS2PH-DNq6Ojc7.js → chunk-GTNCS2PH-DfNZcIoa.js} +1 -1
  17. package/dist/client/assets/{chunk-GWA4HPMP-vgn0kxbp.js → chunk-GWA4HPMP-DQAQRo6A.js} +1 -1
  18. package/dist/client/assets/{chunk-MBY4JIJT-C7fMuqSl.js → chunk-MBY4JIJT-BaSwxH2J.js} +1 -1
  19. package/dist/client/assets/{chunk-NETBCI7D-ZJtjvGwt.js → chunk-NETBCI7D-7ynGMJbY.js} +1 -1
  20. package/dist/client/assets/{chunk-O7XYJQB3-DESjnZZT.js → chunk-O7XYJQB3-B6zthtTM.js} +1 -1
  21. package/dist/client/assets/{chunk-UA2S7LBM-Cmf087s8.js → chunk-UA2S7LBM-C5XT36wS.js} +1 -1
  22. package/dist/client/assets/{chunk-WEXAMYUT-U1TW5ZAF.js → chunk-WEXAMYUT-DKSfnXN1.js} +1 -1
  23. package/dist/client/assets/{chunk-XXDRQBXY-D1B1IYm1.js → chunk-XXDRQBXY-C_bd9P3S.js} +1 -1
  24. package/dist/client/assets/{chunk-Z7XXMR3K-D3BUH0qv.js → chunk-Z7XXMR3K-DX8Rrfc1.js} +1 -1
  25. package/dist/client/assets/{chunk-ZIGJFQKS-Bx6yZhwg.js → chunk-ZIGJFQKS-DYXH6dfg.js} +1 -1
  26. package/dist/client/assets/{classDiagram-v2-NBCMYWYE-sjiBGJdD.js → classDiagram-v2-NBCMYWYE-ppwXe7vx.js} +1 -1
  27. package/dist/client/assets/{core-6IJUkucC.js → core-Cg_mWgd2.js} +1 -1
  28. package/dist/client/assets/{cose-bilkent-JH36ORCC-xaMCCs4V.js → cose-bilkent-JH36ORCC-DBThhQUN.js} +1 -1
  29. package/dist/client/assets/{cynefin-OW5HDTMX-C5k9L5NF.js → cynefin-OW5HDTMX-BzSaDndh.js} +1 -1
  30. package/dist/client/assets/{cynefinDiagram-VND7K2PF-BqiILl4G.js → cynefinDiagram-VND7K2PF-D7pfZG6I.js} +1 -1
  31. package/dist/client/assets/{dagre-6A5THRUB-BtgX5KvU.js → dagre-6A5THRUB-C09fFRAG.js} +1 -1
  32. package/dist/client/assets/{diagram-22UHCM2B-BcDIKY8C.js → diagram-22UHCM2B-Ch1ZxAj-.js} +1 -1
  33. package/dist/client/assets/{diagram-3UASUU5V-C-DrBJ6K.js → diagram-3UASUU5V-BgHiK8FV.js} +1 -1
  34. package/dist/client/assets/{diagram-ATOU4E4O-B0W6FX6y.js → diagram-ATOU4E4O-Bo77_UMN.js} +1 -1
  35. package/dist/client/assets/{diagram-CDSNMT55-D1O9NYQ3.js → diagram-CDSNMT55--hmHMcni.js} +1 -1
  36. package/dist/client/assets/{diagram-MLGK6HIB-WC8izmk2.js → diagram-MLGK6HIB-D9F7Il7J.js} +1 -1
  37. package/dist/client/assets/{diagram-MPIPVDR6-D5ko9V-k.js → diagram-MPIPVDR6-D3d07JsR.js} +1 -1
  38. package/dist/client/assets/{dist-BBmACywa.js → dist-CAR9vYXv.js} +1 -1
  39. package/dist/client/assets/{dist-Bss8uvIz.js → dist-Dgznis65.js} +1 -1
  40. package/dist/client/assets/{ebnfDiagram-ZINNZB2B-BFIYDkrv.js → ebnfDiagram-ZINNZB2B-Bso144Pp.js} +1 -1
  41. package/dist/client/assets/{elk-276RUBZZ-WsSc0CyB.js → elk-276RUBZZ-DsWB0IFq.js} +1 -1
  42. package/dist/client/assets/{engine-oniguruma-DmL6leO7.js → engine-oniguruma-BDu3jbE4.js} +1 -1
  43. package/dist/client/assets/{erDiagram-OPXOYQCR-CjaKeGKh.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-22OFMoat.js → ganttDiagram-FUAMR5RP-DZ8yDc0-.js} +1 -1
  47. package/dist/client/assets/{gitGraph-4MIJSDKK-BP0idayz.js → gitGraph-4MIJSDKK-jziaCjGb.js} +1 -1
  48. package/dist/client/assets/{gitGraphDiagram-X574FWY7-Cx5rPzjc.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-BvZZu8IY.js → info-A6RAGUB7-CVherEOw.js} +1 -1
  52. package/dist/client/assets/{infoDiagram-VRGFBTTK-Bbk1aZco.js → infoDiagram-VRGFBTTK-0xq1jhrR.js} +1 -1
  53. package/dist/client/assets/{ishikawaDiagram-OU5B5YK6-DbyWrUBY.js → ishikawaDiagram-OU5B5YK6-BOs1zS14.js} +1 -1
  54. package/dist/client/assets/{journeyDiagram-ZHPQQLJL-Ka7zlagO.js → journeyDiagram-ZHPQQLJL-D0Ozyf-O.js} +1 -1
  55. package/dist/client/assets/{kanban-definition-PNTS6WVX-Q_NAEdRt.js → kanban-definition-PNTS6WVX-DRE_uQjW.js} +1 -1
  56. package/dist/client/assets/{line-SDAA9fEr.js → line-DaN3iy15.js} +1 -1
  57. package/dist/client/assets/{linear-BPf2OGUv.js → linear-CRCMQGaX.js} +1 -1
  58. package/dist/client/assets/{mermaid-parser.core-BjLzFSoy.js → mermaid-parser.core-oN6M_WEa.js} +3 -3
  59. package/dist/client/assets/{mermaid.core-vvUsQbpX.js → mermaid.core-YJctE5LN.js} +4 -4
  60. package/dist/client/assets/{mindmap-definition-NLK3R4M7-DuVzu_4M.js → mindmap-definition-NLK3R4M7-C6eqCgee.js} +1 -1
  61. package/dist/client/assets/{packet-AYTQ26CC-A66za-wc.js → packet-AYTQ26CC-D09KC4vR.js} +1 -1
  62. package/dist/client/assets/{pegDiagram-GJSIUBJH-ClH92MDY.js → pegDiagram-GJSIUBJH-CbKH-rBq.js} +1 -1
  63. package/dist/client/assets/{pie-WAS4IAKB-sJ2H2jF4.js → pie-WAS4IAKB-whtC2UUK.js} +1 -1
  64. package/dist/client/assets/{pieDiagram-5QR66LMP-BmlcB4fD.js → pieDiagram-5QR66LMP-Dvc9m9wg.js} +1 -1
  65. package/dist/client/assets/{quadrantDiagram-O4NWA36T-BUdNHhtc.js → quadrantDiagram-O4NWA36T-D0G-xgdx.js} +1 -1
  66. package/dist/client/assets/{radar-RG4KPBEZ-B3MRkz7l.js → radar-RG4KPBEZ-UErkZb84.js} +1 -1
  67. package/dist/client/assets/{railroad-74A4TZTK-CPQddZw9.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-B2wicmNk.js → railroadDiagram-XR7U4H2S-DVT-lJ4r.js} +1 -1
  72. package/dist/client/assets/{requirementDiagram-PLB6GJNP-4fGmNry2.js → requirementDiagram-PLB6GJNP-BWJZJJlR.js} +1 -1
  73. package/dist/client/assets/{sankeyDiagram-IPEJSGJF-ByuhL9nO.js → sankeyDiagram-IPEJSGJF-CEh_PdFx.js} +1 -1
  74. package/dist/client/assets/{sequenceDiagram-PO4LG4MO-CHeFXNrS.js → sequenceDiagram-PO4LG4MO-DodtYIMU.js} +1 -1
  75. package/dist/client/assets/{src-DuEx6MBc.js → src-1v74j3tV.js} +1 -1
  76. package/dist/client/assets/{stateDiagram-v2-GCMORJYK-Dsfly8rU.js → stateDiagram-v2-GCMORJYK-kMbRFMI8.js} +1 -1
  77. package/dist/client/assets/{swimlanes-2SLR337P-DDb1xXAV.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-iC4lxMiK.js → timeline-definition-EJHVYXUP-DBiPEcaq.js} +1 -1
  80. package/dist/client/assets/{treeView-Q6P3EWNA-zXF3RWVa.js → treeView-Q6P3EWNA-B4A_X6Ee.js} +1 -1
  81. package/dist/client/assets/{treemap-WGGIJYW6-BvA719M0.js → treemap-WGGIJYW6-BKfnTV1e.js} +1 -1
  82. package/dist/client/assets/{usecaseDiagram-POWQR4AR-CXzh5cdW.js → usecaseDiagram-POWQR4AR-C-wOJ0sN.js} +1 -1
  83. package/dist/client/assets/{vennDiagram-UO4OBE2U-BuMQeNKp.js → vennDiagram-UO4OBE2U-CgBX6mVG.js} +1 -1
  84. package/dist/client/assets/{wardley-WFR3VGLG-m1zZI6-6.js → wardley-WFR3VGLG-Ca0IxSXR.js} +1 -1
  85. package/dist/client/assets/{wardleyDiagram-VNRHLVJA-DiB2QtGU.js → wardleyDiagram-VNRHLVJA-BtiwsIrq.js} +1 -1
  86. package/dist/client/assets/{xychartDiagram-PMCCYNJV-CKRPoaV8.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 +2 -1
  90. package/plugin.json +1 -1
  91. package/dist/client/assets/architecture-7GRP2DOG-Cds-sOVr.js +0 -1
  92. package/dist/client/assets/channel-BI4dhOPo.js +0 -1
  93. package/dist/client/assets/eventmodeling-NTZA5JFV-DE5ql6lo.js +0 -1
  94. package/dist/client/assets/flowDiagram-KWPJA3E3-CObKVtKJ.js +0 -1
  95. package/dist/client/assets/index-CL4K6Y07.css +0 -1
  96. package/dist/client/assets/index-DXMhk4ok.js +0 -93
  97. package/dist/client/assets/railroad-abnf-HS5TGJTU-CijG3JBn.js +0 -1
  98. package/dist/client/assets/railroad-ebnf-LZEXJU2U-CNgXgJgc.js +0 -1
  99. package/dist/client/assets/railroad-peg-WCYAUIDC-BkwGmMtj.js +0 -1
  100. package/dist/client/assets/swimlanesDiagram-TC7HE7FX-DXpYpXP-.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
@@ -3607,8 +3924,9 @@ var DeliveryQueue = class DeliveryQueue {
3607
3924
  stalled = false;
3608
3925
  stallTimer = null;
3609
3926
  betweenPolls = false;
3610
- /** Why the agent is parked between polls: a reply just landed, or its poll
3611
- * ended on its own and the re-poll is expected. */
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. */
3612
3930
  parked = "replied";
3613
3931
  graceTimer = null;
3614
3932
  lastDetach = null;
@@ -3810,7 +4128,7 @@ var DeliveryQueue = class DeliveryQueue {
3810
4128
  if (this.stallTimer !== null) clearTimeout(this.stallTimer);
3811
4129
  this.stallTimer = null;
3812
4130
  }
3813
- armGrace(why = "replied") {
4131
+ armGrace(why = "replied", windowMs = this.replyGraceMs) {
3814
4132
  this.clearGrace();
3815
4133
  this.betweenPolls = true;
3816
4134
  this.parked = why;
@@ -3822,7 +4140,7 @@ var DeliveryQueue = class DeliveryQueue {
3822
4140
  };
3823
4141
  const pid = this.owner;
3824
4142
  if (pid === null) {
3825
- const timer = setTimeout(drop, this.replyGraceMs);
4143
+ const timer = setTimeout(drop, windowMs);
3826
4144
  timer.unref?.();
3827
4145
  this.graceTimer = timer;
3828
4146
  return;
@@ -3866,6 +4184,24 @@ var DeliveryQueue = class DeliveryQueue {
3866
4184
  if (!(pid !== null && !isAlive(pid)) && !this.awaitingReply && this.outlining() === null) this.armGrace("repolling");
3867
4185
  this.notify();
3868
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
+ }
3869
4205
  enqueueThreads(threadIds) {
3870
4206
  const bucket = this.bucket();
3871
4207
  for (const id of threadIds) bucket.threads.add(id);
@@ -4391,7 +4727,7 @@ function threadsFromGithub(pr, imported, files) {
4391
4727
  * body, or a conversation comment.
4392
4728
  */
4393
4729
  function githubPosition(anchor, body, files) {
4394
- if (anchor.kind === "changeset") return null;
4730
+ if (anchor.kind === "changeset" || anchor.kind === "layer") return null;
4395
4731
  if (anchor.kind === "file") return {
4396
4732
  draft: {
4397
4733
  path: anchor.path,
@@ -4472,6 +4808,37 @@ function planPublicLeg(threads, files) {
4472
4808
  resolves
4473
4809
  };
4474
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
+ }
4475
4842
  /** The dialog's rows: what will post, in words. */
4476
4843
  function describeLeg(leg) {
4477
4844
  return {
@@ -4480,7 +4847,8 @@ function describeLeg(leg) {
4480
4847
  anchor: describeAnchor(d.thread.anchor),
4481
4848
  text: d.text,
4482
4849
  downgraded: d.downgraded,
4483
- conversation: d.position === null
4850
+ conversation: d.position === null,
4851
+ ...d.thread.origin ? { fromAgent: { edited: d.thread.origin.edited } } : {}
4484
4852
  })),
4485
4853
  replies: leg.replies.map((r) => ({
4486
4854
  id: r.thread.id,
@@ -5001,21 +5369,67 @@ var ReviewStore = class {
5001
5369
  state: "open",
5002
5370
  ...options.audience === "pr" ? { audience: "pr" } : intent ? { intent } : {},
5003
5371
  ...options.parentId ? { parentId: options.parentId } : {},
5372
+ ...options.audience === "pr" && options.origin ? { origin: options.origin } : {},
5004
5373
  codeContext: capture?.codeContext ?? null,
5005
5374
  ...capture?.anchored ? { anchored: capture.anchored } : {},
5375
+ ...capture?.anchoredLayer ? { anchoredLayer: capture.anchoredLayer } : {},
5006
5376
  codeChanged: false,
5007
5377
  messages: [{
5008
5378
  id: randomUUID(),
5009
5379
  author,
5010
5380
  text,
5011
5381
  at: now,
5012
- ...author === "agent" && options.suggestedReply ? { suggestedReply: options.suggestedReply } : {}
5382
+ ...author === "agent" && options.suggestedReply ? { suggestedReply: options.suggestedReply } : {},
5383
+ ...author === "agent" && options.prComment ? { prComment: { text: options.prComment } } : {}
5013
5384
  }],
5014
5385
  createdAt: now,
5015
5386
  updatedAt: now
5016
5387
  });
5017
5388
  }
5018
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
+ /**
5019
5433
  * The closing note of a Finish round, as a thread. Anchored to the changeset
5020
5434
  * and opened in the reviewer's voice, so it travels the ordinary path: the
5021
5435
  * agent replies to it with `diffo reply`, the queue counts it as feedback
@@ -5057,14 +5471,15 @@ var ReviewStore = class {
5057
5471
  * the reviewer raced in since — appended at the end, it would render as an
5058
5472
  * answer to a comment it never saw, and the raced comment would stop reading
5059
5473
  * as "waiting on the agent" even though it still is. */
5060
- addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false, suggestedReply) {
5474
+ addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false, suggestedReply, prComment) {
5061
5475
  return this.update(threadId, ({ unanswered: _answered, ...thread }) => {
5062
5476
  const message = {
5063
5477
  id: randomUUID(),
5064
5478
  author,
5065
5479
  text,
5066
5480
  at: (/* @__PURE__ */ new Date()).toISOString(),
5067
- ...author === "agent" && suggestedReply ? { suggestedReply } : {}
5481
+ ...author === "agent" && suggestedReply ? { suggestedReply } : {},
5482
+ ...author === "agent" && prComment ? { prComment: { text: prComment } } : {}
5068
5483
  };
5069
5484
  const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => m.author === "reviewer" && Date.parse(m.at) > seenThroughMs) : -1;
5070
5485
  const { awaitingFollowUp: _promised, ...settled } = thread;
@@ -5180,13 +5595,14 @@ var ReviewStore = class {
5180
5595
  }));
5181
5596
  }
5182
5597
  removeThread(threadId) {
5183
- const threads = this.state.threads.filter((t) => t.id !== threadId);
5184
- 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;
5185
5600
  this.state = {
5186
5601
  ...this.state,
5187
- threads
5602
+ threads: this.state.threads.filter((t) => t.id !== threadId)
5188
5603
  };
5189
5604
  this.commit();
5605
+ if (removed.origin) this.restorePrComment(removed.origin.threadId, removed.origin.messageId);
5190
5606
  return true;
5191
5607
  }
5192
5608
  /**
@@ -5229,7 +5645,8 @@ var ReviewStore = class {
5229
5645
  const { layersSuggested: _suggested, ...rest } = this.state;
5230
5646
  this.state = {
5231
5647
  ...rest,
5232
- layers
5648
+ layers,
5649
+ threads: reconcileLayerThreads(rest.threads, layers)
5233
5650
  };
5234
5651
  this.commit();
5235
5652
  return layers;
@@ -5712,9 +6129,32 @@ function migrateLegacySuggestion(value, now) {
5712
6129
  updatedAt: at
5713
6130
  };
5714
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
+ }
5715
6146
  function parseAnchor(value) {
5716
6147
  if (typeof value !== "object" || value === null) return null;
5717
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
+ }
5718
6158
  if (anchor.kind === "hunk") {
5719
6159
  if (typeof anchor.hunkId !== "string" || typeof anchor.path !== "string") return null;
5720
6160
  if (anchor.side !== "old" && anchor.side !== "new") return null;
@@ -5747,6 +6187,53 @@ function normalizeAnchored(value) {
5747
6187
  text: a.text
5748
6188
  };
5749
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
+ }
5750
6237
  function normalizeThread(value, now) {
5751
6238
  if (typeof value !== "object" || value === null) return null;
5752
6239
  const t = value;
@@ -5754,6 +6241,7 @@ function normalizeThread(value, now) {
5754
6241
  const anchor = parseAnchor(t.anchor);
5755
6242
  if (!anchor) return null;
5756
6243
  const anchored = normalizeAnchored(t.anchored);
6244
+ const anchoredLayer = normalizeAnchoredLayer(t.anchoredLayer);
5757
6245
  if (!Array.isArray(t.messages)) return null;
5758
6246
  const messages = [];
5759
6247
  for (const m of t.messages) {
@@ -5770,6 +6258,7 @@ function normalizeThread(value, now) {
5770
6258
  at: typeof msg.at === "string" ? msg.at : now,
5771
6259
  ...typeof msg.durationMs === "number" ? { durationMs: msg.durationMs } : {},
5772
6260
  ...msg.author === "agent" && typeof msg.suggestedReply === "string" && msg.suggestedReply ? { suggestedReply: msg.suggestedReply } : {},
6261
+ ...msg.author === "agent" && normalizePrComment(msg.prComment) ? { prComment: normalizePrComment(msg.prComment) } : {},
5773
6262
  ...typeof msg.editedAt === "string" ? { editedAt: msg.editedAt } : {},
5774
6263
  ...github ? { github } : {}
5775
6264
  });
@@ -5783,10 +6272,12 @@ function normalizeThread(value, now) {
5783
6272
  ...t.audience === "pr" ? { audience: "pr" } : {},
5784
6273
  ...typeof t.parentId === "string" && t.parentId !== "" ? { parentId: t.parentId } : {},
5785
6274
  ...github ? { github } : {},
6275
+ ...t.audience === "pr" && normalizeOrigin(t.origin) ? { origin: normalizeOrigin(t.origin) } : {},
5786
6276
  ...queued ? { queued } : {},
5787
6277
  ...THREAD_INTENTS.includes(t.intent) ? { intent: t.intent } : {},
5788
6278
  codeContext: typeof t.codeContext === "string" ? t.codeContext : null,
5789
6279
  ...anchored ? { anchored } : {},
6280
+ ...anchoredLayer ? { anchoredLayer } : {},
5790
6281
  codeChanged: t.codeChanged === true,
5791
6282
  ...t.unanswered === true ? { unanswered: true } : {},
5792
6283
  ...t.awaitingFollowUp === true && t.unanswered !== true ? { awaitingFollowUp: true } : {},
@@ -6662,15 +7153,27 @@ function createApp(ctx, store, review, queue) {
6662
7153
  "light",
6663
7154
  "dark"
6664
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
+ };
6665
7161
  app.get("/api/settings", (c) => {
6666
- const theme = ctx.uiSettings?.get("theme") ?? null;
6667
- 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
+ });
6668
7166
  });
6669
7167
  app.put("/api/settings", async (c) => {
6670
7168
  if (!ctx.uiSettings) return c.json({ error: "settings unavailable" }, 503);
6671
- const theme = (await c.req.json().catch(() => null))?.theme;
6672
- if (typeof theme !== "string" || !THEME_VALUES.has(theme)) return c.json({ error: "need {theme: \"system\" | \"light\" | \"dark\"}" }, 400);
6673
- 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);
6674
7177
  return c.json({ ok: true });
6675
7178
  });
6676
7179
  const reviewKind = () => reviewKindOf(ctx.spec, ctx.pr !== void 0);
@@ -6707,6 +7210,7 @@ function createApp(ctx, store, review, queue) {
6707
7210
  const promptCtx = (...excludeThreadIds) => ({
6708
7211
  repo: repoInfo(),
6709
7212
  changeset: store?.get() ?? null,
7213
+ ...ctx.pr && review ? { voice: review.get().threads } : {},
6710
7214
  siblings: review?.get().threads.filter((t) => !excludeThreadIds.includes(t.id) && (t.state === "open" || t.state === "sent")) ?? []
6711
7215
  });
6712
7216
  const deliverThreads = (threadIds) => {
@@ -6750,20 +7254,67 @@ function createApp(ctx, store, review, queue) {
6750
7254
  const file = typeof body?.file === "string" && body.file.trim() !== "" ? body.file.trim() : null;
6751
7255
  const line = typeof body?.line === "number" && Number.isInteger(body.line) && body.line >= 1 ? body.line : null;
6752
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);
6753
7259
  const capture = store ? captureAnchor(store.get(), anchor) : null;
6754
- 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
+ }));
6755
7264
  }
6756
7265
  const anchor = parseAnchor(body?.anchor);
6757
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
+ }
6758
7272
  const intent = THREAD_INTENTS.includes(body?.intent) ? body.intent : void 0;
6759
7273
  const audience = body?.audience === "pr" ? "pr" : void 0;
6760
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);
6761
7276
  const parentId = typeof body?.parentId === "string" && review.get().threads.some((t) => t.id === body.parentId) ? body.parentId : void 0;
6762
- const capture = store ? captureAnchor(store.get(), anchor) : null;
6763
- 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", {
6764
7291
  ...audience ? { audience } : {},
6765
- ...parentId ? { parentId } : {}
6766
- }));
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);
6767
7318
  });
6768
7319
  app.post("/api/review/layers", async (c) => {
6769
7320
  if (!review) return c.json({ error: "review unavailable" }, 503);
@@ -6800,9 +7351,11 @@ function createApp(ctx, store, review, queue) {
6800
7351
  if (!review.get().threads.some((t) => t.id === id)) return c.json({ error: "no such thread" }, 404);
6801
7352
  queue?.noteSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
6802
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);
6803
7356
  const waitedMs = queue?.agentReplied(id, more) ?? null;
6804
7357
  const seenThroughMs = waitedMs === null ? void 0 : Date.now() - waitedMs;
6805
- 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);
6806
7359
  if (!thread) return c.json({ error: "no such thread" }, 404);
6807
7360
  if (waitedMs !== null) review.annotateAgentReplies([thread.id], waitedMs);
6808
7361
  return c.json({
@@ -6942,7 +7495,7 @@ function createApp(ctx, store, review, queue) {
6942
7495
  ...note !== void 0 ? { note } : {}
6943
7496
  };
6944
7497
  };
6945
- 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));
6946
7499
  const flushableIds = (threads) => new Set(activeThreads(threads).filter((t) => !untouchedAgentVoice(t)).map((t) => t.id));
6947
7500
  const projectFinish = (threads) => {
6948
7501
  const flushable = flushableIds(threads);
@@ -7004,6 +7557,7 @@ function createApp(ctx, store, review, queue) {
7004
7557
  }, coverage),
7005
7558
  ...leg ? { public: {
7006
7559
  ...describeLeg(leg),
7560
+ undecidedSuggestions: before.filter((t) => liveSuggestion(t) !== null).length,
7007
7561
  canApprove: pr ? !pr.viewer.isAuthor : true,
7008
7562
  pendingReview: (review.get().pr?.pendingReviewId ?? pr?.viewer.pendingReviewId ?? null) !== null
7009
7563
  } } : {}
@@ -7029,7 +7583,8 @@ function createApp(ctx, store, review, queue) {
7029
7583
  event,
7030
7584
  comments: publicOutcome.posted,
7031
7585
  body: coverage.note ?? "",
7032
- ...publicOutcome.url ? { url: publicOutcome.url } : {}
7586
+ ...publicOutcome.url ? { url: publicOutcome.url } : {},
7587
+ suggestions: suggestionsSummary(review.get().threads)
7033
7588
  });
7034
7589
  ctx.pr.refresh?.();
7035
7590
  }
@@ -7212,6 +7767,16 @@ function createApp(ctx, store, review, queue) {
7212
7767
  }
7213
7768
  });
7214
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
+ });
7215
7780
  app.post("/api/agent/end", (c) => {
7216
7781
  if (!queue) return c.json({ error: "agent queue unavailable" }, 503);
7217
7782
  const sessionPid = parseSessionPid(c.req.header("x-diffo-session-pid"));
@@ -8242,6 +8807,23 @@ async function announceOpen(port) {
8242
8807
  });
8243
8808
  } catch {}
8244
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
+ }
8245
8827
  async function fetchChangesetInfo(port) {
8246
8828
  try {
8247
8829
  return await (await fetch(apiUrl(port, "/api/changeset"), { signal: AbortSignal.timeout(2e3) })).json();
@@ -8433,16 +9015,19 @@ if (command.kind === "reply") {
8433
9015
  author: "agent",
8434
9016
  text: message,
8435
9017
  ...command.more ? { more: true } : {},
8436
- ...command.suggestReply ? { suggestedReply: command.suggestReply } : {}
9018
+ ...command.suggestReply ? { suggestedReply: command.suggestReply } : {},
9019
+ ...command.prComment ? { prComment: command.prComment } : {}
8437
9020
  });
8438
9021
  if (status === 404) fail(`no thread with id '${command.threadId}'`);
9022
+ if (status === 400) fail(`reply refused: ${body?.error ?? "bad request"}`);
8439
9023
  if (status !== 200) fail(`reply failed (${status})`);
8440
9024
  const thread = body.thread;
8441
9025
  console.log(JSON.stringify({
8442
9026
  ok: true,
8443
9027
  threadId: thread.id,
8444
9028
  state: thread.state,
8445
- 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
8446
9031
  }));
8447
9032
  process.exit(0);
8448
9033
  }
@@ -8455,13 +9040,16 @@ if (command.kind === "comment") {
8455
9040
  file: command.file,
8456
9041
  line: command.line,
8457
9042
  text: message,
8458
- ...command.suggestReply ? { suggestedReply: command.suggestReply } : {}
9043
+ ...command.suggestReply ? { suggestedReply: command.suggestReply } : {},
9044
+ ...command.prComment ? { prComment: command.prComment } : {}
8459
9045
  });
9046
+ if (status === 400) fail(`comment refused: ${body?.error ?? "bad request"}`);
8460
9047
  if (status !== 200) fail(`comment failed (${status})`);
8461
9048
  console.log(JSON.stringify({
8462
9049
  ok: true,
8463
9050
  threadId: body.id,
8464
- next_step: ACK_NEXT_STEP.comment
9051
+ ...command.prComment ? { prComment: "suggested" } : {},
9052
+ next_step: command.prComment ? ACK_NEXT_STEP.commentPrComment : ACK_NEXT_STEP.comment
8465
9053
  }));
8466
9054
  process.exit(0);
8467
9055
  }
@@ -8624,6 +9212,7 @@ if (existing !== null && takeOverPort === null) {
8624
9212
  console.log(prOpen ? "diffo is already watching this pull request" : "diffo is already watching this repo");
8625
9213
  await warnSpecMismatch(existing.port, spec);
8626
9214
  await announceOpen(existing.port);
9215
+ await announceArrival(existing.port);
8627
9216
  console.log(`→ ${url}`);
8628
9217
  await printAgentNextStep(existing.port, prOpen?.pr);
8629
9218
  process.exit(0);
@@ -8644,6 +9233,7 @@ if (existing !== null && takeOverPort !== null) {
8644
9233
  if (!command.foreground) {
8645
9234
  const daemonPort = await requireServer(command.port, spec.kind === "branch" ? spec.base : void 0, SRC_STAMP, prOpen ? command.target : void 0);
8646
9235
  await announceOpen(daemonPort);
9236
+ await announceArrival(daemonPort);
8647
9237
  const url = reviewUrl(daemonPort);
8648
9238
  await printChangesetSummary(daemonPort);
8649
9239
  console.log(`→ ${url}`);