@diffohq/diffo 0.3.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 (100) hide show
  1. package/README.md +22 -4
  2. package/dist/cli.mjs +524 -13
  3. package/dist/client/assets/{abnfDiagram-O67JEVCF-DdDBSVZ4.js → abnfDiagram-O67JEVCF-b6l_FGHi.js} +1 -1
  4. package/dist/client/assets/{arc-BgNb6rn9.js → arc-Cz0jZwLH.js} +1 -1
  5. package/dist/client/assets/architecture-7GRP2DOG-BXYREMFQ.js +1 -0
  6. package/dist/client/assets/{architectureDiagram-NJMV4G6O-HNLU6Z_V.js → architectureDiagram-NJMV4G6O-CJdtqKce.js} +1 -1
  7. package/dist/client/assets/{blockDiagram-BEXU5L5S-DO434Dqm.js → blockDiagram-BEXU5L5S-Ci-lnLGN.js} +1 -1
  8. package/dist/client/assets/{c4Diagram-YGBWAQC7-CkhXDWcc.js → c4Diagram-YGBWAQC7-DDO2mTMB.js} +1 -1
  9. package/dist/client/assets/channel-CtvNwuQX.js +1 -0
  10. package/dist/client/assets/{chunk-3FUC2YCW-DTfKImVL.js → chunk-3FUC2YCW-VyswqFt3.js} +1 -1
  11. package/dist/client/assets/{chunk-5DYCD2WN-9Pj_Xuzv.js → chunk-5DYCD2WN-B4MBZW5P.js} +1 -1
  12. package/dist/client/assets/{chunk-742MDFTN-DRQNNbBo.js → chunk-742MDFTN-Cxvmvrid.js} +1 -1
  13. package/dist/client/assets/{chunk-7INBJB4K-BqnHF3P0.js → chunk-7INBJB4K-DkBnRFbH.js} +1 -1
  14. package/dist/client/assets/{chunk-7M6MHVWA-NTwtPxj_.js → chunk-7M6MHVWA-BvJgBwEi.js} +1 -1
  15. package/dist/client/assets/{chunk-7PRAP22T-reFtaiUY.js → chunk-7PRAP22T-OfiMNi_y.js} +1 -1
  16. package/dist/client/assets/{chunk-GTNCS2PH-BbSQqqTd.js → chunk-GTNCS2PH-BodN5cRQ.js} +1 -1
  17. package/dist/client/assets/{chunk-GWA4HPMP-B3OAiKhC.js → chunk-GWA4HPMP-KifxxZcy.js} +1 -1
  18. package/dist/client/assets/{chunk-MBY4JIJT-CNIWhzdY.js → chunk-MBY4JIJT-rw02bHlj.js} +1 -1
  19. package/dist/client/assets/{chunk-NETBCI7D-Cu2GMg5A.js → chunk-NETBCI7D-CWjhZoou.js} +1 -1
  20. package/dist/client/assets/{chunk-O7XYJQB3-C616frX3.js → chunk-O7XYJQB3-CyxnSfZ3.js} +1 -1
  21. package/dist/client/assets/{chunk-UA2S7LBM-_7jUrSu1.js → chunk-UA2S7LBM-BReIFgsu.js} +1 -1
  22. package/dist/client/assets/{chunk-WEXAMYUT--WmuTjt8.js → chunk-WEXAMYUT-DHwMy8v7.js} +1 -1
  23. package/dist/client/assets/{chunk-XXDRQBXY-CH68Fn6G.js → chunk-XXDRQBXY-C398QZbS.js} +1 -1
  24. package/dist/client/assets/{chunk-Z7XXMR3K-CMGWkbdd.js → chunk-Z7XXMR3K-hEV1GU64.js} +1 -1
  25. package/dist/client/assets/{chunk-ZIGJFQKS-Bupo8UEA.js → chunk-ZIGJFQKS-DJJL7llO.js} +1 -1
  26. package/dist/client/assets/{classDiagram-v2-NBCMYWYE-BlYlsN2U.js → classDiagram-v2-NBCMYWYE-pSGZI880.js} +1 -1
  27. package/dist/client/assets/{core-BuHBGQx-.js → core-C05I4wHV.js} +1 -1
  28. package/dist/client/assets/{cose-bilkent-JH36ORCC-BtFdPq63.js → cose-bilkent-JH36ORCC-DNV8Dqd8.js} +1 -1
  29. package/dist/client/assets/{cynefin-OW5HDTMX-DQ0j2-fq.js → cynefin-OW5HDTMX-DF-6FOgS.js} +1 -1
  30. package/dist/client/assets/{cynefinDiagram-VND7K2PF-DnctQE5u.js → cynefinDiagram-VND7K2PF-D3-iJq2J.js} +1 -1
  31. package/dist/client/assets/{dagre-6A5THRUB-u940AeXp.js → dagre-6A5THRUB-C9FDZkaa.js} +1 -1
  32. package/dist/client/assets/{diagram-22UHCM2B-B1jzZKtP.js → diagram-22UHCM2B-CfSJBDyN.js} +1 -1
  33. package/dist/client/assets/{diagram-3UASUU5V-BY_6wxmf.js → diagram-3UASUU5V-D946baYo.js} +1 -1
  34. package/dist/client/assets/{diagram-ATOU4E4O-CTsYtnJJ.js → diagram-ATOU4E4O-CGrjukoA.js} +1 -1
  35. package/dist/client/assets/{diagram-CDSNMT55-DuEJO9EQ.js → diagram-CDSNMT55-B47UPtWq.js} +1 -1
  36. package/dist/client/assets/{diagram-MLGK6HIB-CZ10U2Mk.js → diagram-MLGK6HIB-D06yEd5R.js} +1 -1
  37. package/dist/client/assets/{diagram-MPIPVDR6-CkO6cJlF.js → diagram-MPIPVDR6-BRUBT2B9.js} +1 -1
  38. package/dist/client/assets/{dist-DkqXbsqN.js → dist-BfTRyLGm.js} +1 -1
  39. package/dist/client/assets/{dist-DFkgQY1o.js → dist-DVw88SBM.js} +1 -1
  40. package/dist/client/assets/{ebnfDiagram-ZINNZB2B-BJtnMhOp.js → ebnfDiagram-ZINNZB2B-BnuExfrd.js} +1 -1
  41. package/dist/client/assets/{elk-276RUBZZ-CShSerV4.js → elk-276RUBZZ-BmdcLjR9.js} +1 -1
  42. package/dist/client/assets/{engine-oniguruma-DDtqknzG.js → engine-oniguruma-Bw1dOuOO.js} +1 -1
  43. package/dist/client/assets/{erDiagram-OPXOYQCR-2VvQxgaY.js → erDiagram-OPXOYQCR-CKCR3jQT.js} +1 -1
  44. package/dist/client/assets/eventmodeling-NTZA5JFV-C7ouopD6.js +1 -0
  45. package/dist/client/assets/flowDiagram-KWPJA3E3-C2o4wCpi.js +1 -0
  46. package/dist/client/assets/{ganttDiagram-FUAMR5RP-hQJ3ga2L.js → ganttDiagram-FUAMR5RP-BwkoweG7.js} +1 -1
  47. package/dist/client/assets/{gitGraph-4MIJSDKK-CVt38-tW.js → gitGraph-4MIJSDKK-M7_hHVyH.js} +1 -1
  48. package/dist/client/assets/{gitGraphDiagram-X574FWY7-X6_FAtp_.js → gitGraphDiagram-X574FWY7-BVm_sLT_.js} +1 -1
  49. package/dist/client/assets/index-BEG1ZxrX.css +1 -0
  50. package/dist/client/assets/index-BwDjlzOZ.js +91 -0
  51. package/dist/client/assets/{info-A6RAGUB7-CsIS2T8y.js → info-A6RAGUB7-DPBs3m_X.js} +1 -1
  52. package/dist/client/assets/{infoDiagram-VRGFBTTK-CNesth4F.js → infoDiagram-VRGFBTTK-DDb3KyOP.js} +1 -1
  53. package/dist/client/assets/{ishikawaDiagram-OU5B5YK6-C3uowhy1.js → ishikawaDiagram-OU5B5YK6-CPt4RDlr.js} +1 -1
  54. package/dist/client/assets/{journeyDiagram-ZHPQQLJL-Bti7zdNB.js → journeyDiagram-ZHPQQLJL-ECQSHJ0N.js} +1 -1
  55. package/dist/client/assets/{kanban-definition-PNTS6WVX-Co13mJCW.js → kanban-definition-PNTS6WVX-tAmonxA8.js} +1 -1
  56. package/dist/client/assets/{line-CodMwzVg.js → line-x6vAh41a.js} +1 -1
  57. package/dist/client/assets/{linear-D9CKVpWI.js → linear-BqczUNss.js} +1 -1
  58. package/dist/client/assets/{mermaid-parser.core-OY76TKUN.js → mermaid-parser.core-siQPZsry.js} +3 -3
  59. package/dist/client/assets/{mermaid.core-DWE2Tn2R.js → mermaid.core-CLHTQVF-.js} +4 -4
  60. package/dist/client/assets/{mindmap-definition-NLK3R4M7-DJ3MEvZg.js → mindmap-definition-NLK3R4M7-C9fnEdyl.js} +1 -1
  61. package/dist/client/assets/{packet-AYTQ26CC-CW1j_hFQ.js → packet-AYTQ26CC-DAeK0Q4n.js} +1 -1
  62. package/dist/client/assets/{pegDiagram-GJSIUBJH-hOvLvBiF.js → pegDiagram-GJSIUBJH-BrWBFYPO.js} +1 -1
  63. package/dist/client/assets/{pie-WAS4IAKB-D1KPtuKA.js → pie-WAS4IAKB-CnieihkO.js} +1 -1
  64. package/dist/client/assets/{pieDiagram-5QR66LMP-CJN1M2lG.js → pieDiagram-5QR66LMP-Bp5f6Eal.js} +1 -1
  65. package/dist/client/assets/{quadrantDiagram-O4NWA36T-DOysLQ6R.js → quadrantDiagram-O4NWA36T-ECuLA3RS.js} +1 -1
  66. package/dist/client/assets/{radar-RG4KPBEZ-B7XCBnIS.js → radar-RG4KPBEZ-BJXkERy0.js} +1 -1
  67. package/dist/client/assets/{railroad-74A4TZTK-0nmMIDEw.js → railroad-74A4TZTK-Di61fiV5.js} +1 -1
  68. package/dist/client/assets/railroad-abnf-HS5TGJTU-DCBYLLLl.js +1 -0
  69. package/dist/client/assets/railroad-ebnf-LZEXJU2U-VWOalqH1.js +1 -0
  70. package/dist/client/assets/railroad-peg-WCYAUIDC-BTTWaHra.js +1 -0
  71. package/dist/client/assets/{railroadDiagram-XR7U4H2S-BXbeU-WV.js → railroadDiagram-XR7U4H2S-Dn2WPOQe.js} +1 -1
  72. package/dist/client/assets/{requirementDiagram-PLB6GJNP-D3G2HYv-.js → requirementDiagram-PLB6GJNP-uuvtvHmq.js} +1 -1
  73. package/dist/client/assets/{sankeyDiagram-IPEJSGJF-CFSa-Gk4.js → sankeyDiagram-IPEJSGJF-BXk6X0af.js} +1 -1
  74. package/dist/client/assets/{sequenceDiagram-PO4LG4MO-BvmuQTlz.js → sequenceDiagram-PO4LG4MO-BsPoSXhg.js} +1 -1
  75. package/dist/client/assets/{src-CplSIRuf.js → src-B4djUhOi.js} +1 -1
  76. package/dist/client/assets/{stateDiagram-v2-GCMORJYK-C_ceCfHy.js → stateDiagram-v2-GCMORJYK-cCml5k6n.js} +1 -1
  77. package/dist/client/assets/{swimlanes-2SLR337P-D-n44lEC.js → swimlanes-2SLR337P-Dh7v55ZA.js} +1 -1
  78. package/dist/client/assets/swimlanesDiagram-TC7HE7FX-q8VF72XO.js +8 -0
  79. package/dist/client/assets/{timeline-definition-EJHVYXUP-DKYNs1Uj.js → timeline-definition-EJHVYXUP-LFjkFV14.js} +1 -1
  80. package/dist/client/assets/{treeView-Q6P3EWNA-COW2UiIS.js → treeView-Q6P3EWNA-RYZHSXeB.js} +1 -1
  81. package/dist/client/assets/{treemap-WGGIJYW6-DJOuJZmF.js → treemap-WGGIJYW6-UVw_-EPz.js} +1 -1
  82. package/dist/client/assets/{usecaseDiagram-POWQR4AR-BTmpbeJZ.js → usecaseDiagram-POWQR4AR-DZfPogyb.js} +1 -1
  83. package/dist/client/assets/{vennDiagram-UO4OBE2U-C37AKEaJ.js → vennDiagram-UO4OBE2U-CN7Ldg-2.js} +1 -1
  84. package/dist/client/assets/{wardley-WFR3VGLG-CpVTtNOC.js → wardley-WFR3VGLG-CXPbbIy_.js} +1 -1
  85. package/dist/client/assets/{wardleyDiagram-VNRHLVJA-ja-hiiVC.js → wardleyDiagram-VNRHLVJA-5p0A7_g6.js} +1 -1
  86. package/dist/client/assets/{xychartDiagram-PMCCYNJV-BsDoacSf.js → xychartDiagram-PMCCYNJV-gJ7xwZXO.js} +1 -1
  87. package/dist/client/index.html +2 -2
  88. package/package.json +1 -1
  89. package/plugin.json +1 -1
  90. package/skills/diffo/SKILL.md +3 -2
  91. package/dist/client/assets/architecture-7GRP2DOG-CYv-P09J.js +0 -1
  92. package/dist/client/assets/channel-eN698CVO.js +0 -1
  93. package/dist/client/assets/eventmodeling-NTZA5JFV-Ur4Uzv5y.js +0 -1
  94. package/dist/client/assets/flowDiagram-KWPJA3E3-BQS1w4s1.js +0 -1
  95. package/dist/client/assets/index-BpeDzG-X.css +0 -1
  96. package/dist/client/assets/index-t7j89PJp.js +0 -91
  97. package/dist/client/assets/railroad-abnf-HS5TGJTU-DJU_abQP.js +0 -1
  98. package/dist/client/assets/railroad-ebnf-LZEXJU2U-_TwNbNw6.js +0 -1
  99. package/dist/client/assets/railroad-peg-WCYAUIDC-CAYsrf-p.js +0 -1
  100. package/dist/client/assets/swimlanesDiagram-TC7HE7FX-DrlgJMk7.js +0 -8
package/dist/cli.mjs CHANGED
@@ -209,6 +209,9 @@ function buildCliCommands(cli) {
209
209
  reply: `${cli} reply <threadId> --message "<your reply>"`,
210
210
  comment: `${cli} comment [<file>] [--line <line>] --message "<comment>"`,
211
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`,
212
215
  end: `${cli} end`,
213
216
  setup: `${cli} setup`
214
217
  };
@@ -254,6 +257,7 @@ const TAB_TITLE = {
254
257
  };
255
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`;
256
259
  function nextStepFor(kind, actionable) {
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}).`;
257
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.`;
258
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.`;
259
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.`;
@@ -262,6 +266,9 @@ const ACK_NEXT_STEP = {
262
266
  reply: `When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
263
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.",
264
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}\`.`,
265
272
  end: "Detached. Do not reopen or re-poll this review unless the user asks — deliver anything remaining directly in the conversation."
266
273
  };
267
274
  /**
@@ -282,6 +289,34 @@ const GUIDE = {
282
289
  update: "reply to your own guide thread with a short update"
283
290
  };
284
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
+ /**
285
320
  * Printed by `diffo` (open) to a piped stdout when the review has no guide yet.
286
321
  * The skill teaches the same step, but this line is what an agent WITHOUT the
287
322
  * skill sees — payloads and command output must stand alone (see POLL_STANCE).
@@ -297,6 +332,17 @@ function guideNudge(hasGuide) {
297
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}.`;
298
333
  }
299
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.`;
344
+ }
345
+ /**
300
346
  * Printed to stderr when a poll takes the review over and the review already
301
347
  * carries a guide: the new agent inherits it as orientation and updates it in
302
348
  * place — a second guide would give the reviewer two.
@@ -556,6 +602,29 @@ function buildCoalescedPrompt(threads, ctx) {
556
602
  * orientation for the fresh round, so this restates the guide doctrine the way
557
603
  * the open-time nudge does (payloads must stand alone — see POLL_STANCE).
558
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
+ }
559
628
  function buildClearedPrompt(ctx) {
560
629
  return [
561
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.`,
@@ -611,6 +680,145 @@ function buildFinishPrompt(threads, ctx, coverage) {
611
680
  return parts.join("\n");
612
681
  }
613
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
614
822
  //#region src/cliArgs.ts
615
823
  const HELP_TEXT = `diffo — review a changeset the way you'd read a book
616
824
 
@@ -636,6 +844,9 @@ For the agent (the AI that wrote the change):
636
844
  comment [<file>] Start a comment thread as the agent — on a line (--line),
637
845
  a file, or (with no file) the whole changeset; the
638
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)
639
850
  end Detach from the review politely
640
851
  help agent The agent's whole protocol on one page
641
852
 
@@ -684,7 +895,16 @@ The loop:
684
895
  ${GUIDE.stance}.
685
896
  It lands live at the top of their review — never hold the URL back for it.
686
897
  If the changeset later shifts under the guide, ${GUIDE.update}.
687
- 3. Listen: run \`${CLI_COMMANDS.firstPoll}\` — it blocks until the reviewer
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
688
908
  acts, then prints one JSON payload naming the threads to act on. Run it
689
909
  attended: ${POLL_STANCE}.
690
910
  The title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is
@@ -692,19 +912,19 @@ The loop:
692
912
  every other poll is a plain \`diffo poll\`.
693
913
  Killed or timed out? Re-run it; feedback is held in the review, not the
694
914
  poll.
695
- 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
696
916
  answer in the reply and no edit. Your edits reach the reviewer live.
697
- 5. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
917
+ 6. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
698
918
  stdin) — concise, addressed to the reviewer. Markdown renders; a
699
919
  \`\`\`mermaid fence draws a diagram. A reply that only promises a
700
920
  follow-up ("I'll investigate and report back") goes out with \`--more\` —
701
921
  the reviewer keeps seeing you at work — and the real answer follows as a
702
922
  plain reply before the next poll.
703
- 6. Comment (sparingly): \`diffo comment [<file>] [--line <n>] -m "<text>"\`
923
+ 7. Comment (sparingly): \`diffo comment [<file>] [--line <n>] -m "<text>"\`
704
924
  starts a thread in your voice — a concern, or context that helps the read.
705
- 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
706
926
  reviewer you are done with the previous one.
707
- 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.
708
928
 
709
929
  Rules:
710
930
  - Change only what the threads ask about — the reviewer is mid-read, and an
@@ -770,6 +990,29 @@ Output: {"ok":true,"threadId":"t-1","next_step":"…"}
770
990
 
771
991
  Example:
772
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"]}]'`,
773
1016
  end: `diffo end — detach from the review politely
774
1017
 
775
1018
  Usage: diffo end
@@ -825,6 +1068,7 @@ const VERBS = /* @__PURE__ */ new Set([
825
1068
  "poll",
826
1069
  "reply",
827
1070
  "comment",
1071
+ "layers",
828
1072
  "end",
829
1073
  "setup",
830
1074
  "status",
@@ -974,7 +1218,78 @@ function parseCliArgs(argv) {
974
1218
  foreground: values.foreground === true
975
1219
  };
976
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
+ }
977
1291
  function parseVerb(verb, rest) {
1292
+ if (verb === "layers") return parseLayersVerb(rest);
978
1293
  const parsed = tryParse(() => parseArgs({
979
1294
  args: rest,
980
1295
  allowPositionals: true,
@@ -1950,7 +2265,9 @@ var DeliveryQueue = class DeliveryQueue {
1950
2265
  bucket = {
1951
2266
  threads: /* @__PURE__ */ new Set(),
1952
2267
  finish: null,
1953
- cleared: false
2268
+ cleared: false,
2269
+ layers: false,
2270
+ outlining: null
1954
2271
  };
1955
2272
  this.buckets.set(this.scope, bucket);
1956
2273
  }
@@ -1964,10 +2281,20 @@ var DeliveryQueue = class DeliveryQueue {
1964
2281
  }
1965
2282
  presence() {
1966
2283
  if (this.awaitingReply) return "working";
2284
+ if (this.outlining() !== null) return "working";
1967
2285
  if (this.waiter) return "listening";
1968
2286
  if (this.betweenPolls) return "working";
1969
2287
  return "waiting";
1970
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
+ }
1971
2298
  presenceDetail() {
1972
2299
  return {
1973
2300
  state: this.presence(),
@@ -1977,6 +2304,7 @@ var DeliveryQueue = class DeliveryQueue {
1977
2304
  }
1978
2305
  reason() {
1979
2306
  if (this.awaitingReply) return this.stalled ? "stalled" : "delivered";
2307
+ if (this.outlining() !== null) return "delivered";
1980
2308
  if (this.waiter) return "polling";
1981
2309
  if (this.betweenPolls) return "replied";
1982
2310
  return this.lastDetach ?? "no-agent";
@@ -2087,6 +2415,37 @@ var DeliveryQueue = class DeliveryQueue {
2087
2415
  this.wake();
2088
2416
  if (queued) this.notify();
2089
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
+ }
2090
2449
  drop(threadId) {
2091
2450
  for (const bucket of this.buckets.values()) bucket.threads.delete(threadId);
2092
2451
  this.deliveredAt.delete(threadId);
@@ -2104,6 +2463,8 @@ var DeliveryQueue = class DeliveryQueue {
2104
2463
  if (this.batch !== null) {
2105
2464
  if (this.batch.sawReply || Date.now() - this.batch.deliveredAt >= this.batchSettleMs) this.closeBatch("repoll");
2106
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;
2107
2468
  if (this.hasPending()) return Promise.resolve("data");
2108
2469
  return new Promise((resolve) => {
2109
2470
  const waiter = (outcome) => resolve(outcome);
@@ -2146,7 +2507,7 @@ var DeliveryQueue = class DeliveryQueue {
2146
2507
  }
2147
2508
  hasPending() {
2148
2509
  const bucket = this.buckets.get(this.scope);
2149
- 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);
2150
2511
  }
2151
2512
  wake() {
2152
2513
  if (this.waiter === null) return;
@@ -2168,12 +2529,20 @@ var DeliveryQueue = class DeliveryQueue {
2168
2529
  kind: "threads",
2169
2530
  threadIds: [...bucket.threads]
2170
2531
  };
2532
+ if (bucket.layers) return { kind: "layers" };
2171
2533
  if (bucket.cleared) return { kind: "cleared" };
2172
2534
  return null;
2173
2535
  }
2174
2536
  /** The poll response for this snapshot was fully written — NOW it counts as
2175
2537
  * delivered. Clears exactly what the snapshot covered. */
2176
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
+ }
2177
2546
  if (snapshot.kind === "cleared") {
2178
2547
  const bucket = this.buckets.get(this.scope);
2179
2548
  if (bucket) bucket.cleared = false;
@@ -2235,6 +2604,7 @@ var DeliveryQueue = class DeliveryQueue {
2235
2604
  this.releaseWaiter("ended");
2236
2605
  this.closeBatch("ended");
2237
2606
  this.awaitingReply = false;
2607
+ for (const bucket of this.buckets.values()) bucket.outlining = null;
2238
2608
  this.stalled = false;
2239
2609
  this.clearStall();
2240
2610
  this.clearGrace();
@@ -2595,8 +2965,9 @@ var ReviewStore = class {
2595
2965
  */
2596
2966
  reset() {
2597
2967
  const ids = this.state.threads.map((t) => t.id);
2598
- if (ids.length === 0 && !this.state.lastFinish && !this.state.landed && !this.state.title) return [];
2599
- const { lastFinish: _finish, landed: _landed, title: _title, ...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;
2600
2971
  this.state = {
2601
2972
  ...rest,
2602
2973
  threads: []
@@ -2604,6 +2975,41 @@ var ReviewStore = class {
2604
2975
  this.commit();
2605
2976
  return ids;
2606
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
+ }
2607
3013
  /** The agent's name for this changeset, carried by its poll. The newest one
2608
3014
  * wins: a change that grows a second subject renames its own tab. */
2609
3015
  setTitle(title) {
@@ -2785,15 +3191,43 @@ function parseReview(raw) {
2785
3191
  const lastFinish = normalizeLastFinish(parsed.lastFinish, now);
2786
3192
  const landed = normalizeLanded(parsed.landed, now);
2787
3193
  const title = normalizeTitle(parsed.title);
3194
+ const layers = normalizeLayers(parsed.layers, now);
3195
+ const layersSuggested = normalizeSuggested(parsed.layersSuggested);
2788
3196
  return {
2789
3197
  version: 1,
2790
3198
  threads: [...valid, ...migrated],
2791
3199
  ...title ? { title } : {},
2792
3200
  ...lastFinish ? { lastFinish } : {},
2793
3201
  ...typeof parsed.seenHead === "string" && parsed.seenHead !== "" ? { seenHead: parsed.seenHead } : {},
2794
- ...landed ? { landed } : {}
3202
+ ...landed ? { landed } : {},
3203
+ ...layers ? { layers } : {},
3204
+ ...layersSuggested && !layers ? { layersSuggested } : {}
3205
+ };
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
2795
3224
  };
2796
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
+ }
2797
3231
  /** A landed marker without a sha can't be checked against history, so it is
2798
3232
  * dropped; the subject is only a caption and defaults away. */
2799
3233
  function normalizeLanded(value, now) {
@@ -3486,6 +3920,31 @@ function createApp(ctx, store, review, queue) {
3486
3920
  const capture = store ? captureAnchor(store.get(), anchor) : null;
3487
3921
  return c.json(review.createThread(anchor, text, capture, intent));
3488
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
+ });
3489
3948
  app.post("/api/review/threads/:id/messages", async (c) => {
3490
3949
  if (!review) return c.json({ error: "review unavailable" }, 503);
3491
3950
  const body = await c.req.json().catch(() => null);
@@ -3535,6 +3994,7 @@ function createApp(ctx, store, review, queue) {
3535
3994
  const hadRound = before.threads.length > 0 || before.lastFinish !== void 0;
3536
3995
  const removed = review.reset();
3537
3996
  for (const id of removed) queue?.drop(id);
3997
+ queue?.dropLayers();
3538
3998
  if (hadRound && (store?.get().files.length ?? 0) > 0) queue?.enqueueCleared();
3539
3999
  return c.json({ removed: removed.length });
3540
4000
  });
@@ -3680,6 +4140,15 @@ function createApp(ctx, store, review, queue) {
3680
4140
  const pollPayload = (snapshot) => {
3681
4141
  const threads = review?.get().threads ?? [];
3682
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
+ };
3683
4152
  if (snapshot.kind === "cleared") return {
3684
4153
  status: "feedback",
3685
4154
  kind: "cleared",
@@ -3878,7 +4347,8 @@ function createApp(ctx, store, review, queue) {
3878
4347
  ...queue.presenceDetail(),
3879
4348
  workingOn: queue.deliveredThreadIds(),
3880
4349
  queued: queue.queuedThreadIds(),
3881
- answered: queue.currentBatch()?.answered ?? []
4350
+ answered: queue.currentBatch()?.answered ?? [],
4351
+ layers: queue.layersRequest()
3882
4352
  }),
3883
4353
  id: String(id++)
3884
4354
  });
@@ -3901,7 +4371,8 @@ function createApp(ctx, store, review, queue) {
3901
4371
  ...queue.presenceDetail(),
3902
4372
  workingOn: queue.deliveredThreadIds(),
3903
4373
  queued: queue.queuedThreadIds(),
3904
- answered: queue.currentBatch()?.answered ?? []
4374
+ answered: queue.currentBatch()?.answered ?? [],
4375
+ layers: queue.layersRequest()
3905
4376
  }),
3906
4377
  id: String(id++)
3907
4378
  });
@@ -4904,6 +5375,44 @@ if (command.kind === "comment") {
4904
5375
  }));
4905
5376
  process.exit(0);
4906
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
+ }
4907
5416
  if (command.kind === "end") {
4908
5417
  const { status, body } = await postJson(await requireServer(), "/api/agent/end", {});
4909
5418
  if (status !== 200) fail(`end failed (${status})`);
@@ -4930,6 +5439,8 @@ async function printAgentNextStep(port) {
4930
5439
  const review = await fetchReviewState(port);
4931
5440
  const nudge = review ? guideNudge(findGuideThread(review) !== void 0) : null;
4932
5441
  if (nudge) console.log(`first: ${nudge}`);
5442
+ const layers = review ? layersNudge(review) : null;
5443
+ if (layers) console.log(`also: ${layers}`);
4933
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)`);
4934
5445
  }
4935
5446
  /**