@diffohq/diffo 0.1.0 → 0.3.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 +2 -2
- package/dist/cli.mjs +270 -51
- package/dist/client/assets/{abnfDiagram-VCTEODGH-DNZRM2Rn.js → abnfDiagram-O67JEVCF-DdDBSVZ4.js} +1 -1
- package/dist/client/assets/{arc-s7daVIwl.js → arc-BgNb6rn9.js} +1 -1
- package/dist/client/assets/architecture-7GRP2DOG-CYv-P09J.js +1 -0
- package/dist/client/assets/architectureDiagram-NJMV4G6O-HNLU6Z_V.js +36 -0
- package/dist/client/assets/{blockDiagram-NRAW4CY4-DqGD_dcu.js → blockDiagram-BEXU5L5S-DO434Dqm.js} +16 -5
- package/dist/client/assets/c4Diagram-YGBWAQC7-CkhXDWcc.js +38 -0
- package/dist/client/assets/channel-eN698CVO.js +1 -0
- package/dist/client/assets/chunk-3FUC2YCW-DTfKImVL.js +2 -0
- package/dist/client/assets/{chunk-OBVCFTLP-DOl2OrIN.js → chunk-5DYCD2WN-9Pj_Xuzv.js} +1 -1
- package/dist/client/assets/{chunk-PWAF6VOD-BHpo38Be.js → chunk-742MDFTN-DRQNNbBo.js} +1 -1
- package/dist/client/assets/chunk-7INBJB4K-BqnHF3P0.js +62 -0
- package/dist/client/assets/chunk-7M6MHVWA-NTwtPxj_.js +213 -0
- package/dist/client/assets/chunk-7PRAP22T-reFtaiUY.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-CSTzyRmo.js → chunk-GTNCS2PH-BbSQqqTd.js} +1 -1
- package/dist/client/assets/{chunk-F27PBJKO-DRqkoKNg.js → chunk-GWA4HPMP-B3OAiKhC.js} +1 -1
- package/dist/client/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/client/assets/chunk-MBY4JIJT-CNIWhzdY.js +72 -0
- package/dist/client/assets/chunk-NETBCI7D-Cu2GMg5A.js +1 -0
- package/dist/client/assets/chunk-O7XYJQB3-C616frX3.js +126 -0
- package/dist/client/assets/chunk-UA2S7LBM-_7jUrSu1.js +1 -0
- package/dist/client/assets/{chunk-POPQ4Y6H-Dw_D6UWF.js → chunk-WEXAMYUT--WmuTjt8.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-BHwLEQfa.js → chunk-XXDRQBXY-CH68Fn6G.js} +1 -1
- package/dist/client/assets/chunk-Z7XXMR3K-CMGWkbdd.js +10 -0
- package/dist/client/assets/{chunk-75Z2AOVW-HRLiZN7c.js → chunk-ZIGJFQKS-Bupo8UEA.js} +2 -2
- package/dist/client/assets/classDiagram-v2-NBCMYWYE-BlYlsN2U.js +217 -0
- package/dist/client/assets/{core-B9wYK4yO.js → core-BuHBGQx-.js} +2 -2
- package/dist/client/assets/cose-bilkent-JH36ORCC-BtFdPq63.js +1 -0
- package/dist/client/assets/cynefin-OW5HDTMX-DQ0j2-fq.js +1 -0
- package/dist/client/assets/cynefinDiagram-VND7K2PF-DnctQE5u.js +62 -0
- package/dist/client/assets/cytoscape.esm-Yq6u8L66.js +321 -0
- package/dist/client/assets/dagre-6A5THRUB-u940AeXp.js +4 -0
- package/dist/client/assets/diagram-22UHCM2B-B1jzZKtP.js +200 -0
- package/dist/client/assets/{diagram-VX7I27RA-BWGYUOwn.js → diagram-3UASUU5V-BY_6wxmf.js} +2 -2
- package/dist/client/assets/{diagram-VSXAHHWV-DRQH2hZ-.js → diagram-ATOU4E4O-CTsYtnJJ.js} +3 -3
- package/dist/client/assets/{diagram-S7CK7UJ4-BwGXFSb-.js → diagram-CDSNMT55-DuEJO9EQ.js} +3 -3
- package/dist/client/assets/{diagram-Z3DM3KII-C2ETOHnp.js → diagram-MLGK6HIB-CZ10U2Mk.js} +1 -1
- package/dist/client/assets/diagram-MPIPVDR6-CkO6cJlF.js +41 -0
- package/dist/client/assets/dist-DFkgQY1o.js +142 -0
- package/dist/client/assets/{dist-C42IZvNn.js → dist-DkqXbsqN.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-PWID7BFC-CkcgAzr3.js → ebnfDiagram-ZINNZB2B-BJtnMhOp.js} +1 -1
- package/dist/client/assets/elk-276RUBZZ-CShSerV4.js +27 -0
- package/dist/client/assets/{engine-oniguruma-D9HVl69n.js → engine-oniguruma-DDtqknzG.js} +1 -1
- package/dist/client/assets/{erDiagram-SSCWMZ5O-Do6h2U1E.js → erDiagram-OPXOYQCR-2VvQxgaY.js} +15 -15
- package/dist/client/assets/eventmodeling-NTZA5JFV-Ur4Uzv5y.js +1 -0
- package/dist/client/assets/flowDiagram-KWPJA3E3-BQS1w4s1.js +1 -0
- package/dist/client/assets/{ganttDiagram-EL5Y4UJY-cC8gUuqf.js → ganttDiagram-FUAMR5RP-hQJ3ga2L.js} +2 -2
- package/dist/client/assets/gitGraph-4MIJSDKK-CVt38-tW.js +1 -0
- package/dist/client/assets/{gitGraphDiagram-WWUBYQGX-Oj6POMRs.js → gitGraphDiagram-X574FWY7-X6_FAtp_.js} +4 -4
- package/dist/client/assets/index-t7j89PJp.js +91 -0
- package/dist/client/assets/info-A6RAGUB7-CsIS2T8y.js +1 -0
- package/dist/client/assets/infoDiagram-VRGFBTTK-CNesth4F.js +2 -0
- package/dist/client/assets/{ishikawaDiagram-5VMMS53U-BB3HsPau.js → ishikawaDiagram-OU5B5YK6-C3uowhy1.js} +2 -2
- package/dist/client/assets/{journeyDiagram-EYS64GPL-Cj8ezyBN.js → journeyDiagram-ZHPQQLJL-Bti7zdNB.js} +4 -4
- package/dist/client/assets/{kanban-definition-3QL26DDD-D3dbth5Z.js → kanban-definition-PNTS6WVX-Co13mJCW.js} +10 -10
- package/dist/client/assets/{katex-CXMH3UgJ.js → katex-ZlcWpGUi.js} +2 -2
- package/dist/client/assets/{line-tg3RVOrp.js → line-CodMwzVg.js} +1 -1
- package/dist/client/assets/linear-D9CKVpWI.js +1 -0
- package/dist/client/assets/{mermaid-parser.core-DKC3l_TU.js → mermaid-parser.core-OY76TKUN.js} +3 -3
- package/dist/client/assets/mermaid.core-DWE2Tn2R.js +44 -0
- package/dist/client/assets/{mindmap-definition-FBJOCRG2-CSUjR6w9.js → mindmap-definition-NLK3R4M7-DJ3MEvZg.js} +25 -25
- package/dist/client/assets/packet-AYTQ26CC-CW1j_hFQ.js +1 -0
- package/dist/client/assets/{pegDiagram-XKGWAZYB-BhLsv72R.js → pegDiagram-GJSIUBJH-hOvLvBiF.js} +1 -1
- package/dist/client/assets/pie-WAS4IAKB-D1KPtuKA.js +1 -0
- package/dist/client/assets/{pieDiagram-E7YTZNPT-Ci9YzLDy.js → pieDiagram-5QR66LMP-CJN1M2lG.js} +2 -2
- package/dist/client/assets/{quadrantDiagram-AXDQQJYC-D7D5FwMe.js → quadrantDiagram-O4NWA36T-DOysLQ6R.js} +3 -3
- package/dist/client/assets/radar-RG4KPBEZ-B7XCBnIS.js +1 -0
- package/dist/client/assets/railroad-74A4TZTK-0nmMIDEw.js +1 -0
- package/dist/client/assets/railroad-abnf-HS5TGJTU-DJU_abQP.js +1 -0
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-_TwNbNw6.js +1 -0
- package/dist/client/assets/railroad-peg-WCYAUIDC-CAYsrf-p.js +1 -0
- package/dist/client/assets/{railroadDiagram-O6MQD6OU-ByO5lwcN.js → railroadDiagram-XR7U4H2S-BXbeU-WV.js} +1 -1
- package/dist/client/assets/{requirementDiagram-EFPCY7ZU-UAUf-JbW.js → requirementDiagram-PLB6GJNP-D3G2HYv-.js} +11 -11
- package/dist/client/assets/sankeyDiagram-IPEJSGJF-CFSa-Gk4.js +40 -0
- package/dist/client/assets/sequenceDiagram-PO4LG4MO-BvmuQTlz.js +169 -0
- package/dist/client/assets/{src-DQRzrNpT.js → src-CplSIRuf.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-GCMORJYK-C_ceCfHy.js +291 -0
- package/dist/client/assets/swimlanes-2SLR337P-D-n44lEC.js +1 -0
- package/dist/client/assets/swimlanesDiagram-TC7HE7FX-DrlgJMk7.js +8 -0
- package/dist/client/assets/timeline-definition-EJHVYXUP-DKYNs1Uj.js +120 -0
- package/dist/client/assets/treeView-Q6P3EWNA-COW2UiIS.js +1 -0
- package/dist/client/assets/treemap-WGGIJYW6-DJOuJZmF.js +1 -0
- package/dist/client/assets/usecaseDiagram-POWQR4AR-BTmpbeJZ.js +328 -0
- package/dist/client/assets/vennDiagram-UO4OBE2U-C37AKEaJ.js +34 -0
- package/dist/client/assets/wardley-WFR3VGLG-CpVTtNOC.js +1 -0
- package/dist/client/assets/{wardleyDiagram-VM6X3IG4-D9KaADBZ.js → wardleyDiagram-VNRHLVJA-ja-hiiVC.js} +3 -3
- package/dist/client/assets/{xychartDiagram-S5SC5T6Z-CnrJ3a0c.js → xychartDiagram-PMCCYNJV-BsDoacSf.js} +3 -3
- package/dist/client/index.html +1 -1
- package/package.json +5 -4
- package/plugin.json +1 -1
- package/skills/diffo/SKILL.md +61 -124
- package/dist/client/assets/architecture-7GRP2DOG-DzM3CzOq.js +0 -1
- package/dist/client/assets/architectureDiagram-5GKGNRK7-DsxTOcye.js +0 -36
- package/dist/client/assets/c4Diagram-UCG6FXSJ-CMuJod-h.js +0 -38
- package/dist/client/assets/channel-Cftoq4wT.js +0 -1
- package/dist/client/assets/chunk-DU6HZSFF-D6gjp2l1.js +0 -125
- package/dist/client/assets/chunk-G27WJ6UU-QUFgr4za.js +0 -231
- package/dist/client/assets/chunk-GMAD6QVW-DG-OmEbV.js +0 -72
- package/dist/client/assets/chunk-JQ64N6SF-CPdpoyCu.js +0 -1
- package/dist/client/assets/chunk-LCL6LL3I-DoHJ4n3V.js +0 -206
- package/dist/client/assets/chunk-P2QGCYS3-CBWu7ViW.js +0 -1
- package/dist/client/assets/chunk-R7TYR2AO-DgC6z76h.js +0 -10
- package/dist/client/assets/chunk-RHFEMEQ7-BabrfzF-.js +0 -168
- package/dist/client/assets/chunk-TEH6E4GO-M22Cr-Sd.js +0 -62
- package/dist/client/assets/classDiagram-DTDB5LWJ-uMtyQjAg.js +0 -1
- package/dist/client/assets/classDiagram-v2-JRS7N3AN-uMtyQjAg.js +0 -1
- package/dist/client/assets/cose-bilkent-JH36ORCC-BL0lTP0k.js +0 -1
- package/dist/client/assets/cynefin-OW5HDTMX-1zOzoEu-.js +0 -1
- package/dist/client/assets/cynefinDiagram-5FMLGOSQ-DST_IHb1.js +0 -62
- package/dist/client/assets/cytoscape.esm-C2x7inlt.js +0 -321
- package/dist/client/assets/dagre-3AP2YEHR-CoEaLaFu.js +0 -4
- package/dist/client/assets/dagre-PrKaheQc.js +0 -1
- package/dist/client/assets/diagram-UQ7AKVKN-wJMwiRQW.js +0 -41
- package/dist/client/assets/eventmodeling-NTZA5JFV-LK0PWCmm.js +0 -1
- package/dist/client/assets/flowDiagram-A5DVABFB-W_CNjdk0.js +0 -1
- package/dist/client/assets/gitGraph-4MIJSDKK-Con-0IRc.js +0 -1
- package/dist/client/assets/index-BPuQYkRJ.js +0 -90
- package/dist/client/assets/info-A6RAGUB7-Cl45HDP4.js +0 -1
- package/dist/client/assets/infoDiagram-RXCK75RN-DIVe-qvk.js +0 -2
- package/dist/client/assets/linear-CzAWMsuG.js +0 -1
- package/dist/client/assets/mermaid.core-B0ktnMCY.js +0 -44
- package/dist/client/assets/packet-AYTQ26CC-NTsh29Fp.js +0 -1
- package/dist/client/assets/pie-WAS4IAKB-DENes_Cx.js +0 -1
- package/dist/client/assets/radar-RG4KPBEZ-C3OVgpW0.js +0 -1
- package/dist/client/assets/railroad-74A4TZTK-DHWDkPlt.js +0 -1
- package/dist/client/assets/railroad-abnf-HS5TGJTU-XG9NyE2s.js +0 -1
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-BePIURFQ.js +0 -1
- package/dist/client/assets/railroad-peg-WCYAUIDC-THfR3kY_.js +0 -1
- package/dist/client/assets/sankeyDiagram-P5KCCOFB-D3dgv1AD.js +0 -40
- package/dist/client/assets/sequenceDiagram-WJ2MYXX4-DbGvVPa7.js +0 -162
- package/dist/client/assets/stateDiagram-HBIQ2CUA-CJznFEjd.js +0 -1
- package/dist/client/assets/stateDiagram-v2-4QOOHH4V-o6PmCsJ0.js +0 -1
- package/dist/client/assets/swimlanes-XN3QIQJK-Bp4PtrPm.js +0 -1
- package/dist/client/assets/swimlanesDiagram-VK2B7HYN-W1d9PL3X.js +0 -8
- package/dist/client/assets/timeline-definition-24CTP7MA-jqaUS70q.js +0 -120
- package/dist/client/assets/treeView-Q6P3EWNA-4uMQTFyy.js +0 -1
- package/dist/client/assets/treemap-WGGIJYW6-BEvgQaxX.js +0 -1
- package/dist/client/assets/vennDiagram-4TSXK5OY-BzSNLT-7.js +0 -34
- package/dist/client/assets/wardley-WFR3VGLG-0Vpm3lxj.js +0 -1
- /package/dist/client/assets/{sizeCapture-X5ZJPWSS-B0uUizjq.js → sizeCapture-INFHLROL-B0uUizjq.js} +0 -0
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,6 +205,7 @@ 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>"`,
|
|
@@ -217,14 +230,37 @@ const INSTALL_SKILL = {
|
|
|
217
230
|
* restates how to hold the poll — attended, never detached. Mirrors the skill.
|
|
218
231
|
*/
|
|
219
232
|
const POLL_STANCE = "a tracked background task if your harness has one, the foreground if not — never a detached process";
|
|
220
|
-
|
|
233
|
+
/**
|
|
234
|
+
* The tab-title doctrine — the few words an agent hands its first poll, which
|
|
235
|
+
* become the browser tab's name. Stated once and interpolated into every
|
|
236
|
+
* surface that teaches it (the skill, `help agent`, the open-time next step),
|
|
237
|
+
* the same way GUIDE and POLL_STANCE keep their rules from drifting apart.
|
|
238
|
+
*
|
|
239
|
+
* It exists because a reviewer keeps several reviews open and every tab reads
|
|
240
|
+
* "Diffo": the agent is the only party that knows which is which at the moment
|
|
241
|
+
* it starts listening.
|
|
242
|
+
*/
|
|
243
|
+
const TAB_TITLE = {
|
|
244
|
+
/** What to write — and how little room there is to write it in. */
|
|
245
|
+
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",
|
|
246
|
+
/** Why it is worth a flag at all — the reviewer's problem, stated once. */
|
|
247
|
+
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\"",
|
|
248
|
+
/** How to order the words, given where they are read. */
|
|
249
|
+
shape: "the tab cuts off everything past that, so the word telling this review apart from another goes first, not last",
|
|
250
|
+
/** When to send it — and when not to bother. */
|
|
251
|
+
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",
|
|
252
|
+
/** Two to copy the register from — both fit a tab whole. */
|
|
253
|
+
examples: "\"tab titles\", \"flaky upload retries\""
|
|
254
|
+
};
|
|
255
|
+
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
256
|
function nextStepFor(kind, actionable) {
|
|
222
|
-
if (kind === "cleared") return `Post the guide if this changeset warrants one, then run \`${CLI_COMMANDS.
|
|
257
|
+
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
258
|
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
259
|
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
260
|
}
|
|
226
261
|
const ACK_NEXT_STEP = {
|
|
227
262
|
reply: `When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
|
|
263
|
+
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.",
|
|
228
264
|
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}\`.`,
|
|
229
265
|
end: "Detached. Do not reopen or re-poll this review unless the user asks — deliver anything remaining directly in the conversation."
|
|
230
266
|
};
|
|
@@ -250,9 +286,15 @@ const GUIDE = {
|
|
|
250
286
|
* The skill teaches the same step, but this line is what an agent WITHOUT the
|
|
251
287
|
* skill sees — payloads and command output must stand alone (see POLL_STANCE).
|
|
252
288
|
*/
|
|
289
|
+
/**
|
|
290
|
+
* The URL goes out before the guide, never after: composing a guide is the
|
|
291
|
+
* slowest step of an open (a diagram takes real thought), and a reviewer who
|
|
292
|
+
* is waiting for a link should not wait on it. The guide lands live at the top
|
|
293
|
+
* of their review while they are still opening the page.
|
|
294
|
+
*/
|
|
253
295
|
function guideNudge(hasGuide) {
|
|
254
296
|
if (hasGuide) return null;
|
|
255
|
-
return `
|
|
297
|
+
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}.`;
|
|
256
298
|
}
|
|
257
299
|
/**
|
|
258
300
|
* Printed to stderr when a poll takes the review over and the review already
|
|
@@ -271,6 +313,11 @@ const INTENT_CONTRACT = {
|
|
|
271
313
|
fix: "- `issue` threads want a code change. Address each one, or push back in the thread with your reasoning."
|
|
272
314
|
};
|
|
273
315
|
const UNLABELED_CONTRACT = "- Unlabeled threads: judge from the text — a question wants an answer, not an edit.";
|
|
316
|
+
/** The agent spoke last: the thread is answered until the reviewer says more.
|
|
317
|
+
* A reply that promised a follow-up (`--more`) is not an answer yet. */
|
|
318
|
+
function answeredByAgent(thread) {
|
|
319
|
+
return thread.messages.at(-1)?.author === "agent" && thread.awaitingFollowUp !== true;
|
|
320
|
+
}
|
|
274
321
|
/** The closing note is the reviewer summing up, so it earns an answer even when it
|
|
275
322
|
* asks nothing — a note with no reply is the review's one dead end. */
|
|
276
323
|
const CLOSING_CONTRACT = "- The closing note speaks for the whole review: read it first, and reply to it — briefly if it only sums up, in full if it asks something.";
|
|
@@ -281,9 +328,34 @@ function intentContract(threads) {
|
|
|
281
328
|
if (threads.some((t) => t.closingNote)) lines.push(CLOSING_CONTRACT);
|
|
282
329
|
return lines;
|
|
283
330
|
}
|
|
284
|
-
|
|
331
|
+
/**
|
|
332
|
+
* The short form, for a session that already received the full protocol this
|
|
333
|
+
* attachment (tracked per session pid by the DeliveryQueue). The full text is
|
|
334
|
+
* ~580 tokens and re-shipping it with every delivery was the loop's largest
|
|
335
|
+
* recurring cost; the compact form keeps only the per-batch contract — intent
|
|
336
|
+
* rules, the reply command, the re-poll — plus the pointer that reprints the rest.
|
|
337
|
+
*/
|
|
338
|
+
function compactProtocol(threads) {
|
|
339
|
+
return `## How to respond
|
|
340
|
+
|
|
341
|
+
Same protocol as your earlier deliveries (\`${CLI} help agent\` reprints it in full):
|
|
342
|
+
|
|
343
|
+
1. Act on each thread.
|
|
344
|
+
${intentContract(threads).map((line) => ` ${line}`).join("\n")}
|
|
345
|
+
2. Reply per thread as soon as it is handled — what changed and where (\`file:line\`), verify it the cheapest honest way first:
|
|
346
|
+
|
|
347
|
+
${CLI_COMMANDS.reply}
|
|
348
|
+
|
|
349
|
+
3. When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening.
|
|
350
|
+
|
|
351
|
+
Change only what these threads ask about — the reviewer is mid-read. Re-read
|
|
352
|
+
the current file before editing; the code may have moved. Resolving a thread
|
|
353
|
+
is the reviewer's call, never yours.`;
|
|
354
|
+
}
|
|
355
|
+
function replyProtocol(threads, mode = "full") {
|
|
356
|
+
if (mode === "compact") return compactProtocol(threads);
|
|
285
357
|
const batchNote = threads.length > 1 ? "\nRead every thread before you start editing — threads can touch the same\ncode, and an edit for one can move what another is anchored to.\n" : "";
|
|
286
|
-
const answeredNote = threads.some((t) => t
|
|
358
|
+
const answeredNote = threads.some((t) => answeredByAgent(t)) ? [" - Some threads end with your own earlier reply — if nothing new was asked since, don't reply to them again."] : [];
|
|
287
359
|
return `## How to respond
|
|
288
360
|
|
|
289
361
|
Diffo is running locally; talk to it through its CLI.
|
|
@@ -303,6 +375,10 @@ ${[...intentContract(threads).map((line) => ` ${line}`), ...answeredNote].join
|
|
|
303
375
|
is fixed, verify it the cheapest honest way (run the relevant test, re-read
|
|
304
376
|
the change) and mention what you checked.
|
|
305
377
|
Reply as soon as a thread is handled; don't save replies for the end.
|
|
378
|
+
A reply that only promises a follow-up ("I'll investigate and report
|
|
379
|
+
back") is not an answer — post it with \`--more\` so the reviewer keeps
|
|
380
|
+
seeing you at work on that thread, then post the real answer as a plain
|
|
381
|
+
reply (no \`--more\`) before your next poll.
|
|
306
382
|
Replies and comment threads render GitHub-flavored markdown, and a
|
|
307
383
|
\`\`\`mermaid fence renders as a diagram — use one when a flow, sequence,
|
|
308
384
|
or state picture explains the change better than prose. Keep it small
|
|
@@ -418,6 +494,7 @@ function snapshotBlock(thread) {
|
|
|
418
494
|
}
|
|
419
495
|
const lines = codeContext.split("\n");
|
|
420
496
|
const where = thread.anchor.kind === "changeset" ? "the file" : `\`${thread.anchor.path}\``;
|
|
497
|
+
if (thread.deliveredThrough) return [`(diff snapshot delivered to you earlier — read the current ${where} instead)`];
|
|
421
498
|
if (!anchored) return [
|
|
422
499
|
"The commented change:",
|
|
423
500
|
"```diff",
|
|
@@ -457,7 +534,7 @@ function buildThreadPrompt(thread, ctx) {
|
|
|
457
534
|
threadBlock(thread, 0),
|
|
458
535
|
"",
|
|
459
536
|
...siblings ? [siblings, ""] : [],
|
|
460
|
-
replyProtocol([thread]),
|
|
537
|
+
replyProtocol([thread], ctx.protocol),
|
|
461
538
|
""
|
|
462
539
|
].join("\n");
|
|
463
540
|
}
|
|
@@ -469,7 +546,7 @@ function buildCoalescedPrompt(threads, ctx) {
|
|
|
469
546
|
...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
|
|
470
547
|
...threads.map((t, i) => `${threadBlock(t, i)}\n`),
|
|
471
548
|
...siblings ? [siblings, ""] : [],
|
|
472
|
-
replyProtocol(threads),
|
|
549
|
+
replyProtocol(threads, ctx.protocol),
|
|
473
550
|
""
|
|
474
551
|
].join("\n");
|
|
475
552
|
}
|
|
@@ -494,7 +571,9 @@ function buildFinishPrompt(threads, ctx, coverage) {
|
|
|
494
571
|
const repo = ctx.repo;
|
|
495
572
|
const sent = threads.filter((t) => t.state === "sent");
|
|
496
573
|
const closing = sent.find((t) => t.closingNote);
|
|
497
|
-
const
|
|
574
|
+
const ordered = closing ? [closing, ...sent.filter((t) => t !== closing)] : sent;
|
|
575
|
+
const answered = ordered.filter((t) => t !== closing && answeredByAgent(t));
|
|
576
|
+
const actionable = ordered.filter((t) => !answered.includes(t));
|
|
498
577
|
const changed = coverage.changedFiles ?? [];
|
|
499
578
|
const commented = coverage.commentedUnread ?? [];
|
|
500
579
|
const filtered = coverage.filteredOut ?? [];
|
|
@@ -523,11 +602,12 @@ function buildFinishPrompt(threads, ctx, coverage) {
|
|
|
523
602
|
"",
|
|
524
603
|
...owedLines
|
|
525
604
|
];
|
|
605
|
+
if (answered.length > 0) parts.push(`${answered.length} thread${answered.length === 1 ? "" : "s"} you already answered, with nothing new since — no reply owed, not repeated here:`, ...answered.map((t) => `- ${t.id} — ${describeAnchor(t.anchor)}`), "");
|
|
526
606
|
if (coverage.skippedFiles.length > 0) parts.push("The reviewer left the not-marked-read files unread. If any of them hide", "a risky or subtle change you made, say so in a comment thread", `(\`${CLI_COMMANDS.comment}\`) — the reviewer replies to take it up, or resolves it.`, "");
|
|
527
607
|
if (actionable.length === 0) {
|
|
528
608
|
const fullRead = coverage.totalHunks > 0 && coverage.viewedHunks >= coverage.totalHunks && changed.length === 0 && commented.length === 0 && filtered.length === 0 && coverage.skippedFiles.length === 0;
|
|
529
609
|
parts.push(fullRead && coverage.note === void 0 ? "They read everything and left nothing to address — take it as a green light to proceed." : "No open review threads — nothing to act on.", `Run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE});`, "the reviewer may follow up.", "");
|
|
530
|
-
} else parts.push(`${actionable.length} review thread${actionable.length === 1 ? "" : "s"} to act on:`, "", ...actionable.map((t, i) => `${threadBlock(t, i)}\n`), replyProtocol(actionable), "");
|
|
610
|
+
} else parts.push(`${actionable.length} review thread${actionable.length === 1 ? "" : "s"} to act on:`, "", ...actionable.map((t, i) => `${threadBlock(t, i)}\n`), replyProtocol(actionable, ctx.protocol), "");
|
|
531
611
|
return parts.join("\n");
|
|
532
612
|
}
|
|
533
613
|
//#endregion
|
|
@@ -549,6 +629,8 @@ For the reviewer:
|
|
|
549
629
|
For the agent (the AI that wrote the change):
|
|
550
630
|
poll Wait for the reviewer's feedback (blocking long-poll;
|
|
551
631
|
prints one JSON payload; safe to re-run any time)
|
|
632
|
+
(--title "<what the change is>" on the first poll names
|
|
633
|
+
the reviewer's browser tab)
|
|
552
634
|
reply <threadId> Post a reply to a review thread
|
|
553
635
|
(--message "<text>", or pipe the text on stdin)
|
|
554
636
|
comment [<file>] Start a comment thread as the agent — on a line (--line),
|
|
@@ -588,23 +670,36 @@ The loop:
|
|
|
588
670
|
|
|
589
671
|
1. Open: run \`diffo --no-open\` from inside the repo. It returns straight
|
|
590
672
|
away, leaving a background server watching the working tree. Never open a
|
|
591
|
-
browser at the reviewer —
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
673
|
+
browser at the reviewer — share the printed URL instead, the moment it
|
|
674
|
+
prints: a message line right after this command, before the guide, the
|
|
675
|
+
poll, or anything else. Then end your message with it too, and keep
|
|
676
|
+
ending every message with it while you stay attached.
|
|
677
|
+
Attached without ever seeing the URL (a takeover, a fresh session)?
|
|
678
|
+
\`diffo status\` prints it.
|
|
679
|
+
2. Guide — post one only when the changeset needs orientation:
|
|
680
|
+
${GUIDE.when}.
|
|
681
|
+
Right after sharing the URL, while the reviewer opens the page, post ONE
|
|
682
|
+
comment on the whole changeset (\`diffo comment -m "…"\`, no file),
|
|
683
|
+
containing: ${GUIDE.what}.
|
|
684
|
+
${GUIDE.stance}.
|
|
685
|
+
It lands live at the top of their review — never hold the URL back for it.
|
|
686
|
+
If the changeset later shifts under the guide, ${GUIDE.update}.
|
|
687
|
+
3. Listen: run \`${CLI_COMMANDS.firstPoll}\` — it blocks until the reviewer
|
|
688
|
+
acts, then prints one JSON payload naming the threads to act on. Run it
|
|
689
|
+
attended: ${POLL_STANCE}.
|
|
690
|
+
The title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is
|
|
691
|
+
read — ${TAB_TITLE.shape} (${TAB_TITLE.examples}). Send it ${TAB_TITLE.when};
|
|
692
|
+
every other poll is a plain \`diffo poll\`.
|
|
601
693
|
Killed or timed out? Re-run it; feedback is held in the review, not the
|
|
602
694
|
poll.
|
|
603
695
|
4. Act: \`[issue]\` threads want a code change; \`[question]\` threads want an
|
|
604
696
|
answer in the reply and no edit. Your edits reach the reviewer live.
|
|
605
697
|
5. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
|
|
606
698
|
stdin) — concise, addressed to the reviewer. Markdown renders; a
|
|
607
|
-
\`\`\`mermaid fence draws a diagram.
|
|
699
|
+
\`\`\`mermaid fence draws a diagram. A reply that only promises a
|
|
700
|
+
follow-up ("I'll investigate and report back") goes out with \`--more\` —
|
|
701
|
+
the reviewer keeps seeing you at work — and the real answer follows as a
|
|
702
|
+
plain reply before the next poll.
|
|
608
703
|
6. Comment (sparingly): \`diffo comment [<file>] [--line <n>] -m "<text>"\`
|
|
609
704
|
starts a thread in your voice — a concern, or context that helps the read.
|
|
610
705
|
7. Poll again only when the whole batch is handled — a new poll tells the
|
|
@@ -615,11 +710,13 @@ Rules:
|
|
|
615
710
|
- Change only what the threads ask about — the reviewer is mid-read, and an
|
|
616
711
|
unrelated edit moves the diff under them.
|
|
617
712
|
- One attached agent at a time: the newest poll carries the review; don't
|
|
618
|
-
re-poll to take it back from another session — tell the user instead.
|
|
713
|
+
re-poll to take it back from another session — tell the user instead. When
|
|
714
|
+
a poll says it took the review over, or returns status "superseded", say so
|
|
715
|
+
to the user: their feedback moved with it.
|
|
619
716
|
- Resolving a thread is the reviewer's call, never yours.`,
|
|
620
717
|
poll: `diffo poll — wait for the reviewer's feedback
|
|
621
718
|
|
|
622
|
-
Usage: diffo poll
|
|
719
|
+
Usage: diffo poll [--title "<what the change is>"]
|
|
623
720
|
|
|
624
721
|
Blocks (streaming whitespace heartbeats) until the reviewer acts, then prints
|
|
625
722
|
one JSON payload naming the review threads to act on, and exits. Run it
|
|
@@ -628,10 +725,15 @@ payload that reaches a process nobody is listening to never reaches you.
|
|
|
628
725
|
Safe to re-run any time: feedback is held in the review itself, so
|
|
629
726
|
nothing is lost when a poll is killed or times out — the next poll gets it.
|
|
630
727
|
|
|
728
|
+
--title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is read —
|
|
729
|
+
${TAB_TITLE.shape} (${TAB_TITLE.examples}). Send it ${TAB_TITLE.when}; the
|
|
730
|
+
newest title wins, and a poll without one leaves the name it finds alone.
|
|
731
|
+
|
|
631
732
|
Output: one JSON object, e.g.
|
|
632
733
|
{"status":"feedback","threadIds":["t-3"],"prompt":"…what to do…"}
|
|
633
734
|
|
|
634
|
-
|
|
735
|
+
Examples:
|
|
736
|
+
diffo poll --title "tab titles from the agent"
|
|
635
737
|
diffo poll`,
|
|
636
738
|
reply: `diffo reply — post a reply to a review thread
|
|
637
739
|
|
|
@@ -640,6 +742,10 @@ Usage: diffo reply <threadId> --message "<text>"
|
|
|
640
742
|
|
|
641
743
|
Thread ids arrive in poll payloads. Each run posts one message, so don't
|
|
642
744
|
re-run a reply that succeeded.
|
|
745
|
+
--more marks the reply as interim: you are still working, and a follow-up
|
|
746
|
+
reply on this thread is promised. The reviewer keeps seeing the thread as
|
|
747
|
+
"with the agent" until your next plain reply; going quiet instead marks it
|
|
748
|
+
unanswered.
|
|
643
749
|
Messages render GitHub-flavored markdown; a \`\`\`mermaid fence renders as a
|
|
644
750
|
diagram in the review.
|
|
645
751
|
|
|
@@ -878,6 +984,8 @@ function parseVerb(verb, rest) {
|
|
|
878
984
|
short: "m"
|
|
879
985
|
},
|
|
880
986
|
line: { type: "string" },
|
|
987
|
+
title: { type: "string" },
|
|
988
|
+
more: { type: "boolean" },
|
|
881
989
|
json: { type: "boolean" },
|
|
882
990
|
help: {
|
|
883
991
|
type: "boolean",
|
|
@@ -898,6 +1006,14 @@ function parseVerb(verb, rest) {
|
|
|
898
1006
|
kind: "error",
|
|
899
1007
|
message: `'${verb}' takes no --json`
|
|
900
1008
|
};
|
|
1009
|
+
if (values.more && verb !== "reply") return {
|
|
1010
|
+
kind: "error",
|
|
1011
|
+
message: `'${verb}' takes no --more`
|
|
1012
|
+
};
|
|
1013
|
+
if (values.title !== void 0 && verb !== "poll") return {
|
|
1014
|
+
kind: "error",
|
|
1015
|
+
message: `'${verb}' takes no --title`
|
|
1016
|
+
};
|
|
901
1017
|
if (verb === "poll" || verb === "end" || verb === "setup" || verb === "status" || verb === "stop") {
|
|
902
1018
|
if (positionals.length > 0) return {
|
|
903
1019
|
kind: "error",
|
|
@@ -911,6 +1027,17 @@ function parseVerb(verb, rest) {
|
|
|
911
1027
|
kind: "status",
|
|
912
1028
|
json: values.json === true
|
|
913
1029
|
};
|
|
1030
|
+
if (verb === "poll") {
|
|
1031
|
+
const title = normalizeTitle(values.title);
|
|
1032
|
+
if (values.title !== void 0 && title === null) return {
|
|
1033
|
+
kind: "error",
|
|
1034
|
+
message: "--title needs a few words naming the change"
|
|
1035
|
+
};
|
|
1036
|
+
return {
|
|
1037
|
+
kind: "poll",
|
|
1038
|
+
title
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
914
1041
|
return { kind: verb };
|
|
915
1042
|
}
|
|
916
1043
|
if (verb === "reply") {
|
|
@@ -930,7 +1057,8 @@ function parseVerb(verb, rest) {
|
|
|
930
1057
|
return {
|
|
931
1058
|
kind: "reply",
|
|
932
1059
|
threadId,
|
|
933
|
-
message: values.message ?? null
|
|
1060
|
+
message: values.message ?? null,
|
|
1061
|
+
more: values.more === true
|
|
934
1062
|
};
|
|
935
1063
|
}
|
|
936
1064
|
const file = positionals[0] ?? null;
|
|
@@ -1531,7 +1659,8 @@ var DiffoDb = class {
|
|
|
1531
1659
|
const known = /* @__PURE__ */ new Set([
|
|
1532
1660
|
"reviews",
|
|
1533
1661
|
"servers",
|
|
1534
|
-
"repo_ports"
|
|
1662
|
+
"repo_ports",
|
|
1663
|
+
"ui_settings"
|
|
1535
1664
|
]);
|
|
1536
1665
|
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
1537
1666
|
for (const { name } of tables) if (!known.has(name)) this.db.exec(`DROP TABLE IF EXISTS "${name.replaceAll("\"", "\"\"")}"`);
|
|
@@ -1556,6 +1685,10 @@ var DiffoDb = class {
|
|
|
1556
1685
|
repo_path TEXT PRIMARY KEY,
|
|
1557
1686
|
port INTEGER NOT NULL
|
|
1558
1687
|
);
|
|
1688
|
+
CREATE TABLE IF NOT EXISTS ui_settings (
|
|
1689
|
+
key TEXT PRIMARY KEY,
|
|
1690
|
+
value TEXT NOT NULL
|
|
1691
|
+
);
|
|
1559
1692
|
PRAGMA user_version = ${SCHEMA_VERSION};
|
|
1560
1693
|
`);
|
|
1561
1694
|
this.pruneVanishedRepos();
|
|
@@ -1631,6 +1764,16 @@ var DiffoDb = class {
|
|
|
1631
1764
|
this.db.prepare(`INSERT INTO repo_ports (repo_path, port) VALUES (?, ?)
|
|
1632
1765
|
ON CONFLICT(repo_path) DO UPDATE SET port = excluded.port`).run(repoPath, port);
|
|
1633
1766
|
}
|
|
1767
|
+
/** Reviewer preferences that must outlive one server's origin — every repo is
|
|
1768
|
+
* served from its own port, so localStorage alone can't hold a choice like
|
|
1769
|
+
* the theme. Keyed blobs, no scope: these are per-human, not per-repo. */
|
|
1770
|
+
getUiSetting(key) {
|
|
1771
|
+
return this.db.prepare("SELECT value FROM ui_settings WHERE key = ?").get(key)?.value ?? null;
|
|
1772
|
+
}
|
|
1773
|
+
setUiSetting(key, value) {
|
|
1774
|
+
this.db.prepare(`INSERT INTO ui_settings (key, value) VALUES (?, ?)
|
|
1775
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value);
|
|
1776
|
+
}
|
|
1634
1777
|
getServer(repoPath) {
|
|
1635
1778
|
const row = this.db.prepare("SELECT port, pid, started_at FROM servers WHERE repo_path = ?").get(repoPath);
|
|
1636
1779
|
return row ? {
|
|
@@ -1710,6 +1853,11 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
1710
1853
|
reason: "no-agent"
|
|
1711
1854
|
};
|
|
1712
1855
|
owner = null;
|
|
1856
|
+
/** The session pid that last received the full reply protocol. Later
|
|
1857
|
+
* deliveries to the same session carry the compact form — the full text is
|
|
1858
|
+
* the loop's largest recurring token cost. A takeover (new pid) or an
|
|
1859
|
+
* anonymous session (null pid) always gets the full form again. */
|
|
1860
|
+
protocolPid = null;
|
|
1713
1861
|
batch = null;
|
|
1714
1862
|
batchWatch = null;
|
|
1715
1863
|
waiterWatch = null;
|
|
@@ -1770,6 +1918,12 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
1770
1918
|
ownerPid() {
|
|
1771
1919
|
return this.ownerConnected() ? this.owner : null;
|
|
1772
1920
|
}
|
|
1921
|
+
needsFullProtocol() {
|
|
1922
|
+
return this.owner === null || this.owner !== this.protocolPid;
|
|
1923
|
+
}
|
|
1924
|
+
markProtocolSent() {
|
|
1925
|
+
this.protocolPid = this.owner;
|
|
1926
|
+
}
|
|
1773
1927
|
/**
|
|
1774
1928
|
* A poll attaches for its session; the newest poll always carries the review.
|
|
1775
1929
|
* Returns the pid of a still-live session it took the review from, so both
|
|
@@ -2045,10 +2199,19 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
2045
2199
|
this.notify();
|
|
2046
2200
|
}
|
|
2047
2201
|
/** The agent replied on a thread. Returns how long since that thread's feedback
|
|
2048
|
-
* was delivered, or null when the reply wasn't an answer to a delivery.
|
|
2049
|
-
|
|
2202
|
+
* was delivered, or null when the reply wasn't an answer to a delivery.
|
|
2203
|
+
* `interim` (a `--more` reply) keeps the thread on the clock: a follow-up is
|
|
2204
|
+
* still owed, so the spinner stays and a batch that closes without the
|
|
2205
|
+
* follow-up counts the thread unanswered. */
|
|
2206
|
+
agentReplied(threadId, interim = false) {
|
|
2050
2207
|
const at = this.deliveredAt.get(threadId);
|
|
2051
|
-
this.deliveredAt.delete(threadId);
|
|
2208
|
+
if (!interim) this.deliveredAt.delete(threadId);
|
|
2209
|
+
else if (at === void 0 && this.batch) {
|
|
2210
|
+
this.deliveredAt.set(threadId, Date.now());
|
|
2211
|
+
if (!this.batch.threadIds.includes(threadId)) this.batch.threadIds.push(threadId);
|
|
2212
|
+
this.awaitingReply = true;
|
|
2213
|
+
this.clearGrace();
|
|
2214
|
+
}
|
|
2052
2215
|
if (this.batch) this.batch.sawReply = true;
|
|
2053
2216
|
if (this.deliveredAt.size > 0) {
|
|
2054
2217
|
if (this.awaitingReply) {
|
|
@@ -2328,7 +2491,7 @@ var ReviewStore = class {
|
|
|
2328
2491
|
* the reviewer raced in since — appended at the end, it would render as an
|
|
2329
2492
|
* answer to a comment it never saw, and the raced comment would stop reading
|
|
2330
2493
|
* as "waiting on the agent" even though it still is. */
|
|
2331
|
-
addMessage(threadId, author, text, withheld = false, seenThroughMs) {
|
|
2494
|
+
addMessage(threadId, author, text, withheld = false, seenThroughMs, followUp = false) {
|
|
2332
2495
|
return this.update(threadId, ({ unanswered: _answered, ...thread }) => {
|
|
2333
2496
|
const message = {
|
|
2334
2497
|
id: randomUUID(),
|
|
@@ -2336,9 +2499,11 @@ var ReviewStore = class {
|
|
|
2336
2499
|
text,
|
|
2337
2500
|
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2338
2501
|
};
|
|
2339
|
-
const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => Date.parse(m.at) > seenThroughMs) : -1;
|
|
2502
|
+
const raced = author === "agent" && seenThroughMs !== void 0 ? thread.messages.findIndex((m) => m.author === "reviewer" && Date.parse(m.at) > seenThroughMs) : -1;
|
|
2503
|
+
const { awaitingFollowUp: _promised, ...settled } = thread;
|
|
2340
2504
|
return {
|
|
2341
|
-
...thread,
|
|
2505
|
+
...author === "agent" ? settled : thread,
|
|
2506
|
+
...author === "agent" && followUp ? { awaitingFollowUp: true } : {},
|
|
2342
2507
|
...withheld && author === "reviewer" ? { withheld: true } : {},
|
|
2343
2508
|
messages: raced === -1 ? [...thread.messages, message] : [
|
|
2344
2509
|
...thread.messages.slice(0, raced),
|
|
@@ -2387,7 +2552,7 @@ var ReviewStore = class {
|
|
|
2387
2552
|
const threads = this.state.threads.map((thread) => {
|
|
2388
2553
|
if (!wanted.has(thread.id) || !applies(thread)) return thread;
|
|
2389
2554
|
changed = true;
|
|
2390
|
-
const { unanswered: _was, ...rest } = thread;
|
|
2555
|
+
const { unanswered: _was, awaitingFollowUp: _promised, ...rest } = thread;
|
|
2391
2556
|
return thread.unanswered ? rest : {
|
|
2392
2557
|
...rest,
|
|
2393
2558
|
unanswered: true
|
|
@@ -2401,8 +2566,9 @@ var ReviewStore = class {
|
|
|
2401
2566
|
this.commit();
|
|
2402
2567
|
}
|
|
2403
2568
|
setState(threadId, state) {
|
|
2404
|
-
return this.update(threadId, (thread) => ({
|
|
2569
|
+
return this.update(threadId, ({ awaitingFollowUp: promised, ...thread }) => ({
|
|
2405
2570
|
...thread,
|
|
2571
|
+
...promised && state !== "resolved" ? { awaitingFollowUp: true } : {},
|
|
2406
2572
|
state,
|
|
2407
2573
|
...state === "sent" && !thread.sentAt ? { sentAt: (/* @__PURE__ */ new Date()).toISOString() } : {},
|
|
2408
2574
|
...state === "resolved" ? { codeContext: null } : {}
|
|
@@ -2419,16 +2585,18 @@ var ReviewStore = class {
|
|
|
2419
2585
|
return true;
|
|
2420
2586
|
}
|
|
2421
2587
|
/**
|
|
2422
|
-
* Start the review over: threads, the last-finish record,
|
|
2423
|
-
*
|
|
2424
|
-
* ids from the dead changeset into the next review's "since last
|
|
2425
|
-
* lens, reporting everything as new
|
|
2588
|
+
* Start the review over: threads, the last-finish record, the landed marker,
|
|
2589
|
+
* and the tab title all go. Not just the threads — a kept `lastFinish` would
|
|
2590
|
+
* carry hunk ids from the dead changeset into the next review's "since last
|
|
2591
|
+
* review" lens, reporting everything as new, and a kept title would name the
|
|
2592
|
+
* work that just ended. The cleared agent is woken (see the DELETE route), so
|
|
2593
|
+
* its next poll names the new round. `seenHead` survives: it describes the
|
|
2426
2594
|
* repo, not the review being discarded.
|
|
2427
2595
|
*/
|
|
2428
2596
|
reset() {
|
|
2429
2597
|
const ids = this.state.threads.map((t) => t.id);
|
|
2430
|
-
if (ids.length === 0 && !this.state.lastFinish && !this.state.landed) return [];
|
|
2431
|
-
const { lastFinish: _finish, landed: _landed, ...rest } = this.state;
|
|
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;
|
|
2432
2600
|
this.state = {
|
|
2433
2601
|
...rest,
|
|
2434
2602
|
threads: []
|
|
@@ -2436,6 +2604,16 @@ var ReviewStore = class {
|
|
|
2436
2604
|
this.commit();
|
|
2437
2605
|
return ids;
|
|
2438
2606
|
}
|
|
2607
|
+
/** The agent's name for this changeset, carried by its poll. The newest one
|
|
2608
|
+
* wins: a change that grows a second subject renames its own tab. */
|
|
2609
|
+
setTitle(title) {
|
|
2610
|
+
if (this.state.title === title) return;
|
|
2611
|
+
this.state = {
|
|
2612
|
+
...this.state,
|
|
2613
|
+
title
|
|
2614
|
+
};
|
|
2615
|
+
this.commit();
|
|
2616
|
+
}
|
|
2439
2617
|
/** The base the work under review sits on — see `ReviewState.seenHead` for
|
|
2440
2618
|
* when the caller must NOT move it. */
|
|
2441
2619
|
noteHead(sha) {
|
|
@@ -2606,9 +2784,11 @@ function parseReview(raw) {
|
|
|
2606
2784
|
const migrated = Array.isArray(parsed.suggestions) ? parsed.suggestions.map((s) => migrateLegacySuggestion(s, now)).filter((t) => t !== null) : [];
|
|
2607
2785
|
const lastFinish = normalizeLastFinish(parsed.lastFinish, now);
|
|
2608
2786
|
const landed = normalizeLanded(parsed.landed, now);
|
|
2787
|
+
const title = normalizeTitle(parsed.title);
|
|
2609
2788
|
return {
|
|
2610
2789
|
version: 1,
|
|
2611
2790
|
threads: [...valid, ...migrated],
|
|
2791
|
+
...title ? { title } : {},
|
|
2612
2792
|
...lastFinish ? { lastFinish } : {},
|
|
2613
2793
|
...typeof parsed.seenHead === "string" && parsed.seenHead !== "" ? { seenHead: parsed.seenHead } : {},
|
|
2614
2794
|
...landed ? { landed } : {}
|
|
@@ -2746,6 +2926,7 @@ function normalizeThread(value, now) {
|
|
|
2746
2926
|
...anchored ? { anchored } : {},
|
|
2747
2927
|
codeChanged: t.codeChanged === true,
|
|
2748
2928
|
...t.unanswered === true ? { unanswered: true } : {},
|
|
2929
|
+
...t.awaitingFollowUp === true && t.unanswered !== true ? { awaitingFollowUp: true } : {},
|
|
2749
2930
|
...t.closingNote === true ? { closingNote: true } : {},
|
|
2750
2931
|
...typeof t.sentAt === "string" ? { sentAt: t.sentAt } : {},
|
|
2751
2932
|
messages,
|
|
@@ -3231,6 +3412,22 @@ function createApp(ctx, store, review, queue) {
|
|
|
3231
3412
|
note: "shutting down"
|
|
3232
3413
|
});
|
|
3233
3414
|
});
|
|
3415
|
+
const THEME_VALUES = /* @__PURE__ */ new Set([
|
|
3416
|
+
"system",
|
|
3417
|
+
"light",
|
|
3418
|
+
"dark"
|
|
3419
|
+
]);
|
|
3420
|
+
app.get("/api/settings", (c) => {
|
|
3421
|
+
const theme = ctx.uiSettings?.get("theme") ?? null;
|
|
3422
|
+
return c.json({ theme: theme !== null && THEME_VALUES.has(theme) ? theme : null });
|
|
3423
|
+
});
|
|
3424
|
+
app.put("/api/settings", async (c) => {
|
|
3425
|
+
if (!ctx.uiSettings) return c.json({ error: "settings unavailable" }, 503);
|
|
3426
|
+
const theme = (await c.req.json().catch(() => null))?.theme;
|
|
3427
|
+
if (typeof theme !== "string" || !THEME_VALUES.has(theme)) return c.json({ error: "need {theme: \"system\" | \"light\" | \"dark\"}" }, 400);
|
|
3428
|
+
ctx.uiSettings.set("theme", theme);
|
|
3429
|
+
return c.json({ ok: true });
|
|
3430
|
+
});
|
|
3234
3431
|
const repoInfo = () => store?.get().repo ?? {
|
|
3235
3432
|
path: resolve(ctx.root),
|
|
3236
3433
|
name: "",
|
|
@@ -3298,9 +3495,10 @@ function createApp(ctx, store, review, queue) {
|
|
|
3298
3495
|
const id = c.req.param("id");
|
|
3299
3496
|
if (!review.get().threads.some((t) => t.id === id)) return c.json({ error: "no such thread" }, 404);
|
|
3300
3497
|
queue?.noteSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
|
|
3301
|
-
const
|
|
3498
|
+
const more = body?.more === true;
|
|
3499
|
+
const waitedMs = queue?.agentReplied(id, more) ?? null;
|
|
3302
3500
|
const seenThroughMs = waitedMs === null ? void 0 : Date.now() - waitedMs;
|
|
3303
|
-
const thread = review.addMessage(id, "agent", text, false, seenThroughMs);
|
|
3501
|
+
const thread = review.addMessage(id, "agent", text, false, seenThroughMs, more);
|
|
3304
3502
|
if (!thread) return c.json({ error: "no such thread" }, 404);
|
|
3305
3503
|
if (waitedMs !== null) review.annotateAgentReplies([thread.id], waitedMs);
|
|
3306
3504
|
return c.json({
|
|
@@ -3311,6 +3509,7 @@ function createApp(ctx, store, review, queue) {
|
|
|
3311
3509
|
const deliver = body?.deliver !== false;
|
|
3312
3510
|
let thread = review.addMessage(c.req.param("id"), "reviewer", text, !deliver);
|
|
3313
3511
|
if (!thread) return c.json({ error: "no such thread" }, 404);
|
|
3512
|
+
if (deliver && thread.state === "open" && startedByAgent(thread)) thread = review.send(thread.id) ?? thread;
|
|
3314
3513
|
let delivered = false;
|
|
3315
3514
|
if (deliver && (thread.state === "sent" || thread.state === "addressed")) {
|
|
3316
3515
|
delivered = deliverThreads([thread.id]);
|
|
@@ -3360,8 +3559,9 @@ function createApp(ctx, store, review, queue) {
|
|
|
3360
3559
|
const handedOver = thread.state === "sent" || thread.state === "addressed" && thread.withheld === true;
|
|
3361
3560
|
const delivered = handedOver ? deliverThreads([thread.id]) : false;
|
|
3362
3561
|
if (handedOver) review.clearWithheld([thread.id]);
|
|
3562
|
+
const current = review.get().threads.find((t) => t.id === thread.id) ?? thread;
|
|
3363
3563
|
return c.json({
|
|
3364
|
-
thread,
|
|
3564
|
+
thread: current,
|
|
3365
3565
|
prompt,
|
|
3366
3566
|
delivered,
|
|
3367
3567
|
presence
|
|
@@ -3479,6 +3679,7 @@ function createApp(ctx, store, review, queue) {
|
|
|
3479
3679
|
});
|
|
3480
3680
|
const pollPayload = (snapshot) => {
|
|
3481
3681
|
const threads = review?.get().threads ?? [];
|
|
3682
|
+
const protocol = queue?.needsFullProtocol() === false ? "compact" : void 0;
|
|
3482
3683
|
if (snapshot.kind === "cleared") return {
|
|
3483
3684
|
status: "feedback",
|
|
3484
3685
|
kind: "cleared",
|
|
@@ -3493,15 +3694,22 @@ function createApp(ctx, store, review, queue) {
|
|
|
3493
3694
|
return {
|
|
3494
3695
|
status: "feedback",
|
|
3495
3696
|
kind: "finish",
|
|
3496
|
-
threadIds: batch.filter((t) => t.state === "sent" && t
|
|
3697
|
+
threadIds: batch.filter((t) => t.state === "sent" && !answeredByAgent(t)).map((t) => t.id),
|
|
3497
3698
|
prompt: buildFinishPrompt(batch, {
|
|
3498
3699
|
repo: repoInfo(),
|
|
3499
|
-
changeset: store?.get() ?? null
|
|
3700
|
+
changeset: store?.get() ?? null,
|
|
3701
|
+
protocol
|
|
3500
3702
|
}, snapshot.coverage)
|
|
3501
3703
|
};
|
|
3502
3704
|
}
|
|
3503
3705
|
const delivered = threads.filter((t) => snapshot.threadIds.includes(t.id));
|
|
3504
|
-
const prompt = delivered.length === 1 ? buildThreadPrompt(delivered[0],
|
|
3706
|
+
const prompt = delivered.length === 1 ? buildThreadPrompt(delivered[0], {
|
|
3707
|
+
...promptCtx(delivered[0].id),
|
|
3708
|
+
protocol
|
|
3709
|
+
}) : buildCoalescedPrompt(delivered, {
|
|
3710
|
+
...promptCtx(...snapshot.threadIds),
|
|
3711
|
+
protocol
|
|
3712
|
+
});
|
|
3505
3713
|
return {
|
|
3506
3714
|
status: "feedback",
|
|
3507
3715
|
kind: "threads",
|
|
@@ -3514,6 +3722,8 @@ function createApp(ctx, store, review, queue) {
|
|
|
3514
3722
|
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);
|
|
3515
3723
|
const tookOverFrom = queue.claimSession(parseSessionPid(c.req.header("x-diffo-session-pid")));
|
|
3516
3724
|
if (tookOverFrom !== null) c.header("x-diffo-took-over-from", String(tookOverFrom));
|
|
3725
|
+
const title = normalizeTitle(c.req.query("title"));
|
|
3726
|
+
if (title) review.setTitle(title);
|
|
3517
3727
|
const heartbeatMs = ctx.pollHeartbeatMs ?? POLL_HEARTBEAT_MS;
|
|
3518
3728
|
return stream(c, async (s) => {
|
|
3519
3729
|
let aborted = false;
|
|
@@ -3579,6 +3789,7 @@ function createApp(ctx, store, review, queue) {
|
|
|
3579
3789
|
await s.write(JSON.stringify(payload));
|
|
3580
3790
|
if (aborted) return;
|
|
3581
3791
|
queue.confirm(snapshot, payload.threadIds);
|
|
3792
|
+
if (payload.threadIds.length > 0) queue.markProtocolSent();
|
|
3582
3793
|
review.markDelivered(payload.threadIds);
|
|
3583
3794
|
review.clearUnanswered(payload.threadIds);
|
|
3584
3795
|
if (snapshot.kind === "finish") review.markFinishCollected();
|
|
@@ -3737,6 +3948,8 @@ function markDevIndex(html, isDev) {
|
|
|
3737
3948
|
return html.replace(/<title>[^<]*<\/title>/, "<title>diffo-dev</title>").replace(/<\/head>/, " <meta name=\"diffo-env\" content=\"development\" />\n </head>");
|
|
3738
3949
|
}
|
|
3739
3950
|
function rehydrateQueue(review, queue) {
|
|
3951
|
+
const promised = review.get().threads.filter((t) => t.awaitingFollowUp === true).map((t) => t.id);
|
|
3952
|
+
if (promised.length > 0) review.markUnanswered(promised);
|
|
3740
3953
|
const state = review.get();
|
|
3741
3954
|
queue.enqueueThreads(undeliveredThreadIds(state.threads));
|
|
3742
3955
|
const finish = state.lastFinish;
|
|
@@ -3808,7 +4021,11 @@ function startServer(options) {
|
|
|
3808
4021
|
const app = createApp({
|
|
3809
4022
|
...options,
|
|
3810
4023
|
idle,
|
|
3811
|
-
onShutdownRequest: options.onShutdownRequest ?? (() => process.exit(0))
|
|
4024
|
+
onShutdownRequest: options.onShutdownRequest ?? (() => process.exit(0)),
|
|
4025
|
+
uiSettings: {
|
|
4026
|
+
get: (key) => db.getUiSetting(key),
|
|
4027
|
+
set: (key, value) => db.setUiSetting(key, value)
|
|
4028
|
+
}
|
|
3812
4029
|
}, store, review, queue);
|
|
3813
4030
|
const server = serve({
|
|
3814
4031
|
fetch: app.fetch,
|
|
@@ -4631,9 +4848,10 @@ if (command.kind === "stop") {
|
|
|
4631
4848
|
}
|
|
4632
4849
|
if (command.kind === "poll") {
|
|
4633
4850
|
const port = await requireServer();
|
|
4634
|
-
process.stderr.write("diffo: waiting for the reviewer — keep this process attended: a tracked\nbackground task or the foreground, never detached
|
|
4851
|
+
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");
|
|
4635
4852
|
try {
|
|
4636
|
-
const
|
|
4853
|
+
const path = command.title === null ? "/api/agent/poll" : `/api/agent/poll?title=${encodeURIComponent(command.title)}`;
|
|
4854
|
+
const res = await fetch(apiUrl(port, path), { headers: sessionHeaders() });
|
|
4637
4855
|
if (!res.ok) fail(`poll failed (${res.status})`);
|
|
4638
4856
|
const tookOverFrom = res.headers.get("x-diffo-took-over-from");
|
|
4639
4857
|
if (tookOverFrom) {
|
|
@@ -4654,7 +4872,8 @@ if (command.kind === "reply") {
|
|
|
4654
4872
|
if (!message) fail("reply needs a message — pass --message \"<text>\" or pipe it on stdin");
|
|
4655
4873
|
const { status, body } = await postJson(await requireServer(), `/api/review/threads/${encodeURIComponent(command.threadId)}/messages`, {
|
|
4656
4874
|
author: "agent",
|
|
4657
|
-
text: message
|
|
4875
|
+
text: message,
|
|
4876
|
+
...command.more ? { more: true } : {}
|
|
4658
4877
|
});
|
|
4659
4878
|
if (status === 404) fail(`no thread with id '${command.threadId}'`);
|
|
4660
4879
|
if (status !== 200) fail(`reply failed (${status})`);
|
|
@@ -4663,7 +4882,7 @@ if (command.kind === "reply") {
|
|
|
4663
4882
|
ok: true,
|
|
4664
4883
|
threadId: thread.id,
|
|
4665
4884
|
state: thread.state,
|
|
4666
|
-
next_step: ACK_NEXT_STEP.reply
|
|
4885
|
+
next_step: command.more ? ACK_NEXT_STEP.replyMore : ACK_NEXT_STEP.reply
|
|
4667
4886
|
}));
|
|
4668
4887
|
process.exit(0);
|
|
4669
4888
|
}
|
|
@@ -4711,7 +4930,7 @@ async function printAgentNextStep(port) {
|
|
|
4711
4930
|
const review = await fetchReviewState(port);
|
|
4712
4931
|
const nudge = review ? guideNudge(findGuideThread(review) !== void 0) : null;
|
|
4713
4932
|
if (nudge) console.log(`first: ${nudge}`);
|
|
4714
|
-
console.log(
|
|
4933
|
+
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)`);
|
|
4715
4934
|
}
|
|
4716
4935
|
/**
|
|
4717
4936
|
* Dev builds serve the client from the checkout's `dist/client`, which nothing
|