@diffohq/diffo 0.3.0 → 0.4.1
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 +524 -13
- package/dist/client/assets/{abnfDiagram-O67JEVCF-DdDBSVZ4.js → abnfDiagram-O67JEVCF-b6l_FGHi.js} +1 -1
- package/dist/client/assets/{arc-BgNb6rn9.js → arc-Cz0jZwLH.js} +1 -1
- package/dist/client/assets/architecture-7GRP2DOG-BXYREMFQ.js +1 -0
- package/dist/client/assets/{architectureDiagram-NJMV4G6O-HNLU6Z_V.js → architectureDiagram-NJMV4G6O-CJdtqKce.js} +1 -1
- package/dist/client/assets/{blockDiagram-BEXU5L5S-DO434Dqm.js → blockDiagram-BEXU5L5S-Ci-lnLGN.js} +1 -1
- package/dist/client/assets/{c4Diagram-YGBWAQC7-CkhXDWcc.js → c4Diagram-YGBWAQC7-DDO2mTMB.js} +1 -1
- package/dist/client/assets/channel-CtvNwuQX.js +1 -0
- package/dist/client/assets/{chunk-3FUC2YCW-DTfKImVL.js → chunk-3FUC2YCW-VyswqFt3.js} +1 -1
- package/dist/client/assets/{chunk-5DYCD2WN-9Pj_Xuzv.js → chunk-5DYCD2WN-B4MBZW5P.js} +1 -1
- package/dist/client/assets/{chunk-742MDFTN-DRQNNbBo.js → chunk-742MDFTN-Cxvmvrid.js} +1 -1
- package/dist/client/assets/{chunk-7INBJB4K-BqnHF3P0.js → chunk-7INBJB4K-DkBnRFbH.js} +1 -1
- package/dist/client/assets/{chunk-7M6MHVWA-NTwtPxj_.js → chunk-7M6MHVWA-BvJgBwEi.js} +1 -1
- package/dist/client/assets/{chunk-7PRAP22T-reFtaiUY.js → chunk-7PRAP22T-OfiMNi_y.js} +1 -1
- package/dist/client/assets/{chunk-GTNCS2PH-BbSQqqTd.js → chunk-GTNCS2PH-BodN5cRQ.js} +1 -1
- package/dist/client/assets/{chunk-GWA4HPMP-B3OAiKhC.js → chunk-GWA4HPMP-KifxxZcy.js} +1 -1
- package/dist/client/assets/{chunk-MBY4JIJT-CNIWhzdY.js → chunk-MBY4JIJT-rw02bHlj.js} +1 -1
- package/dist/client/assets/{chunk-NETBCI7D-Cu2GMg5A.js → chunk-NETBCI7D-CWjhZoou.js} +1 -1
- package/dist/client/assets/{chunk-O7XYJQB3-C616frX3.js → chunk-O7XYJQB3-CyxnSfZ3.js} +1 -1
- package/dist/client/assets/{chunk-UA2S7LBM-_7jUrSu1.js → chunk-UA2S7LBM-BReIFgsu.js} +1 -1
- package/dist/client/assets/{chunk-WEXAMYUT--WmuTjt8.js → chunk-WEXAMYUT-DHwMy8v7.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-CH68Fn6G.js → chunk-XXDRQBXY-C398QZbS.js} +1 -1
- package/dist/client/assets/{chunk-Z7XXMR3K-CMGWkbdd.js → chunk-Z7XXMR3K-hEV1GU64.js} +1 -1
- package/dist/client/assets/{chunk-ZIGJFQKS-Bupo8UEA.js → chunk-ZIGJFQKS-DJJL7llO.js} +1 -1
- package/dist/client/assets/{classDiagram-v2-NBCMYWYE-BlYlsN2U.js → classDiagram-v2-NBCMYWYE-pSGZI880.js} +1 -1
- package/dist/client/assets/{core-BuHBGQx-.js → core-C05I4wHV.js} +1 -1
- package/dist/client/assets/{cose-bilkent-JH36ORCC-BtFdPq63.js → cose-bilkent-JH36ORCC-DNV8Dqd8.js} +1 -1
- package/dist/client/assets/{cynefin-OW5HDTMX-DQ0j2-fq.js → cynefin-OW5HDTMX-DF-6FOgS.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-VND7K2PF-DnctQE5u.js → cynefinDiagram-VND7K2PF-D3-iJq2J.js} +1 -1
- package/dist/client/assets/{dagre-6A5THRUB-u940AeXp.js → dagre-6A5THRUB-C9FDZkaa.js} +1 -1
- package/dist/client/assets/{diagram-22UHCM2B-B1jzZKtP.js → diagram-22UHCM2B-CfSJBDyN.js} +1 -1
- package/dist/client/assets/{diagram-3UASUU5V-BY_6wxmf.js → diagram-3UASUU5V-D946baYo.js} +1 -1
- package/dist/client/assets/{diagram-ATOU4E4O-CTsYtnJJ.js → diagram-ATOU4E4O-CGrjukoA.js} +1 -1
- package/dist/client/assets/{diagram-CDSNMT55-DuEJO9EQ.js → diagram-CDSNMT55-B47UPtWq.js} +1 -1
- package/dist/client/assets/{diagram-MLGK6HIB-CZ10U2Mk.js → diagram-MLGK6HIB-D06yEd5R.js} +1 -1
- package/dist/client/assets/{diagram-MPIPVDR6-CkO6cJlF.js → diagram-MPIPVDR6-BRUBT2B9.js} +1 -1
- package/dist/client/assets/{dist-DkqXbsqN.js → dist-BfTRyLGm.js} +1 -1
- package/dist/client/assets/{dist-DFkgQY1o.js → dist-DVw88SBM.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-ZINNZB2B-BJtnMhOp.js → ebnfDiagram-ZINNZB2B-BnuExfrd.js} +1 -1
- package/dist/client/assets/{elk-276RUBZZ-CShSerV4.js → elk-276RUBZZ-BmdcLjR9.js} +1 -1
- package/dist/client/assets/{engine-oniguruma-DDtqknzG.js → engine-oniguruma-Bw1dOuOO.js} +1 -1
- package/dist/client/assets/{erDiagram-OPXOYQCR-2VvQxgaY.js → erDiagram-OPXOYQCR-CKCR3jQT.js} +1 -1
- 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-FUAMR5RP-hQJ3ga2L.js → ganttDiagram-FUAMR5RP-BwkoweG7.js} +1 -1
- package/dist/client/assets/{gitGraph-4MIJSDKK-CVt38-tW.js → gitGraph-4MIJSDKK-M7_hHVyH.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-X574FWY7-X6_FAtp_.js → gitGraphDiagram-X574FWY7-BVm_sLT_.js} +1 -1
- package/dist/client/assets/index-BEG1ZxrX.css +1 -0
- package/dist/client/assets/index-BwDjlzOZ.js +91 -0
- package/dist/client/assets/{info-A6RAGUB7-CsIS2T8y.js → info-A6RAGUB7-DPBs3m_X.js} +1 -1
- package/dist/client/assets/{infoDiagram-VRGFBTTK-CNesth4F.js → infoDiagram-VRGFBTTK-DDb3KyOP.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-OU5B5YK6-C3uowhy1.js → ishikawaDiagram-OU5B5YK6-CPt4RDlr.js} +1 -1
- package/dist/client/assets/{journeyDiagram-ZHPQQLJL-Bti7zdNB.js → journeyDiagram-ZHPQQLJL-ECQSHJ0N.js} +1 -1
- package/dist/client/assets/{kanban-definition-PNTS6WVX-Co13mJCW.js → kanban-definition-PNTS6WVX-tAmonxA8.js} +1 -1
- package/dist/client/assets/{line-CodMwzVg.js → line-x6vAh41a.js} +1 -1
- package/dist/client/assets/{linear-D9CKVpWI.js → linear-BqczUNss.js} +1 -1
- package/dist/client/assets/{mermaid-parser.core-OY76TKUN.js → mermaid-parser.core-siQPZsry.js} +3 -3
- package/dist/client/assets/{mermaid.core-DWE2Tn2R.js → mermaid.core-CLHTQVF-.js} +4 -4
- package/dist/client/assets/{mindmap-definition-NLK3R4M7-DJ3MEvZg.js → mindmap-definition-NLK3R4M7-C9fnEdyl.js} +1 -1
- package/dist/client/assets/{packet-AYTQ26CC-CW1j_hFQ.js → packet-AYTQ26CC-DAeK0Q4n.js} +1 -1
- package/dist/client/assets/{pegDiagram-GJSIUBJH-hOvLvBiF.js → pegDiagram-GJSIUBJH-BrWBFYPO.js} +1 -1
- package/dist/client/assets/{pie-WAS4IAKB-D1KPtuKA.js → pie-WAS4IAKB-CnieihkO.js} +1 -1
- package/dist/client/assets/{pieDiagram-5QR66LMP-CJN1M2lG.js → pieDiagram-5QR66LMP-Bp5f6Eal.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-O4NWA36T-DOysLQ6R.js → quadrantDiagram-O4NWA36T-ECuLA3RS.js} +1 -1
- package/dist/client/assets/{radar-RG4KPBEZ-B7XCBnIS.js → radar-RG4KPBEZ-BJXkERy0.js} +1 -1
- package/dist/client/assets/{railroad-74A4TZTK-0nmMIDEw.js → railroad-74A4TZTK-Di61fiV5.js} +1 -1
- 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-XR7U4H2S-BXbeU-WV.js → railroadDiagram-XR7U4H2S-Dn2WPOQe.js} +1 -1
- package/dist/client/assets/{requirementDiagram-PLB6GJNP-D3G2HYv-.js → requirementDiagram-PLB6GJNP-uuvtvHmq.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-IPEJSGJF-CFSa-Gk4.js → sankeyDiagram-IPEJSGJF-BXk6X0af.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-PO4LG4MO-BvmuQTlz.js → sequenceDiagram-PO4LG4MO-BsPoSXhg.js} +1 -1
- package/dist/client/assets/{src-CplSIRuf.js → src-B4djUhOi.js} +1 -1
- package/dist/client/assets/{stateDiagram-v2-GCMORJYK-C_ceCfHy.js → stateDiagram-v2-GCMORJYK-cCml5k6n.js} +1 -1
- package/dist/client/assets/{swimlanes-2SLR337P-D-n44lEC.js → swimlanes-2SLR337P-Dh7v55ZA.js} +1 -1
- package/dist/client/assets/swimlanesDiagram-TC7HE7FX-q8VF72XO.js +8 -0
- package/dist/client/assets/{timeline-definition-EJHVYXUP-DKYNs1Uj.js → timeline-definition-EJHVYXUP-LFjkFV14.js} +1 -1
- package/dist/client/assets/{treeView-Q6P3EWNA-COW2UiIS.js → treeView-Q6P3EWNA-RYZHSXeB.js} +1 -1
- package/dist/client/assets/{treemap-WGGIJYW6-DJOuJZmF.js → treemap-WGGIJYW6-UVw_-EPz.js} +1 -1
- package/dist/client/assets/{usecaseDiagram-POWQR4AR-BTmpbeJZ.js → usecaseDiagram-POWQR4AR-DZfPogyb.js} +1 -1
- package/dist/client/assets/{vennDiagram-UO4OBE2U-C37AKEaJ.js → vennDiagram-UO4OBE2U-CN7Ldg-2.js} +1 -1
- package/dist/client/assets/{wardley-WFR3VGLG-CpVTtNOC.js → wardley-WFR3VGLG-CXPbbIy_.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-VNRHLVJA-ja-hiiVC.js → wardleyDiagram-VNRHLVJA-5p0A7_g6.js} +1 -1
- package/dist/client/assets/{xychartDiagram-PMCCYNJV-BsDoacSf.js → xychartDiagram-PMCCYNJV-gJ7xwZXO.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/diffo/SKILL.md +6 -4
- package/dist/client/assets/architecture-7GRP2DOG-CYv-P09J.js +0 -1
- package/dist/client/assets/channel-eN698CVO.js +0 -1
- package/dist/client/assets/eventmodeling-NTZA5JFV-Ur4Uzv5y.js +0 -1
- package/dist/client/assets/flowDiagram-KWPJA3E3-BQS1w4s1.js +0 -1
- package/dist/client/assets/index-BpeDzG-X.css +0 -1
- package/dist/client/assets/index-t7j89PJp.js +0 -91
- package/dist/client/assets/railroad-abnf-HS5TGJTU-DJU_abQP.js +0 -1
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-_TwNbNw6.js +0 -1
- package/dist/client/assets/railroad-peg-WCYAUIDC-CAYsrf-p.js +0 -1
- package/dist/client/assets/swimlanesDiagram-TC7HE7FX-DrlgJMk7.js +0 -8
package/dist/cli.mjs
CHANGED
|
@@ -209,6 +209,9 @@ function buildCliCommands(cli) {
|
|
|
209
209
|
reply: `${cli} reply <threadId> --message "<your reply>"`,
|
|
210
210
|
comment: `${cli} comment [<file>] [--line <line>] --message "<comment>"`,
|
|
211
211
|
guide: `${cli} comment --message "<what the change does>"`,
|
|
212
|
+
layersSuggest: `${cli} layers --suggest "<why, in one line>"`,
|
|
213
|
+
layers: `${cli} layers --json '<Layer[]>'`,
|
|
214
|
+
layersStdin: `${cli} layers --stdin`,
|
|
212
215
|
end: `${cli} end`,
|
|
213
216
|
setup: `${cli} setup`
|
|
214
217
|
};
|
|
@@ -254,6 +257,7 @@ const TAB_TITLE = {
|
|
|
254
257
|
};
|
|
255
258
|
const JOIN_PROMPT = `join the diffo review: run \`${CLI_COMMANDS.firstPoll}\` (${POLL_STANCE}; the title is ${TAB_TITLE.what} — ${TAB_TITLE.why}) and follow the JSON payload it prints — each payload carries its own instructions`;
|
|
256
259
|
function nextStepFor(kind, actionable) {
|
|
260
|
+
if (kind === "layers") return `Post the outline with \`${CLI_COMMANDS.layers}\` (or pipe it to \`${CLI_COMMANDS.layersStdin}\`), then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`;
|
|
257
261
|
if (kind === "cleared") return `Post the guide if this changeset warrants one, then run \`${CLI_COMMANDS.firstPoll}\` again to keep listening (${POLL_STANCE}) — this is a fresh round, so give it a fresh title.`;
|
|
258
262
|
if (kind === "finish" && actionable === 0) return `Nothing to act on — run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}); the reviewer may follow up.`;
|
|
259
263
|
return `${kind === "finish" ? "The reviewer is done reading — work the whole batch. " : ""}Act on each thread, reply with \`${CLI_COMMANDS.reply}\`, then run \`${CLI_COMMANDS.poll}\` again to keep listening.`;
|
|
@@ -262,6 +266,9 @@ const ACK_NEXT_STEP = {
|
|
|
262
266
|
reply: `When every thread is handled, run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
|
|
263
267
|
replyMore: "Interim reply posted — the reviewer still sees you working on this thread. Post the follow-up as a plain reply (no --more) BEFORE your next poll: re-polling closes the batch and counts the promise as never kept.",
|
|
264
268
|
comment: `It's in the review as your comment, labeled as yours — the reviewer replies to take it up, or resolves it. Continue with the review threads, then run \`${CLI_COMMANDS.poll}\`.`,
|
|
269
|
+
layers: `The outline is live in the reviewer's Layers tab, resolved against the changeset as it moves. Files you touch later land in a trailing "Since your review" layer until you re-post the whole list. Continue with the review threads, then run \`${CLI_COMMANDS.poll}\`.`,
|
|
270
|
+
layersSuggested: `The review now offers the outline to the reviewer. Mention it in your handoff too — "say layers and I'll outline it" — and post it with \`${CLI_COMMANDS.layers}\` when they ask. Then run \`${CLI_COMMANDS.poll}\`.`,
|
|
271
|
+
layersAlready: `This review already carries layers, so there is nothing to suggest — re-post the whole list with \`${CLI_COMMANDS.layers}\` if the outline is stale. Then run \`${CLI_COMMANDS.poll}\`.`,
|
|
265
272
|
end: "Detached. Do not reopen or re-poll this review unless the user asks — deliver anything remaining directly in the conversation."
|
|
266
273
|
};
|
|
267
274
|
/**
|
|
@@ -282,6 +289,34 @@ const GUIDE = {
|
|
|
282
289
|
update: "reply to your own guide thread with a short update"
|
|
283
290
|
};
|
|
284
291
|
/**
|
|
292
|
+
* The layers doctrine — the agent's reading plan for a changeset with an order
|
|
293
|
+
* worth explaining. Same single-source rule as GUIDE: the skill, `help agent`,
|
|
294
|
+
* `help layers`, and the open-time nudge all interpolate these, so no surface
|
|
295
|
+
* can teach a different bar for what a layer is or when to offer one.
|
|
296
|
+
*
|
|
297
|
+
* Layers come from the agent only. Every other tool infers a walkthrough by
|
|
298
|
+
* reading the diff back; the session that wrote the code still remembers the
|
|
299
|
+
* order it would explain it in, and that is the whole edge — so the doctrine is
|
|
300
|
+
* about that order, and about not spending the reviewer's attention on a plan
|
|
301
|
+
* a flat file list already gives them.
|
|
302
|
+
*/
|
|
303
|
+
const LAYERS = {
|
|
304
|
+
/** What one is. */
|
|
305
|
+
what: "one step of the change — a coherent unit you would explain in one breath — with the files that belong to it and a one- or two-sentence summary (markdown; a ```mermaid fence renders)",
|
|
306
|
+
/** How to order them. */
|
|
307
|
+
order: "the order you would explain it, not the order you wrote it — the file that explains the rest first, mechanical consequences last; for a feature, follow the request from entry point to effect; for a refactor, contract first, then consumers",
|
|
308
|
+
/** When to raise the flag at open — and that not raising it is the common case. */
|
|
309
|
+
suggest: "suggest layers when the change has an order worth explaining; a wide diff with one idea does not need them",
|
|
310
|
+
/** The one tag, and the bar for it. */
|
|
311
|
+
mechanical: "tag a layer \"kind\": \"mechanical\" only when it changes no behaviour — a rename, call sites following a signature; if unsure, don't tag",
|
|
312
|
+
/** Summaries orient reading, never pre-review: the guide's line, verbatim. */
|
|
313
|
+
stance: GUIDE.stance,
|
|
314
|
+
/** A post is the whole list. */
|
|
315
|
+
replace: "a post replaces the whole list, never merges; ids are kept for titles that match, so a re-post never moves the reviewer's place",
|
|
316
|
+
/** The payload, in one line. */
|
|
317
|
+
shape: "[{ \"title\": \"…\", \"summary\": \"…\", \"kind\": \"mechanical\" (optional), \"files\": [\"src/a.ts\", { \"path\": \"src/b.ts\", \"note\": \"why this file is in this step\" }] }]"
|
|
318
|
+
};
|
|
319
|
+
/**
|
|
285
320
|
* Printed by `diffo` (open) to a piped stdout when the review has no guide yet.
|
|
286
321
|
* The skill teaches the same step, but this line is what an agent WITHOUT the
|
|
287
322
|
* skill sees — payloads and command output must stand alone (see POLL_STANCE).
|
|
@@ -297,6 +332,17 @@ function guideNudge(hasGuide) {
|
|
|
297
332
|
return `share the URL above with the user right now, as a message line, before anything else. Then, while they open it, orient them if this changeset needs it (${GUIDE.when}): post a guide — one comment on the whole changeset: ${GUIDE.what}: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset; it appears live at the top of their review). ${GUIDE.stance}.`;
|
|
298
333
|
}
|
|
299
334
|
/**
|
|
335
|
+
* Printed by `diffo` (open) next to the guide nudge while the review has neither
|
|
336
|
+
* layers nor a suggestion. The open is the one moment the agent still holds the
|
|
337
|
+
* order it would explain the change in, so the flag is raised here; the outline
|
|
338
|
+
* itself waits for the reviewer to ask, because writing it is the heavy step
|
|
339
|
+
* and most changesets never need it.
|
|
340
|
+
*/
|
|
341
|
+
function layersNudge(review) {
|
|
342
|
+
if (review.layers || review.layersSuggested) return null;
|
|
343
|
+
return `if this changeset reads better in order — ${LAYERS.suggest} — flag it: \`${CLI_COMMANDS.layersSuggest}\`, and offer it in your handoff ("say layers and I'll outline it"). Post the outline only when asked: \`${CLI_COMMANDS.layers}\` — each layer ${LAYERS.what}; ${LAYERS.order}. ${LAYERS.stance}. \`diffo help layers\` has the shape.`;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
300
346
|
* Printed to stderr when a poll takes the review over and the review already
|
|
301
347
|
* carries a guide: the new agent inherits it as orientation and updates it in
|
|
302
348
|
* place — a second guide would give the reviewer two.
|
|
@@ -556,6 +602,29 @@ function buildCoalescedPrompt(threads, ctx) {
|
|
|
556
602
|
* orientation for the fresh round, so this restates the guide doctrine the way
|
|
557
603
|
* the open-time nudge does (payloads must stand alone — see POLL_STANCE).
|
|
558
604
|
*/
|
|
605
|
+
/**
|
|
606
|
+
* The reviewer pressed Outline (or refresh): the poll item that asks for the
|
|
607
|
+
* layers. Standalone by the same rule as every payload — an agent with no
|
|
608
|
+
* skill and no memory of `help layers` still gets the whole doctrine here.
|
|
609
|
+
*/
|
|
610
|
+
function buildLayersRequestPrompt(ctx, existing) {
|
|
611
|
+
const refresh = existing !== null && existing.length > 0;
|
|
612
|
+
const titles = refresh ? existing.map((l) => `"${l.title}"`).join(", ") : "";
|
|
613
|
+
return [
|
|
614
|
+
refresh ? `The reviewer asked you to refresh the layers in \`${ctx.repo.name}\` (branch \`${ctx.repo.branch}\`): the code moved since you outlined it, and files outside the outline have been gathering under "Since your review". Re-post the whole list as the change stands now.` : `The reviewer asked for layers in \`${ctx.repo.name}\` (branch \`${ctx.repo.branch}\`): outline the changeset as steps to read in order. The Layers tab reads "The agent is outlining…" until you post.`,
|
|
615
|
+
"",
|
|
616
|
+
...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
|
|
617
|
+
...refresh ? [`The outline they have: ${titles}. Keep a title that still fits its step, rename or drop the ones that don't — ${LAYERS.replace}.`, ""] : [],
|
|
618
|
+
`Each layer is ${LAYERS.what}. Order: ${LAYERS.order}. ${LAYERS.mechanical}. ${LAYERS.stance}. Files are whole files, by path relative to the repo root; list every file of the changeset somewhere, or the leftovers land in "Since your review".`,
|
|
619
|
+
"",
|
|
620
|
+
`Shape: ${LAYERS.shape}`,
|
|
621
|
+
"",
|
|
622
|
+
`Post it: \`${CLI_COMMANDS.layers}\`, or pipe the JSON to \`${CLI_COMMANDS.layersStdin}\` when it is long. Nothing else is owed for this item — no reply, no comment.`,
|
|
623
|
+
"",
|
|
624
|
+
`Then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
|
|
625
|
+
""
|
|
626
|
+
].join("\n");
|
|
627
|
+
}
|
|
559
628
|
function buildClearedPrompt(ctx) {
|
|
560
629
|
return [
|
|
561
630
|
`The reviewer cleared the review in \`${ctx.repo.name}\` (branch \`${ctx.repo.branch}\`): the previous round landed, and its threads and guide are gone. What the reviewer sees now is a fresh round.`,
|
|
@@ -611,6 +680,145 @@ function buildFinishPrompt(threads, ctx, coverage) {
|
|
|
611
680
|
return parts.join("\n");
|
|
612
681
|
}
|
|
613
682
|
//#endregion
|
|
683
|
+
//#region src/shared/layers.ts
|
|
684
|
+
/** A summary is one or two sentences by doctrine; this is the backstop against
|
|
685
|
+
* an essay, the same cap a closing note gets. */
|
|
686
|
+
const SUMMARY_CAP = 4e3;
|
|
687
|
+
/** A note is one line by contract — anything past the first newline is dropped,
|
|
688
|
+
* and the line itself is capped so the file header stays a header. */
|
|
689
|
+
const NOTE_CAP = 200;
|
|
690
|
+
const REPO_PATH = /^[^\0]+$/;
|
|
691
|
+
/**
|
|
692
|
+
* Paths are relative to the repo root: no leading slash, no `..` segment, no
|
|
693
|
+
* Windows drive. Unknown paths are fine — the file may land later — but a path
|
|
694
|
+
* that could never name a file in this repo is a mistake worth naming now.
|
|
695
|
+
*/
|
|
696
|
+
function badPath(path) {
|
|
697
|
+
if (path === "" || !REPO_PATH.test(path)) return "is empty";
|
|
698
|
+
if (path.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(path)) return "is absolute — use a repo-relative path";
|
|
699
|
+
if (path.split("/").some((seg) => seg === "..")) return "escapes the repo with `..`";
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
function parseFile(raw, where) {
|
|
703
|
+
if (typeof raw === "string") {
|
|
704
|
+
const path = raw.trim();
|
|
705
|
+
const why = badPath(path.replace(/:\d+-\d+$/, ""));
|
|
706
|
+
if (why) return {
|
|
707
|
+
ok: false,
|
|
708
|
+
error: `${where}: path "${raw}" ${why}`
|
|
709
|
+
};
|
|
710
|
+
return {
|
|
711
|
+
ok: true,
|
|
712
|
+
file: path
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
716
|
+
ok: false,
|
|
717
|
+
error: `${where}: each file is a path string or { path, note? }`
|
|
718
|
+
};
|
|
719
|
+
const f = raw;
|
|
720
|
+
if (typeof f.path !== "string") return {
|
|
721
|
+
ok: false,
|
|
722
|
+
error: `${where}: a file object needs a "path"`
|
|
723
|
+
};
|
|
724
|
+
const path = f.path.trim();
|
|
725
|
+
const why = badPath(path.replace(/:\d+-\d+$/, ""));
|
|
726
|
+
if (why) return {
|
|
727
|
+
ok: false,
|
|
728
|
+
error: `${where}: path "${f.path}" ${why}`
|
|
729
|
+
};
|
|
730
|
+
if (f.note !== void 0 && typeof f.note !== "string") return {
|
|
731
|
+
ok: false,
|
|
732
|
+
error: `${where}: "note" on ${path} must be a string`
|
|
733
|
+
};
|
|
734
|
+
const note = typeof f.note === "string" ? oneLine(f.note, NOTE_CAP) : void 0;
|
|
735
|
+
return {
|
|
736
|
+
ok: true,
|
|
737
|
+
file: note ? {
|
|
738
|
+
path,
|
|
739
|
+
note
|
|
740
|
+
} : { path }
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/** First line, trimmed, capped. Empty when nothing survives. */
|
|
744
|
+
function oneLine(text, cap) {
|
|
745
|
+
const line = text.split("\n", 1)[0].trim();
|
|
746
|
+
return line.length > cap ? `${line.slice(0, cap - 1).trimEnd()}…` : line;
|
|
747
|
+
}
|
|
748
|
+
/** The reason behind `--suggest`, fit for the one line the empty state quotes
|
|
749
|
+
* it in. Undefined when there is nothing usable — the suggestion stands alone. */
|
|
750
|
+
function parseSuggestReason(raw) {
|
|
751
|
+
if (typeof raw !== "string") return void 0;
|
|
752
|
+
const line = oneLine(raw, NOTE_CAP);
|
|
753
|
+
return line === "" ? void 0 : line;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* The whole post, or the first thing wrong with it. Titles and file lists are
|
|
757
|
+
* required and non-empty; everything else is optional and normalised — a
|
|
758
|
+
* summary trimmed and capped, a note cut to one line, an unknown `kind`
|
|
759
|
+
* refused rather than silently dropped (the agent meant something by it). Any
|
|
760
|
+
* `id` the agent sends is ignored: the server mints them.
|
|
761
|
+
*/
|
|
762
|
+
function parseLayersInput(raw) {
|
|
763
|
+
if (!Array.isArray(raw)) return {
|
|
764
|
+
ok: false,
|
|
765
|
+
error: "layers must be a JSON array of layers"
|
|
766
|
+
};
|
|
767
|
+
if (raw.length === 0) return {
|
|
768
|
+
ok: false,
|
|
769
|
+
error: "layers must list at least one layer"
|
|
770
|
+
};
|
|
771
|
+
const items = [];
|
|
772
|
+
const titles = /* @__PURE__ */ new Set();
|
|
773
|
+
for (const [i, entry] of raw.entries()) {
|
|
774
|
+
const where = `layer ${i + 1}`;
|
|
775
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return {
|
|
776
|
+
ok: false,
|
|
777
|
+
error: `${where}: each layer is an object { title, files, summary?, kind? }`
|
|
778
|
+
};
|
|
779
|
+
const l = entry;
|
|
780
|
+
const title = typeof l.title === "string" ? oneLine(l.title, 120) : "";
|
|
781
|
+
if (title === "") return {
|
|
782
|
+
ok: false,
|
|
783
|
+
error: `${where}: needs a non-empty "title"`
|
|
784
|
+
};
|
|
785
|
+
if (titles.has(title)) return {
|
|
786
|
+
ok: false,
|
|
787
|
+
error: `${where}: title "${title}" is used twice`
|
|
788
|
+
};
|
|
789
|
+
titles.add(title);
|
|
790
|
+
if (!Array.isArray(l.files) || l.files.length === 0) return {
|
|
791
|
+
ok: false,
|
|
792
|
+
error: `${where} ("${title}"): needs a non-empty "files" list`
|
|
793
|
+
};
|
|
794
|
+
const files = [];
|
|
795
|
+
for (const f of l.files) {
|
|
796
|
+
const parsed = parseFile(f, `${where} ("${title}")`);
|
|
797
|
+
if (!parsed.ok) return parsed;
|
|
798
|
+
files.push(parsed.file);
|
|
799
|
+
}
|
|
800
|
+
if (l.summary !== void 0 && typeof l.summary !== "string") return {
|
|
801
|
+
ok: false,
|
|
802
|
+
error: `${where} ("${title}"): "summary" must be a markdown string`
|
|
803
|
+
};
|
|
804
|
+
const summary = typeof l.summary === "string" ? l.summary.trim().slice(0, SUMMARY_CAP) : "";
|
|
805
|
+
if (l.kind !== void 0 && l.kind !== "mechanical") return {
|
|
806
|
+
ok: false,
|
|
807
|
+
error: `${where} ("${title}"): "kind" can only be "mechanical" — leave it out otherwise`
|
|
808
|
+
};
|
|
809
|
+
items.push({
|
|
810
|
+
title,
|
|
811
|
+
files,
|
|
812
|
+
...summary !== "" ? { summary } : {},
|
|
813
|
+
...l.kind === "mechanical" ? { kind: "mechanical" } : {}
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
ok: true,
|
|
818
|
+
items
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
//#endregion
|
|
614
822
|
//#region src/cliArgs.ts
|
|
615
823
|
const HELP_TEXT = `diffo — review a changeset the way you'd read a book
|
|
616
824
|
|
|
@@ -636,6 +844,9 @@ For the agent (the AI that wrote the change):
|
|
|
636
844
|
comment [<file>] Start a comment thread as the agent — on a line (--line),
|
|
637
845
|
a file, or (with no file) the whole changeset; the
|
|
638
846
|
reviewer replies to take it up, or resolves it
|
|
847
|
+
layers Outline the changeset as steps to read in order
|
|
848
|
+
(--suggest ["why"] flags it at open; --json '<Layer[]>'
|
|
849
|
+
or --stdin posts the list, replacing the last one)
|
|
639
850
|
end Detach from the review politely
|
|
640
851
|
help agent The agent's whole protocol on one page
|
|
641
852
|
|
|
@@ -684,7 +895,16 @@ The loop:
|
|
|
684
895
|
${GUIDE.stance}.
|
|
685
896
|
It lands live at the top of their review — never hold the URL back for it.
|
|
686
897
|
If the changeset later shifts under the guide, ${GUIDE.update}.
|
|
687
|
-
3.
|
|
898
|
+
3. Layers — offer them when the changeset reads better in order:
|
|
899
|
+
${LAYERS.suggest}.
|
|
900
|
+
Flag it at open with \`diffo layers --suggest "<why, in one line>"\` and
|
|
901
|
+
say so in your handoff ("say layers and I'll outline it"). Post the
|
|
902
|
+
outline only when the reviewer asks — in chat, or through the poll as a
|
|
903
|
+
\`"kind": "layers"\` payload: \`diffo layers --json '<Layer[]>'\`
|
|
904
|
+
(or pipe it to \`diffo layers --stdin\`). Each layer is ${LAYERS.what}.
|
|
905
|
+
Order: ${LAYERS.order}. ${LAYERS.mechanical}. ${LAYERS.stance}.
|
|
906
|
+
${LAYERS.replace}. \`diffo help layers\` has the shape.
|
|
907
|
+
4. Listen: run \`${CLI_COMMANDS.firstPoll}\` — it blocks until the reviewer
|
|
688
908
|
acts, then prints one JSON payload naming the threads to act on. Run it
|
|
689
909
|
attended: ${POLL_STANCE}.
|
|
690
910
|
The title is ${TAB_TITLE.what}: ${TAB_TITLE.why}. Write it the way it is
|
|
@@ -692,19 +912,19 @@ The loop:
|
|
|
692
912
|
every other poll is a plain \`diffo poll\`.
|
|
693
913
|
Killed or timed out? Re-run it; feedback is held in the review, not the
|
|
694
914
|
poll.
|
|
695
|
-
|
|
915
|
+
5. Act: \`[issue]\` threads want a code change; \`[question]\` threads want an
|
|
696
916
|
answer in the reply and no edit. Your edits reach the reviewer live.
|
|
697
|
-
|
|
917
|
+
6. Reply: \`diffo reply <threadId> --message "<text>"\` (pipe long replies on
|
|
698
918
|
stdin) — concise, addressed to the reviewer. Markdown renders; a
|
|
699
919
|
\`\`\`mermaid fence draws a diagram. A reply that only promises a
|
|
700
920
|
follow-up ("I'll investigate and report back") goes out with \`--more\` —
|
|
701
921
|
the reviewer keeps seeing you at work — and the real answer follows as a
|
|
702
922
|
plain reply before the next poll.
|
|
703
|
-
|
|
923
|
+
7. Comment (sparingly): \`diffo comment [<file>] [--line <n>] -m "<text>"\`
|
|
704
924
|
starts a thread in your voice — a concern, or context that helps the read.
|
|
705
|
-
|
|
925
|
+
8. Poll again only when the whole batch is handled — a new poll tells the
|
|
706
926
|
reviewer you are done with the previous one.
|
|
707
|
-
|
|
927
|
+
9. Detach: run \`diffo end\` when the review is over or the user moves on.
|
|
708
928
|
|
|
709
929
|
Rules:
|
|
710
930
|
- Change only what the threads ask about — the reviewer is mid-read, and an
|
|
@@ -770,6 +990,29 @@ Output: {"ok":true,"threadId":"t-1","next_step":"…"}
|
|
|
770
990
|
|
|
771
991
|
Example:
|
|
772
992
|
diffo comment src/auth.ts --line 42 --message "this branch is unreachable"`,
|
|
993
|
+
layers: `diffo layers — outline the changeset as steps to read in order
|
|
994
|
+
|
|
995
|
+
Usage: diffo layers --suggest ["<why, in one line>"] at open: this read benefits from layers
|
|
996
|
+
diffo layers --json '<Layer[]>' post the outline, replacing the last one
|
|
997
|
+
… | diffo layers --stdin the same payload, piped
|
|
998
|
+
|
|
999
|
+
Each layer is ${LAYERS.what}.
|
|
1000
|
+
Order: ${LAYERS.order}.
|
|
1001
|
+
${LAYERS.mechanical}.
|
|
1002
|
+
${LAYERS.stance}.
|
|
1003
|
+
${LAYERS.replace}. Paths are relative to the repo root; an unknown path is
|
|
1004
|
+
accepted (the file may land later) and simply shows nothing until it does.
|
|
1005
|
+
Files you touch after posting land in a trailing "Since your review" layer
|
|
1006
|
+
until you re-post.
|
|
1007
|
+
|
|
1008
|
+
Shape: ${LAYERS.shape}
|
|
1009
|
+
|
|
1010
|
+
Output: {"ok":true,"layers":4,"next_step":"…"}
|
|
1011
|
+
{"ok":true,"suggested":true,"next_step":"…"}
|
|
1012
|
+
|
|
1013
|
+
Examples:
|
|
1014
|
+
diffo layers --suggest "the parser change explains the rest"
|
|
1015
|
+
diffo layers --json '[{"title":"Parser contract","summary":"parse() now returns null instead of throwing.","files":["src/parse.ts"]},{"title":"Callers adapted","kind":"mechanical","files":["src/cli.ts","src/api.ts"]}]'`,
|
|
773
1016
|
end: `diffo end — detach from the review politely
|
|
774
1017
|
|
|
775
1018
|
Usage: diffo end
|
|
@@ -825,6 +1068,7 @@ const VERBS = /* @__PURE__ */ new Set([
|
|
|
825
1068
|
"poll",
|
|
826
1069
|
"reply",
|
|
827
1070
|
"comment",
|
|
1071
|
+
"layers",
|
|
828
1072
|
"end",
|
|
829
1073
|
"setup",
|
|
830
1074
|
"status",
|
|
@@ -974,7 +1218,78 @@ function parseCliArgs(argv) {
|
|
|
974
1218
|
foreground: values.foreground === true
|
|
975
1219
|
};
|
|
976
1220
|
}
|
|
1221
|
+
/**
|
|
1222
|
+
* `layers` parses on its own: its `--json` takes a value where `status --json`
|
|
1223
|
+
* is a switch, and its one positional is the suggestion's reason rather than a
|
|
1224
|
+
* file. Exactly one source per run — a post and a suggestion mean different
|
|
1225
|
+
* things to the review, and silently picking one would hide the other.
|
|
1226
|
+
*/
|
|
1227
|
+
function parseLayersVerb(rest) {
|
|
1228
|
+
const parsed = tryParse(() => parseArgs({
|
|
1229
|
+
args: rest,
|
|
1230
|
+
allowPositionals: true,
|
|
1231
|
+
options: {
|
|
1232
|
+
json: { type: "string" },
|
|
1233
|
+
stdin: { type: "boolean" },
|
|
1234
|
+
suggest: { type: "boolean" },
|
|
1235
|
+
help: {
|
|
1236
|
+
type: "boolean",
|
|
1237
|
+
short: "h"
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}));
|
|
1241
|
+
if (!parsed.ok) return {
|
|
1242
|
+
kind: "error",
|
|
1243
|
+
message: parsed.message
|
|
1244
|
+
};
|
|
1245
|
+
const { values, positionals } = parsed.value;
|
|
1246
|
+
if (values.help) return {
|
|
1247
|
+
kind: "help",
|
|
1248
|
+
topic: "layers"
|
|
1249
|
+
};
|
|
1250
|
+
if ([
|
|
1251
|
+
values.json !== void 0,
|
|
1252
|
+
values.stdin === true,
|
|
1253
|
+
values.suggest === true
|
|
1254
|
+
].filter(Boolean).length !== 1) return {
|
|
1255
|
+
kind: "error",
|
|
1256
|
+
message: "layers takes exactly one of --json <Layer[]>, --stdin, or --suggest [\"<why>\"]"
|
|
1257
|
+
};
|
|
1258
|
+
if (values.suggest) {
|
|
1259
|
+
if (positionals.length > 1) return {
|
|
1260
|
+
kind: "error",
|
|
1261
|
+
message: "--suggest takes at most one reason — quote it"
|
|
1262
|
+
};
|
|
1263
|
+
return {
|
|
1264
|
+
kind: "layers",
|
|
1265
|
+
source: {
|
|
1266
|
+
kind: "suggest",
|
|
1267
|
+
reason: parseSuggestReason(positionals[0]) ?? null
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
if (positionals.length > 0) return {
|
|
1272
|
+
kind: "error",
|
|
1273
|
+
message: "pass the layers with --json or on stdin (--stdin), not as an argument"
|
|
1274
|
+
};
|
|
1275
|
+
if (values.stdin) return {
|
|
1276
|
+
kind: "layers",
|
|
1277
|
+
source: { kind: "stdin" }
|
|
1278
|
+
};
|
|
1279
|
+
if (values.json.trim() === "") return {
|
|
1280
|
+
kind: "error",
|
|
1281
|
+
message: "--json needs a JSON array of layers — see `diffo help layers`"
|
|
1282
|
+
};
|
|
1283
|
+
return {
|
|
1284
|
+
kind: "layers",
|
|
1285
|
+
source: {
|
|
1286
|
+
kind: "json",
|
|
1287
|
+
text: values.json
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
977
1291
|
function parseVerb(verb, rest) {
|
|
1292
|
+
if (verb === "layers") return parseLayersVerb(rest);
|
|
978
1293
|
const parsed = tryParse(() => parseArgs({
|
|
979
1294
|
args: rest,
|
|
980
1295
|
allowPositionals: true,
|
|
@@ -1950,7 +2265,9 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
1950
2265
|
bucket = {
|
|
1951
2266
|
threads: /* @__PURE__ */ new Set(),
|
|
1952
2267
|
finish: null,
|
|
1953
|
-
cleared: false
|
|
2268
|
+
cleared: false,
|
|
2269
|
+
layers: false,
|
|
2270
|
+
outlining: null
|
|
1954
2271
|
};
|
|
1955
2272
|
this.buckets.set(this.scope, bucket);
|
|
1956
2273
|
}
|
|
@@ -1964,10 +2281,20 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
1964
2281
|
}
|
|
1965
2282
|
presence() {
|
|
1966
2283
|
if (this.awaitingReply) return "working";
|
|
2284
|
+
if (this.outlining() !== null) return "working";
|
|
1967
2285
|
if (this.waiter) return "listening";
|
|
1968
2286
|
if (this.betweenPolls) return "working";
|
|
1969
2287
|
return "waiting";
|
|
1970
2288
|
}
|
|
2289
|
+
/** Where the reviewer's layers request stands (see `LayersRequest`). */
|
|
2290
|
+
layersRequest() {
|
|
2291
|
+
if (this.outlining() !== null) return "outlining";
|
|
2292
|
+
if (this.buckets.get(this.scope)?.layers) return "queued";
|
|
2293
|
+
return null;
|
|
2294
|
+
}
|
|
2295
|
+
outlining() {
|
|
2296
|
+
return this.buckets.get(this.scope)?.outlining ?? null;
|
|
2297
|
+
}
|
|
1971
2298
|
presenceDetail() {
|
|
1972
2299
|
return {
|
|
1973
2300
|
state: this.presence(),
|
|
@@ -1977,6 +2304,7 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
1977
2304
|
}
|
|
1978
2305
|
reason() {
|
|
1979
2306
|
if (this.awaitingReply) return this.stalled ? "stalled" : "delivered";
|
|
2307
|
+
if (this.outlining() !== null) return "delivered";
|
|
1980
2308
|
if (this.waiter) return "polling";
|
|
1981
2309
|
if (this.betweenPolls) return "replied";
|
|
1982
2310
|
return this.lastDetach ?? "no-agent";
|
|
@@ -2087,6 +2415,37 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
2087
2415
|
this.wake();
|
|
2088
2416
|
if (queued) this.notify();
|
|
2089
2417
|
}
|
|
2418
|
+
/** The reviewer asked for layers — the outline, or a fresh one. Rides like
|
|
2419
|
+
* the cleared heads-up: a boolean per scope, surfaced once real feedback is
|
|
2420
|
+
* answered. */
|
|
2421
|
+
enqueueLayers() {
|
|
2422
|
+
this.bucket().layers = true;
|
|
2423
|
+
const queued = this.waiter === null;
|
|
2424
|
+
this.wake();
|
|
2425
|
+
if (queued) this.notify();
|
|
2426
|
+
}
|
|
2427
|
+
/** The reviewer cleared the review: whatever request was standing is
|
|
2428
|
+
* withdrawn — parked or already in the agent's hands. A post that arrives
|
|
2429
|
+
* anyway still lands; the store does not consult the queue. */
|
|
2430
|
+
dropLayers() {
|
|
2431
|
+
const bucket = this.buckets.get(this.scope);
|
|
2432
|
+
if (bucket) {
|
|
2433
|
+
bucket.layers = false;
|
|
2434
|
+
bucket.outlining = null;
|
|
2435
|
+
}
|
|
2436
|
+
this.notify();
|
|
2437
|
+
}
|
|
2438
|
+
/** The agent posted layers. Concludes an outstanding request; a spontaneous
|
|
2439
|
+
* post concludes nothing. Returns how long the outline took, or null. */
|
|
2440
|
+
layersPosted() {
|
|
2441
|
+
const bucket = this.buckets.get(this.scope);
|
|
2442
|
+
const since = bucket?.outlining ?? null;
|
|
2443
|
+
if (bucket === void 0 || since === null) return null;
|
|
2444
|
+
bucket.outlining = null;
|
|
2445
|
+
if (!this.awaitingReply && !this.betweenPolls) this.armGrace();
|
|
2446
|
+
this.notify();
|
|
2447
|
+
return Date.now() - since;
|
|
2448
|
+
}
|
|
2090
2449
|
drop(threadId) {
|
|
2091
2450
|
for (const bucket of this.buckets.values()) bucket.threads.delete(threadId);
|
|
2092
2451
|
this.deliveredAt.delete(threadId);
|
|
@@ -2104,6 +2463,8 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
2104
2463
|
if (this.batch !== null) {
|
|
2105
2464
|
if (this.batch.sawReply || Date.now() - this.batch.deliveredAt >= this.batchSettleMs) this.closeBatch("repoll");
|
|
2106
2465
|
}
|
|
2466
|
+
const owing = this.buckets.get(this.scope);
|
|
2467
|
+
if (owing !== void 0 && owing.outlining !== null && Date.now() - owing.outlining >= this.batchSettleMs) owing.outlining = null;
|
|
2107
2468
|
if (this.hasPending()) return Promise.resolve("data");
|
|
2108
2469
|
return new Promise((resolve) => {
|
|
2109
2470
|
const waiter = (outcome) => resolve(outcome);
|
|
@@ -2146,7 +2507,7 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
2146
2507
|
}
|
|
2147
2508
|
hasPending() {
|
|
2148
2509
|
const bucket = this.buckets.get(this.scope);
|
|
2149
|
-
return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null || bucket.cleared);
|
|
2510
|
+
return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null || bucket.cleared || bucket.layers);
|
|
2150
2511
|
}
|
|
2151
2512
|
wake() {
|
|
2152
2513
|
if (this.waiter === null) return;
|
|
@@ -2168,12 +2529,20 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
2168
2529
|
kind: "threads",
|
|
2169
2530
|
threadIds: [...bucket.threads]
|
|
2170
2531
|
};
|
|
2532
|
+
if (bucket.layers) return { kind: "layers" };
|
|
2171
2533
|
if (bucket.cleared) return { kind: "cleared" };
|
|
2172
2534
|
return null;
|
|
2173
2535
|
}
|
|
2174
2536
|
/** The poll response for this snapshot was fully written — NOW it counts as
|
|
2175
2537
|
* delivered. Clears exactly what the snapshot covered. */
|
|
2176
2538
|
confirm(snapshot, deliveredThreadIds) {
|
|
2539
|
+
if (snapshot.kind === "layers") {
|
|
2540
|
+
const bucket = this.bucket();
|
|
2541
|
+
bucket.layers = false;
|
|
2542
|
+
bucket.outlining = Date.now();
|
|
2543
|
+
this.notify();
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2177
2546
|
if (snapshot.kind === "cleared") {
|
|
2178
2547
|
const bucket = this.buckets.get(this.scope);
|
|
2179
2548
|
if (bucket) bucket.cleared = false;
|
|
@@ -2235,6 +2604,7 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
2235
2604
|
this.releaseWaiter("ended");
|
|
2236
2605
|
this.closeBatch("ended");
|
|
2237
2606
|
this.awaitingReply = false;
|
|
2607
|
+
for (const bucket of this.buckets.values()) bucket.outlining = null;
|
|
2238
2608
|
this.stalled = false;
|
|
2239
2609
|
this.clearStall();
|
|
2240
2610
|
this.clearGrace();
|
|
@@ -2595,8 +2965,9 @@ var ReviewStore = class {
|
|
|
2595
2965
|
*/
|
|
2596
2966
|
reset() {
|
|
2597
2967
|
const ids = this.state.threads.map((t) => t.id);
|
|
2598
|
-
|
|
2599
|
-
|
|
2968
|
+
const { lastFinish, landed, title, layers, layersSuggested } = this.state;
|
|
2969
|
+
if (ids.length === 0 && !lastFinish && !landed && !title && !layers && !layersSuggested) return [];
|
|
2970
|
+
const { lastFinish: _finish, landed: _landed, title: _title, layers: _layers, layersSuggested: _suggested, ...rest } = this.state;
|
|
2600
2971
|
this.state = {
|
|
2601
2972
|
...rest,
|
|
2602
2973
|
threads: []
|
|
@@ -2604,6 +2975,41 @@ var ReviewStore = class {
|
|
|
2604
2975
|
this.commit();
|
|
2605
2976
|
return ids;
|
|
2606
2977
|
}
|
|
2978
|
+
/**
|
|
2979
|
+
* Replace the reading plan — the whole list, never a merge, so the agent never
|
|
2980
|
+
* has to diff its own outline. Ids are minted here and kept for any layer
|
|
2981
|
+
* whose title matches an existing one: that is what holds the reviewer's
|
|
2982
|
+
* active layer in place across a re-post. Posting also answers the suggestion,
|
|
2983
|
+
* so it goes.
|
|
2984
|
+
*/
|
|
2985
|
+
setLayers(items) {
|
|
2986
|
+
const kept = new Map((this.state.layers?.items ?? []).map((l) => [l.title, l.id]));
|
|
2987
|
+
const layers = {
|
|
2988
|
+
items: items.map((item) => ({
|
|
2989
|
+
id: kept.get(item.title) ?? randomUUID(),
|
|
2990
|
+
...item
|
|
2991
|
+
})),
|
|
2992
|
+
postedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2993
|
+
};
|
|
2994
|
+
const { layersSuggested: _suggested, ...rest } = this.state;
|
|
2995
|
+
this.state = {
|
|
2996
|
+
...rest,
|
|
2997
|
+
layers
|
|
2998
|
+
};
|
|
2999
|
+
this.commit();
|
|
3000
|
+
return layers;
|
|
3001
|
+
}
|
|
3002
|
+
/** The agent's flag at open: this read benefits from layers. Once layers exist
|
|
3003
|
+
* the flag has nothing to add, so it is refused rather than recorded. */
|
|
3004
|
+
suggestLayers(reason) {
|
|
3005
|
+
if (this.state.layers) return false;
|
|
3006
|
+
this.state = {
|
|
3007
|
+
...this.state,
|
|
3008
|
+
layersSuggested: reason ? { reason } : {}
|
|
3009
|
+
};
|
|
3010
|
+
this.commit();
|
|
3011
|
+
return true;
|
|
3012
|
+
}
|
|
2607
3013
|
/** The agent's name for this changeset, carried by its poll. The newest one
|
|
2608
3014
|
* wins: a change that grows a second subject renames its own tab. */
|
|
2609
3015
|
setTitle(title) {
|
|
@@ -2785,15 +3191,43 @@ function parseReview(raw) {
|
|
|
2785
3191
|
const lastFinish = normalizeLastFinish(parsed.lastFinish, now);
|
|
2786
3192
|
const landed = normalizeLanded(parsed.landed, now);
|
|
2787
3193
|
const title = normalizeTitle(parsed.title);
|
|
3194
|
+
const layers = normalizeLayers(parsed.layers, now);
|
|
3195
|
+
const layersSuggested = normalizeSuggested(parsed.layersSuggested);
|
|
2788
3196
|
return {
|
|
2789
3197
|
version: 1,
|
|
2790
3198
|
threads: [...valid, ...migrated],
|
|
2791
3199
|
...title ? { title } : {},
|
|
2792
3200
|
...lastFinish ? { lastFinish } : {},
|
|
2793
3201
|
...typeof parsed.seenHead === "string" && parsed.seenHead !== "" ? { seenHead: parsed.seenHead } : {},
|
|
2794
|
-
...landed ? { landed } : {}
|
|
3202
|
+
...landed ? { landed } : {},
|
|
3203
|
+
...layers ? { layers } : {},
|
|
3204
|
+
...layersSuggested && !layers ? { layersSuggested } : {}
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
/** Stored layers go back through the same validation a post does; a list that
|
|
3208
|
+
* would be refused at the door is dropped whole rather than half-kept. Ids are
|
|
3209
|
+
* the one field a post never carries, so they are read here and re-minted only
|
|
3210
|
+
* when missing. */
|
|
3211
|
+
function normalizeLayers(value, now) {
|
|
3212
|
+
if (typeof value !== "object" || value === null) return null;
|
|
3213
|
+
const l = value;
|
|
3214
|
+
if (!Array.isArray(l.items)) return null;
|
|
3215
|
+
const parsed = parseLayersInput(l.items);
|
|
3216
|
+
if (!parsed.ok) return null;
|
|
3217
|
+
const ids = l.items.map((item) => typeof item === "object" && item !== null && typeof item.id === "string" ? item.id : randomUUID());
|
|
3218
|
+
return {
|
|
3219
|
+
items: parsed.items.map((item, i) => ({
|
|
3220
|
+
id: ids[i],
|
|
3221
|
+
...item
|
|
3222
|
+
})),
|
|
3223
|
+
postedAt: typeof l.postedAt === "string" ? l.postedAt : now
|
|
2795
3224
|
};
|
|
2796
3225
|
}
|
|
3226
|
+
function normalizeSuggested(value) {
|
|
3227
|
+
if (typeof value !== "object" || value === null) return null;
|
|
3228
|
+
const reason = parseSuggestReason(value.reason);
|
|
3229
|
+
return reason ? { reason } : {};
|
|
3230
|
+
}
|
|
2797
3231
|
/** A landed marker without a sha can't be checked against history, so it is
|
|
2798
3232
|
* dropped; the subject is only a caption and defaults away. */
|
|
2799
3233
|
function normalizeLanded(value, now) {
|
|
@@ -3486,6 +3920,31 @@ function createApp(ctx, store, review, queue) {
|
|
|
3486
3920
|
const capture = store ? captureAnchor(store.get(), anchor) : null;
|
|
3487
3921
|
return c.json(review.createThread(anchor, text, capture, intent));
|
|
3488
3922
|
});
|
|
3923
|
+
app.post("/api/review/layers", async (c) => {
|
|
3924
|
+
if (!review) return c.json({ error: "review unavailable" }, 503);
|
|
3925
|
+
const body = await c.req.json().catch(() => null);
|
|
3926
|
+
if (body?.suggest === true) {
|
|
3927
|
+
const suggested = review.suggestLayers(parseSuggestReason(body.reason));
|
|
3928
|
+
return c.json({
|
|
3929
|
+
suggested,
|
|
3930
|
+
...suggested ? {} : { note: "layers are already posted — nothing to suggest" }
|
|
3931
|
+
});
|
|
3932
|
+
}
|
|
3933
|
+
const parsed = parseLayersInput(body?.items);
|
|
3934
|
+
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
|
|
3935
|
+
const layers = review.setLayers(parsed.items);
|
|
3936
|
+
queue?.layersPosted();
|
|
3937
|
+
return c.json({ layers });
|
|
3938
|
+
});
|
|
3939
|
+
app.post("/api/review/layers/request", (c) => {
|
|
3940
|
+
if (!review || !queue) return c.json({ error: "review unavailable" }, 503);
|
|
3941
|
+
queue.enqueueLayers();
|
|
3942
|
+
return c.json({
|
|
3943
|
+
ok: true,
|
|
3944
|
+
request: queue.layersRequest(),
|
|
3945
|
+
presence: queue.presence()
|
|
3946
|
+
});
|
|
3947
|
+
});
|
|
3489
3948
|
app.post("/api/review/threads/:id/messages", async (c) => {
|
|
3490
3949
|
if (!review) return c.json({ error: "review unavailable" }, 503);
|
|
3491
3950
|
const body = await c.req.json().catch(() => null);
|
|
@@ -3535,6 +3994,7 @@ function createApp(ctx, store, review, queue) {
|
|
|
3535
3994
|
const hadRound = before.threads.length > 0 || before.lastFinish !== void 0;
|
|
3536
3995
|
const removed = review.reset();
|
|
3537
3996
|
for (const id of removed) queue?.drop(id);
|
|
3997
|
+
queue?.dropLayers();
|
|
3538
3998
|
if (hadRound && (store?.get().files.length ?? 0) > 0) queue?.enqueueCleared();
|
|
3539
3999
|
return c.json({ removed: removed.length });
|
|
3540
4000
|
});
|
|
@@ -3680,6 +4140,15 @@ function createApp(ctx, store, review, queue) {
|
|
|
3680
4140
|
const pollPayload = (snapshot) => {
|
|
3681
4141
|
const threads = review?.get().threads ?? [];
|
|
3682
4142
|
const protocol = queue?.needsFullProtocol() === false ? "compact" : void 0;
|
|
4143
|
+
if (snapshot.kind === "layers") return {
|
|
4144
|
+
status: "feedback",
|
|
4145
|
+
kind: "layers",
|
|
4146
|
+
threadIds: [],
|
|
4147
|
+
prompt: buildLayersRequestPrompt({
|
|
4148
|
+
repo: repoInfo(),
|
|
4149
|
+
changeset: store?.get() ?? null
|
|
4150
|
+
}, review?.get().layers?.items ?? null)
|
|
4151
|
+
};
|
|
3683
4152
|
if (snapshot.kind === "cleared") return {
|
|
3684
4153
|
status: "feedback",
|
|
3685
4154
|
kind: "cleared",
|
|
@@ -3878,7 +4347,8 @@ function createApp(ctx, store, review, queue) {
|
|
|
3878
4347
|
...queue.presenceDetail(),
|
|
3879
4348
|
workingOn: queue.deliveredThreadIds(),
|
|
3880
4349
|
queued: queue.queuedThreadIds(),
|
|
3881
|
-
answered: queue.currentBatch()?.answered ?? []
|
|
4350
|
+
answered: queue.currentBatch()?.answered ?? [],
|
|
4351
|
+
layers: queue.layersRequest()
|
|
3882
4352
|
}),
|
|
3883
4353
|
id: String(id++)
|
|
3884
4354
|
});
|
|
@@ -3901,7 +4371,8 @@ function createApp(ctx, store, review, queue) {
|
|
|
3901
4371
|
...queue.presenceDetail(),
|
|
3902
4372
|
workingOn: queue.deliveredThreadIds(),
|
|
3903
4373
|
queued: queue.queuedThreadIds(),
|
|
3904
|
-
answered: queue.currentBatch()?.answered ?? []
|
|
4374
|
+
answered: queue.currentBatch()?.answered ?? [],
|
|
4375
|
+
layers: queue.layersRequest()
|
|
3905
4376
|
}),
|
|
3906
4377
|
id: String(id++)
|
|
3907
4378
|
});
|
|
@@ -4904,6 +5375,44 @@ if (command.kind === "comment") {
|
|
|
4904
5375
|
}));
|
|
4905
5376
|
process.exit(0);
|
|
4906
5377
|
}
|
|
5378
|
+
if (command.kind === "layers") {
|
|
5379
|
+
const { source } = command;
|
|
5380
|
+
if (source.kind === "stdin" && process.stdin.isTTY) fail("layers --stdin needs the JSON piped on stdin — or pass it with --json");
|
|
5381
|
+
const port = await requireServer();
|
|
5382
|
+
if (source.kind === "suggest") {
|
|
5383
|
+
const { status, body } = await postJson(port, "/api/review/layers", {
|
|
5384
|
+
suggest: true,
|
|
5385
|
+
...source.reason === null ? {} : { reason: source.reason }
|
|
5386
|
+
});
|
|
5387
|
+
if (status !== 200) fail(`layers failed (${status})`);
|
|
5388
|
+
const { suggested } = body;
|
|
5389
|
+
console.log(JSON.stringify({
|
|
5390
|
+
ok: true,
|
|
5391
|
+
suggested,
|
|
5392
|
+
next_step: suggested ? ACK_NEXT_STEP.layersSuggested : ACK_NEXT_STEP.layersAlready
|
|
5393
|
+
}));
|
|
5394
|
+
process.exit(0);
|
|
5395
|
+
}
|
|
5396
|
+
const text = source.kind === "json" ? source.text : await readStdin();
|
|
5397
|
+
let raw;
|
|
5398
|
+
try {
|
|
5399
|
+
raw = JSON.parse(text);
|
|
5400
|
+
} catch (err) {
|
|
5401
|
+
fail(`layers needs a JSON array of layers — ${err.message}`);
|
|
5402
|
+
}
|
|
5403
|
+
const parsed = parseLayersInput(raw);
|
|
5404
|
+
if (!parsed.ok) fail(`layers: ${parsed.error}`);
|
|
5405
|
+
const { status, body } = await postJson(port, "/api/review/layers", { items: parsed.items });
|
|
5406
|
+
if (status === 400) fail(`layers: ${body?.error ?? "rejected"}`);
|
|
5407
|
+
if (status !== 200) fail(`layers failed (${status})`);
|
|
5408
|
+
const { layers } = body;
|
|
5409
|
+
console.log(JSON.stringify({
|
|
5410
|
+
ok: true,
|
|
5411
|
+
layers: layers.items.length,
|
|
5412
|
+
next_step: ACK_NEXT_STEP.layers
|
|
5413
|
+
}));
|
|
5414
|
+
process.exit(0);
|
|
5415
|
+
}
|
|
4907
5416
|
if (command.kind === "end") {
|
|
4908
5417
|
const { status, body } = await postJson(await requireServer(), "/api/agent/end", {});
|
|
4909
5418
|
if (status !== 200) fail(`end failed (${status})`);
|
|
@@ -4930,6 +5439,8 @@ async function printAgentNextStep(port) {
|
|
|
4930
5439
|
const review = await fetchReviewState(port);
|
|
4931
5440
|
const nudge = review ? guideNudge(findGuideThread(review) !== void 0) : null;
|
|
4932
5441
|
if (nudge) console.log(`first: ${nudge}`);
|
|
5442
|
+
const layers = review ? layersNudge(review) : null;
|
|
5443
|
+
if (layers) console.log(`also: ${layers}`);
|
|
4933
5444
|
console.log(`next: run \`${CLI_COMMANDS.firstPoll}\` to receive the reviewer's feedback — the title is ${TAB_TITLE.what} (${TAB_TITLE.examples}): ${TAB_TITLE.why} (\`diffo help agent\` prints the whole loop)`);
|
|
4934
5445
|
}
|
|
4935
5446
|
/**
|