@diffohq/diffo 0.0.2 → 0.1.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 +3 -3
- package/dist/cli.mjs +333 -66
- package/dist/client/assets/{abnfDiagram-VCTEODGH--nG4YFZf.js → abnfDiagram-VCTEODGH-DNZRM2Rn.js} +1 -1
- package/dist/client/assets/{arc-DUDGa5yC.js → arc-s7daVIwl.js} +1 -1
- package/dist/client/assets/architecture-7GRP2DOG-DzM3CzOq.js +1 -0
- package/dist/client/assets/{architectureDiagram-5GKGNRK7-DvSKG4lO.js → architectureDiagram-5GKGNRK7-DsxTOcye.js} +1 -1
- package/dist/client/assets/{blockDiagram-NRAW4CY4-t9YA0Zds.js → blockDiagram-NRAW4CY4-DqGD_dcu.js} +1 -1
- package/dist/client/assets/{c4Diagram-UCG6FXSJ-D0NJEiQf.js → c4Diagram-UCG6FXSJ-CMuJod-h.js} +1 -1
- package/dist/client/assets/channel-Cftoq4wT.js +1 -0
- package/dist/client/assets/{chunk-75Z2AOVW-Bai5ank7.js → chunk-75Z2AOVW-HRLiZN7c.js} +1 -1
- package/dist/client/assets/{chunk-DU6HZSFF-D2kJr6Sd.js → chunk-DU6HZSFF-D6gjp2l1.js} +1 -1
- package/dist/client/assets/{chunk-F27PBJKO-Bfqt90G-.js → chunk-F27PBJKO-DRqkoKNg.js} +1 -1
- package/dist/client/assets/{chunk-G27WJ6UU-BaH77Llj.js → chunk-G27WJ6UU-QUFgr4za.js} +1 -1
- package/dist/client/assets/{chunk-GMAD6QVW-DEk8GHWm.js → chunk-GMAD6QVW-DG-OmEbV.js} +1 -1
- package/dist/client/assets/{chunk-JQ64N6SF-DSC3eBmu.js → chunk-JQ64N6SF-CPdpoyCu.js} +1 -1
- package/dist/client/assets/{chunk-LCL6LL3I-CbXTQnS7.js → chunk-LCL6LL3I-DoHJ4n3V.js} +1 -1
- package/dist/client/assets/{chunk-OBVCFTLP-DTR5L5DZ.js → chunk-OBVCFTLP-DOl2OrIN.js} +1 -1
- package/dist/client/assets/{chunk-P2QGCYS3-B4CbyKz8.js → chunk-P2QGCYS3-CBWu7ViW.js} +1 -1
- package/dist/client/assets/{chunk-POPQ4Y6H-bUwnKEA6.js → chunk-POPQ4Y6H-Dw_D6UWF.js} +1 -1
- package/dist/client/assets/{chunk-PWAF6VOD-zdMmz14g.js → chunk-PWAF6VOD-BHpo38Be.js} +1 -1
- package/dist/client/assets/{chunk-R7TYR2AO-Bij6COIm.js → chunk-R7TYR2AO-DgC6z76h.js} +1 -1
- package/dist/client/assets/{chunk-RHFEMEQ7-B2_qXJHf.js → chunk-RHFEMEQ7-BabrfzF-.js} +1 -1
- package/dist/client/assets/{chunk-SVP7TREG-2A6DuC2c.js → chunk-SVP7TREG-CSTzyRmo.js} +1 -1
- package/dist/client/assets/{chunk-TEH6E4GO-ByR-u0fj.js → chunk-TEH6E4GO-M22Cr-Sd.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-D_O-C3As.js → chunk-XXDRQBXY-BHwLEQfa.js} +1 -1
- package/dist/client/assets/classDiagram-DTDB5LWJ-uMtyQjAg.js +1 -0
- package/dist/client/assets/classDiagram-v2-JRS7N3AN-uMtyQjAg.js +1 -0
- package/dist/client/assets/{core-Bnn4HFdG.js → core-B9wYK4yO.js} +1 -1
- package/dist/client/assets/{cose-bilkent-JH36ORCC-Z148C14w.js → cose-bilkent-JH36ORCC-BL0lTP0k.js} +1 -1
- package/dist/client/assets/{cynefin-OW5HDTMX-_nvdHJ_6.js → cynefin-OW5HDTMX-1zOzoEu-.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-5FMLGOSQ-DgjK3dBx.js → cynefinDiagram-5FMLGOSQ-DST_IHb1.js} +1 -1
- package/dist/client/assets/{dagre-3AP2YEHR-ZpUDL3As.js → dagre-3AP2YEHR-CoEaLaFu.js} +1 -1
- package/dist/client/assets/{diagram-S7CK7UJ4-CkZIS9iX.js → diagram-S7CK7UJ4-BwGXFSb-.js} +1 -1
- package/dist/client/assets/{diagram-UQ7AKVKN-CQ18pzfD.js → diagram-UQ7AKVKN-wJMwiRQW.js} +1 -1
- package/dist/client/assets/{diagram-VSXAHHWV-BHksZhNT.js → diagram-VSXAHHWV-DRQH2hZ-.js} +1 -1
- package/dist/client/assets/{diagram-VX7I27RA-JaMo95UB.js → diagram-VX7I27RA-BWGYUOwn.js} +1 -1
- package/dist/client/assets/{diagram-Z3DM3KII-BtbUi7Zp.js → diagram-Z3DM3KII-C2ETOHnp.js} +1 -1
- package/dist/client/assets/{dist-frF0Fo17.js → dist-C42IZvNn.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-PWID7BFC-CrkTnETC.js → ebnfDiagram-PWID7BFC-CkcgAzr3.js} +1 -1
- package/dist/client/assets/{engine-oniguruma-BDMkTjCm.js → engine-oniguruma-D9HVl69n.js} +1 -1
- package/dist/client/assets/{erDiagram-SSCWMZ5O-BU_DeMcX.js → erDiagram-SSCWMZ5O-Do6h2U1E.js} +1 -1
- package/dist/client/assets/eventmodeling-NTZA5JFV-LK0PWCmm.js +1 -0
- package/dist/client/assets/flowDiagram-A5DVABFB-W_CNjdk0.js +1 -0
- package/dist/client/assets/{ganttDiagram-EL5Y4UJY-DCoVu2Ob.js → ganttDiagram-EL5Y4UJY-cC8gUuqf.js} +1 -1
- package/dist/client/assets/{gitGraph-4MIJSDKK-D_vOLjJd.js → gitGraph-4MIJSDKK-Con-0IRc.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-WWUBYQGX-CbJeUVdZ.js → gitGraphDiagram-WWUBYQGX-Oj6POMRs.js} +1 -1
- package/dist/client/assets/index-BPuQYkRJ.js +90 -0
- package/dist/client/assets/index-BpeDzG-X.css +1 -0
- package/dist/client/assets/{info-A6RAGUB7-CRcr_2Kl.js → info-A6RAGUB7-Cl45HDP4.js} +1 -1
- package/dist/client/assets/{infoDiagram-RXCK75RN-B4UffpYj.js → infoDiagram-RXCK75RN-DIVe-qvk.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-5VMMS53U-DO6STczE.js → ishikawaDiagram-5VMMS53U-BB3HsPau.js} +1 -1
- package/dist/client/assets/{journeyDiagram-EYS64GPL-CJqu4rjo.js → journeyDiagram-EYS64GPL-Cj8ezyBN.js} +1 -1
- package/dist/client/assets/{kanban-definition-3QL26DDD-ugjVwpzB.js → kanban-definition-3QL26DDD-D3dbth5Z.js} +1 -1
- package/dist/client/assets/{line-C42aEa5T.js → line-tg3RVOrp.js} +1 -1
- package/dist/client/assets/{linear-CAfNWogY.js → linear-CzAWMsuG.js} +1 -1
- package/dist/client/assets/{mermaid-parser.core-DIpynd7a.js → mermaid-parser.core-DKC3l_TU.js} +3 -3
- package/dist/client/assets/{mermaid.core-b7ipG6MC.js → mermaid.core-B0ktnMCY.js} +4 -4
- package/dist/client/assets/{mindmap-definition-FBJOCRG2-Btow11vP.js → mindmap-definition-FBJOCRG2-CSUjR6w9.js} +1 -1
- package/dist/client/assets/{packet-AYTQ26CC-DmMiSJ_n.js → packet-AYTQ26CC-NTsh29Fp.js} +1 -1
- package/dist/client/assets/{pegDiagram-XKGWAZYB-ZE6QXs-N.js → pegDiagram-XKGWAZYB-BhLsv72R.js} +1 -1
- package/dist/client/assets/{pie-WAS4IAKB-CWO1GXQk.js → pie-WAS4IAKB-DENes_Cx.js} +1 -1
- package/dist/client/assets/{pieDiagram-E7YTZNPT-CkYClGbv.js → pieDiagram-E7YTZNPT-Ci9YzLDy.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-AXDQQJYC-CLah-4Vu.js → quadrantDiagram-AXDQQJYC-D7D5FwMe.js} +1 -1
- package/dist/client/assets/{radar-RG4KPBEZ-VI14cwZd.js → radar-RG4KPBEZ-C3OVgpW0.js} +1 -1
- package/dist/client/assets/{railroad-74A4TZTK-s1iFKSLC.js → railroad-74A4TZTK-DHWDkPlt.js} +1 -1
- package/dist/client/assets/railroad-abnf-HS5TGJTU-XG9NyE2s.js +1 -0
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-BePIURFQ.js +1 -0
- package/dist/client/assets/railroad-peg-WCYAUIDC-THfR3kY_.js +1 -0
- package/dist/client/assets/{railroadDiagram-O6MQD6OU-DSEEuZLE.js → railroadDiagram-O6MQD6OU-ByO5lwcN.js} +1 -1
- package/dist/client/assets/{requirementDiagram-EFPCY7ZU-DKPeLCXy.js → requirementDiagram-EFPCY7ZU-UAUf-JbW.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-P5KCCOFB-CoRGTY0Z.js → sankeyDiagram-P5KCCOFB-D3dgv1AD.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-WJ2MYXX4-vOT2DQAD.js → sequenceDiagram-WJ2MYXX4-DbGvVPa7.js} +1 -1
- package/dist/client/assets/{src-COpTHlse.js → src-DQRzrNpT.js} +1 -1
- package/dist/client/assets/{stateDiagram-HBIQ2CUA-B1Q5gx1k.js → stateDiagram-HBIQ2CUA-CJznFEjd.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-4QOOHH4V-o6PmCsJ0.js +1 -0
- package/dist/client/assets/{swimlanes-XN3QIQJK-BHsSTPP_.js → swimlanes-XN3QIQJK-Bp4PtrPm.js} +1 -1
- package/dist/client/assets/swimlanesDiagram-VK2B7HYN-W1d9PL3X.js +8 -0
- package/dist/client/assets/{timeline-definition-24CTP7MA-gMAK8Huz.js → timeline-definition-24CTP7MA-jqaUS70q.js} +1 -1
- package/dist/client/assets/{treeView-Q6P3EWNA-D_ac7x_f.js → treeView-Q6P3EWNA-4uMQTFyy.js} +1 -1
- package/dist/client/assets/{treemap-WGGIJYW6-CmKDF5Bk.js → treemap-WGGIJYW6-BEvgQaxX.js} +1 -1
- package/dist/client/assets/{vennDiagram-4TSXK5OY-BwoJOmDU.js → vennDiagram-4TSXK5OY-BzSNLT-7.js} +1 -1
- package/dist/client/assets/{wardley-WFR3VGLG-DBwnOUBJ.js → wardley-WFR3VGLG-0Vpm3lxj.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-VM6X3IG4-DSpTGNPm.js → wardleyDiagram-VM6X3IG4-D9KaADBZ.js} +1 -1
- package/dist/client/assets/{xychartDiagram-S5SC5T6Z-I-7pacDu.js → xychartDiagram-S5SC5T6Z-CnrJ3a0c.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/dist/client/assets/architecture-7GRP2DOG-BS3omA-i.js +0 -1
- package/dist/client/assets/channel-C2N97jf-.js +0 -1
- package/dist/client/assets/classDiagram-DTDB5LWJ-DkiwiYZN.js +0 -1
- package/dist/client/assets/classDiagram-v2-JRS7N3AN-DkiwiYZN.js +0 -1
- package/dist/client/assets/eventmodeling-NTZA5JFV-C6iBQfJc.js +0 -1
- package/dist/client/assets/flowDiagram-A5DVABFB-Dq3MklmQ.js +0 -1
- package/dist/client/assets/index-DUY5AD4r.js +0 -90
- package/dist/client/assets/index-pWZ6lr0e.css +0 -1
- package/dist/client/assets/railroad-abnf-HS5TGJTU-DCYBjW-8.js +0 -1
- package/dist/client/assets/railroad-ebnf-LZEXJU2U-Cpg28CUR.js +0 -1
- package/dist/client/assets/railroad-peg-WCYAUIDC-D3OHUWgZ.js +0 -1
- package/dist/client/assets/stateDiagram-v2-4QOOHH4V-Bq1OmWqW.js +0 -1
- package/dist/client/assets/swimlanesDiagram-VK2B7HYN-DACxnx54.js +0 -8
package/dist/cli.mjs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
4
|
-
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from "node:fs";
|
|
4
|
+
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, watch, writeFileSync } from "node:fs";
|
|
5
5
|
import { createServer } from "node:net";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { parseArgs, promisify } from "node:util";
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
11
|
import { readFile } from "node:fs/promises";
|
|
11
12
|
import { serve } from "@hono/node-server";
|
|
12
13
|
import { Hono } from "hono";
|
|
13
14
|
import { stream, streamSSE } from "hono/streaming";
|
|
14
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
15
15
|
//#region src/agentSession.ts
|
|
16
16
|
/** A row whose leading token is a shell is never the session. Tested on the FIRST
|
|
17
17
|
* token only: under Claude Code the transient `zsh -c` carries
|
|
@@ -94,11 +94,6 @@ 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
|
-
const REVIEW_VERDICTS = [
|
|
98
|
-
"comment",
|
|
99
|
-
"approve",
|
|
100
|
-
"request-changes"
|
|
101
|
-
];
|
|
102
97
|
const EMPTY_REVIEW = {
|
|
103
98
|
version: 1,
|
|
104
99
|
threads: []
|
|
@@ -128,26 +123,59 @@ function threadsInChangeset(files, threads) {
|
|
|
128
123
|
past
|
|
129
124
|
};
|
|
130
125
|
}
|
|
126
|
+
/** The line part of a hunk anchor's label: `12`, or `12-20` for a range. */
|
|
127
|
+
function anchorSpan(anchor) {
|
|
128
|
+
return anchor.endLine === void 0 ? `${anchor.line}` : `${anchor.line}-${anchor.endLine}`;
|
|
129
|
+
}
|
|
131
130
|
function describeAnchor(anchor) {
|
|
132
131
|
if (anchor.kind === "changeset") return "the whole changeset";
|
|
133
132
|
if (anchor.kind === "file") return anchor.path;
|
|
134
|
-
return `${anchor.path}:${anchor
|
|
133
|
+
return `${anchor.path}:${anchorSpan(anchor)} (${anchor.side} side)`;
|
|
135
134
|
}
|
|
136
135
|
//#endregion
|
|
137
136
|
//#region src/server/prompt.ts
|
|
138
|
-
|
|
137
|
+
/** A comment can anchor to a long range; the frozen text keeps its head. */
|
|
138
|
+
const ANCHORED_TEXT_CAP = 10;
|
|
139
|
+
function findHunk(changeset, hunkId) {
|
|
139
140
|
for (const file of changeset.files) {
|
|
140
141
|
const hunk = file.hunks.find((h) => h.id === hunkId);
|
|
141
|
-
if (
|
|
142
|
-
const marker = {
|
|
143
|
-
add: "+",
|
|
144
|
-
del: "-",
|
|
145
|
-
context: " "
|
|
146
|
-
};
|
|
147
|
-
return hunk.lines.map((l) => marker[l.kind] + l.text).join("\n");
|
|
142
|
+
if (hunk) return hunk;
|
|
148
143
|
}
|
|
149
144
|
return null;
|
|
150
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Freeze what a new thread anchors to: the whole hunk as a diff snapshot, plus
|
|
148
|
+
* — for the anchored line range — where those rows sit in it and their text.
|
|
149
|
+
* Null for non-hunk anchors and for a hunk the changeset no longer has.
|
|
150
|
+
*/
|
|
151
|
+
function captureAnchor(changeset, anchor) {
|
|
152
|
+
if (anchor.kind !== "hunk") return null;
|
|
153
|
+
const hunk = findHunk(changeset, anchor.hunkId);
|
|
154
|
+
if (!hunk) return null;
|
|
155
|
+
const marker = {
|
|
156
|
+
add: "+",
|
|
157
|
+
del: "-",
|
|
158
|
+
context: " "
|
|
159
|
+
};
|
|
160
|
+
const rows = hunk.lines.map((l) => marker[l.kind] + l.text);
|
|
161
|
+
const codeContext = rows.join("\n");
|
|
162
|
+
const last = anchor.endLine ?? anchor.line;
|
|
163
|
+
const covered = hunk.lines.map((l, row) => ({
|
|
164
|
+
no: anchor.side === "old" ? l.oldNo : l.newNo,
|
|
165
|
+
row
|
|
166
|
+
})).filter((r) => r.no !== null && r.no >= anchor.line && r.no <= last);
|
|
167
|
+
if (covered.length === 0) return { codeContext };
|
|
168
|
+
const start = covered[0].row;
|
|
169
|
+
const end = covered.at(-1).row;
|
|
170
|
+
return {
|
|
171
|
+
codeContext,
|
|
172
|
+
anchored: {
|
|
173
|
+
start,
|
|
174
|
+
end,
|
|
175
|
+
text: rows.slice(start, Math.min(end + 1, start + ANCHORED_TEXT_CAP)).join("\n")
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
151
179
|
const NPX = `npx -y @diffohq/diffo`;
|
|
152
180
|
/** This checkout's root — `src/server/prompt.ts` → up two. */
|
|
153
181
|
const CHECKOUT_ROOT = fileURLToPath(new URL("../..", import.meta.url)).replace(/\/$/, "");
|
|
@@ -191,6 +219,7 @@ const INSTALL_SKILL = {
|
|
|
191
219
|
const POLL_STANCE = "a tracked background task if your harness has one, the foreground if not — never a detached process";
|
|
192
220
|
const JOIN_PROMPT = `join the diffo review: run \`${CLI_COMMANDS.poll}\` (${POLL_STANCE}) and follow the JSON payload it prints — each payload carries its own instructions`;
|
|
193
221
|
function nextStepFor(kind, actionable) {
|
|
222
|
+
if (kind === "cleared") return `Post the guide if this changeset warrants one, then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`;
|
|
194
223
|
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.`;
|
|
195
224
|
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.`;
|
|
196
225
|
}
|
|
@@ -341,20 +370,83 @@ function siblingLines(siblings) {
|
|
|
341
370
|
].join("\n");
|
|
342
371
|
}
|
|
343
372
|
const SNAPSHOT_LINE_CAP = 25;
|
|
373
|
+
/** The heading's movement-proof identifier: the first anchored line's text,
|
|
374
|
+
* stripped of its diff marker. The line numbers in `describeAnchor` go stale
|
|
375
|
+
* the moment the code moves; this text is what the agent can still search for. */
|
|
376
|
+
function anchorQuote(thread) {
|
|
377
|
+
const first = thread.anchored?.text.split("\n").map((line) => line.slice(1).trim()).find((line) => line !== "")?.slice(0, 80);
|
|
378
|
+
if (!first) return "";
|
|
379
|
+
return thread.anchor.kind === "hunk" && thread.anchor.endLine !== void 0 ? ` — starts at: "${first}"` : ` — "${first}"`;
|
|
380
|
+
}
|
|
381
|
+
/** When `path:line` must not be trusted against the working tree: an old-side
|
|
382
|
+
* anchor never matches it, and a rotated hunk means the code moved under the
|
|
383
|
+
* numbers. At most one note — removed code can't also have "moved". */
|
|
384
|
+
function anchorNote(thread) {
|
|
385
|
+
if (thread.anchor.kind !== "hunk") return [];
|
|
386
|
+
if (thread.anchor.side === "old") return ["This comments on removed code — the anchored lines are the old version, not the current file."];
|
|
387
|
+
if (thread.codeChanged) return ["The code under this comment changed after the comment was written — its line numbers are stale. Find the code by its anchored lines, not by number."];
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
/** The `[from, to)` slice of the snapshot to show: the anchored rows centered
|
|
391
|
+
* inside the cap. A range longer than the cap keeps its head. */
|
|
392
|
+
function snapshotWindow(total, anchored) {
|
|
393
|
+
const size = Math.min(total, SNAPSHOT_LINE_CAP);
|
|
394
|
+
const span = anchored.end - anchored.start + 1;
|
|
395
|
+
const pad = Math.floor(Math.max(0, size - span) / 2);
|
|
396
|
+
const from = Math.min(Math.max(0, anchored.start - pad), total - size);
|
|
397
|
+
return {
|
|
398
|
+
from,
|
|
399
|
+
to: from + size
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/** The snapshot as the agent sees it: windowed on the anchored rows, each of
|
|
403
|
+
* them marked with `>`. Threads from before `anchored` existed keep the old
|
|
404
|
+
* head-of-hunk window. */
|
|
405
|
+
function snapshotBlock(thread) {
|
|
406
|
+
const { codeContext, anchored } = thread;
|
|
407
|
+
if (!codeContext) {
|
|
408
|
+
if (!anchored) return [];
|
|
409
|
+
const shown = anchored.text.split("\n");
|
|
410
|
+
const cut = anchored.end - anchored.start + 1 - shown.length;
|
|
411
|
+
return [
|
|
412
|
+
"The commented lines, as they were when the comment was written:",
|
|
413
|
+
"```diff",
|
|
414
|
+
...shown,
|
|
415
|
+
"```",
|
|
416
|
+
...cut > 0 ? [`(… and ${cut} more commented lines)`] : []
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
const lines = codeContext.split("\n");
|
|
420
|
+
const where = thread.anchor.kind === "changeset" ? "the file" : `\`${thread.anchor.path}\``;
|
|
421
|
+
if (!anchored) return [
|
|
422
|
+
"The commented change:",
|
|
423
|
+
"```diff",
|
|
424
|
+
...lines.slice(0, SNAPSHOT_LINE_CAP),
|
|
425
|
+
"```",
|
|
426
|
+
...lines.length > SNAPSHOT_LINE_CAP ? [`(… and ${lines.length - SNAPSHOT_LINE_CAP} more snapshot lines — read the current ${where} instead)`] : []
|
|
427
|
+
];
|
|
428
|
+
const { from, to } = snapshotWindow(lines.length, anchored);
|
|
429
|
+
const shown = lines.slice(from, to).map((line, i) => from + i >= anchored.start && from + i <= anchored.end ? `>${line}` : line);
|
|
430
|
+
const cut = lines.length - (to - from);
|
|
431
|
+
return [
|
|
432
|
+
"The commented change (`>` marks the commented lines):",
|
|
433
|
+
"```diff",
|
|
434
|
+
...shown,
|
|
435
|
+
"```",
|
|
436
|
+
...cut > 0 ? [`(windowed to the commented lines — ${cut} more snapshot lines around them; read the current ${where} for the full change)`] : []
|
|
437
|
+
];
|
|
438
|
+
}
|
|
344
439
|
function threadBlock(thread, index) {
|
|
345
440
|
const label = startedByAgent(thread) ? " [your comment — the reviewer replied]" : thread.closingNote ? " [their closing note on the whole review]" : thread.intent ? ` [${INTENT_LABEL[thread.intent]}]` : "";
|
|
346
441
|
const again = thread.unanswered ? " — YOU NEVER ANSWERED THIS" : "";
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
parts.push("Messages:", ...thread.messages.map((m) => `- ${m.author}: ${m.text.replace(/\n/g, "\n ")}`));
|
|
357
|
-
return parts.join("\n");
|
|
442
|
+
return [
|
|
443
|
+
`### Thread ${index + 1}${label} — ${describeAnchor(thread.anchor)}${anchorQuote(thread)}${again}`,
|
|
444
|
+
`id: ${thread.id}`,
|
|
445
|
+
...anchorNote(thread),
|
|
446
|
+
...snapshotBlock(thread),
|
|
447
|
+
"Messages:",
|
|
448
|
+
...thread.messages.map((m) => `- ${m.author}: ${m.text.replace(/\n/g, "\n ")}`)
|
|
449
|
+
].join("\n");
|
|
358
450
|
}
|
|
359
451
|
function buildThreadPrompt(thread, ctx) {
|
|
360
452
|
const siblings = siblingLines(ctx.siblings ?? []);
|
|
@@ -381,6 +473,23 @@ function buildCoalescedPrompt(threads, ctx) {
|
|
|
381
473
|
""
|
|
382
474
|
].join("\n");
|
|
383
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* The reviewer started the review over — the previous round landed, and its
|
|
478
|
+
* threads and guide were cleared. Nothing to act on; the one thing owed is
|
|
479
|
+
* orientation for the fresh round, so this restates the guide doctrine the way
|
|
480
|
+
* the open-time nudge does (payloads must stand alone — see POLL_STANCE).
|
|
481
|
+
*/
|
|
482
|
+
function buildClearedPrompt(ctx) {
|
|
483
|
+
return [
|
|
484
|
+
`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.`,
|
|
485
|
+
"",
|
|
486
|
+
...ctx.changeset ? [specLine(ctx.changeset), ""] : [],
|
|
487
|
+
`There is no feedback to act on. But the fresh round has no guide — if this changeset needs one (${GUIDE.when}): post it — one comment on the whole changeset: ${GUIDE.what}: \`${CLI_COMMANDS.guide}\` (no file, so it anchors to the changeset). ${GUIDE.stance}.`,
|
|
488
|
+
"",
|
|
489
|
+
`Then run \`${CLI_COMMANDS.poll}\` again to keep listening (${POLL_STANCE}).`,
|
|
490
|
+
""
|
|
491
|
+
].join("\n");
|
|
492
|
+
}
|
|
384
493
|
function buildFinishPrompt(threads, ctx, coverage) {
|
|
385
494
|
const repo = ctx.repo;
|
|
386
495
|
const sent = threads.filter((t) => t.state === "sent");
|
|
@@ -396,7 +505,6 @@ function buildFinishPrompt(threads, ctx, coverage) {
|
|
|
396
505
|
coverage.skippedFiles.length > 0 ? ` Not marked read: ${coverage.skippedFiles.join(", ")}.` : ""
|
|
397
506
|
].join("");
|
|
398
507
|
const files = coverage.totalFiles !== void 0 && coverage.totalFiles > 0 ? `${coverage.viewedFiles ?? 0}/${coverage.totalFiles} files read, ` : "";
|
|
399
|
-
const verdictLine = coverage.verdict === "approve" ? "Verdict: **approved** — the reviewer is happy for this changeset to proceed." : coverage.verdict === "request-changes" ? "Verdict: **changes requested** — do not treat this review as done until the threads below are addressed." : null;
|
|
400
508
|
const noteLines = coverage.note === void 0 ? [] : closing ? ["Their closing note is Thread 1 below — answer it there, like any other thread."] : ["Their closing note:", ...coverage.note.split("\n").map((l) => `> ${l}`)];
|
|
401
509
|
const owed = actionable.filter((t) => t.unanswered);
|
|
402
510
|
const owedLines = owed.length > 0 ? [
|
|
@@ -409,7 +517,6 @@ function buildFinishPrompt(threads, ctx, coverage) {
|
|
|
409
517
|
const parts = [
|
|
410
518
|
`A reviewer finished reading your changes in \`${repo.name}\` (branch \`${repo.branch}\`).`,
|
|
411
519
|
"",
|
|
412
|
-
...verdictLine ? [verdictLine, ""] : [],
|
|
413
520
|
...noteLines.length > 0 ? [...noteLines, ""] : [],
|
|
414
521
|
...ctx.changeset ? [changesetFrame(ctx.changeset), ""] : [],
|
|
415
522
|
`Coverage: ${files}${coverage.viewedHunks}/${coverage.totalHunks} hunks read.${skipped}`,
|
|
@@ -417,8 +524,10 @@ function buildFinishPrompt(threads, ctx, coverage) {
|
|
|
417
524
|
...owedLines
|
|
418
525
|
];
|
|
419
526
|
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.`, "");
|
|
420
|
-
if (actionable.length === 0)
|
|
421
|
-
|
|
527
|
+
if (actionable.length === 0) {
|
|
528
|
+
const fullRead = coverage.totalHunks > 0 && coverage.viewedHunks >= coverage.totalHunks && changed.length === 0 && commented.length === 0 && filtered.length === 0 && coverage.skippedFiles.length === 0;
|
|
529
|
+
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), "");
|
|
422
531
|
return parts.join("\n");
|
|
423
532
|
}
|
|
424
533
|
//#endregion
|
|
@@ -849,6 +958,57 @@ function parseVerb(verb, rest) {
|
|
|
849
958
|
};
|
|
850
959
|
}
|
|
851
960
|
//#endregion
|
|
961
|
+
//#region src/devStamp.ts
|
|
962
|
+
/**
|
|
963
|
+
* A fingerprint of the dev checkout's source. The dev CLI runs `src/` straight
|
|
964
|
+
* through tsx, so "which build is this?" isn't answered by the package version —
|
|
965
|
+
* every edit is a new build with the same version string. The stamp is what
|
|
966
|
+
* changes instead: the server reports the stamp of the source it loaded, the
|
|
967
|
+
* next `diffo` open compares it to the source on disk, and a mismatch retires
|
|
968
|
+
* the server exactly like a version bump would.
|
|
969
|
+
*
|
|
970
|
+
* Tests are skipped — they never run inside the server — and `index.html` is
|
|
971
|
+
* included because the client bundle is built from it.
|
|
972
|
+
*/
|
|
973
|
+
function computeSrcStamp(checkoutRoot) {
|
|
974
|
+
const hash = createHash("sha256");
|
|
975
|
+
const file = (path, name) => {
|
|
976
|
+
hash.update(name);
|
|
977
|
+
hash.update("\0");
|
|
978
|
+
hash.update(readFileSync(path));
|
|
979
|
+
hash.update("\0");
|
|
980
|
+
};
|
|
981
|
+
const walk = (dir, prefix) => {
|
|
982
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
983
|
+
for (const entry of entries) {
|
|
984
|
+
if (entry.name.endsWith(".test.ts") || entry.name.endsWith(".test.tsx")) continue;
|
|
985
|
+
const path = join(dir, entry.name);
|
|
986
|
+
const name = `${prefix}${entry.name}`;
|
|
987
|
+
if (entry.isDirectory()) walk(path, `${name}/`);
|
|
988
|
+
else if (entry.isFile()) file(path, name);
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
walk(join(checkoutRoot, "src"), "src/");
|
|
992
|
+
try {
|
|
993
|
+
file(join(checkoutRoot, "index.html"), "index.html");
|
|
994
|
+
} catch {}
|
|
995
|
+
return hash.digest("hex").slice(0, 16);
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* This process's stamp, captured at import — for the server that means the
|
|
999
|
+
* source it actually loaded, not whatever is on disk by the time someone asks.
|
|
1000
|
+
* Null outside dev (a published build has no `src/` to stamp, and its version
|
|
1001
|
+
* string already does this job).
|
|
1002
|
+
*/
|
|
1003
|
+
const SRC_STAMP = (() => {
|
|
1004
|
+
if (!IS_DEV) return null;
|
|
1005
|
+
try {
|
|
1006
|
+
return computeSrcStamp(CHECKOUT_ROOT);
|
|
1007
|
+
} catch {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
})();
|
|
1011
|
+
//#endregion
|
|
852
1012
|
//#region src/server/git.ts
|
|
853
1013
|
const MAX_BUFFER = 52428800;
|
|
854
1014
|
const SNIFF_BYTES = 8192;
|
|
@@ -1507,7 +1667,7 @@ const VERSION = (() => {
|
|
|
1507
1667
|
dir = resolve(dir, "..");
|
|
1508
1668
|
try {
|
|
1509
1669
|
const pkg = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"));
|
|
1510
|
-
if (pkg.name === "diffo" && typeof pkg.version === "string") return pkg.version;
|
|
1670
|
+
if (pkg.name === "@diffohq/diffo" && typeof pkg.version === "string") return pkg.version;
|
|
1511
1671
|
} catch {}
|
|
1512
1672
|
}
|
|
1513
1673
|
return "0.0.0";
|
|
@@ -1635,7 +1795,8 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
1635
1795
|
if (!bucket) {
|
|
1636
1796
|
bucket = {
|
|
1637
1797
|
threads: /* @__PURE__ */ new Set(),
|
|
1638
|
-
finish: null
|
|
1798
|
+
finish: null,
|
|
1799
|
+
cleared: false
|
|
1639
1800
|
};
|
|
1640
1801
|
this.buckets.set(this.scope, bucket);
|
|
1641
1802
|
}
|
|
@@ -1764,6 +1925,14 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
1764
1925
|
this.bucket().finish = coverage;
|
|
1765
1926
|
this.wake();
|
|
1766
1927
|
}
|
|
1928
|
+
/** The reviewer cleared the review. A boolean, not a queue: clearing twice
|
|
1929
|
+
* before a poll still owes exactly one heads-up. */
|
|
1930
|
+
enqueueCleared() {
|
|
1931
|
+
this.bucket().cleared = true;
|
|
1932
|
+
const queued = this.waiter === null;
|
|
1933
|
+
this.wake();
|
|
1934
|
+
if (queued) this.notify();
|
|
1935
|
+
}
|
|
1767
1936
|
drop(threadId) {
|
|
1768
1937
|
for (const bucket of this.buckets.values()) bucket.threads.delete(threadId);
|
|
1769
1938
|
this.deliveredAt.delete(threadId);
|
|
@@ -1823,7 +1992,7 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
1823
1992
|
}
|
|
1824
1993
|
hasPending() {
|
|
1825
1994
|
const bucket = this.buckets.get(this.scope);
|
|
1826
|
-
return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null);
|
|
1995
|
+
return bucket !== void 0 && (bucket.threads.size > 0 || bucket.finish !== null || bucket.cleared);
|
|
1827
1996
|
}
|
|
1828
1997
|
wake() {
|
|
1829
1998
|
if (this.waiter === null) return;
|
|
@@ -1845,11 +2014,19 @@ var DeliveryQueue = class DeliveryQueue {
|
|
|
1845
2014
|
kind: "threads",
|
|
1846
2015
|
threadIds: [...bucket.threads]
|
|
1847
2016
|
};
|
|
2017
|
+
if (bucket.cleared) return { kind: "cleared" };
|
|
1848
2018
|
return null;
|
|
1849
2019
|
}
|
|
1850
2020
|
/** The poll response for this snapshot was fully written — NOW it counts as
|
|
1851
2021
|
* delivered. Clears exactly what the snapshot covered. */
|
|
1852
2022
|
confirm(snapshot, deliveredThreadIds) {
|
|
2023
|
+
if (snapshot.kind === "cleared") {
|
|
2024
|
+
const bucket = this.buckets.get(this.scope);
|
|
2025
|
+
if (bucket) bucket.cleared = false;
|
|
2026
|
+
this.armGrace();
|
|
2027
|
+
this.notify();
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
1853
2030
|
for (const bucket of this.buckets.values()) if (snapshot.kind === "finish") {
|
|
1854
2031
|
if (bucket.finish === snapshot.coverage) bucket.finish = null;
|
|
1855
2032
|
for (const id of snapshot.absorbedThreadIds) bucket.threads.delete(id);
|
|
@@ -1981,6 +2158,15 @@ var IdleMonitor = class {
|
|
|
1981
2158
|
* committed. Stamp `landed` — the UI's cue to offer a fresh start. A stash
|
|
1982
2159
|
* empties the diff without moving HEAD, so it never stamps; a branch switch
|
|
1983
2160
|
* is rescoped to its own review before this runs.
|
|
2161
|
+
* - The commit can also go unwitnessed: it lands and the next round starts
|
|
2162
|
+
* while no server watches, so no recompute ever sees the empty diff. The
|
|
2163
|
+
* evidence survives anyway — HEAD advanced past `seenHead` AND every hunk
|
|
2164
|
+
* the review pointed at (thread anchors, the last finish) is gone from the
|
|
2165
|
+
* current changeset. Both together stamp even over a non-empty diff; either
|
|
2166
|
+
* alone does not (an unrelated commit leaves the reviewed hunks in place, an
|
|
2167
|
+
* agent rewriting every hunk between recomputes leaves HEAD at `seenHead`).
|
|
2168
|
+
* A review that never anchored to a hunk has no such evidence and waits for
|
|
2169
|
+
* the witnessed path.
|
|
1984
2170
|
* - A landed commit that left HEAD's history (reset, or an amend rewriting it)
|
|
1985
2171
|
* drops the marker — and an amend re-stamps in the same pass, because
|
|
1986
2172
|
* `seenHead` is still the pre-landing base and IS an ancestor of the
|
|
@@ -1993,7 +2179,7 @@ var IdleMonitor = class {
|
|
|
1993
2179
|
* empty diff hangs over a non-empty review it stays frozen at the base — the
|
|
1994
2180
|
* fixed point that makes "amended" and "reset back" distinguishable above.
|
|
1995
2181
|
*/
|
|
1996
|
-
function maintainLanded(review, hasFiles, git) {
|
|
2182
|
+
function maintainLanded(review, hasFiles, currentHunkIds, git) {
|
|
1997
2183
|
const head = git.head();
|
|
1998
2184
|
if (head === null) return null;
|
|
1999
2185
|
const state = review.get();
|
|
@@ -2005,7 +2191,7 @@ function maintainLanded(review, hasFiles, git) {
|
|
|
2005
2191
|
outcome = "cleared";
|
|
2006
2192
|
}
|
|
2007
2193
|
}
|
|
2008
|
-
if (!review.get().landed && !hasFiles && !reviewEmpty && state.seenHead !== void 0 && state.seenHead !== head && git.isAncestor(state.seenHead, head) === true) {
|
|
2194
|
+
if (!review.get().landed && (!hasFiles || reviewedHunksAllLanded(state, currentHunkIds)) && !reviewEmpty && state.seenHead !== void 0 && state.seenHead !== head && git.isAncestor(state.seenHead, head) === true) {
|
|
2009
2195
|
review.markLanded({
|
|
2010
2196
|
sha: head,
|
|
2011
2197
|
subject: git.subject(head),
|
|
@@ -2016,6 +2202,18 @@ function maintainLanded(review, hasFiles, git) {
|
|
|
2016
2202
|
if (hasFiles || reviewEmpty) review.noteHead(head);
|
|
2017
2203
|
return outcome;
|
|
2018
2204
|
}
|
|
2205
|
+
/** The unwitnessed-landing evidence: the review pointed at hunks, and none of
|
|
2206
|
+
* them survive in the current changeset. Vacuous truth deliberately fails —
|
|
2207
|
+
* a review of only file- and changeset-anchored threads names no hunks, so it
|
|
2208
|
+
* cannot testify that they landed. */
|
|
2209
|
+
function reviewedHunksAllLanded(state, currentHunkIds) {
|
|
2210
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
2211
|
+
for (const thread of state.threads) if (thread.anchor.kind === "hunk") referenced.add(thread.anchor.hunkId);
|
|
2212
|
+
for (const id of state.lastFinish?.hunkIds ?? []) referenced.add(id);
|
|
2213
|
+
if (referenced.size === 0) return false;
|
|
2214
|
+
for (const id of referenced) if (currentHunkIds.has(id)) return false;
|
|
2215
|
+
return true;
|
|
2216
|
+
}
|
|
2019
2217
|
//#endregion
|
|
2020
2218
|
//#region src/server/review.ts
|
|
2021
2219
|
const STATES = [
|
|
@@ -2068,14 +2266,15 @@ var ReviewStore = class {
|
|
|
2068
2266
|
this.listeners.add(listener);
|
|
2069
2267
|
return () => this.listeners.delete(listener);
|
|
2070
2268
|
}
|
|
2071
|
-
createThread(anchor, text,
|
|
2269
|
+
createThread(anchor, text, capture, intent, author = "reviewer") {
|
|
2072
2270
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2073
2271
|
return this.insert({
|
|
2074
2272
|
id: randomUUID(),
|
|
2075
2273
|
anchor,
|
|
2076
2274
|
state: "open",
|
|
2077
2275
|
...intent ? { intent } : {},
|
|
2078
|
-
codeContext,
|
|
2276
|
+
codeContext: capture?.codeContext ?? null,
|
|
2277
|
+
...capture?.anchored ? { anchored: capture.anchored } : {},
|
|
2079
2278
|
codeChanged: false,
|
|
2080
2279
|
messages: [{
|
|
2081
2280
|
id: randomUUID(),
|
|
@@ -2454,7 +2653,6 @@ function normalizeCoverage(value) {
|
|
|
2454
2653
|
skippedFiles: fileList(c.skippedFiles) ?? [],
|
|
2455
2654
|
...changedFiles !== void 0 ? { changedFiles } : {},
|
|
2456
2655
|
...commentedUnread !== void 0 ? { commentedUnread } : {},
|
|
2457
|
-
...REVIEW_VERDICTS.includes(c.verdict) ? { verdict: c.verdict } : {},
|
|
2458
2656
|
...typeof c.note === "string" ? { note: c.note } : {}
|
|
2459
2657
|
};
|
|
2460
2658
|
}
|
|
@@ -2489,12 +2687,14 @@ function parseAnchor(value) {
|
|
|
2489
2687
|
if (typeof anchor.hunkId !== "string" || typeof anchor.path !== "string") return null;
|
|
2490
2688
|
if (anchor.side !== "old" && anchor.side !== "new") return null;
|
|
2491
2689
|
if (typeof anchor.line !== "number") return null;
|
|
2690
|
+
const endLine = typeof anchor.endLine === "number" && Number.isInteger(anchor.endLine) && anchor.endLine > anchor.line ? anchor.endLine : void 0;
|
|
2492
2691
|
return {
|
|
2493
2692
|
kind: "hunk",
|
|
2494
2693
|
hunkId: anchor.hunkId,
|
|
2495
2694
|
path: anchor.path,
|
|
2496
2695
|
side: anchor.side,
|
|
2497
|
-
line: anchor.line
|
|
2696
|
+
line: anchor.line,
|
|
2697
|
+
...endLine !== void 0 ? { endLine } : {}
|
|
2498
2698
|
};
|
|
2499
2699
|
}
|
|
2500
2700
|
if (anchor.kind === "file") return typeof anchor.path === "string" ? {
|
|
@@ -2503,12 +2703,25 @@ function parseAnchor(value) {
|
|
|
2503
2703
|
} : null;
|
|
2504
2704
|
return anchor.kind === "changeset" ? { kind: "changeset" } : null;
|
|
2505
2705
|
}
|
|
2706
|
+
/** Dropped when malformed — the thread is still fine without it. */
|
|
2707
|
+
function normalizeAnchored(value) {
|
|
2708
|
+
if (typeof value !== "object" || value === null) return null;
|
|
2709
|
+
const a = value;
|
|
2710
|
+
if (typeof a.start !== "number" || typeof a.end !== "number" || typeof a.text !== "string") return null;
|
|
2711
|
+
if (!Number.isInteger(a.start) || !Number.isInteger(a.end) || a.start < 0 || a.end < a.start) return null;
|
|
2712
|
+
return {
|
|
2713
|
+
start: a.start,
|
|
2714
|
+
end: a.end,
|
|
2715
|
+
text: a.text
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2506
2718
|
function normalizeThread(value, now) {
|
|
2507
2719
|
if (typeof value !== "object" || value === null) return null;
|
|
2508
2720
|
const t = value;
|
|
2509
2721
|
if (typeof t.id !== "string" || !STATES.includes(t.state)) return null;
|
|
2510
2722
|
const anchor = parseAnchor(t.anchor);
|
|
2511
2723
|
if (!anchor) return null;
|
|
2724
|
+
const anchored = normalizeAnchored(t.anchored);
|
|
2512
2725
|
if (!Array.isArray(t.messages)) return null;
|
|
2513
2726
|
const messages = [];
|
|
2514
2727
|
for (const m of t.messages) {
|
|
@@ -2530,6 +2743,7 @@ function normalizeThread(value, now) {
|
|
|
2530
2743
|
state: t.state,
|
|
2531
2744
|
...THREAD_INTENTS.includes(t.intent) ? { intent: t.intent } : {},
|
|
2532
2745
|
codeContext: typeof t.codeContext === "string" ? t.codeContext : null,
|
|
2746
|
+
...anchored ? { anchored } : {},
|
|
2533
2747
|
codeChanged: t.codeChanged === true,
|
|
2534
2748
|
...t.unanswered === true ? { unanswered: true } : {},
|
|
2535
2749
|
...t.closingNote === true ? { closingNote: true } : {},
|
|
@@ -3007,7 +3221,8 @@ function createApp(ctx, store, review, queue) {
|
|
|
3007
3221
|
repo: resolve(ctx.root),
|
|
3008
3222
|
app: "diffo",
|
|
3009
3223
|
version: VERSION,
|
|
3010
|
-
pid: process.pid
|
|
3224
|
+
pid: process.pid,
|
|
3225
|
+
...SRC_STAMP === null ? {} : { srcStamp: SRC_STAMP }
|
|
3011
3226
|
}));
|
|
3012
3227
|
app.post("/api/shutdown", (c) => {
|
|
3013
3228
|
setImmediate(() => ctx.onShutdownRequest?.());
|
|
@@ -3065,14 +3280,14 @@ function createApp(ctx, store, review, queue) {
|
|
|
3065
3280
|
const file = typeof body?.file === "string" && body.file.trim() !== "" ? body.file.trim() : null;
|
|
3066
3281
|
const line = typeof body?.line === "number" && Number.isInteger(body.line) && body.line >= 1 ? body.line : null;
|
|
3067
3282
|
const anchor = agentAnchor(file, line);
|
|
3068
|
-
const
|
|
3069
|
-
return c.json(review.createThread(anchor, text,
|
|
3283
|
+
const capture = store ? captureAnchor(store.get(), anchor) : null;
|
|
3284
|
+
return c.json(review.createThread(anchor, text, capture, void 0, "agent"));
|
|
3070
3285
|
}
|
|
3071
3286
|
const anchor = parseAnchor(body?.anchor);
|
|
3072
3287
|
if (!anchor) return c.json({ error: "need {anchor, text}" }, 400);
|
|
3073
3288
|
const intent = THREAD_INTENTS.includes(body?.intent) ? body.intent : void 0;
|
|
3074
|
-
const
|
|
3075
|
-
return c.json(review.createThread(anchor, text,
|
|
3289
|
+
const capture = store ? captureAnchor(store.get(), anchor) : null;
|
|
3290
|
+
return c.json(review.createThread(anchor, text, capture, intent));
|
|
3076
3291
|
});
|
|
3077
3292
|
app.post("/api/review/threads/:id/messages", async (c) => {
|
|
3078
3293
|
if (!review) return c.json({ error: "review unavailable" }, 503);
|
|
@@ -3117,8 +3332,11 @@ function createApp(ctx, store, review, queue) {
|
|
|
3117
3332
|
});
|
|
3118
3333
|
app.delete("/api/review/threads", (c) => {
|
|
3119
3334
|
if (!review) return c.json({ error: "review unavailable" }, 503);
|
|
3335
|
+
const before = review.get();
|
|
3336
|
+
const hadRound = before.threads.length > 0 || before.lastFinish !== void 0;
|
|
3120
3337
|
const removed = review.reset();
|
|
3121
3338
|
for (const id of removed) queue?.drop(id);
|
|
3339
|
+
if (hadRound && (store?.get().files.length ?? 0) > 0) queue?.enqueueCleared();
|
|
3122
3340
|
return c.json({ removed: removed.length });
|
|
3123
3341
|
});
|
|
3124
3342
|
app.delete("/api/review/landed", (c) => {
|
|
@@ -3157,7 +3375,6 @@ function createApp(ctx, store, review, queue) {
|
|
|
3157
3375
|
const n = Number(value);
|
|
3158
3376
|
return Number.isFinite(n) ? n : void 0;
|
|
3159
3377
|
};
|
|
3160
|
-
const verdict = REVIEW_VERDICTS.includes(src.verdict) ? src.verdict : void 0;
|
|
3161
3378
|
const note = typeof src.note === "string" && src.note.trim() !== "" ? src.note.trim().slice(0, 4e3) : void 0;
|
|
3162
3379
|
const fileList = (value) => Array.isArray(value) ? value.filter((f) => typeof f === "string") : void 0;
|
|
3163
3380
|
const changedFiles = fileList(src.changedFiles);
|
|
@@ -3170,7 +3387,6 @@ function createApp(ctx, store, review, queue) {
|
|
|
3170
3387
|
skippedFiles: fileList(src.skippedFiles) ?? [],
|
|
3171
3388
|
...changedFiles !== void 0 ? { changedFiles } : {},
|
|
3172
3389
|
...commentedUnread !== void 0 ? { commentedUnread } : {},
|
|
3173
|
-
...verdict !== void 0 ? { verdict } : {},
|
|
3174
3390
|
...note !== void 0 ? { note } : {}
|
|
3175
3391
|
};
|
|
3176
3392
|
};
|
|
@@ -3263,12 +3479,21 @@ function createApp(ctx, store, review, queue) {
|
|
|
3263
3479
|
});
|
|
3264
3480
|
const pollPayload = (snapshot) => {
|
|
3265
3481
|
const threads = review?.get().threads ?? [];
|
|
3482
|
+
if (snapshot.kind === "cleared") return {
|
|
3483
|
+
status: "feedback",
|
|
3484
|
+
kind: "cleared",
|
|
3485
|
+
threadIds: [],
|
|
3486
|
+
prompt: buildClearedPrompt({
|
|
3487
|
+
repo: repoInfo(),
|
|
3488
|
+
changeset: store?.get() ?? null
|
|
3489
|
+
})
|
|
3490
|
+
};
|
|
3266
3491
|
if (snapshot.kind === "finish") {
|
|
3267
3492
|
const batch = activeThreads(threads);
|
|
3268
3493
|
return {
|
|
3269
3494
|
status: "feedback",
|
|
3270
3495
|
kind: "finish",
|
|
3271
|
-
threadIds: batch.filter((t) => t.state === "sent").map((t) => t.id),
|
|
3496
|
+
threadIds: batch.filter((t) => t.state === "sent" && t.messages.at(-1)?.author === "reviewer").map((t) => t.id),
|
|
3272
3497
|
prompt: buildFinishPrompt(batch, {
|
|
3273
3498
|
repo: repoInfo(),
|
|
3274
3499
|
changeset: store?.get() ?? null
|
|
@@ -3549,8 +3774,8 @@ function startServer(options) {
|
|
|
3549
3774
|
isAncestor: (ancestor, descendant) => isAncestor(options.root, ancestor, descendant),
|
|
3550
3775
|
subject: (sha) => getCommitSubject(options.root, sha)
|
|
3551
3776
|
};
|
|
3552
|
-
const checkLanded = (hasFiles) => {
|
|
3553
|
-
if (maintainLanded(review, hasFiles, landedGit) === "stamped") {
|
|
3777
|
+
const checkLanded = (hasFiles, hunkIds) => {
|
|
3778
|
+
if (maintainLanded(review, hasFiles, hunkIds, landedGit) === "stamped") {
|
|
3554
3779
|
const landed = review.get().landed;
|
|
3555
3780
|
if (landed) log(`changeset landed in ${landed.sha.slice(0, 7)} — offering a fresh review`);
|
|
3556
3781
|
}
|
|
@@ -3561,11 +3786,13 @@ function startServer(options) {
|
|
|
3561
3786
|
review.rescope(changeset.repo.branch);
|
|
3562
3787
|
queue.rescope(changeset.repo.branch);
|
|
3563
3788
|
if (review.scope.branch !== before) log(`branch ${before || "detached"} → ${changeset.repo.branch || "detached"}`);
|
|
3564
|
-
|
|
3565
|
-
|
|
3789
|
+
const hunkIds = new Set(changeset.files.flatMap((f) => f.hunks.map((h) => h.id)));
|
|
3790
|
+
review.reconcile(hunkIds);
|
|
3791
|
+
checkLanded(changeset.files.length > 0, hunkIds);
|
|
3566
3792
|
});
|
|
3567
|
-
|
|
3568
|
-
|
|
3793
|
+
const startupHunkIds = new Set(store.get().files.flatMap((f) => f.hunks.map((h) => h.id)));
|
|
3794
|
+
review.reconcile(startupHunkIds);
|
|
3795
|
+
checkLanded(store.get().files.length > 0, startupHunkIds);
|
|
3569
3796
|
const idleTimeoutMs = resolveIdleTimeoutMs(process.env);
|
|
3570
3797
|
let idle;
|
|
3571
3798
|
if (idleTimeoutMs !== null) {
|
|
@@ -3635,9 +3862,17 @@ function reviewUrl(port) {
|
|
|
3635
3862
|
//#endregion
|
|
3636
3863
|
//#region src/serverLifecycle.ts
|
|
3637
3864
|
const MAX_LOG_BYTES = 2097152;
|
|
3638
|
-
|
|
3865
|
+
/**
|
|
3866
|
+
* `srcStamp` is the dev-build refinement of the version check: a dev server's
|
|
3867
|
+
* version never changes across source edits, so an expected stamp (non-null)
|
|
3868
|
+
* makes a server started from other source a 'replace' too. Callers that must
|
|
3869
|
+
* not churn a running review (poll, reply) simply pass none.
|
|
3870
|
+
*/
|
|
3871
|
+
function assessRunningServer(health, repoPath, version, srcStamp = null) {
|
|
3639
3872
|
if (health?.ok !== true || health.repo !== repoPath) return "foreign";
|
|
3640
|
-
|
|
3873
|
+
if (health.app !== "diffo" || health.version !== version) return "replace";
|
|
3874
|
+
if (srcStamp !== null && health.srcStamp !== srcStamp) return "replace";
|
|
3875
|
+
return "reuse";
|
|
3641
3876
|
}
|
|
3642
3877
|
/**
|
|
3643
3878
|
* argv for the daemon: the same runtime flags executing this CLI, the same entry
|
|
@@ -3711,17 +3946,17 @@ async function retireServer(port, pid, reportedPid = null) {
|
|
|
3711
3946
|
* their own copy and write the whole blob back, so the slower watcher erases the
|
|
3712
3947
|
* reviewer's threads, and clearing the row would hand the claim to a newcomer.
|
|
3713
3948
|
*/
|
|
3714
|
-
async function settleExistingServer(db, repoPath, version, onStatus = () => {}) {
|
|
3949
|
+
async function settleExistingServer(db, repoPath, version, onStatus = () => {}, srcStamp = null) {
|
|
3715
3950
|
const record = db.getServer(repoPath);
|
|
3716
3951
|
if (!record) return null;
|
|
3717
3952
|
const health = await fetchHealth(record.port);
|
|
3718
|
-
const verdict = assessRunningServer(health, repoPath, version);
|
|
3953
|
+
const verdict = assessRunningServer(health, repoPath, version, srcStamp);
|
|
3719
3954
|
if (verdict === "reuse") return {
|
|
3720
3955
|
port: record.port,
|
|
3721
3956
|
pid: health?.pid ?? record.pid ?? null
|
|
3722
3957
|
};
|
|
3723
3958
|
if (verdict === "replace") {
|
|
3724
|
-
onStatus(`replacing the diffo server from another build (${health?.version ?? "pre-handshake"} → ${version})`);
|
|
3959
|
+
onStatus(health?.version === version ? `replacing the dev server — the checkout's source changed since it started` : `replacing the diffo server from another build (${health?.version ?? "pre-handshake"} → ${version})`);
|
|
3725
3960
|
if (!await retireServer(record.port, record.pid ?? null, health?.pid ?? null)) throw new Error(`a diffo server from another build is stuck on port ${record.port}` + (record.pid ? ` (pid ${record.pid})` : "") + ` and would not step aside — stop it, then re-run`);
|
|
3726
3961
|
}
|
|
3727
3962
|
db.removeServer(repoPath, record.port);
|
|
@@ -3781,7 +4016,7 @@ async function ensureServer(opts, deps = {}) {
|
|
|
3781
4016
|
const status = opts.onStatus ?? (() => {});
|
|
3782
4017
|
const db = new DiffoDb(opts.dbPath);
|
|
3783
4018
|
try {
|
|
3784
|
-
const existing = await settleExistingServer(db, opts.repoPath, opts.version, status);
|
|
4019
|
+
const existing = await settleExistingServer(db, opts.repoPath, opts.version, status, opts.srcStamp ?? null);
|
|
3785
4020
|
if (existing) return existing.port;
|
|
3786
4021
|
status("no diffo server for this repo — starting one");
|
|
3787
4022
|
mkdirSync(dirname(opts.logPath), { recursive: true });
|
|
@@ -3792,7 +4027,7 @@ async function ensureServer(opts, deps = {}) {
|
|
|
3792
4027
|
if (!await waitUntil(async () => {
|
|
3793
4028
|
const row = db.getServer(opts.repoPath);
|
|
3794
4029
|
if (!row) return false;
|
|
3795
|
-
if (assessRunningServer(await fetchHealth(row.port, 500), opts.repoPath, opts.version) !== "reuse") return false;
|
|
4030
|
+
if (assessRunningServer(await fetchHealth(row.port, 500), opts.repoPath, opts.version, opts.srcStamp ?? null) !== "reuse") return false;
|
|
3796
4031
|
port = row.port;
|
|
3797
4032
|
return true;
|
|
3798
4033
|
}, opts.spawnTimeoutMs ?? 8e3) || port === null) {
|
|
@@ -4201,10 +4436,15 @@ if (command.kind === "setup") {
|
|
|
4201
4436
|
const root = findRepoRoot(process.cwd());
|
|
4202
4437
|
if (!root) fail("not inside a git repository");
|
|
4203
4438
|
const repoPath = resolve(root);
|
|
4439
|
+
/**
|
|
4440
|
+
* Only the open path settles with the source stamp: opening a review is the
|
|
4441
|
+
* moment "run the current source" is the promise, while poll/reply/comment must
|
|
4442
|
+
* never restart the server under a review in progress.
|
|
4443
|
+
*/
|
|
4204
4444
|
async function discoverServer() {
|
|
4205
4445
|
const db = new DiffoDb();
|
|
4206
4446
|
try {
|
|
4207
|
-
return await settleExistingServer(db, repoPath, VERSION, (line) => process.stderr.write(`diffo: ${line}\n`));
|
|
4447
|
+
return await settleExistingServer(db, repoPath, VERSION, (line) => process.stderr.write(`diffo: ${line}\n`), SRC_STAMP);
|
|
4208
4448
|
} catch (err) {
|
|
4209
4449
|
fail(err.message);
|
|
4210
4450
|
} finally {
|
|
@@ -4218,7 +4458,7 @@ function sessionHeaders() {
|
|
|
4218
4458
|
...pid === null ? {} : { "x-diffo-session-pid": String(pid) }
|
|
4219
4459
|
};
|
|
4220
4460
|
}
|
|
4221
|
-
async function requireServer(port, base) {
|
|
4461
|
+
async function requireServer(port, base, srcStamp = null) {
|
|
4222
4462
|
try {
|
|
4223
4463
|
return await ensureServer({
|
|
4224
4464
|
repoPath,
|
|
@@ -4228,6 +4468,7 @@ async function requireServer(port, base) {
|
|
|
4228
4468
|
execArgv: process.execArgv,
|
|
4229
4469
|
port,
|
|
4230
4470
|
base,
|
|
4471
|
+
srcStamp,
|
|
4231
4472
|
logPath: process.env.DIFFO_SERVER_LOG || defaultLogPath(repoPath),
|
|
4232
4473
|
onStatus: (line) => process.stderr.write(`diffo: ${line}\n`)
|
|
4233
4474
|
});
|
|
@@ -4343,7 +4584,7 @@ if (command.kind === "status") {
|
|
|
4343
4584
|
db.close();
|
|
4344
4585
|
}
|
|
4345
4586
|
const health = record ? await fetchHealth(record.port) : null;
|
|
4346
|
-
const verdict = record ? assessRunningServer(health, repoPath, VERSION) : "foreign";
|
|
4587
|
+
const verdict = record ? assessRunningServer(health, repoPath, VERSION, SRC_STAMP) : "foreign";
|
|
4347
4588
|
if (!record || verdict === "foreign") {
|
|
4348
4589
|
if (command.json) console.log(JSON.stringify({ running: false }));
|
|
4349
4590
|
else console.log(record ? "no diffo server is watching this repo (its last registration is stale)" : "no diffo server is watching this repo");
|
|
@@ -4362,7 +4603,7 @@ if (command.kind === "status") {
|
|
|
4362
4603
|
process.exit(0);
|
|
4363
4604
|
}
|
|
4364
4605
|
await printChangesetSummary(record.port);
|
|
4365
|
-
console.log(`server: port ${record.port}${pid ? ` · pid ${pid}` : ""} · v${health?.version ?? "pre-handshake"}` + (verdict === "replace" ? ` (this CLI is v${VERSION} — the next \`diffo\` replaces it)` : ""));
|
|
4606
|
+
console.log(`server: port ${record.port}${pid ? ` · pid ${pid}` : ""} · v${health?.version ?? "pre-handshake"}` + (verdict === "replace" ? health?.version === VERSION ? ` (the checkout's source changed — the next \`diffo\` replaces it)` : ` (this CLI is v${VERSION} — the next \`diffo\` replaces it)` : ""));
|
|
4366
4607
|
console.log(`→ ${url}`);
|
|
4367
4608
|
process.exit(0);
|
|
4368
4609
|
}
|
|
@@ -4472,6 +4713,32 @@ async function printAgentNextStep(port) {
|
|
|
4472
4713
|
if (nudge) console.log(`first: ${nudge}`);
|
|
4473
4714
|
console.log("next: run `diffo poll` to receive the reviewer's feedback (`diffo help agent` prints the whole loop)");
|
|
4474
4715
|
}
|
|
4716
|
+
/**
|
|
4717
|
+
* Dev builds serve the client from the checkout's `dist/client`, which nothing
|
|
4718
|
+
* rebuilds on its own — so opening a review after a source edit showed last
|
|
4719
|
+
* build's UI over this build's server. Rebuild it here, before the server is
|
|
4720
|
+
* settled: the server reads client files from disk per request, so even a
|
|
4721
|
+
* reused server serves the fresh bundle. `.dev-stamp` (written after a
|
|
4722
|
+
* successful build, into a directory vite has just emptied) makes the no-change
|
|
4723
|
+
* open free. The foreground path is exempt — that is `pnpm dev:server`'s tsx
|
|
4724
|
+
* watch loop, where the client is vite's dev server, not this bundle.
|
|
4725
|
+
*/
|
|
4726
|
+
function rebuildDevClient(foreground) {
|
|
4727
|
+
if (SRC_STAMP === null || foreground) return;
|
|
4728
|
+
const stampFile = join(CHECKOUT_ROOT, "dist", "client", ".dev-stamp");
|
|
4729
|
+
try {
|
|
4730
|
+
if (readFileSync(stampFile, "utf-8").trim() === SRC_STAMP) return;
|
|
4731
|
+
} catch {}
|
|
4732
|
+
process.stderr.write(`diffo: dev checkout changed — rebuilding the client bundle\n`);
|
|
4733
|
+
const vite = join(CHECKOUT_ROOT, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
|
|
4734
|
+
const built = spawnSync(vite, ["build"], {
|
|
4735
|
+
cwd: CHECKOUT_ROOT,
|
|
4736
|
+
encoding: "utf-8"
|
|
4737
|
+
});
|
|
4738
|
+
if (built.status !== 0) fail(`the client rebuild failed — fix the build, then re-run\n${(built.stderr || built.stdout || String(built.error ?? "")).trim().slice(-2e3)}`);
|
|
4739
|
+
writeFileSync(stampFile, `${SRC_STAMP}\n`);
|
|
4740
|
+
}
|
|
4741
|
+
rebuildDevClient(command.foreground);
|
|
4475
4742
|
const existing = await discoverServer();
|
|
4476
4743
|
const takeOverPort = existing !== null && command.foreground && command.port !== void 0 && command.port !== existing.port ? command.port : null;
|
|
4477
4744
|
if (existing !== null && takeOverPort === null) {
|
|
@@ -4496,7 +4763,7 @@ if (existing !== null && takeOverPort !== null) {
|
|
|
4496
4763
|
db.close();
|
|
4497
4764
|
}
|
|
4498
4765
|
if (!command.foreground) {
|
|
4499
|
-
const daemonPort = await requireServer(command.port, command.spec.kind === "branch" ? command.spec.base : void 0);
|
|
4766
|
+
const daemonPort = await requireServer(command.port, command.spec.kind === "branch" ? command.spec.base : void 0, SRC_STAMP);
|
|
4500
4767
|
const url = reviewUrl(daemonPort);
|
|
4501
4768
|
await printChangesetSummary(daemonPort);
|
|
4502
4769
|
console.log(`→ ${url}`);
|