@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.
- package/README.md +22 -4
- package/dist/cli.mjs +632 -32
- package/dist/client/assets/{abnfDiagram-VCTEODGH-BK74b0Uj.js → abnfDiagram-O67JEVCF-b6l_FGHi.js} +1 -1
- package/dist/client/assets/{arc-BZJjnFHJ.js → arc-Cz0jZwLH.js} +1 -1
- package/dist/client/assets/architecture-7GRP2DOG-BXYREMFQ.js +1 -0
- package/dist/client/assets/architectureDiagram-NJMV4G6O-CJdtqKce.js +36 -0
- package/dist/client/assets/{blockDiagram-I7D4REHJ-BWSjZ0YR.js → blockDiagram-BEXU5L5S-Ci-lnLGN.js} +15 -4
- package/dist/client/assets/c4Diagram-YGBWAQC7-DDO2mTMB.js +38 -0
- package/dist/client/assets/channel-CtvNwuQX.js +1 -0
- package/dist/client/assets/chunk-3FUC2YCW-VyswqFt3.js +2 -0
- package/dist/client/assets/{chunk-GVQU2GXP-3NWb-Kjs.js → chunk-5DYCD2WN-B4MBZW5P.js} +1 -1
- package/dist/client/assets/{chunk-PWAF6VOD-BRN4CzQ0.js → chunk-742MDFTN-Cxvmvrid.js} +1 -1
- package/dist/client/assets/chunk-7INBJB4K-DkBnRFbH.js +62 -0
- package/dist/client/assets/chunk-7M6MHVWA-BvJgBwEi.js +213 -0
- package/dist/client/assets/chunk-7PRAP22T-OfiMNi_y.js +1 -0
- package/dist/client/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/client/assets/{chunk-FOHPRMQF-DHwB1DNv.js → chunk-FOHPRMQF-DzxwRta7.js} +9 -9
- package/dist/client/assets/{chunk-SVP7TREG-CsO-w42m.js → chunk-GTNCS2PH-BodN5cRQ.js} +1 -1
- package/dist/client/assets/{chunk-F27PBJKO-C6X74WsU.js → chunk-GWA4HPMP-KifxxZcy.js} +1 -1
- package/dist/client/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/client/assets/chunk-MBY4JIJT-rw02bHlj.js +72 -0
- package/dist/client/assets/chunk-NETBCI7D-CWjhZoou.js +1 -0
- package/dist/client/assets/chunk-O7XYJQB3-CyxnSfZ3.js +126 -0
- package/dist/client/assets/chunk-UA2S7LBM-BReIFgsu.js +1 -0
- package/dist/client/assets/{chunk-POPQ4Y6H-DKwUAqbe.js → chunk-WEXAMYUT-DHwMy8v7.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-BikhrMBF.js → chunk-XXDRQBXY-C398QZbS.js} +1 -1
- package/dist/client/assets/{chunk-OSK3NFVY-BJ8i4cd-.js → chunk-Z7XXMR3K-hEV1GU64.js} +3 -3
- package/dist/client/assets/{chunk-75Z2AOVW-B_bGdfJP.js → chunk-ZIGJFQKS-DJJL7llO.js} +2 -2
- package/dist/client/assets/classDiagram-v2-NBCMYWYE-pSGZI880.js +217 -0
- package/dist/client/assets/{core-BJUznnCg.js → core-C05I4wHV.js} +2 -2
- package/dist/client/assets/cose-bilkent-JH36ORCC-DNV8Dqd8.js +1 -0
- package/dist/client/assets/cynefin-OW5HDTMX-DF-6FOgS.js +1 -0
- package/dist/client/assets/cynefinDiagram-VND7K2PF-D3-iJq2J.js +62 -0
- package/dist/client/assets/cytoscape.esm-Yq6u8L66.js +321 -0
- package/dist/client/assets/dagre-6A5THRUB-C9FDZkaa.js +4 -0
- package/dist/client/assets/diagram-22UHCM2B-CfSJBDyN.js +200 -0
- package/dist/client/assets/{diagram-VX7I27RA-BmHtC5T2.js → diagram-3UASUU5V-D946baYo.js} +2 -2
- package/dist/client/assets/{diagram-VSXAHHWV-BMNFierD.js → diagram-ATOU4E4O-CGrjukoA.js} +3 -3
- package/dist/client/assets/{diagram-S7CK7UJ4-C1jP2NX5.js → diagram-CDSNMT55-B47UPtWq.js} +3 -3
- package/dist/client/assets/{diagram-Z3DM3KII-CzCV6KsS.js → diagram-MLGK6HIB-D06yEd5R.js} +1 -1
- package/dist/client/assets/diagram-MPIPVDR6-BRUBT2B9.js +41 -0
- package/dist/client/assets/{dist-bii_eoxi.js → dist-BfTRyLGm.js} +1 -1
- package/dist/client/assets/dist-DVw88SBM.js +142 -0
- package/dist/client/assets/{ebnfDiagram-PWID7BFC-DuVnjPWa.js → ebnfDiagram-ZINNZB2B-BnuExfrd.js} +1 -1
- package/dist/client/assets/elk-276RUBZZ-BmdcLjR9.js +27 -0
- package/dist/client/assets/{engine-oniguruma-BlIEwGaN.js → engine-oniguruma-Bw1dOuOO.js} +1 -1
- package/dist/client/assets/{erDiagram-RLTQ6QDP-CtwYHyZu.js → erDiagram-OPXOYQCR-CKCR3jQT.js} +15 -15
- package/dist/client/assets/eventmodeling-NTZA5JFV-C7ouopD6.js +1 -0
- package/dist/client/assets/flowDiagram-KWPJA3E3-C2o4wCpi.js +1 -0
- package/dist/client/assets/{ganttDiagram-EL5Y4UJY-CR8Sa06t.js → ganttDiagram-FUAMR5RP-BwkoweG7.js} +2 -2
- package/dist/client/assets/gitGraph-4MIJSDKK-M7_hHVyH.js +1 -0
- package/dist/client/assets/{gitGraphDiagram-WWUBYQGX-C36JIyKh.js → gitGraphDiagram-X574FWY7-BVm_sLT_.js} +4 -4
- package/dist/client/assets/index-BEG1ZxrX.css +1 -0
- package/dist/client/assets/index-BwDjlzOZ.js +91 -0
- package/dist/client/assets/info-A6RAGUB7-DPBs3m_X.js +1 -0
- package/dist/client/assets/infoDiagram-VRGFBTTK-DDb3KyOP.js +2 -0
- package/dist/client/assets/{ishikawaDiagram-5VMMS53U-CsPYEqSH.js → ishikawaDiagram-OU5B5YK6-CPt4RDlr.js} +2 -2
- package/dist/client/assets/{journeyDiagram-3NMN7TZE-DbHeKN0X.js → journeyDiagram-ZHPQQLJL-ECQSHJ0N.js} +3 -3
- package/dist/client/assets/{kanban-definition-UXKFOSKX-CbYeTY2r.js → kanban-definition-PNTS6WVX-tAmonxA8.js} +10 -10
- package/dist/client/assets/{katex-CXMH3UgJ.js → katex-ZlcWpGUi.js} +2 -2
- package/dist/client/assets/{line-BeOqlVB1.js → line-x6vAh41a.js} +1 -1
- package/dist/client/assets/linear-BqczUNss.js +1 -0
- package/dist/client/assets/{mermaid-parser.core-BLSiQUXp.js → mermaid-parser.core-siQPZsry.js} +3 -3
- package/dist/client/assets/mermaid.core-CLHTQVF-.js +44 -0
- package/dist/client/assets/{mindmap-definition-YA3MSWOX-o49TTsg4.js → mindmap-definition-NLK3R4M7-C9fnEdyl.js} +25 -25
- package/dist/client/assets/packet-AYTQ26CC-DAeK0Q4n.js +1 -0
- package/dist/client/assets/{pegDiagram-XKGWAZYB-CZxoewnI.js → pegDiagram-GJSIUBJH-BrWBFYPO.js} +1 -1
- package/dist/client/assets/pie-WAS4IAKB-CnieihkO.js +1 -0
- package/dist/client/assets/{pieDiagram-E7YTZNPT-dAiwtigh.js → pieDiagram-5QR66LMP-Bp5f6Eal.js} +2 -2
- package/dist/client/assets/{quadrantDiagram-AXDQQJYC-mefTaSBl.js → quadrantDiagram-O4NWA36T-ECuLA3RS.js} +3 -3
- package/dist/client/assets/radar-RG4KPBEZ-BJXkERy0.js +1 -0
- package/dist/client/assets/railroad-74A4TZTK-Di61fiV5.js +1 -0
- package/dist/client/assets/railroad-abnf-HS5TGJTU-DCBYLLLl.js +1 -0
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-VWOalqH1.js +1 -0
- package/dist/client/assets/railroad-peg-WCYAUIDC-BTTWaHra.js +1 -0
- package/dist/client/assets/{railroadDiagram-O6MQD6OU-DdhfhlaU.js → railroadDiagram-XR7U4H2S-Dn2WPOQe.js} +1 -1
- package/dist/client/assets/{requirementDiagram-BXWQKSXE-BVrRfVMy.js → requirementDiagram-PLB6GJNP-uuvtvHmq.js} +11 -11
- package/dist/client/assets/sankeyDiagram-IPEJSGJF-BXk6X0af.js +40 -0
- package/dist/client/assets/sequenceDiagram-PO4LG4MO-BsPoSXhg.js +169 -0
- package/dist/client/assets/{src-CfhDi0bm.js → src-B4djUhOi.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-GCMORJYK-cCml5k6n.js +291 -0
- package/dist/client/assets/swimlanes-2SLR337P-Dh7v55ZA.js +1 -0
- package/dist/client/assets/swimlanesDiagram-TC7HE7FX-q8VF72XO.js +8 -0
- package/dist/client/assets/timeline-definition-EJHVYXUP-LFjkFV14.js +120 -0
- package/dist/client/assets/treeView-Q6P3EWNA-RYZHSXeB.js +1 -0
- package/dist/client/assets/treemap-WGGIJYW6-UVw_-EPz.js +1 -0
- package/dist/client/assets/usecaseDiagram-POWQR4AR-DZfPogyb.js +328 -0
- package/dist/client/assets/vennDiagram-UO4OBE2U-CN7Ldg-2.js +34 -0
- package/dist/client/assets/wardley-WFR3VGLG-CXPbbIy_.js +1 -0
- package/dist/client/assets/{wardleyDiagram-VM6X3IG4-CFaIuWQk.js → wardleyDiagram-VNRHLVJA-5p0A7_g6.js} +3 -3
- package/dist/client/assets/{xychartDiagram-S5SC5T6Z-et5vk4QS.js → xychartDiagram-PMCCYNJV-gJ7xwZXO.js} +3 -3
- package/dist/client/index.html +2 -2
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/skills/diffo/SKILL.md +20 -10
- package/dist/client/assets/architecture-7GRP2DOG-BMofp_0u.js +0 -1
- package/dist/client/assets/architectureDiagram-5GKGNRK7-BBjd1L77.js +0 -36
- package/dist/client/assets/c4Diagram-7LVT6UL2-z-xUGKCJ.js +0 -38
- package/dist/client/assets/channel-CmX7fcUa.js +0 -1
- package/dist/client/assets/chunk-4HAMMTFA-CAN-T2o-.js +0 -62
- package/dist/client/assets/chunk-DU6HZSFF-wyqPpFTy.js +0 -125
- package/dist/client/assets/chunk-GMAD6QVW-DBossm8a.js +0 -72
- package/dist/client/assets/chunk-IMKFNOWR-BCvjP66C.js +0 -231
- package/dist/client/assets/chunk-L3NEJ4N5-DppEbElX.js +0 -1
- package/dist/client/assets/chunk-P2QGCYS3-fvNVbdI1.js +0 -1
- package/dist/client/assets/chunk-SHT3W25Y-BfzjiqOe.js +0 -168
- package/dist/client/assets/chunk-TICWLB2K-DPzmokY4.js +0 -206
- package/dist/client/assets/classDiagram-ZZMXUADV-BSMgXeCj.js +0 -1
- package/dist/client/assets/classDiagram-v2-VYDZK3BY-BSMgXeCj.js +0 -1
- package/dist/client/assets/cose-bilkent-JH36ORCC-CBu7-BXr.js +0 -1
- package/dist/client/assets/cynefin-OW5HDTMX-iJEmw--0.js +0 -1
- package/dist/client/assets/cynefinDiagram-5FMLGOSQ-B4JnaLZu.js +0 -62
- package/dist/client/assets/cytoscape.esm-CGd-uY3x.js +0 -321
- package/dist/client/assets/dagre-GXQ25YYZ-bmDafESr.js +0 -4
- package/dist/client/assets/dagre-PrKaheQc.js +0 -1
- package/dist/client/assets/diagram-UQ7AKVKN-DssGkq6D.js +0 -41
- package/dist/client/assets/dist-BT7WB1vp.js +0 -142
- package/dist/client/assets/eventmodeling-NTZA5JFV-DBQsn9JF.js +0 -1
- package/dist/client/assets/flowDiagram-HODETNUW-C_zt1wER.js +0 -1
- package/dist/client/assets/gitGraph-4MIJSDKK-DoWc_DJ0.js +0 -1
- package/dist/client/assets/index-BUq9UmeC.js +0 -91
- package/dist/client/assets/index-BpeDzG-X.css +0 -1
- package/dist/client/assets/info-A6RAGUB7-DULcTga7.js +0 -1
- package/dist/client/assets/infoDiagram-27XIBGKW-eQbUxJ9T.js +0 -2
- package/dist/client/assets/linear-D5d3UlAm.js +0 -1
- package/dist/client/assets/mermaid.core-BoXUi08V.js +0 -44
- package/dist/client/assets/packet-AYTQ26CC-Blxpg-nI.js +0 -1
- package/dist/client/assets/pie-WAS4IAKB-D-fqeiW7.js +0 -1
- package/dist/client/assets/radar-RG4KPBEZ-VaODqy4w.js +0 -1
- package/dist/client/assets/railroad-74A4TZTK-BwoigfOV.js +0 -1
- package/dist/client/assets/railroad-abnf-HS5TGJTU-BYYKbIBi.js +0 -1
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-BFA23uel.js +0 -1
- package/dist/client/assets/railroad-peg-WCYAUIDC-Cq02Hm_P.js +0 -1
- package/dist/client/assets/sankeyDiagram-P5KCCOFB-BalSSH4U.js +0 -40
- package/dist/client/assets/sequenceDiagram-WJ2MYXX4-B2KsE_Z_.js +0 -162
- package/dist/client/assets/stateDiagram-D77RDMKH-CoH6bKG5.js +0 -1
- package/dist/client/assets/stateDiagram-v2-MP3YSRHH-rXsredo-.js +0 -1
- package/dist/client/assets/swimlanes-42K2YHIH-3IkzoE1X.js +0 -1
- package/dist/client/assets/swimlanesDiagram-VR7AAH4N-BeXrrDxh.js +0 -8
- package/dist/client/assets/timeline-definition-24CTP7MA-B2M9pP5h.js +0 -120
- package/dist/client/assets/treeView-Q6P3EWNA-Brp55dHV.js +0 -1
- package/dist/client/assets/treemap-WGGIJYW6-zRe9RDJV.js +0 -1
- package/dist/client/assets/vennDiagram-4TSXK5OY-PNF-rFET.js +0 -34
- 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
|
-
|
|
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 === "
|
|
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 `
|
|
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 —
|
|
631
|
-
|
|
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
|
-
|
|
637
|
-
(\`diffo comment -m "…"\`, no file),
|
|
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.
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
2519
|
-
*
|
|
2520
|
-
* ids from the dead changeset into the next review's "since last
|
|
2521
|
-
* lens, reporting everything as new
|
|
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
|
-
|
|
2527
|
-
|
|
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
|
|
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
|
-
|
|
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
|