@diffohq/diffo 0.2.0 → 0.4.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 (144) hide show
  1. package/README.md +22 -4
  2. package/dist/cli.mjs +632 -32
  3. package/dist/client/assets/{abnfDiagram-VCTEODGH-BK74b0Uj.js → abnfDiagram-O67JEVCF-b6l_FGHi.js} +1 -1
  4. package/dist/client/assets/{arc-BZJjnFHJ.js → arc-Cz0jZwLH.js} +1 -1
  5. package/dist/client/assets/architecture-7GRP2DOG-BXYREMFQ.js +1 -0
  6. package/dist/client/assets/architectureDiagram-NJMV4G6O-CJdtqKce.js +36 -0
  7. package/dist/client/assets/{blockDiagram-I7D4REHJ-BWSjZ0YR.js → blockDiagram-BEXU5L5S-Ci-lnLGN.js} +15 -4
  8. package/dist/client/assets/c4Diagram-YGBWAQC7-DDO2mTMB.js +38 -0
  9. package/dist/client/assets/channel-CtvNwuQX.js +1 -0
  10. package/dist/client/assets/chunk-3FUC2YCW-VyswqFt3.js +2 -0
  11. package/dist/client/assets/{chunk-GVQU2GXP-3NWb-Kjs.js → chunk-5DYCD2WN-B4MBZW5P.js} +1 -1
  12. package/dist/client/assets/{chunk-PWAF6VOD-BRN4CzQ0.js → chunk-742MDFTN-Cxvmvrid.js} +1 -1
  13. package/dist/client/assets/chunk-7INBJB4K-DkBnRFbH.js +62 -0
  14. package/dist/client/assets/chunk-7M6MHVWA-BvJgBwEi.js +213 -0
  15. package/dist/client/assets/chunk-7PRAP22T-OfiMNi_y.js +1 -0
  16. package/dist/client/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  17. package/dist/client/assets/{chunk-FOHPRMQF-DHwB1DNv.js → chunk-FOHPRMQF-DzxwRta7.js} +9 -9
  18. package/dist/client/assets/{chunk-SVP7TREG-CsO-w42m.js → chunk-GTNCS2PH-BodN5cRQ.js} +1 -1
  19. package/dist/client/assets/{chunk-F27PBJKO-C6X74WsU.js → chunk-GWA4HPMP-KifxxZcy.js} +1 -1
  20. package/dist/client/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  21. package/dist/client/assets/chunk-MBY4JIJT-rw02bHlj.js +72 -0
  22. package/dist/client/assets/chunk-NETBCI7D-CWjhZoou.js +1 -0
  23. package/dist/client/assets/chunk-O7XYJQB3-CyxnSfZ3.js +126 -0
  24. package/dist/client/assets/chunk-UA2S7LBM-BReIFgsu.js +1 -0
  25. package/dist/client/assets/{chunk-POPQ4Y6H-DKwUAqbe.js → chunk-WEXAMYUT-DHwMy8v7.js} +1 -1
  26. package/dist/client/assets/{chunk-XXDRQBXY-BikhrMBF.js → chunk-XXDRQBXY-C398QZbS.js} +1 -1
  27. package/dist/client/assets/{chunk-OSK3NFVY-BJ8i4cd-.js → chunk-Z7XXMR3K-hEV1GU64.js} +3 -3
  28. package/dist/client/assets/{chunk-75Z2AOVW-B_bGdfJP.js → chunk-ZIGJFQKS-DJJL7llO.js} +2 -2
  29. package/dist/client/assets/classDiagram-v2-NBCMYWYE-pSGZI880.js +217 -0
  30. package/dist/client/assets/{core-BJUznnCg.js → core-C05I4wHV.js} +2 -2
  31. package/dist/client/assets/cose-bilkent-JH36ORCC-DNV8Dqd8.js +1 -0
  32. package/dist/client/assets/cynefin-OW5HDTMX-DF-6FOgS.js +1 -0
  33. package/dist/client/assets/cynefinDiagram-VND7K2PF-D3-iJq2J.js +62 -0
  34. package/dist/client/assets/cytoscape.esm-Yq6u8L66.js +321 -0
  35. package/dist/client/assets/dagre-6A5THRUB-C9FDZkaa.js +4 -0
  36. package/dist/client/assets/diagram-22UHCM2B-CfSJBDyN.js +200 -0
  37. package/dist/client/assets/{diagram-VX7I27RA-BmHtC5T2.js → diagram-3UASUU5V-D946baYo.js} +2 -2
  38. package/dist/client/assets/{diagram-VSXAHHWV-BMNFierD.js → diagram-ATOU4E4O-CGrjukoA.js} +3 -3
  39. package/dist/client/assets/{diagram-S7CK7UJ4-C1jP2NX5.js → diagram-CDSNMT55-B47UPtWq.js} +3 -3
  40. package/dist/client/assets/{diagram-Z3DM3KII-CzCV6KsS.js → diagram-MLGK6HIB-D06yEd5R.js} +1 -1
  41. package/dist/client/assets/diagram-MPIPVDR6-BRUBT2B9.js +41 -0
  42. package/dist/client/assets/{dist-bii_eoxi.js → dist-BfTRyLGm.js} +1 -1
  43. package/dist/client/assets/dist-DVw88SBM.js +142 -0
  44. package/dist/client/assets/{ebnfDiagram-PWID7BFC-DuVnjPWa.js → ebnfDiagram-ZINNZB2B-BnuExfrd.js} +1 -1
  45. package/dist/client/assets/elk-276RUBZZ-BmdcLjR9.js +27 -0
  46. package/dist/client/assets/{engine-oniguruma-BlIEwGaN.js → engine-oniguruma-Bw1dOuOO.js} +1 -1
  47. package/dist/client/assets/{erDiagram-RLTQ6QDP-CtwYHyZu.js → erDiagram-OPXOYQCR-CKCR3jQT.js} +15 -15
  48. package/dist/client/assets/eventmodeling-NTZA5JFV-C7ouopD6.js +1 -0
  49. package/dist/client/assets/flowDiagram-KWPJA3E3-C2o4wCpi.js +1 -0
  50. package/dist/client/assets/{ganttDiagram-EL5Y4UJY-CR8Sa06t.js → ganttDiagram-FUAMR5RP-BwkoweG7.js} +2 -2
  51. package/dist/client/assets/gitGraph-4MIJSDKK-M7_hHVyH.js +1 -0
  52. package/dist/client/assets/{gitGraphDiagram-WWUBYQGX-C36JIyKh.js → gitGraphDiagram-X574FWY7-BVm_sLT_.js} +4 -4
  53. package/dist/client/assets/index-BEG1ZxrX.css +1 -0
  54. package/dist/client/assets/index-BwDjlzOZ.js +91 -0
  55. package/dist/client/assets/info-A6RAGUB7-DPBs3m_X.js +1 -0
  56. package/dist/client/assets/infoDiagram-VRGFBTTK-DDb3KyOP.js +2 -0
  57. package/dist/client/assets/{ishikawaDiagram-5VMMS53U-CsPYEqSH.js → ishikawaDiagram-OU5B5YK6-CPt4RDlr.js} +2 -2
  58. package/dist/client/assets/{journeyDiagram-3NMN7TZE-DbHeKN0X.js → journeyDiagram-ZHPQQLJL-ECQSHJ0N.js} +3 -3
  59. package/dist/client/assets/{kanban-definition-UXKFOSKX-CbYeTY2r.js → kanban-definition-PNTS6WVX-tAmonxA8.js} +10 -10
  60. package/dist/client/assets/{katex-CXMH3UgJ.js → katex-ZlcWpGUi.js} +2 -2
  61. package/dist/client/assets/{line-BeOqlVB1.js → line-x6vAh41a.js} +1 -1
  62. package/dist/client/assets/linear-BqczUNss.js +1 -0
  63. package/dist/client/assets/{mermaid-parser.core-BLSiQUXp.js → mermaid-parser.core-siQPZsry.js} +3 -3
  64. package/dist/client/assets/mermaid.core-CLHTQVF-.js +44 -0
  65. package/dist/client/assets/{mindmap-definition-YA3MSWOX-o49TTsg4.js → mindmap-definition-NLK3R4M7-C9fnEdyl.js} +25 -25
  66. package/dist/client/assets/packet-AYTQ26CC-DAeK0Q4n.js +1 -0
  67. package/dist/client/assets/{pegDiagram-XKGWAZYB-CZxoewnI.js → pegDiagram-GJSIUBJH-BrWBFYPO.js} +1 -1
  68. package/dist/client/assets/pie-WAS4IAKB-CnieihkO.js +1 -0
  69. package/dist/client/assets/{pieDiagram-E7YTZNPT-dAiwtigh.js → pieDiagram-5QR66LMP-Bp5f6Eal.js} +2 -2
  70. package/dist/client/assets/{quadrantDiagram-AXDQQJYC-mefTaSBl.js → quadrantDiagram-O4NWA36T-ECuLA3RS.js} +3 -3
  71. package/dist/client/assets/radar-RG4KPBEZ-BJXkERy0.js +1 -0
  72. package/dist/client/assets/railroad-74A4TZTK-Di61fiV5.js +1 -0
  73. package/dist/client/assets/railroad-abnf-HS5TGJTU-DCBYLLLl.js +1 -0
  74. package/dist/client/assets/railroad-ebnf-LZEXJU2U-VWOalqH1.js +1 -0
  75. package/dist/client/assets/railroad-peg-WCYAUIDC-BTTWaHra.js +1 -0
  76. package/dist/client/assets/{railroadDiagram-O6MQD6OU-DdhfhlaU.js → railroadDiagram-XR7U4H2S-Dn2WPOQe.js} +1 -1
  77. package/dist/client/assets/{requirementDiagram-BXWQKSXE-BVrRfVMy.js → requirementDiagram-PLB6GJNP-uuvtvHmq.js} +11 -11
  78. package/dist/client/assets/sankeyDiagram-IPEJSGJF-BXk6X0af.js +40 -0
  79. package/dist/client/assets/sequenceDiagram-PO4LG4MO-BsPoSXhg.js +169 -0
  80. package/dist/client/assets/{src-CfhDi0bm.js → src-B4djUhOi.js} +1 -1
  81. package/dist/client/assets/stateDiagram-v2-GCMORJYK-cCml5k6n.js +291 -0
  82. package/dist/client/assets/swimlanes-2SLR337P-Dh7v55ZA.js +1 -0
  83. package/dist/client/assets/swimlanesDiagram-TC7HE7FX-q8VF72XO.js +8 -0
  84. package/dist/client/assets/timeline-definition-EJHVYXUP-LFjkFV14.js +120 -0
  85. package/dist/client/assets/treeView-Q6P3EWNA-RYZHSXeB.js +1 -0
  86. package/dist/client/assets/treemap-WGGIJYW6-UVw_-EPz.js +1 -0
  87. package/dist/client/assets/usecaseDiagram-POWQR4AR-DZfPogyb.js +328 -0
  88. package/dist/client/assets/vennDiagram-UO4OBE2U-CN7Ldg-2.js +34 -0
  89. package/dist/client/assets/wardley-WFR3VGLG-CXPbbIy_.js +1 -0
  90. package/dist/client/assets/{wardleyDiagram-VM6X3IG4-CFaIuWQk.js → wardleyDiagram-VNRHLVJA-5p0A7_g6.js} +3 -3
  91. package/dist/client/assets/{xychartDiagram-S5SC5T6Z-et5vk4QS.js → xychartDiagram-PMCCYNJV-gJ7xwZXO.js} +3 -3
  92. package/dist/client/index.html +2 -2
  93. package/package.json +4 -4
  94. package/plugin.json +1 -1
  95. package/skills/diffo/SKILL.md +20 -10
  96. package/dist/client/assets/architecture-7GRP2DOG-BMofp_0u.js +0 -1
  97. package/dist/client/assets/architectureDiagram-5GKGNRK7-BBjd1L77.js +0 -36
  98. package/dist/client/assets/c4Diagram-7LVT6UL2-z-xUGKCJ.js +0 -38
  99. package/dist/client/assets/channel-CmX7fcUa.js +0 -1
  100. package/dist/client/assets/chunk-4HAMMTFA-CAN-T2o-.js +0 -62
  101. package/dist/client/assets/chunk-DU6HZSFF-wyqPpFTy.js +0 -125
  102. package/dist/client/assets/chunk-GMAD6QVW-DBossm8a.js +0 -72
  103. package/dist/client/assets/chunk-IMKFNOWR-BCvjP66C.js +0 -231
  104. package/dist/client/assets/chunk-L3NEJ4N5-DppEbElX.js +0 -1
  105. package/dist/client/assets/chunk-P2QGCYS3-fvNVbdI1.js +0 -1
  106. package/dist/client/assets/chunk-SHT3W25Y-BfzjiqOe.js +0 -168
  107. package/dist/client/assets/chunk-TICWLB2K-DPzmokY4.js +0 -206
  108. package/dist/client/assets/classDiagram-ZZMXUADV-BSMgXeCj.js +0 -1
  109. package/dist/client/assets/classDiagram-v2-VYDZK3BY-BSMgXeCj.js +0 -1
  110. package/dist/client/assets/cose-bilkent-JH36ORCC-CBu7-BXr.js +0 -1
  111. package/dist/client/assets/cynefin-OW5HDTMX-iJEmw--0.js +0 -1
  112. package/dist/client/assets/cynefinDiagram-5FMLGOSQ-B4JnaLZu.js +0 -62
  113. package/dist/client/assets/cytoscape.esm-CGd-uY3x.js +0 -321
  114. package/dist/client/assets/dagre-GXQ25YYZ-bmDafESr.js +0 -4
  115. package/dist/client/assets/dagre-PrKaheQc.js +0 -1
  116. package/dist/client/assets/diagram-UQ7AKVKN-DssGkq6D.js +0 -41
  117. package/dist/client/assets/dist-BT7WB1vp.js +0 -142
  118. package/dist/client/assets/eventmodeling-NTZA5JFV-DBQsn9JF.js +0 -1
  119. package/dist/client/assets/flowDiagram-HODETNUW-C_zt1wER.js +0 -1
  120. package/dist/client/assets/gitGraph-4MIJSDKK-DoWc_DJ0.js +0 -1
  121. package/dist/client/assets/index-BUq9UmeC.js +0 -91
  122. package/dist/client/assets/index-BpeDzG-X.css +0 -1
  123. package/dist/client/assets/info-A6RAGUB7-DULcTga7.js +0 -1
  124. package/dist/client/assets/infoDiagram-27XIBGKW-eQbUxJ9T.js +0 -2
  125. package/dist/client/assets/linear-D5d3UlAm.js +0 -1
  126. package/dist/client/assets/mermaid.core-BoXUi08V.js +0 -44
  127. package/dist/client/assets/packet-AYTQ26CC-Blxpg-nI.js +0 -1
  128. package/dist/client/assets/pie-WAS4IAKB-D-fqeiW7.js +0 -1
  129. package/dist/client/assets/radar-RG4KPBEZ-VaODqy4w.js +0 -1
  130. package/dist/client/assets/railroad-74A4TZTK-BwoigfOV.js +0 -1
  131. package/dist/client/assets/railroad-abnf-HS5TGJTU-BYYKbIBi.js +0 -1
  132. package/dist/client/assets/railroad-ebnf-LZEXJU2U-BFA23uel.js +0 -1
  133. package/dist/client/assets/railroad-peg-WCYAUIDC-Cq02Hm_P.js +0 -1
  134. package/dist/client/assets/sankeyDiagram-P5KCCOFB-BalSSH4U.js +0 -40
  135. package/dist/client/assets/sequenceDiagram-WJ2MYXX4-B2KsE_Z_.js +0 -162
  136. package/dist/client/assets/stateDiagram-D77RDMKH-CoH6bKG5.js +0 -1
  137. package/dist/client/assets/stateDiagram-v2-MP3YSRHH-rXsredo-.js +0 -1
  138. package/dist/client/assets/swimlanes-42K2YHIH-3IkzoE1X.js +0 -1
  139. package/dist/client/assets/swimlanesDiagram-VR7AAH4N-BeXrrDxh.js +0 -8
  140. package/dist/client/assets/timeline-definition-24CTP7MA-B2M9pP5h.js +0 -120
  141. package/dist/client/assets/treeView-Q6P3EWNA-Brp55dHV.js +0 -1
  142. package/dist/client/assets/treemap-WGGIJYW6-zRe9RDJV.js +0 -1
  143. package/dist/client/assets/vennDiagram-4TSXK5OY-PNF-rFET.js +0 -34
  144. package/dist/client/assets/wardley-WFR3VGLG-BSDDZIR6.js +0 -1
package/dist/cli.mjs CHANGED
@@ -94,6 +94,18 @@ function untouchedAgentVoice(thread) {
94
94
  function undeliveredThreadIds(threads) {
95
95
  return threads.filter((t) => (t.state === "sent" || t.state === "addressed") && t.unanswered !== true && t.withheld !== true && t.messages.at(-1)?.author === "reviewer").sort((a, b) => (a.sentAt ?? "").localeCompare(b.sentAt ?? "")).map((t) => t.id);
96
96
  }
97
+ /**
98
+ * Whatever the agent sent, made fit to be a tab's name: controls stripped,
99
+ * whitespace collapsed to one line, capped. Null for anything that isn't a
100
+ * usable title — the caller then leaves the existing name alone rather than
101
+ * blanking it.
102
+ */
103
+ function normalizeTitle(raw) {
104
+ if (typeof raw !== "string") return null;
105
+ const line = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
106
+ if (line === "") return null;
107
+ return line.length > 40 ? `${line.slice(0, 39).trimEnd()}…` : line;
108
+ }
97
109
  const EMPTY_REVIEW = {
98
110
  version: 1,
99
111
  threads: []
@@ -193,9 +205,13 @@ function buildCliCommands(cli) {
193
205
  return {
194
206
  open: cli,
195
207
  poll: `${cli} poll`,
208
+ firstPoll: `${cli} poll --title "<the change, in 2-3 words>"`,
196
209
  reply: `${cli} reply <threadId> --message "<your reply>"`,
197
210
  comment: `${cli} comment [<file>] [--line <line>] --message "<comment>"`,
198
211
  guide: `${cli} comment --message "<what the change does>"`,
212
+ layersSuggest: `${cli} layers --suggest "<why, in one line>"`,
213
+ layers: `${cli} layers --json '<Layer[]>'`,
214
+ layersStdin: `${cli} layers --stdin`,
199
215
  end: `${cli} end`,
200
216
  setup: `${cli} setup`
201
217
  };
@@ -217,9 +233,32 @@ const INSTALL_SKILL = {
217
233
  * restates how to hold the poll — attended, never detached. Mirrors the skill.
218
234
  */
219
235
  const POLL_STANCE = "a tracked background task if your harness has one, the foreground if not — never a detached process";
220
- const JOIN_PROMPT = `join the diffo review: run \`${CLI_COMMANDS.poll}\` (${POLL_STANCE}) and follow the JSON payload it prints — each payload carries its own instructions`;
236
+ /**
237
+ * The tab-title doctrine — the few words an agent hands its first poll, which
238
+ * become the browser tab's name. Stated once and interpolated into every
239
+ * surface that teaches it (the skill, `help agent`, the open-time next step),
240
+ * the same way GUIDE and POLL_STANCE keep their rules from drifting apart.
241
+ *
242
+ * It exists because a reviewer keeps several reviews open and every tab reads
243
+ * "Diffo": the agent is the only party that knows which is which at the moment
244
+ * it starts listening.
245
+ */
246
+ const TAB_TITLE = {
247
+ /** What to write — and how little room there is to write it in. */
248
+ what: "two or three words naming what the change IS, not what you did to it — about 20 characters, because that is all a browser tab shows",
249
+ /** Why it is worth a flag at all — the reviewer's problem, stated once. */
250
+ why: "it becomes the name of the reviewer's browser tab, and a reviewer with several reviews open sees every one of them titled \"Diffo\"",
251
+ /** How to order the words, given where they are read. */
252
+ shape: "the tab cuts off everything past that, so the word telling this review apart from another goes first, not last",
253
+ /** When to send it — and when not to bother. */
254
+ when: "on your first poll of a review; on a later one only if the changeset has become something the old title no longer names",
255
+ /** Two to copy the register from — both fit a tab whole. */
256
+ examples: "\"tab titles\", \"flaky upload retries\""
257
+ };
258
+ const JOIN_PROMPT = `join the diffo review: run \`${CLI_COMMANDS.firstPoll}\` (${POLL_STANCE}; the title is ${TAB_TITLE.what} — ${TAB_TITLE.why}) and follow the JSON payload it prints — each payload carries its own instructions`;
221
259
  function nextStepFor(kind, actionable) {
222
- if (kind === "cleared") return `Post the guide if this changeset warrants one, then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`;
260
+ if (kind === "layers") return `Post the outline with \`${CLI_COMMANDS.layers}\` (or pipe it to \`${CLI_COMMANDS.layersStdin}\`), then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`;
261
+ if (kind === "cleared") return `Post the guide if this changeset warrants one, then run \`${CLI_COMMANDS.firstPoll}\` again to keep listening (${POLL_STANCE}) — this is a fresh round, so give it a fresh title.`;
223
262
  if (kind === "finish" && actionable === 0) return `Nothing to act on — run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}); the reviewer may follow up.`;
224
263
  return `${kind === "finish" ? "The reviewer is done reading — work the whole batch. " : ""}Act on each thread, reply with \`${CLI_COMMANDS.reply}\`, then run \`${CLI_COMMANDS.poll}\` again to keep listening.`;
225
264
  }
@@ -227,6 +266,9 @@ const ACK_NEXT_STEP = {
227
266
  reply: `When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
228
267
  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.",
229
268
  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}\`.`,
269
+ 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}\`.`,
270
+ 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}\`.`,
271
+ 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}\`.`,
230
272
  end: "Detached. Do not reopen or re-poll this review unless the user asks — deliver anything remaining directly in the conversation."
231
273
  };
232
274
  /**
@@ -247,13 +289,58 @@ const GUIDE = {
247
289
  update: "reply to your own guide thread with a short update"
248
290
  };
249
291
  /**
292
+ * The layers doctrine — the agent's reading plan for a changeset with an order
293
+ * worth explaining. Same single-source rule as GUIDE: the skill, `help agent`,
294
+ * `help layers`, and the open-time nudge all interpolate these, so no surface
295
+ * can teach a different bar for what a layer is or when to offer one.
296
+ *
297
+ * Layers come from the agent only. Every other tool infers a walkthrough by
298
+ * reading the diff back; the session that wrote the code still remembers the
299
+ * order it would explain it in, and that is the whole edge — so the doctrine is
300
+ * about that order, and about not spending the reviewer's attention on a plan
301
+ * a flat file list already gives them.
302
+ */
303
+ const LAYERS = {
304
+ /** What one is. */
305
+ what: "one step of the change — a coherent unit you would explain in one breath — with the files that belong to it and a one- or two-sentence summary (markdown; a ```mermaid fence renders)",
306
+ /** How to order them. */
307
+ order: "the order you would explain it, not the order you wrote it — 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",
308
+ /** When to raise the flag at open — and that not raising it is the common case. */
309
+ suggest: "suggest layers when the change has an order worth explaining; a wide diff with one idea does not need them",
310
+ /** The one tag, and the bar for it. */
311
+ mechanical: "tag a layer \"kind\": \"mechanical\" only when it changes no behaviour — a rename, call sites following a signature; if unsure, don't tag",
312
+ /** Summaries orient reading, never pre-review: the guide's line, verbatim. */
313
+ stance: GUIDE.stance,
314
+ /** A post is the whole list. */
315
+ 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",
316
+ /** The payload, in one line. */
317
+ shape: "[{ \"title\": \"…\", \"summary\": \"…\", \"kind\": \"mechanical\" (optional), \"files\": [\"src/a.ts\", { \"path\": \"src/b.ts\", \"note\": \"why this file is in this step\" }] }]"
318
+ };
319
+ /**
250
320
  * Printed by `diffo` (open) to a piped stdout when the review has no guide yet.
251
321
  * The skill teaches the same step, but this line is what an agent WITHOUT the
252
322
  * skill sees — payloads and command output must stand alone (see POLL_STANCE).
253
323
  */
324
+ /**
325
+ * The URL goes out before the guide, never after: composing a guide is the
326
+ * slowest step of an open (a diagram takes real thought), and a reviewer who
327
+ * is waiting for a link should not wait on it. The guide lands live at the top
328
+ * of their review while they are still opening the page.
329
+ */
254
330
  function guideNudge(hasGuide) {
255
331
  if (hasGuide) return null;
256
- return `orient the reviewer before sharing the URL, if this changeset needs it (${GUIDE.when}): post a guide — one comment on the whole changeset: ${GUIDE.what}: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset). ${GUIDE.stance}.`;
332
+ return `share the URL above with the user right now, as a message line, before anything else. Then, while they open it, orient them if this changeset needs it (${GUIDE.when}): post a guide — one comment on the whole changeset: ${GUIDE.what}: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset; it appears live at the top of their review). ${GUIDE.stance}.`;
333
+ }
334
+ /**
335
+ * Printed by `diffo` (open) next to the guide nudge while the review has neither
336
+ * layers nor a suggestion. The open is the one moment the agent still holds the
337
+ * order it would explain the change in, so the flag is raised here; the outline
338
+ * itself waits for the reviewer to ask, because writing it is the heavy step
339
+ * and most changesets never need it.
340
+ */
341
+ function layersNudge(review) {
342
+ if (review.layers || review.layersSuggested) return null;
343
+ return `if this changeset reads better in order — ${LAYERS.suggest} — flag it: \`${CLI_COMMANDS.layersSuggest}\`, and offer it in your handoff ("say layers and I'll outline it"). Post the outline only when asked: \`${CLI_COMMANDS.layers}\` — each layer ${LAYERS.what}; ${LAYERS.order}. ${LAYERS.stance}. \`diffo help layers\` has the shape.`;
257
344
  }
258
345
  /**
259
346
  * Printed to stderr when a poll takes the review over and the review already
@@ -515,6 +602,29 @@ function buildCoalescedPrompt(threads, ctx) {
515
602
  * orientation for the fresh round, so this restates the guide doctrine the way
516
603
  * the open-time nudge does (payloads must stand alone — see POLL_STANCE).
517
604
  */
605
+ /**
606
+ * The reviewer pressed Outline (or refresh): the poll item that asks for the
607
+ * layers. Standalone by the same rule as every payload — an agent with no
608
+ * skill and no memory of `help layers` still gets the whole doctrine here.
609
+ */
610
+ function buildLayersRequestPrompt(ctx, existing) {
611
+ const refresh = existing !== null && existing.length > 0;
612
+ const titles = refresh ? existing.map((l) => `"${l.title}"`).join(", ") : "";
613
+ return [
614
+ refresh ? `The reviewer asked you to refresh the layers in \`${ctx.repo.name}\` (branch \`${ctx.repo.branch}\`): the code moved since you outlined it, and files outside the outline have been gathering under "Since your review". Re-post the whole list as the change stands now.` : `The reviewer asked for layers in \`${ctx.repo.name}\` (branch \`${ctx.repo.branch}\`): outline the changeset as steps to read in order. The Layers tab reads "The agent is outlining…" until you post.`,
615
+ "",
616
+ ...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
617
+ ...refresh ? [`The outline they have: ${titles}. Keep a title that still fits its step, rename or drop the ones that don't — ${LAYERS.replace}.`, ""] : [],
618
+ `Each layer is ${LAYERS.what}. Order: ${LAYERS.order}. ${LAYERS.mechanical}. ${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".`,
619
+ "",
620
+ `Shape: ${LAYERS.shape}`,
621
+ "",
622
+ `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.`,
623
+ "",
624
+ `Then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
625
+ ""
626
+ ].join("\n");
627
+ }
518
628
  function buildClearedPrompt(ctx) {
519
629
  return [
520
630
  `The reviewer cleared the review in \`${ctx.repo.name}\` (branch \`${ctx.repo.branch}\`): the previous round landed, and its threads and guide are gone. What the reviewer sees now is a fresh round.`,
@@ -570,6 +680,145 @@ function buildFinishPrompt(threads, ctx, coverage) {
570
680
  return parts.join("\n");
571
681
  }
572
682
  //#endregion
683
+ //#region src/shared/layers.ts
684
+ /** A summary is one or two sentences by doctrine; this is the backstop against
685
+ * an essay, the same cap a closing note gets. */
686
+ const SUMMARY_CAP = 4e3;
687
+ /** A note is one line by contract — anything past the first newline is dropped,
688
+ * and the line itself is capped so the file header stays a header. */
689
+ const NOTE_CAP = 200;
690
+ const REPO_PATH = /^[^\0]+$/;
691
+ /**
692
+ * Paths are relative to the repo root: no leading slash, no `..` segment, no
693
+ * Windows drive. Unknown paths are fine — the file may land later — but a path
694
+ * that could never name a file in this repo is a mistake worth naming now.
695
+ */
696
+ function badPath(path) {
697
+ if (path === "" || !REPO_PATH.test(path)) return "is empty";
698
+ if (path.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(path)) return "is absolute — use a repo-relative path";
699
+ if (path.split("/").some((seg) => seg === "..")) return "escapes the repo with `..`";
700
+ return null;
701
+ }
702
+ function parseFile(raw, where) {
703
+ if (typeof raw === "string") {
704
+ const path = raw.trim();
705
+ const why = badPath(path.replace(/:\d+-\d+$/, ""));
706
+ if (why) return {
707
+ ok: false,
708
+ error: `${where}: path "${raw}" ${why}`
709
+ };
710
+ return {
711
+ ok: true,
712
+ file: path
713
+ };
714
+ }
715
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
716
+ ok: false,
717
+ error: `${where}: each file is a path string or { path, note? }`
718
+ };
719
+ const f = raw;
720
+ if (typeof f.path !== "string") return {
721
+ ok: false,
722
+ error: `${where}: a file object needs a "path"`
723
+ };
724
+ const path = f.path.trim();
725
+ const why = badPath(path.replace(/:\d+-\d+$/, ""));
726
+ if (why) return {
727
+ ok: false,
728
+ error: `${where}: path "${f.path}" ${why}`
729
+ };
730
+ if (f.note !== void 0 && typeof f.note !== "string") return {
731
+ ok: false,
732
+ error: `${where}: "note" on ${path} must be a string`
733
+ };
734
+ const note = typeof f.note === "string" ? oneLine(f.note, NOTE_CAP) : void 0;
735
+ return {
736
+ ok: true,
737
+ file: note ? {
738
+ path,
739
+ note
740
+ } : { path }
741
+ };
742
+ }
743
+ /** First line, trimmed, capped. Empty when nothing survives. */
744
+ function oneLine(text, cap) {
745
+ const line = text.split("\n", 1)[0].trim();
746
+ return line.length > cap ? `${line.slice(0, cap - 1).trimEnd()}…` : line;
747
+ }
748
+ /** The reason behind `--suggest`, fit for the one line the empty state quotes
749
+ * it in. Undefined when there is nothing usable — the suggestion stands alone. */
750
+ function parseSuggestReason(raw) {
751
+ if (typeof raw !== "string") return void 0;
752
+ const line = oneLine(raw, NOTE_CAP);
753
+ return line === "" ? void 0 : line;
754
+ }
755
+ /**
756
+ * The whole post, or the first thing wrong with it. Titles and file lists are
757
+ * required and non-empty; everything else is optional and normalised — a
758
+ * summary trimmed and capped, a note cut to one line, an unknown `kind`
759
+ * refused rather than silently dropped (the agent meant something by it). Any
760
+ * `id` the agent sends is ignored: the server mints them.
761
+ */
762
+ function parseLayersInput(raw) {
763
+ if (!Array.isArray(raw)) return {
764
+ ok: false,
765
+ error: "layers must be a JSON array of layers"
766
+ };
767
+ if (raw.length === 0) return {
768
+ ok: false,
769
+ error: "layers must list at least one layer"
770
+ };
771
+ const items = [];
772
+ const titles = /* @__PURE__ */ new Set();
773
+ for (const [i, entry] of raw.entries()) {
774
+ const where = `layer ${i + 1}`;
775
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return {
776
+ ok: false,
777
+ error: `${where}: each layer is an object { title, files, summary?, kind? }`
778
+ };
779
+ const l = entry;
780
+ const title = typeof l.title === "string" ? oneLine(l.title, 120) : "";
781
+ if (title === "") return {
782
+ ok: false,
783
+ error: `${where}: needs a non-empty "title"`
784
+ };
785
+ if (titles.has(title)) return {
786
+ ok: false,
787
+ error: `${where}: title "${title}" is used twice`
788
+ };
789
+ titles.add(title);
790
+ if (!Array.isArray(l.files) || l.files.length === 0) return {
791
+ ok: false,
792
+ error: `${where} ("${title}"): needs a non-empty "files" list`
793
+ };
794
+ const files = [];
795
+ for (const f of l.files) {
796
+ const parsed = parseFile(f, `${where} ("${title}")`);
797
+ if (!parsed.ok) return parsed;
798
+ files.push(parsed.file);
799
+ }
800
+ if (l.summary !== void 0 && typeof l.summary !== "string") return {
801
+ ok: false,
802
+ error: `${where} ("${title}"): "summary" must be a markdown string`
803
+ };
804
+ const summary = typeof l.summary === "string" ? l.summary.trim().slice(0, SUMMARY_CAP) : "";
805
+ if (l.kind !== void 0 && l.kind !== "mechanical") return {
806
+ ok: false,
807
+ error: `${where} ("${title}"): "kind" can only be "mechanical" — leave it out otherwise`
808
+ };
809
+ items.push({
810
+ title,
811
+ files,
812
+ ...summary !== "" ? { summary } : {},
813
+ ...l.kind === "mechanical" ? { kind: "mechanical" } : {}
814
+ });
815
+ }
816
+ return {
817
+ ok: true,
818
+ items
819
+ };
820
+ }
821
+ //#endregion
573
822
  //#region src/cliArgs.ts
574
823
  const HELP_TEXT = `diffo — review a changeset the way you'd read a book
575
824
 
@@ -588,11 +837,16 @@ For the reviewer:
588
837
  For the agent (the AI that wrote the change):
589
838
  poll Wait for the reviewer's feedback (blocking long-poll;
590
839
  prints one JSON payload; safe to re-run any time)
840
+ (--title "<what the change is>" on the first poll names
841
+ the reviewer's browser tab)
591
842
  reply <threadId> Post a reply to a review thread
592
843
  (--message "<text>", or pipe the text on stdin)
593
844
  comment [<file>] Start a comment thread as the agent — on a line (--line),
594
845
  a file, or (with no file) the whole changeset; the
595
846
  reviewer replies to take it up, or resolves it
847
+ layers Outline the changeset as steps to read in order
848
+ (--suggest ["why"] flags it at open; --json '<Layer[]>'
849
+ or --stdin posts the list, replacing the last one)
596
850
  end Detach from the review politely
597
851
  help agent The agent's whole protocol on one page
598
852
 
@@ -627,35 +881,50 @@ The loop:
627
881
 
628
882
  1. Open: run \`diffo --no-open\` from inside the repo. It returns straight
629
883
  away, leaving a background server watching the working tree. Never open a
630
- browser at the reviewer — end your message with the printed URL instead,
631
- and keep ending every message with it while you stay attached.
884
+ browser at the reviewer — share the printed URL instead, the moment it
885
+ prints: a message line right after this command, before the guide, the
886
+ poll, or anything else. Then end your message with it too, and keep
887
+ ending every message with it while you stay attached.
632
888
  Attached without ever seeing the URL (a takeover, a fresh session)?
633
889
  \`diffo status\` prints it.
634
890
  2. Guide — post one only when the changeset needs orientation:
635
891
  ${GUIDE.when}.
636
- Before sharing the URL, post ONE comment on the whole changeset
637
- (\`diffo comment -m "…"\`, no file), containing:
638
- ${GUIDE.what}.
892
+ Right after sharing the URL, while the reviewer opens the page, post ONE
893
+ comment on the whole changeset (\`diffo comment -m "…"\`, no file),
894
+ containing: ${GUIDE.what}.
639
895
  ${GUIDE.stance}.
896
+ It lands live at the top of their review — never hold the URL back for it.
640
897
  If the changeset later shifts under the guide, ${GUIDE.update}.
641
- 3. Listen: run \`diffo poll\` — it blocks until the reviewer acts, then prints
642
- one JSON payload naming the threads to act on. Run it attended:
643
- ${POLL_STANCE}.
898
+ 3. Layers — offer them when the changeset reads better in order:
899
+ ${LAYERS.suggest}.
900
+ Flag it at open with \`diffo layers --suggest "<why, in one line>"\` and
901
+ say so in your handoff ("say layers and I'll outline it"). Post the
902
+ outline only when the reviewer asks — in chat, or through the poll as a
903
+ \`"kind": "layers"\` payload: \`diffo layers --json '<Layer[]>'\`
904
+ (or pipe it to \`diffo layers --stdin\`). Each layer is ${LAYERS.what}.
905
+ Order: ${LAYERS.order}. ${LAYERS.mechanical}. ${LAYERS.stance}.
906
+ ${LAYERS.replace}. \`diffo help layers\` has the shape.
907
+ 4. Listen: run \`${CLI_COMMANDS.firstPoll}\` — it blocks until the reviewer
908
+ acts, then prints one JSON payload naming the threads to act on. Run it
909
+ attended: ${POLL_STANCE}.
910
+ The title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is
911
+ read — ${TAB_TITLE.shape} (${TAB_TITLE.examples}). Send it ${TAB_TITLE.when};
912
+ every other poll is a plain \`diffo poll\`.
644
913
  Killed or timed out? Re-run it; feedback is held in the review, not the
645
914
  poll.
646
- 4. Act: \`[issue]\` threads want a code change; \`[question]\` threads want an
915
+ 5. Act: \`[issue]\` threads want a code change; \`[question]\` threads want an
647
916
  answer in the reply and no edit. Your edits reach the reviewer live.
648
- 5. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
917
+ 6. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
649
918
  stdin) — concise, addressed to the reviewer. Markdown renders; a
650
919
  \`\`\`mermaid fence draws a diagram. A reply that only promises a
651
920
  follow-up ("I'll investigate and report back") goes out with \`--more\` —
652
921
  the reviewer keeps seeing you at work — and the real answer follows as a
653
922
  plain reply before the next poll.
654
- 6. Comment (sparingly): \`diffo comment [<file>] [--line <n>] -m "<text>"\`
923
+ 7. Comment (sparingly): \`diffo comment [<file>] [--line <n>] -m "<text>"\`
655
924
  starts a thread in your voice — a concern, or context that helps the read.
656
- 7. Poll again only when the whole batch is handled — a new poll tells the
925
+ 8. Poll again only when the whole batch is handled — a new poll tells the
657
926
  reviewer you are done with the previous one.
658
- 8. Detach: run \`diffo end\` when the review is over or the user moves on.
927
+ 9. Detach: run \`diffo end\` when the review is over or the user moves on.
659
928
 
660
929
  Rules:
661
930
  - Change only what the threads ask about — the reviewer is mid-read, and an
@@ -667,7 +936,7 @@ Rules:
667
936
  - Resolving a thread is the reviewer's call, never yours.`,
668
937
  poll: `diffo poll — wait for the reviewer's feedback
669
938
 
670
- Usage: diffo poll
939
+ Usage: diffo poll [--title "<what the change is>"]
671
940
 
672
941
  Blocks (streaming whitespace heartbeats) until the reviewer acts, then prints
673
942
  one JSON payload naming the review threads to act on, and exits. Run it
@@ -676,10 +945,15 @@ payload that reaches a process nobody is listening to never reaches you.
676
945
  Safe to re-run any time: feedback is held in the review itself, so
677
946
  nothing is lost when a poll is killed or times out — the next poll gets it.
678
947
 
948
+ --title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is read —
949
+ ${TAB_TITLE.shape} (${TAB_TITLE.examples}). Send it ${TAB_TITLE.when}; the
950
+ newest title wins, and a poll without one leaves the name it finds alone.
951
+
679
952
  Output: one JSON object, e.g.
680
953
  {"status":"feedback","threadIds":["t-3"],"prompt":"…what to do…"}
681
954
 
682
- Example:
955
+ Examples:
956
+ diffo poll --title "tab titles from the agent"
683
957
  diffo poll`,
684
958
  reply: `diffo reply — post a reply to a review thread
685
959
 
@@ -716,6 +990,29 @@ Output: {"ok":true,"threadId":"t-1","next_step":"…"}
716
990
 
717
991
  Example:
718
992
  diffo comment src/auth.ts --line 42 --message "this branch is unreachable"`,
993
+ layers: `diffo layers — outline the changeset as steps to read in order
994
+
995
+ Usage: diffo layers --suggest ["<why, in one line>"] at open: this read benefits from layers
996
+ diffo layers --json '<Layer[]>' post the outline, replacing the last one
997
+ … | diffo layers --stdin the same payload, piped
998
+
999
+ Each layer is ${LAYERS.what}.
1000
+ Order: ${LAYERS.order}.
1001
+ ${LAYERS.mechanical}.
1002
+ ${LAYERS.stance}.
1003
+ ${LAYERS.replace}. Paths are relative to the repo root; an unknown path is
1004
+ accepted (the file may land later) and simply shows nothing until it does.
1005
+ Files you touch after posting land in a trailing "Since your review" layer
1006
+ until you re-post.
1007
+
1008
+ Shape: ${LAYERS.shape}
1009
+
1010
+ Output: {"ok":true,"layers":4,"next_step":"…"}
1011
+ {"ok":true,"suggested":true,"next_step":"…"}
1012
+
1013
+ Examples:
1014
+ diffo layers --suggest "the parser change explains the rest"
1015
+ diffo layers --json '[{"title":"Parser contract","summary":"parse() now returns null instead of throwing.","files":["src/parse.ts"]},{"title":"Callers adapted","kind":"mechanical","files":["src/cli.ts","src/api.ts"]}]'`,
719
1016
  end: `diffo end — detach from the review politely
720
1017
 
721
1018
  Usage: diffo end
@@ -771,6 +1068,7 @@ const VERBS = /* @__PURE__ */ new Set([
771
1068
  "poll",
772
1069
  "reply",
773
1070
  "comment",
1071
+ "layers",
774
1072
  "end",
775
1073
  "setup",
776
1074
  "status",
@@ -920,7 +1218,78 @@ function parseCliArgs(argv) {
920
1218
  foreground: values.foreground === true
921
1219
  };
922
1220
  }
1221
+ /**
1222
+ * `layers` parses on its own: its `--json` takes a value where `status --json`
1223
+ * is a switch, and its one positional is the suggestion's reason rather than a
1224
+ * file. Exactly one source per run — a post and a suggestion mean different
1225
+ * things to the review, and silently picking one would hide the other.
1226
+ */
1227
+ function parseLayersVerb(rest) {
1228
+ const parsed = tryParse(() => parseArgs({
1229
+ args: rest,
1230
+ allowPositionals: true,
1231
+ options: {
1232
+ json: { type: "string" },
1233
+ stdin: { type: "boolean" },
1234
+ suggest: { type: "boolean" },
1235
+ help: {
1236
+ type: "boolean",
1237
+ short: "h"
1238
+ }
1239
+ }
1240
+ }));
1241
+ if (!parsed.ok) return {
1242
+ kind: "error",
1243
+ message: parsed.message
1244
+ };
1245
+ const { values, positionals } = parsed.value;
1246
+ if (values.help) return {
1247
+ kind: "help",
1248
+ topic: "layers"
1249
+ };
1250
+ if ([
1251
+ values.json !== void 0,
1252
+ values.stdin === true,
1253
+ values.suggest === true
1254
+ ].filter(Boolean).length !== 1) return {
1255
+ kind: "error",
1256
+ message: "layers takes exactly one of --json <Layer[]>, --stdin, or --suggest [\"<why>\"]"
1257
+ };
1258
+ if (values.suggest) {
1259
+ if (positionals.length > 1) return {
1260
+ kind: "error",
1261
+ message: "--suggest takes at most one reason — quote it"
1262
+ };
1263
+ return {
1264
+ kind: "layers",
1265
+ source: {
1266
+ kind: "suggest",
1267
+ reason: parseSuggestReason(positionals[0]) ?? null
1268
+ }
1269
+ };
1270
+ }
1271
+ if (positionals.length > 0) return {
1272
+ kind: "error",
1273
+ message: "pass the layers with --json or on stdin (--stdin), not as an argument"
1274
+ };
1275
+ if (values.stdin) return {
1276
+ kind: "layers",
1277
+ source: { kind: "stdin" }
1278
+ };
1279
+ if (values.json.trim() === "") return {
1280
+ kind: "error",
1281
+ message: "--json needs a JSON array of layers — see `diffo help layers`"
1282
+ };
1283
+ return {
1284
+ kind: "layers",
1285
+ source: {
1286
+ kind: "json",
1287
+ text: values.json
1288
+ }
1289
+ };
1290
+ }
923
1291
  function parseVerb(verb, rest) {
1292
+ if (verb === "layers") return parseLayersVerb(rest);
924
1293
  const parsed = tryParse(() => parseArgs({
925
1294
  args: rest,
926
1295
  allowPositionals: true,
@@ -930,6 +1299,7 @@ function parseVerb(verb, rest) {
930
1299
  short: "m"
931
1300
  },
932
1301
  line: { type: "string" },
1302
+ title: { type: "string" },
933
1303
  more: { type: "boolean" },
934
1304
  json: { type: "boolean" },
935
1305
  help: {
@@ -955,6 +1325,10 @@ function parseVerb(verb, rest) {
955
1325
  kind: "error",
956
1326
  message: `'${verb}' takes no --more`
957
1327
  };
1328
+ if (values.title !== void 0 && verb !== "poll") return {
1329
+ kind: "error",
1330
+ message: `'${verb}' takes no --title`
1331
+ };
958
1332
  if (verb === "poll" || verb === "end" || verb === "setup" || verb === "status" || verb === "stop") {
959
1333
  if (positionals.length > 0) return {
960
1334
  kind: "error",
@@ -968,6 +1342,17 @@ function parseVerb(verb, rest) {
968
1342
  kind: "status",
969
1343
  json: values.json === true
970
1344
  };
1345
+ if (verb === "poll") {
1346
+ const title = normalizeTitle(values.title);
1347
+ if (values.title !== void 0 && title === null) return {
1348
+ kind: "error",
1349
+ message: "--title needs a few words naming the change"
1350
+ };
1351
+ return {
1352
+ kind: "poll",
1353
+ title
1354
+ };
1355
+ }
971
1356
  return { kind: verb };
972
1357
  }
973
1358
  if (verb === "reply") {
@@ -1880,7 +2265,9 @@ var DeliveryQueue = class DeliveryQueue {
1880
2265
  bucket = {
1881
2266
  threads: /* @__PURE__ */ new Set(),
1882
2267
  finish: null,
1883
- cleared: false
2268
+ cleared: false,
2269
+ layers: false,
2270
+ outlining: null
1884
2271
  };
1885
2272
  this.buckets.set(this.scope, bucket);
1886
2273
  }
@@ -1894,10 +2281,20 @@ var DeliveryQueue = class DeliveryQueue {
1894
2281
  }
1895
2282
  presence() {
1896
2283
  if (this.awaitingReply) return "working";
2284
+ if (this.outlining() !== null) return "working";
1897
2285
  if (this.waiter) return "listening";
1898
2286
  if (this.betweenPolls) return "working";
1899
2287
  return "waiting";
1900
2288
  }
2289
+ /** Where the reviewer's layers request stands (see `LayersRequest`). */
2290
+ layersRequest() {
2291
+ if (this.outlining() !== null) return "outlining";
2292
+ if (this.buckets.get(this.scope)?.layers) return "queued";
2293
+ return null;
2294
+ }
2295
+ outlining() {
2296
+ return this.buckets.get(this.scope)?.outlining ?? null;
2297
+ }
1901
2298
  presenceDetail() {
1902
2299
  return {
1903
2300
  state: this.presence(),
@@ -1907,6 +2304,7 @@ var DeliveryQueue = class DeliveryQueue {
1907
2304
  }
1908
2305
  reason() {
1909
2306
  if (this.awaitingReply) return this.stalled ? "stalled" : "delivered";
2307
+ if (this.outlining() !== null) return "delivered";
1910
2308
  if (this.waiter) return "polling";
1911
2309
  if (this.betweenPolls) return "replied";
1912
2310
  return this.lastDetach ?? "no-agent";
@@ -2017,6 +2415,37 @@ var DeliveryQueue = class DeliveryQueue {
2017
2415
  this.wake();
2018
2416
  if (queued) this.notify();
2019
2417
  }
2418
+ /** The reviewer asked for layers — the outline, or a fresh one. Rides like
2419
+ * the cleared heads-up: a boolean per scope, surfaced once real feedback is
2420
+ * answered. */
2421
+ enqueueLayers() {
2422
+ this.bucket().layers = true;
2423
+ const queued = this.waiter === null;
2424
+ this.wake();
2425
+ if (queued) this.notify();
2426
+ }
2427
+ /** The reviewer cleared the review: whatever request was standing is
2428
+ * withdrawn — parked or already in the agent's hands. A post that arrives
2429
+ * anyway still lands; the store does not consult the queue. */
2430
+ dropLayers() {
2431
+ const bucket = this.buckets.get(this.scope);
2432
+ if (bucket) {
2433
+ bucket.layers = false;
2434
+ bucket.outlining = null;
2435
+ }
2436
+ this.notify();
2437
+ }
2438
+ /** The agent posted layers. Concludes an outstanding request; a spontaneous
2439
+ * post concludes nothing. Returns how long the outline took, or null. */
2440
+ layersPosted() {
2441
+ const bucket = this.buckets.get(this.scope);
2442
+ const since = bucket?.outlining ?? null;
2443
+ if (bucket === void 0 || since === null) return null;
2444
+ bucket.outlining = null;
2445
+ if (!this.awaitingReply && !this.betweenPolls) this.armGrace();
2446
+ this.notify();
2447
+ return Date.now() - since;
2448
+ }
2020
2449
  drop(threadId) {
2021
2450
  for (const bucket of this.buckets.values()) bucket.threads.delete(threadId);
2022
2451
  this.deliveredAt.delete(threadId);
@@ -2034,6 +2463,8 @@ var DeliveryQueue = class DeliveryQueue {
2034
2463
  if (this.batch !== null) {
2035
2464
  if (this.batch.sawReply || Date.now() - this.batch.deliveredAt >= this.batchSettleMs) this.closeBatch("repoll");
2036
2465
  }
2466
+ const owing = this.buckets.get(this.scope);
2467
+ if (owing !== void 0 && owing.outlining !== null && Date.now() - owing.outlining >= this.batchSettleMs) owing.outlining = null;
2037
2468
  if (this.hasPending()) return Promise.resolve("data");
2038
2469
  return new Promise((resolve) => {
2039
2470
  const waiter = (outcome) => resolve(outcome);
@@ -2076,7 +2507,7 @@ var DeliveryQueue = class DeliveryQueue {
2076
2507
  }
2077
2508
  hasPending() {
2078
2509
  const bucket = this.buckets.get(this.scope);
2079
- return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null || bucket.cleared);
2510
+ return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null || bucket.cleared || bucket.layers);
2080
2511
  }
2081
2512
  wake() {
2082
2513
  if (this.waiter === null) return;
@@ -2098,12 +2529,20 @@ var DeliveryQueue = class DeliveryQueue {
2098
2529
  kind: "threads",
2099
2530
  threadIds: [...bucket.threads]
2100
2531
  };
2532
+ if (bucket.layers) return { kind: "layers" };
2101
2533
  if (bucket.cleared) return { kind: "cleared" };
2102
2534
  return null;
2103
2535
  }
2104
2536
  /** The poll response for this snapshot was fully written — NOW it counts as
2105
2537
  * delivered. Clears exactly what the snapshot covered. */
2106
2538
  confirm(snapshot, deliveredThreadIds) {
2539
+ if (snapshot.kind === "layers") {
2540
+ const bucket = this.bucket();
2541
+ bucket.layers = false;
2542
+ bucket.outlining = Date.now();
2543
+ this.notify();
2544
+ return;
2545
+ }
2107
2546
  if (snapshot.kind === "cleared") {
2108
2547
  const bucket = this.buckets.get(this.scope);
2109
2548
  if (bucket) bucket.cleared = false;
@@ -2165,6 +2604,7 @@ var DeliveryQueue = class DeliveryQueue {
2165
2604
  this.releaseWaiter("ended");
2166
2605
  this.closeBatch("ended");
2167
2606
  this.awaitingReply = false;
2607
+ for (const bucket of this.buckets.values()) bucket.outlining = null;
2168
2608
  this.stalled = false;
2169
2609
  this.clearStall();
2170
2610
  this.clearGrace();
@@ -2515,16 +2955,19 @@ var ReviewStore = class {
2515
2955
  return true;
2516
2956
  }
2517
2957
  /**
2518
- * Start the review over: threads, the last-finish record, and the landed
2519
- * marker all go. Not just the threads — a kept `lastFinish` would carry hunk
2520
- * ids from the dead changeset into the next review's "since last review"
2521
- * lens, reporting everything as new. `seenHead` survives: it describes the
2958
+ * Start the review over: threads, the last-finish record, the landed marker,
2959
+ * and the tab title all go. Not just the threads — a kept `lastFinish` would
2960
+ * carry hunk ids from the dead changeset into the next review's "since last
2961
+ * review" lens, reporting everything as new, and a kept title would name the
2962
+ * work that just ended. The cleared agent is woken (see the DELETE route), so
2963
+ * its next poll names the new round. `seenHead` survives: it describes the
2522
2964
  * repo, not the review being discarded.
2523
2965
  */
2524
2966
  reset() {
2525
2967
  const ids = this.state.threads.map((t) => t.id);
2526
- if (ids.length === 0 && !this.state.lastFinish && !this.state.landed) return [];
2527
- const { lastFinish: _finish, landed: _landed, ...rest } = this.state;
2968
+ const { lastFinish, landed, title, layers, layersSuggested } = this.state;
2969
+ if (ids.length === 0 && !lastFinish && !landed && !title && !layers && !layersSuggested) return [];
2970
+ const { lastFinish: _finish, landed: _landed, title: _title, layers: _layers, layersSuggested: _suggested, ...rest } = this.state;
2528
2971
  this.state = {
2529
2972
  ...rest,
2530
2973
  threads: []
@@ -2532,6 +2975,51 @@ var ReviewStore = class {
2532
2975
  this.commit();
2533
2976
  return ids;
2534
2977
  }
2978
+ /**
2979
+ * Replace the reading plan — the whole list, never a merge, so the agent never
2980
+ * has to diff its own outline. Ids are minted here and kept for any layer
2981
+ * whose title matches an existing one: that is what holds the reviewer's
2982
+ * active layer in place across a re-post. Posting also answers the suggestion,
2983
+ * so it goes.
2984
+ */
2985
+ setLayers(items) {
2986
+ const kept = new Map((this.state.layers?.items ?? []).map((l) => [l.title, l.id]));
2987
+ const layers = {
2988
+ items: items.map((item) => ({
2989
+ id: kept.get(item.title) ?? randomUUID(),
2990
+ ...item
2991
+ })),
2992
+ postedAt: (/* @__PURE__ */ new Date()).toISOString()
2993
+ };
2994
+ const { layersSuggested: _suggested, ...rest } = this.state;
2995
+ this.state = {
2996
+ ...rest,
2997
+ layers
2998
+ };
2999
+ this.commit();
3000
+ return layers;
3001
+ }
3002
+ /** The agent's flag at open: this read benefits from layers. Once layers exist
3003
+ * the flag has nothing to add, so it is refused rather than recorded. */
3004
+ suggestLayers(reason) {
3005
+ if (this.state.layers) return false;
3006
+ this.state = {
3007
+ ...this.state,
3008
+ layersSuggested: reason ? { reason } : {}
3009
+ };
3010
+ this.commit();
3011
+ return true;
3012
+ }
3013
+ /** The agent's name for this changeset, carried by its poll. The newest one
3014
+ * wins: a change that grows a second subject renames its own tab. */
3015
+ setTitle(title) {
3016
+ if (this.state.title === title) return;
3017
+ this.state = {
3018
+ ...this.state,
3019
+ title
3020
+ };
3021
+ this.commit();
3022
+ }
2535
3023
  /** The base the work under review sits on — see `ReviewState.seenHead` for
2536
3024
  * when the caller must NOT move it. */
2537
3025
  noteHead(sha) {
@@ -2702,14 +3190,44 @@ function parseReview(raw) {
2702
3190
  const migrated = Array.isArray(parsed.suggestions) ? parsed.suggestions.map((s) => migrateLegacySuggestion(s, now)).filter((t) => t !== null) : [];
2703
3191
  const lastFinish = normalizeLastFinish(parsed.lastFinish, now);
2704
3192
  const landed = normalizeLanded(parsed.landed, now);
3193
+ const title = normalizeTitle(parsed.title);
3194
+ const layers = normalizeLayers(parsed.layers, now);
3195
+ const layersSuggested = normalizeSuggested(parsed.layersSuggested);
2705
3196
  return {
2706
3197
  version: 1,
2707
3198
  threads: [...valid, ...migrated],
3199
+ ...title ? { title } : {},
2708
3200
  ...lastFinish ? { lastFinish } : {},
2709
3201
  ...typeof parsed.seenHead === "string" && parsed.seenHead !== "" ? { seenHead: parsed.seenHead } : {},
2710
- ...landed ? { landed } : {}
3202
+ ...landed ? { landed } : {},
3203
+ ...layers ? { layers } : {},
3204
+ ...layersSuggested && !layers ? { layersSuggested } : {}
2711
3205
  };
2712
3206
  }
3207
+ /** Stored layers go back through the same validation a post does; a list that
3208
+ * would be refused at the door is dropped whole rather than half-kept. Ids are
3209
+ * the one field a post never carries, so they are read here and re-minted only
3210
+ * when missing. */
3211
+ function normalizeLayers(value, now) {
3212
+ if (typeof value !== "object" || value === null) return null;
3213
+ const l = value;
3214
+ if (!Array.isArray(l.items)) return null;
3215
+ const parsed = parseLayersInput(l.items);
3216
+ if (!parsed.ok) return null;
3217
+ const ids = l.items.map((item) => typeof item === "object" && item !== null && typeof item.id === "string" ? item.id : randomUUID());
3218
+ return {
3219
+ items: parsed.items.map((item, i) => ({
3220
+ id: ids[i],
3221
+ ...item
3222
+ })),
3223
+ postedAt: typeof l.postedAt === "string" ? l.postedAt : now
3224
+ };
3225
+ }
3226
+ function normalizeSuggested(value) {
3227
+ if (typeof value !== "object" || value === null) return null;
3228
+ const reason = parseSuggestReason(value.reason);
3229
+ return reason ? { reason } : {};
3230
+ }
2713
3231
  /** A landed marker without a sha can't be checked against history, so it is
2714
3232
  * dropped; the subject is only a caption and defaults away. */
2715
3233
  function normalizeLanded(value, now) {
@@ -3402,6 +3920,31 @@ function createApp(ctx, store, review, queue) {
3402
3920
  const capture = store ? captureAnchor(store.get(), anchor) : null;
3403
3921
  return c.json(review.createThread(anchor, text, capture, intent));
3404
3922
  });
3923
+ app.post("/api/review/layers", async (c) => {
3924
+ if (!review) return c.json({ error: "review unavailable" }, 503);
3925
+ const body = await c.req.json().catch(() => null);
3926
+ if (body?.suggest === true) {
3927
+ const suggested = review.suggestLayers(parseSuggestReason(body.reason));
3928
+ return c.json({
3929
+ suggested,
3930
+ ...suggested ? {} : { note: "layers are already posted — nothing to suggest" }
3931
+ });
3932
+ }
3933
+ const parsed = parseLayersInput(body?.items);
3934
+ if (!parsed.ok) return c.json({ error: parsed.error }, 400);
3935
+ const layers = review.setLayers(parsed.items);
3936
+ queue?.layersPosted();
3937
+ return c.json({ layers });
3938
+ });
3939
+ app.post("/api/review/layers/request", (c) => {
3940
+ if (!review || !queue) return c.json({ error: "review unavailable" }, 503);
3941
+ queue.enqueueLayers();
3942
+ return c.json({
3943
+ ok: true,
3944
+ request: queue.layersRequest(),
3945
+ presence: queue.presence()
3946
+ });
3947
+ });
3405
3948
  app.post("/api/review/threads/:id/messages", async (c) => {
3406
3949
  if (!review) return c.json({ error: "review unavailable" }, 503);
3407
3950
  const body = await c.req.json().catch(() => null);
@@ -3425,6 +3968,7 @@ function createApp(ctx, store, review, queue) {
3425
3968
  const deliver = body?.deliver !== false;
3426
3969
  let thread = review.addMessage(c.req.param("id"), "reviewer", text, !deliver);
3427
3970
  if (!thread) return c.json({ error: "no such thread" }, 404);
3971
+ if (deliver && thread.state === "open" && startedByAgent(thread)) thread = review.send(thread.id) ?? thread;
3428
3972
  let delivered = false;
3429
3973
  if (deliver && (thread.state === "sent" || thread.state === "addressed")) {
3430
3974
  delivered = deliverThreads([thread.id]);
@@ -3450,6 +3994,7 @@ function createApp(ctx, store, review, queue) {
3450
3994
  const hadRound = before.threads.length > 0 || before.lastFinish !== void 0;
3451
3995
  const removed = review.reset();
3452
3996
  for (const id of removed) queue?.drop(id);
3997
+ queue?.dropLayers();
3453
3998
  if (hadRound && (store?.get().files.length ?? 0) > 0) queue?.enqueueCleared();
3454
3999
  return c.json({ removed: removed.length });
3455
4000
  });
@@ -3474,8 +4019,9 @@ function createApp(ctx, store, review, queue) {
3474
4019
  const handedOver = thread.state === "sent" || thread.state === "addressed" && thread.withheld === true;
3475
4020
  const delivered = handedOver ? deliverThreads([thread.id]) : false;
3476
4021
  if (handedOver) review.clearWithheld([thread.id]);
4022
+ const current = review.get().threads.find((t) => t.id === thread.id) ?? thread;
3477
4023
  return c.json({
3478
- thread,
4024
+ thread: current,
3479
4025
  prompt,
3480
4026
  delivered,
3481
4027
  presence
@@ -3594,6 +4140,15 @@ function createApp(ctx, store, review, queue) {
3594
4140
  const pollPayload = (snapshot) => {
3595
4141
  const threads = review?.get().threads ?? [];
3596
4142
  const protocol = queue?.needsFullProtocol() === false ? "compact" : void 0;
4143
+ if (snapshot.kind === "layers") return {
4144
+ status: "feedback",
4145
+ kind: "layers",
4146
+ threadIds: [],
4147
+ prompt: buildLayersRequestPrompt({
4148
+ repo: repoInfo(),
4149
+ changeset: store?.get() ?? null
4150
+ }, review?.get().layers?.items ?? null)
4151
+ };
3597
4152
  if (snapshot.kind === "cleared") return {
3598
4153
  status: "feedback",
3599
4154
  kind: "cleared",
@@ -3636,6 +4191,8 @@ function createApp(ctx, store, review, queue) {
3636
4191
  if (!c.req.header("x-diffo-agent") && !c.req.header("x-diffo-session-pid")) return c.json({ error: "agent polls must send the x-diffo-agent header — use `diffo poll`" }, 403);
3637
4192
  const tookOverFrom = queue.claimSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
3638
4193
  if (tookOverFrom !== null) c.header("x-diffo-took-over-from", String(tookOverFrom));
4194
+ const title = normalizeTitle(c.req.query("title"));
4195
+ if (title) review.setTitle(title);
3639
4196
  const heartbeatMs = ctx.pollHeartbeatMs ?? POLL_HEARTBEAT_MS;
3640
4197
  return stream(c, async (s) => {
3641
4198
  let aborted = false;
@@ -3790,7 +4347,8 @@ function createApp(ctx, store, review, queue) {
3790
4347
  ...queue.presenceDetail(),
3791
4348
  workingOn: queue.deliveredThreadIds(),
3792
4349
  queued: queue.queuedThreadIds(),
3793
- answered: queue.currentBatch()?.answered ?? []
4350
+ answered: queue.currentBatch()?.answered ?? [],
4351
+ layers: queue.layersRequest()
3794
4352
  }),
3795
4353
  id: String(id++)
3796
4354
  });
@@ -3813,7 +4371,8 @@ function createApp(ctx, store, review, queue) {
3813
4371
  ...queue.presenceDetail(),
3814
4372
  workingOn: queue.deliveredThreadIds(),
3815
4373
  queued: queue.queuedThreadIds(),
3816
- answered: queue.currentBatch()?.answered ?? []
4374
+ answered: queue.currentBatch()?.answered ?? [],
4375
+ layers: queue.layersRequest()
3817
4376
  }),
3818
4377
  id: String(id++)
3819
4378
  });
@@ -4762,7 +5321,8 @@ if (command.kind === "poll") {
4762
5321
  const port = await requireServer();
4763
5322
  process.stderr.write("diffo: waiting for the reviewer — keep this process attended: a tracked\nbackground task or the foreground, never detached. If it dies, just\nre-run it — feedback is held in the review and survives.\n");
4764
5323
  try {
4765
- const res = await fetch(apiUrl(port, "/api/agent/poll"), { headers: sessionHeaders() });
5324
+ const path = command.title === null ? "/api/agent/poll" : `/api/agent/poll?title=${encodeURIComponent(command.title)}`;
5325
+ const res = await fetch(apiUrl(port, path), { headers: sessionHeaders() });
4766
5326
  if (!res.ok) fail(`poll failed (${res.status})`);
4767
5327
  const tookOverFrom = res.headers.get("x-diffo-took-over-from");
4768
5328
  if (tookOverFrom) {
@@ -4815,6 +5375,44 @@ if (command.kind === "comment") {
4815
5375
  }));
4816
5376
  process.exit(0);
4817
5377
  }
5378
+ if (command.kind === "layers") {
5379
+ const { source } = command;
5380
+ if (source.kind === "stdin" && process.stdin.isTTY) fail("layers --stdin needs the JSON piped on stdin — or pass it with --json");
5381
+ const port = await requireServer();
5382
+ if (source.kind === "suggest") {
5383
+ const { status, body } = await postJson(port, "/api/review/layers", {
5384
+ suggest: true,
5385
+ ...source.reason === null ? {} : { reason: source.reason }
5386
+ });
5387
+ if (status !== 200) fail(`layers failed (${status})`);
5388
+ const { suggested } = body;
5389
+ console.log(JSON.stringify({
5390
+ ok: true,
5391
+ suggested,
5392
+ next_step: suggested ? ACK_NEXT_STEP.layersSuggested : ACK_NEXT_STEP.layersAlready
5393
+ }));
5394
+ process.exit(0);
5395
+ }
5396
+ const text = source.kind === "json" ? source.text : await readStdin();
5397
+ let raw;
5398
+ try {
5399
+ raw = JSON.parse(text);
5400
+ } catch (err) {
5401
+ fail(`layers needs a JSON array of layers — ${err.message}`);
5402
+ }
5403
+ const parsed = parseLayersInput(raw);
5404
+ if (!parsed.ok) fail(`layers: ${parsed.error}`);
5405
+ const { status, body } = await postJson(port, "/api/review/layers", { items: parsed.items });
5406
+ if (status === 400) fail(`layers: ${body?.error ?? "rejected"}`);
5407
+ if (status !== 200) fail(`layers failed (${status})`);
5408
+ const { layers } = body;
5409
+ console.log(JSON.stringify({
5410
+ ok: true,
5411
+ layers: layers.items.length,
5412
+ next_step: ACK_NEXT_STEP.layers
5413
+ }));
5414
+ process.exit(0);
5415
+ }
4818
5416
  if (command.kind === "end") {
4819
5417
  const { status, body } = await postJson(await requireServer(), "/api/agent/end", {});
4820
5418
  if (status !== 200) fail(`end failed (${status})`);
@@ -4841,7 +5439,9 @@ async function printAgentNextStep(port) {
4841
5439
  const review = await fetchReviewState(port);
4842
5440
  const nudge = review ? guideNudge(findGuideThread(review) !== void 0) : null;
4843
5441
  if (nudge) console.log(`first: ${nudge}`);
4844
- console.log("next: run `diffo poll` to receive the reviewer's feedback (`diffo help agent` prints the whole loop)");
5442
+ const layers = review ? layersNudge(review) : null;
5443
+ if (layers) console.log(`also: ${layers}`);
5444
+ console.log(`next: run \`${CLI_COMMANDS.firstPoll}\` to receive the reviewer's feedback — the title is ${TAB_TITLE.what} (${TAB_TITLE.examples}): ${TAB_TITLE.why} (\`diffo help agent\` prints the whole loop)`);
4845
5445
  }
4846
5446
  /**
4847
5447
  * Dev builds serve the client from the checkout's `dist/client`, which nothing