@shiyuphan/follow-me 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -69
- package/dist/src/cli.js +1069 -140
- package/dist/src/comments.js +361 -0
- package/dist/src/git.js +32 -3
- package/dist/src/host.js +106 -9
- package/dist/src/install.js +22 -1
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +282 -26
- package/dist/src/prompt.js +711 -98
- package/dist/src/server.js +573 -140
- package/dist/src/session.js +106 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +3 -1
- package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-DcmzRkVZ.js +1 -0
- package/dist/ui/dist/assets/arc-T5QB0T1C.js +1 -0
- package/dist/ui/dist/assets/architecture-WOLXFQ4H-DUIkQTeE.js +1 -0
- package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-BUYi4-bA.js +36 -0
- package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-dyJxtT9e.js +140 -0
- package/dist/ui/dist/assets/c4Diagram-XDPPPE54-CrAgiaZR.js +38 -0
- package/dist/ui/dist/assets/channel-wW7ebm0i.js +1 -0
- package/dist/ui/dist/assets/chunk-2E7WHVMD-C6fAf4Vj.js +88 -0
- package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/dist/assets/chunk-4EMNKR2T-BLZjwZ4Q.js +1 -0
- package/dist/ui/dist/assets/chunk-55XCU76K-DGLK2Q-L.js +1 -0
- package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/dist/assets/chunk-7S6XSTFO-BablszTX.js +2 -0
- package/dist/ui/dist/assets/chunk-AQDTSKZB-Cuk3B_vb.js +72 -0
- package/dist/ui/dist/assets/chunk-DSNLSHJZ-DiC78RqE.js +127 -0
- package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/ui/dist/assets/chunk-HAGFOTED-X_RbvixX.js +62 -0
- package/dist/ui/dist/assets/chunk-HNE4Q36P-C14_x5ck.js +1 -0
- package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/ui/dist/assets/chunk-NQOKRVIF-Dxd0dtYR.js +213 -0
- package/dist/ui/dist/assets/chunk-UHW7PFBP-BIxePNUS.js +1 -0
- package/dist/ui/dist/assets/chunk-VQ5W62QI-CKSKLbj4.js +1 -0
- package/dist/ui/dist/assets/chunk-WQQL4CDK-CWNmZGlR.js +10 -0
- package/dist/ui/dist/assets/chunk-XXDRQBXY-D-I5QHgG.js +1 -0
- package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/dist/assets/chunk-YZO67HFG-DFP68CFd.js +1 -0
- package/dist/ui/dist/assets/chunk-ZAUX2LTX-DY90d4QG.js +1 -0
- package/dist/ui/dist/assets/chunk-ZCHMQSOR-Dlpr-5UC.js +2 -0
- package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-CH9uhoLJ.js +217 -0
- package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-DtYHrp8D.js +1 -0
- package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtslbtrY.js +1 -0
- package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUU1Jcc7.js +62 -0
- package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/ui/dist/assets/dagre-NK4GIZVX-CDlP-dRW.js +4 -0
- package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/dist/assets/diagram-CBK3MWTC-DiV2fn-r.js +24 -0
- package/dist/ui/dist/assets/diagram-I5XIIV2D-Bg5RehGi.js +24 -0
- package/dist/ui/dist/assets/diagram-ODIL2UCW-CFmazz3f.js +30 -0
- package/dist/ui/dist/assets/diagram-PFBEJUXE-uw3ZBqDp.js +3 -0
- package/dist/ui/dist/assets/diagram-RJA3I4VV-uEOYYRYa.js +41 -0
- package/dist/ui/dist/assets/diagram-TYUNFSNR-BN58LF_3.js +200 -0
- package/dist/ui/dist/assets/dist-tWS_lXDS.js +1 -0
- package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-B68Tz-w8.js +1 -0
- package/dist/ui/dist/assets/elk-QONTBCTQ-DZZohNea.js +27 -0
- package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BH_m74pv.js +99 -0
- package/dist/ui/dist/assets/eventmodeling-K75KTNOO-CTp5FHos.js +1 -0
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +1 -0
- package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-BDau7_0f.js +292 -0
- package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/ui/dist/assets/gitGraph-VSP46ZUC-Bv84IoCm.js +1 -0
- package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-DzXcMlm8.js +106 -0
- package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/dist/assets/index-BTJoqVVN.js +133 -0
- package/dist/ui/dist/assets/index-D60U3smc.css +1 -0
- package/dist/ui/dist/assets/info-OHQRW6UA-222J0O_j.js +1 -0
- package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-DE2sa3Vz.js +2 -0
- package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js +70 -0
- package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-JSUXX5Hb.js +139 -0
- package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-Bg79b49M.js +89 -0
- package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
- package/dist/ui/dist/assets/line-Bk5qQSIa.js +1 -0
- package/dist/ui/dist/assets/linear-PFnVMru5.js +1 -0
- package/dist/ui/dist/assets/mermaid-parser.core-Dcxlzc-S.js +7 -0
- package/dist/ui/dist/assets/mermaid.core-TeOHQJ00.js +44 -0
- package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-IaNk8NnW.js +98 -0
- package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/dist/assets/packet-JDAUHWVQ-DMff5cVK.js +1 -0
- package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-66Xu4YRc.js +1 -0
- package/dist/ui/dist/assets/pie-XZMESJXO-GEtzLCIM.js +1 -0
- package/dist/ui/dist/assets/pieDiagram-GNMPY74T-7jMBi-kU.js +39 -0
- package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
- package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
- package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
- package/dist/ui/dist/assets/prism-core-FDHlWQ43.js +1 -0
- package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
- package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
- package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
- package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
- package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
- package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
- package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
- package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
- package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
- package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
- package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
- package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
- package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
- package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
- package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
- package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
- package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
- package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
- package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
- package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
- package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
- package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-8hIWayC2.js +7 -0
- package/dist/ui/dist/assets/radar-ABXABTNO-BTdwUOOF.js +1 -0
- package/dist/ui/dist/assets/railroad-I3PHUGI6-DcMbDqBL.js +1 -0
- package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-gTzUTWeI.js +1 -0
- package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-CNtdUPtk.js +1 -0
- package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-DOiExAtE.js +1 -0
- package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-2kE_ygvM.js +1 -0
- package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-CoIAHRq2.js +84 -0
- package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-7p8D3wim.js +40 -0
- package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-C6QFnMvn.js +169 -0
- package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/ui/dist/assets/src-FgijoBVs.js +1 -0
- package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-n9eAb_bT.js +291 -0
- package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-U18mtUzX.js +1 -0
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +8 -0
- package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-CD2Z8QGP.js +120 -0
- package/dist/ui/dist/assets/treeView-D4JQ5SDB-sqUjH5gB.js +1 -0
- package/dist/ui/dist/assets/treemap-SAJKECNS-DvZ38FP7.js +1 -0
- package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-CqOGidaZ.js +328 -0
- package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D1SXSBQ5.js +34 -0
- package/dist/ui/dist/assets/wardley-7MLQ67FV-Bpwio-Dx.js +1 -0
- package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-JSUXHMnk.js +78 -0
- package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-B4IFqQTN.js +7 -0
- package/dist/ui/dist/index.html +15 -0
- package/dist/ui/dist/style.css +121 -0
- package/package.json +31 -8
- package/skills/follow-me/SKILL.md +15 -61
- package/dist/src/anthropic.js +0 -101
- package/dist/ui/app.js +0 -797
- package/dist/ui/index.html +0 -40
- package/dist/ui/playback.js +0 -175
- package/dist/ui/style.css +0 -466
package/dist/src/prompt.js
CHANGED
|
@@ -53,7 +53,7 @@ function renderPreviousPlan(plan) {
|
|
|
53
53
|
})
|
|
54
54
|
.join("\n");
|
|
55
55
|
}
|
|
56
|
-
function describeAnchor(anchor) {
|
|
56
|
+
function describeAnchor(anchor, ctx) {
|
|
57
57
|
switch (anchor.kind) {
|
|
58
58
|
case "plan":
|
|
59
59
|
return "the whole plan";
|
|
@@ -62,7 +62,16 @@ function describeAnchor(anchor) {
|
|
|
62
62
|
case "position":
|
|
63
63
|
return `the position of commit ${anchor.commit + 1} (the reviewer thinks it belongs elsewhere)`;
|
|
64
64
|
case "hunk": {
|
|
65
|
-
const
|
|
65
|
+
const pick = ctx === null ? null : pickRows(ctx, anchor);
|
|
66
|
+
if (pick !== null) {
|
|
67
|
+
const rows = formatRanges(pick.offsets);
|
|
68
|
+
return `${pick.label} in commit ${anchor.commit + 1} (${anchor.unit} rows ${rows}; ${pick.counts})`;
|
|
69
|
+
}
|
|
70
|
+
const where = anchor.offsets !== undefined
|
|
71
|
+
? `rows ${formatRanges(anchor.offsets)}`
|
|
72
|
+
: anchor.changes === null
|
|
73
|
+
? "the whole unit"
|
|
74
|
+
: `change lines ${formatRanges(anchor.changes)}`;
|
|
66
75
|
return `${anchor.unit} (${where}) in commit ${anchor.commit + 1}`;
|
|
67
76
|
}
|
|
68
77
|
default: {
|
|
@@ -71,16 +80,117 @@ function describeAnchor(anchor) {
|
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
/** The hunk around the picked rows with the picked ones marked `>`; empty without offsets. */
|
|
84
|
+
function anchorRows(ctx, anchor) {
|
|
85
|
+
return (ctx === null ? null : pickRows(ctx, anchor))?.rows ?? [];
|
|
86
|
+
}
|
|
87
|
+
const PICK_MARGIN = 3;
|
|
88
|
+
function plural(n, noun) {
|
|
89
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolves offsets against the hunk. Line numbers are the branch tip's, or the base's when a
|
|
93
|
+
* picked row is a removal, since a removed line has no tip number. Rows are shown as the commit
|
|
94
|
+
* writes them: its own changes marked +/-, everything else as context.
|
|
95
|
+
*/
|
|
96
|
+
function pickRows(ctx, anchor) {
|
|
97
|
+
if (anchor.kind !== "hunk" || anchor.offsets === undefined)
|
|
98
|
+
return null;
|
|
99
|
+
const hunk = ctx.units.get(anchor.unit);
|
|
100
|
+
const { offsets } = anchor;
|
|
101
|
+
const first = offsets[0];
|
|
102
|
+
const last = offsets.at(-1);
|
|
103
|
+
if (hunk?.kind !== "hunk" || first === undefined || last === undefined)
|
|
104
|
+
return null;
|
|
105
|
+
if (last >= hunk.body.length)
|
|
106
|
+
return null;
|
|
107
|
+
// Without the projected commit, the hunk is quoted as git shows it.
|
|
108
|
+
const projected = anchor.commit < ctx.commits.length;
|
|
109
|
+
const owners = ownersOf(ctx.commits, hunk);
|
|
110
|
+
const picked = new Set(offsets);
|
|
111
|
+
let tip = tipStartOf(hunk, ctx.units);
|
|
112
|
+
let base = hunk.oldStart + 1;
|
|
113
|
+
const rows = [];
|
|
114
|
+
const lines = [];
|
|
115
|
+
hunk.body.forEach((line, i) => {
|
|
116
|
+
lines.push({ op: line.op, tip, base });
|
|
117
|
+
let op = line.op;
|
|
118
|
+
let shown = true;
|
|
119
|
+
if (projected && line.op !== " ") {
|
|
120
|
+
const owner = owners[line.change] ?? null;
|
|
121
|
+
const applied = owner !== null && owner < anchor.commit;
|
|
122
|
+
if (owner !== anchor.commit) {
|
|
123
|
+
shown = line.op === "+" ? applied : !applied;
|
|
124
|
+
op = " ";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (picked.has(i) && !shown) {
|
|
128
|
+
shown = true;
|
|
129
|
+
op = line.op;
|
|
130
|
+
}
|
|
131
|
+
const inWindow = i >= first - PICK_MARGIN && i <= last + PICK_MARGIN;
|
|
132
|
+
if (shown && inWindow)
|
|
133
|
+
rows.push(`${picked.has(i) ? ">" : " "} ${op}${stripEol(line.text)}`);
|
|
134
|
+
if (line.op !== "-")
|
|
135
|
+
tip += 1;
|
|
136
|
+
if (line.op !== "+")
|
|
137
|
+
base += 1;
|
|
138
|
+
});
|
|
139
|
+
const chosen = offsets.flatMap((i) => lines[i] ?? []);
|
|
140
|
+
const removal = chosen.some((l) => l.op === "-");
|
|
141
|
+
const numbers = removal
|
|
142
|
+
? chosen.filter((l) => l.op !== "+").map((l) => l.base)
|
|
143
|
+
: chosen.map((l) => l.tip);
|
|
144
|
+
const from = Math.min(...numbers);
|
|
145
|
+
const to = Math.max(...numbers);
|
|
146
|
+
const range = from === to ? `${from}` : `${from}-${to}`;
|
|
147
|
+
const count = (op) => chosen.filter((l) => l.op === op).length;
|
|
148
|
+
const counts = [
|
|
149
|
+
count(" ") === 0 ? null : plural(count(" "), "context line"),
|
|
150
|
+
count("+") === 0 ? null : `${count("+")} added`,
|
|
151
|
+
count("-") === 0 ? null : `${count("-")} removed`,
|
|
152
|
+
].filter((part) => part !== null);
|
|
153
|
+
return {
|
|
154
|
+
offsets,
|
|
155
|
+
label: `${hunk.path}:${range}${removal ? " (base side)" : ""}`,
|
|
156
|
+
range,
|
|
157
|
+
baseSide: removal,
|
|
158
|
+
line: from,
|
|
159
|
+
counts: counts.join(", "),
|
|
160
|
+
rows,
|
|
161
|
+
touchesContext: count(" ") > 0,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Said once wherever comments reach the agent, with the same words. */
|
|
165
|
+
const CONTEXT_RULE = "A comment on a context row (marked `>` with no + or -) is about code this branch leaves alone or depends on. The plan cannot move it, so do not move lines because of it. Answer a question with an explanation, or point at the commit that last touched those lines. Answer a change request as a code issue, not by reordering.";
|
|
166
|
+
function renderComment(comment, ctx) {
|
|
167
|
+
const lines = [`- ${comment.id} on ${describeAnchor(comment.anchor, ctx)}`];
|
|
168
|
+
const rows = anchorRows(ctx, comment.anchor);
|
|
169
|
+
if (comment.follows)
|
|
170
|
+
lines.push(` asks again after ${comment.follows}`);
|
|
76
171
|
if (comment.context.commitMessage) {
|
|
77
172
|
lines.push(` commit message then: ${JSON.stringify(comment.context.commitMessage)}`);
|
|
78
173
|
}
|
|
79
174
|
if (comment.context.path)
|
|
80
175
|
lines.push(` path: ${comment.context.path}`);
|
|
176
|
+
if (rows.length > 0) {
|
|
177
|
+
lines.push(" rows asked about, picked ones marked >:", ...rows.map((r) => ` ${r}`));
|
|
178
|
+
}
|
|
179
|
+
else if (comment.context.lines && comment.context.lines.length > 0) {
|
|
180
|
+
lines.push(" lines asked about:", ...comment.context.lines.map((l) => ` ${l}`));
|
|
181
|
+
}
|
|
182
|
+
if (comment.severity)
|
|
183
|
+
lines.push(` severity: ${comment.severity}`);
|
|
81
184
|
lines.push(` comment: ${JSON.stringify(comment.body)}`);
|
|
185
|
+
for (const reply of comment.replies ?? []) {
|
|
186
|
+
lines.push(` ${reply.author} replied: ${JSON.stringify(reply.body)}`);
|
|
187
|
+
}
|
|
82
188
|
return lines.join("\n");
|
|
83
189
|
}
|
|
190
|
+
const QUESTIONS = `A question is not a request to change the plan. Answer it by explaining, not by moving lines. Where the asked-about lines now live, add or rewrite the \`narration\` (the page shows it as your note) of that commit or unit so it answers the question, in plain sentences. When the answer is about flow, structure or order across files, add a \`diagram\` there too. Then write one response with \`"status": "answered"\`, a \`note\` of one to three sentences that answers the question directly, and \`"at"\` naming the commit (1-based) and unit that carry the explanation. Move lines only if answering shows the order itself was wrong. Say so in the note, and answer the question as well. If the diff cannot answer it, use \`"deferred"\` and say what is missing.`;
|
|
191
|
+
const CONCERNS = `You are also a careful reviewer. Add a concern only for something you would raise in a pull request: a bug or unhandled case, a change with no test where one is expected, a name that misleads, or a risky change such as a migration, concurrency or security. Pin it to the change lines with \`lines\`. Say what goes wrong and when, in one or two sentences. Severity: \`blocker\` breaks behaviour or loses data, \`major\` should be fixed before merge, \`minor\` is worth a note. Do not flag style, formatting or taste. Do not repeat the note. Most commits deserve no concern, and a plan with none is fine. Never write more than one concern per unit. Concerns are in your voice as a reviewer, while the note stays the author's.
|
|
192
|
+
|
|
193
|
+
A concern is \`{ "severity": "blocker" | "major" | "minor", "kind": "bug" | "missing-test" | "naming" | "risk", "text": "...", "lines": [0, 1] }\`. \`lines\` holds change indices of that unit and is allowed only on a unit ref. Write \`"concerns": [ ... ]\` on a commit, or on a unit written as an object: \`{ "unit": "h_x:0-3", "concerns": [ ... ] }\`. Never write an id; follow-me derives one.`;
|
|
84
194
|
const ORDERING = `Order the commits so the story reads naturally:
|
|
85
195
|
- Scaffolding and types come before the code that uses them.
|
|
86
196
|
- A failing test comes before the fix that makes it pass.
|
|
@@ -90,7 +200,8 @@ const ORDERING = `Order the commits so the story reads naturally:
|
|
|
90
200
|
- The last commit must bring the tree to exactly the tip, which follows from covering every unit.
|
|
91
201
|
|
|
92
202
|
Write each commit message as a short imperative title, then a blank line and a body that
|
|
93
|
-
says why
|
|
203
|
+
says why. Put the why in the body first, before anything else. Keep the title to 50
|
|
204
|
+
characters or fewer; put detail in the body.`;
|
|
94
205
|
const GOALS = {
|
|
95
206
|
split: {
|
|
96
207
|
title: "plan the commits",
|
|
@@ -102,17 +213,16 @@ ${ORDERING}`,
|
|
|
102
213
|
},
|
|
103
214
|
walk: {
|
|
104
215
|
title: "tell the story of this branch",
|
|
105
|
-
goal: `The reader is a reviewer meeting this branch for the first time. They will
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
grouped and ordered, and what the author is thinking while writing them.
|
|
216
|
+
goal: `The reader is a reviewer meeting this branch for the first time. They will read it commit
|
|
217
|
+
by commit and comment on the code itself. The final tree is fixed. You decide how its change
|
|
218
|
+
lines are grouped and ordered, and why each step is there.
|
|
109
219
|
|
|
110
220
|
${ORDERING}
|
|
111
221
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
222
|
+
Add a \`narration\` to a commit, or to a unit, only when it says something the commit message
|
|
223
|
+
body does not. The page shows it as your note. A note is one plain paragraph on why, in plain
|
|
224
|
+
text with no markdown. Say why, not what the lines already show. Leave it out when the body
|
|
225
|
+
already says it.`,
|
|
116
226
|
},
|
|
117
227
|
};
|
|
118
228
|
const FORMAT = (draftPath, base, tip) => `Write the plan as JSON to ${draftPath}. Write only the file, in this format:
|
|
@@ -125,7 +235,7 @@ const FORMAT = (draftPath, base, tip) => `Write the plan as JSON to ${draftPath}
|
|
|
125
235
|
{ "message": "Add the Token type\\n\\nThe parser needs it.", "units": ["h_0123456789ab", "h_ba9876543210:0-3,7"] },
|
|
126
236
|
{
|
|
127
237
|
"message": "Parse tokens",
|
|
128
|
-
"narration": "
|
|
238
|
+
"narration": "Tokens are read in one pass, so the loop below never backtracks.",
|
|
129
239
|
"units": [{ "unit": "h_ba9876543210", "narration": "The loop is boring on purpose." }, "f_0123456789ab"]
|
|
130
240
|
}
|
|
131
241
|
],
|
|
@@ -134,10 +244,22 @@ const FORMAT = (draftPath, base, tip) => `Write the plan as JSON to ${draftPath}
|
|
|
134
244
|
]
|
|
135
245
|
}
|
|
136
246
|
|
|
137
|
-
\`narration\` is optional on a commit and on a unit
|
|
138
|
-
\`{ "unit": "<ref>", "narration": "..." }\` instead of the plain ref string.
|
|
247
|
+
\`narration\` is optional on a commit and on a unit, and the page shows it as your note. A unit
|
|
248
|
+
with a note is written as \`{ "unit": "<ref>", "narration": "..." }\` instead of the plain ref string.
|
|
249
|
+
|
|
250
|
+
\`diagram\` is optional on a commit and on a unit, written the same way:
|
|
251
|
+
\`{ "unit": "<ref>", "diagram": "..." }\`. It is Mermaid source that the review page draws beside
|
|
252
|
+
the code: a commit's diagram under its commit, a unit's under its hunk. Add one only where a picture says what the code and prose cannot: data flowing between
|
|
253
|
+
modules, a state machine, a call graph, or the before and after shape of a structure. Most commits
|
|
254
|
+
need none. The first line names the diagram type, for example:
|
|
255
|
+
|
|
256
|
+
"diagram": "flowchart LR\\n parse --> validate --> apply"
|
|
257
|
+
|
|
258
|
+
The note stays plain text even when a diagram is present.
|
|
139
259
|
\`responses\` is [] when there are no review comments. \`status\` is one of "addressed",
|
|
140
|
-
"declined" or "
|
|
260
|
+
"declined", "deferred" or, for a question, "answered", and \`note\` says what you did or why not.
|
|
261
|
+
An "answered" response also carries \`"at": { "commit": 3, "unit": "h_x" }\`: the commit, counted from 1,
|
|
262
|
+
and optionally the unit that holds the explanation.`;
|
|
141
263
|
const RULES = (checkCommand) => `- A unit is "h_…" (a text hunk), "f_…" (a whole file) or "m_…" (a mode change).
|
|
142
264
|
- "h_x:0-3,7" takes change lines 0, 1, 2, 3 and 7 of hunk h_x. Indices are the [n] shown below.
|
|
143
265
|
- A bare "h_x" takes every change line of that hunk that no other commit claimed.
|
|
@@ -145,50 +267,63 @@ const RULES = (checkCommand) => `- A unit is "h_…" (a text hunk), "f_…" (a w
|
|
|
145
267
|
- Use the unit ids exactly as printed. Do not invent ids.
|
|
146
268
|
- "f_…" and "m_…" units are taken whole, never with a line list.
|
|
147
269
|
- Every commit needs a non-empty message and at least one unit.
|
|
148
|
-
-
|
|
270
|
+
- Keep every file syntactically whole in every commit: a function with its closing brace, a block with both ends, an import before or with its first use. Commits need not build or pass tests, but each file a commit writes must parse.
|
|
271
|
+
- Check your work with \`${checkCommand}\` and fix every issue it prints, syntax errors included.`;
|
|
149
272
|
/** The planner prompt for a coding agent. Pure and deterministic for the same input. */
|
|
150
273
|
export function renderPrompt(input) {
|
|
151
274
|
const { diff, draftPath } = input;
|
|
152
275
|
const { title, goal } = GOALS[input.mode];
|
|
153
|
-
const
|
|
276
|
+
const head = [
|
|
154
277
|
`# follow-me: ${title}\n\n${goal}`,
|
|
155
278
|
`## Output format\n\n${FORMAT(draftPath, diff.base, diff.tip)}`,
|
|
156
279
|
`## Rules\n\n${RULES(input.checkCommand)}`,
|
|
157
|
-
`## Units\n\nChange lines are shown as \`[n]+ text\` or \`[n]- text\`. Indented lines without a bracket are context and cannot be selected.\n\n${renderUnits(diff)}`,
|
|
158
280
|
];
|
|
159
|
-
|
|
281
|
+
const units = `## Units\n\nChange lines are shown as \`[n]+ text\` or \`[n]- text\`. Indented lines without a bracket are context and cannot be selected.\n\n${renderUnits(diff)}`;
|
|
282
|
+
if (input.mode === "walk") {
|
|
283
|
+
const sections = [
|
|
284
|
+
...head,
|
|
285
|
+
`## Concerns\n\n${CONCERNS}`,
|
|
286
|
+
`## Questions from the reviewer\n\n${QUESTIONS}\n\n${CONTEXT_RULE}`,
|
|
287
|
+
units,
|
|
288
|
+
];
|
|
160
289
|
return `${sections.join("\n\n")}\n`;
|
|
290
|
+
}
|
|
291
|
+
const sections = [...head, units];
|
|
161
292
|
const { previousPlan, review } = input;
|
|
162
293
|
if (previousPlan) {
|
|
163
294
|
sections.push(`## Previous plan\n\nCommits are numbered from 1.\n\n${renderPreviousPlan(previousPlan)}`);
|
|
164
295
|
}
|
|
165
|
-
|
|
296
|
+
const theirs = review?.comments.filter((c) => c.author !== "agent") ?? [];
|
|
297
|
+
const asked = theirs.filter((c) => c.kind === "question");
|
|
298
|
+
const changes = theirs.filter((c) => c.kind !== "question");
|
|
299
|
+
const ctx = { commits: input.previousCommits ?? [], units: diff.units };
|
|
300
|
+
const questions = [`## Questions from the reviewer\n`, QUESTIONS, `\n${CONTEXT_RULE}`];
|
|
301
|
+
if (review && asked.length > 0) {
|
|
302
|
+
questions.push(`\nThe reviewer asked ${asked.length === 1 ? "this question" : "these questions"} about plan v${review.planVersion}. Commits are numbered from 1. Write one response for each.\n`, asked.map((c) => renderComment(c, ctx)).join("\n"));
|
|
303
|
+
}
|
|
304
|
+
sections.push(questions.join("\n"));
|
|
305
|
+
if (review && changes.length > 0) {
|
|
166
306
|
sections.push([
|
|
167
307
|
`## Open review comments\n`,
|
|
168
308
|
`A reviewer commented on plan v${review.planVersion}. Commits are numbered from 1.`,
|
|
169
309
|
"Answer every comment below with one entry in `responses`, changing the plan where you agree.\n",
|
|
170
|
-
|
|
310
|
+
changes.map((c) => renderComment(c, ctx)).join("\n"),
|
|
171
311
|
].join("\n"));
|
|
172
312
|
}
|
|
173
313
|
return `${sections.join("\n\n")}\n`;
|
|
174
314
|
}
|
|
175
315
|
/** The owning commit of each change line of a hunk, or null when the plan leaves it out. */
|
|
176
|
-
function ownersOf(
|
|
316
|
+
function ownersOf(commits, hunk) {
|
|
177
317
|
const owners = new Array(hunk.changeCount).fill(null);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (ref.unit !== hunk.id)
|
|
318
|
+
commits.forEach((commit, k) => {
|
|
319
|
+
for (const ref of commit.refs) {
|
|
320
|
+
if (ref.unit !== hunk.id || ref.select.kind !== "lines")
|
|
182
321
|
continue;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
else
|
|
186
|
-
for (const c of ref.select.changes)
|
|
187
|
-
owners[c] = k;
|
|
322
|
+
for (const c of ref.select.changes)
|
|
323
|
+
owners[c] = k;
|
|
188
324
|
}
|
|
189
325
|
});
|
|
190
|
-
|
|
191
|
-
return owners.map((o) => o ?? restOwner);
|
|
326
|
+
return owners;
|
|
192
327
|
}
|
|
193
328
|
/**
|
|
194
329
|
* The hunk as it reads in commit `k`: context, lines earlier commits left in place, and this
|
|
@@ -232,93 +367,300 @@ function fence(lines) {
|
|
|
232
367
|
return "`".repeat(Math.max(3, longest + 1));
|
|
233
368
|
}
|
|
234
369
|
const EXCERPT_CONTEXT = 3;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const hunk =
|
|
238
|
-
if (
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
let lines = excerpt(hunk, tipStartOf(hunk, units), ownersOf(plan, hunk), anchor.commit);
|
|
242
|
-
const picked = anchor.changes;
|
|
370
|
+
/** The lines of a hunk as commit `k` writes them, cut to the picked change lines. Null if no hunk. */
|
|
371
|
+
function quoteHunk(units, commits, unit, k, picked) {
|
|
372
|
+
const hunk = units.get(unit);
|
|
373
|
+
if (hunk?.kind !== "hunk")
|
|
374
|
+
return null;
|
|
375
|
+
let lines = excerpt(hunk, tipStartOf(hunk, units), ownersOf(commits, hunk), k);
|
|
243
376
|
const marked = lines.flatMap((l, i) => l.change !== null && (picked === null || picked.includes(l.change)) ? [i] : []);
|
|
244
377
|
const first = marked[0];
|
|
245
378
|
const last = marked.at(-1);
|
|
246
|
-
const
|
|
247
|
-
const where = at === undefined ? "" : `, line ${at.tipLine}`;
|
|
379
|
+
const line = first === undefined ? null : (lines[first]?.tipLine ?? null);
|
|
248
380
|
if (picked !== null && first !== undefined && last !== undefined) {
|
|
249
381
|
lines = lines.slice(Math.max(0, first - EXCERPT_CONTEXT), last + EXCERPT_CONTEXT + 1);
|
|
250
382
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
383
|
+
return { line, text: lines.map((l) => l.text) };
|
|
384
|
+
}
|
|
385
|
+
const SEVERITY_RANK = { blocker: 0, major: 1, minor: 2 };
|
|
386
|
+
function lineLabel(line, fallback) {
|
|
387
|
+
return line === null ? fallback : `line ${line}`;
|
|
388
|
+
}
|
|
389
|
+
function whereComment(ctx, c) {
|
|
390
|
+
const { anchor } = c;
|
|
391
|
+
switch (anchor.kind) {
|
|
392
|
+
case "plan":
|
|
393
|
+
return { path: null, line: null, label: "whole plan", quote: [] };
|
|
394
|
+
case "message":
|
|
395
|
+
return { path: null, line: null, label: `commit ${anchor.commit + 1} message`, quote: [] };
|
|
396
|
+
case "position":
|
|
397
|
+
return { path: null, line: null, label: `commit ${anchor.commit + 1} position`, quote: [] };
|
|
398
|
+
case "hunk": {
|
|
399
|
+
const pick = pickRows(ctx, anchor);
|
|
400
|
+
if (pick !== null) {
|
|
401
|
+
return {
|
|
402
|
+
path: ctx.units.get(anchor.unit)?.path ?? c.context.path,
|
|
403
|
+
line: pick.line,
|
|
404
|
+
label: `${pick.range.includes("-") ? "lines" : "line"} ${pick.range}${pick.baseSide ? " (base side)" : ""}`,
|
|
405
|
+
quote: pick.rows,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const quoted = quoteHunk(ctx.units, ctx.commits, anchor.unit, anchor.commit, anchor.changes);
|
|
409
|
+
const path = ctx.units.get(anchor.unit)?.path ?? c.context.path;
|
|
410
|
+
if (quoted === null) {
|
|
411
|
+
return {
|
|
412
|
+
path,
|
|
413
|
+
line: null,
|
|
414
|
+
label: "code that has since changed",
|
|
415
|
+
quote: c.context.lines ?? [],
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
path,
|
|
420
|
+
line: quoted.line,
|
|
421
|
+
label: lineLabel(quoted.line, anchor.unit),
|
|
422
|
+
quote: quoted.text,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
default: {
|
|
426
|
+
const exhaustive = anchor;
|
|
427
|
+
return exhaustive;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function whereConcern(ctx, kept) {
|
|
432
|
+
const { concern, commit } = kept;
|
|
433
|
+
if (concern.unit === undefined) {
|
|
434
|
+
return { path: null, line: null, label: `commit ${commit + 1}`, quote: [] };
|
|
435
|
+
}
|
|
436
|
+
const path = ctx.units.get(concern.unit)?.path ?? null;
|
|
437
|
+
const quoted = quoteHunk(ctx.units, ctx.commits, concern.unit, commit, concern.lines ?? null);
|
|
438
|
+
if (quoted === null)
|
|
439
|
+
return { path, line: null, label: concern.unit, quote: [] };
|
|
440
|
+
return {
|
|
441
|
+
path,
|
|
442
|
+
line: quoted.line,
|
|
443
|
+
label: lineLabel(quoted.line, concern.unit),
|
|
444
|
+
quote: quoted.text,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/** Whether a comment is about the same lines as a concern: same unit, and lines that intersect. */
|
|
448
|
+
function overlaps(kept, c) {
|
|
449
|
+
const { anchor } = c;
|
|
450
|
+
const { concern } = kept;
|
|
451
|
+
if (concern.unit === undefined) {
|
|
452
|
+
return ((anchor.kind === "message" || anchor.kind === "position") && anchor.commit === kept.commit);
|
|
453
|
+
}
|
|
454
|
+
if (anchor.kind !== "hunk" || anchor.unit !== concern.unit)
|
|
455
|
+
return false;
|
|
456
|
+
if (anchor.changes === null || concern.lines === undefined)
|
|
457
|
+
return true;
|
|
458
|
+
const mine = concern.lines;
|
|
459
|
+
return anchor.changes.some((n) => mine.includes(n));
|
|
254
460
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
461
|
+
function keptConcerns(review, commits) {
|
|
462
|
+
const decisions = new Map((review.concerns ?? []).map((d) => [d.concern, d]));
|
|
463
|
+
return commits.flatMap((commit, k) => (commit.concerns ?? []).flatMap((concern) => {
|
|
464
|
+
const decision = decisions.get(concern.id);
|
|
465
|
+
if (decision?.decision !== "kept")
|
|
466
|
+
return [];
|
|
467
|
+
return [
|
|
468
|
+
{
|
|
469
|
+
concern,
|
|
470
|
+
commit: k,
|
|
471
|
+
severity: decision.severity ?? concern.severity,
|
|
472
|
+
note: decision.note ?? null,
|
|
473
|
+
},
|
|
474
|
+
];
|
|
475
|
+
}));
|
|
476
|
+
}
|
|
477
|
+
function strongest(severities) {
|
|
478
|
+
return severities.reduce((best, s) => {
|
|
479
|
+
if (s === null)
|
|
480
|
+
return best;
|
|
481
|
+
return best === null || SEVERITY_RANK[s] < SEVERITY_RANK[best] ? s : best;
|
|
482
|
+
}, null);
|
|
483
|
+
}
|
|
484
|
+
/** Kept concerns and rated or code comments become items; a comment joins the concern it overlaps. */
|
|
485
|
+
function buildItems(ctx, kept, comments) {
|
|
486
|
+
const items = kept.map((k) => ({
|
|
487
|
+
where: whereConcern(ctx, k),
|
|
488
|
+
severity: k.severity,
|
|
489
|
+
kept: k,
|
|
490
|
+
comments: [],
|
|
491
|
+
}));
|
|
492
|
+
const merged = new Map();
|
|
493
|
+
const alone = [];
|
|
494
|
+
for (const c of comments) {
|
|
495
|
+
const home = items.find((item) => item.kept !== null && overlaps(item.kept, c));
|
|
496
|
+
if (home !== undefined)
|
|
497
|
+
merged.set(home, [...(merged.get(home) ?? []), c]);
|
|
498
|
+
else {
|
|
499
|
+
alone.push({
|
|
500
|
+
where: whereComment(ctx, c),
|
|
501
|
+
severity: c.severity ?? null,
|
|
502
|
+
kept: null,
|
|
503
|
+
comments: [c],
|
|
504
|
+
});
|
|
505
|
+
}
|
|
261
506
|
}
|
|
262
|
-
|
|
507
|
+
const joined = items.map((item) => {
|
|
508
|
+
const mine = merged.get(item) ?? [];
|
|
509
|
+
return {
|
|
510
|
+
...item,
|
|
511
|
+
severity: strongest([item.severity, ...mine.map((c) => c.severity ?? null)]),
|
|
512
|
+
comments: mine,
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
return [...joined, ...alone];
|
|
516
|
+
}
|
|
517
|
+
/** Files in diff order, then by line on the branch; items without a line close their file. */
|
|
518
|
+
function sortItems(items, units) {
|
|
263
519
|
const order = new Map();
|
|
264
520
|
for (const u of units.values())
|
|
265
521
|
if (!order.has(u.path))
|
|
266
522
|
order.set(u.path, order.size);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
const rank = (path) => order.get(path) ?? order.size;
|
|
277
|
-
const paths = [...byPath.keys()].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
278
|
-
for (const path of paths) {
|
|
279
|
-
out.push(`## ${path}`, "");
|
|
280
|
-
for (const c of byPath.get(path) ?? [])
|
|
281
|
-
out.push(...renderCodeComment(c, plan, units));
|
|
282
|
-
}
|
|
283
|
-
if (general.length > 0) {
|
|
284
|
-
out.push("## General", "");
|
|
285
|
-
for (const c of general)
|
|
286
|
-
out.push(...renderCodeComment(c, plan, units));
|
|
287
|
-
}
|
|
288
|
-
out.push("Address each comment above in the code on this branch, then tell the reviewer what you changed and what you chose not to change and why.", "");
|
|
289
|
-
return out.join("\n");
|
|
523
|
+
const rank = (path) => path === null ? order.size + 1 : (order.get(path) ?? order.size);
|
|
524
|
+
return items
|
|
525
|
+
.map((item, i) => ({ item, i }))
|
|
526
|
+
.sort((a, b) => rank(a.item.where.path) - rank(b.item.where.path) ||
|
|
527
|
+
(a.item.where.line ?? Number.MAX_SAFE_INTEGER) -
|
|
528
|
+
(b.item.where.line ?? Number.MAX_SAFE_INTEGER) ||
|
|
529
|
+
a.i - b.i)
|
|
530
|
+
.map(({ item }) => item);
|
|
290
531
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
532
|
+
function quoteBlock(quote) {
|
|
533
|
+
if (quote.length === 0)
|
|
534
|
+
return [];
|
|
535
|
+
const f = fence(quote);
|
|
536
|
+
return [`${f}diff`, ...quote, f];
|
|
537
|
+
}
|
|
538
|
+
function voices(item) {
|
|
539
|
+
const { kept } = item;
|
|
540
|
+
const out = [];
|
|
541
|
+
if (kept !== null)
|
|
542
|
+
out.push(`Agent: ${kept.concern.text}`);
|
|
543
|
+
if (kept?.note)
|
|
544
|
+
out.push(`Reviewer: ${kept.note}`);
|
|
545
|
+
for (const c of item.comments) {
|
|
546
|
+
out.push(`Reviewer: ${c.body}`, ...renderReplies(c));
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
function renderReplies(c) {
|
|
551
|
+
return (c.replies ?? []).flatMap((r) => {
|
|
552
|
+
const label = r.author === "agent" ? "Agent replied" : "Reviewer replied";
|
|
553
|
+
return [
|
|
554
|
+
`${label}: ${r.body}`,
|
|
555
|
+
...(r.diagram === undefined ? [] : ["```mermaid", r.diagram.trimEnd(), "```"]),
|
|
556
|
+
];
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
function itemHeading(item, level) {
|
|
560
|
+
const { where, kept } = item;
|
|
561
|
+
const parts = [where.label, item.severity ?? "unrated"];
|
|
562
|
+
if (kept !== null)
|
|
563
|
+
parts.push(kept.concern.kind);
|
|
564
|
+
parts.push(kept === null ? "raised by the reviewer" : "raised by the agent, kept by the reviewer");
|
|
565
|
+
return `${"#".repeat(level)} ${parts.join(" · ")}`;
|
|
566
|
+
}
|
|
567
|
+
function renderItems(items, units, level) {
|
|
568
|
+
const out = [];
|
|
569
|
+
const sorted = sortItems(items, units);
|
|
570
|
+
const paths = [...new Set(sorted.flatMap((i) => (i.where.path === null ? [] : [i.where.path])))];
|
|
571
|
+
const group = (title, members) => {
|
|
572
|
+
if (members.length === 0)
|
|
573
|
+
return;
|
|
574
|
+
out.push(`${"#".repeat(level)} ${title}`, "");
|
|
575
|
+
for (const item of members) {
|
|
576
|
+
out.push(itemHeading(item, level + 1), "", ...quoteBlock(item.where.quote));
|
|
577
|
+
if (item.where.quote.length > 0)
|
|
578
|
+
out.push("");
|
|
579
|
+
out.push(...voices(item), "");
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
for (const path of paths)
|
|
583
|
+
group(path, sorted.filter((i) => i.where.path === path));
|
|
584
|
+
group("General", sorted.filter((i) => i.where.path === null));
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
function summarizeIssues(items) {
|
|
588
|
+
if (items.length === 0)
|
|
589
|
+
return "no issues";
|
|
590
|
+
const count = (s) => items.filter((i) => i.severity === s).length;
|
|
591
|
+
const tally = [
|
|
592
|
+
[count("blocker"), "blocker"],
|
|
593
|
+
[count("major"), "major"],
|
|
594
|
+
[count("minor"), "minor"],
|
|
595
|
+
[count(null), "unrated"],
|
|
596
|
+
];
|
|
597
|
+
const parts = tally.flatMap(([n, label]) => (n === 0 ? [] : [`${n} ${label}`]));
|
|
598
|
+
return `${items.length} ${items.length === 1 ? "issue" : "issues"}: ${parts.join(", ")}`;
|
|
599
|
+
}
|
|
600
|
+
function renderQuestions(ctx, questions, responses) {
|
|
601
|
+
const items = sortItems(questions.map((c) => ({ where: whereComment(ctx, c), comment: c })), ctx.units);
|
|
602
|
+
const out = ["## Questions", ""];
|
|
603
|
+
for (const { where, comment } of items) {
|
|
604
|
+
const place = where.path === null ? where.label : `${where.path} · ${where.label}`;
|
|
605
|
+
out.push(`### ${comment.id} · ${place}`, "", ...quoteBlock(where.quote));
|
|
606
|
+
if (where.quote.length > 0)
|
|
607
|
+
out.push("");
|
|
608
|
+
out.push(`Reviewer asked: ${comment.body}`, ...renderReplies(comment));
|
|
609
|
+
const answer = responses.find((r) => r.comment === comment.id);
|
|
610
|
+
if (answer !== undefined) {
|
|
611
|
+
const at = answer.at;
|
|
612
|
+
const explained = at === undefined
|
|
613
|
+
? ""
|
|
614
|
+
: `, explained in commit ${at.commit}${at.unit === undefined ? "" : ` (${at.unit})`}`;
|
|
615
|
+
out.push(`Answer (${answer.status}${explained}): ${answer.note}`);
|
|
616
|
+
}
|
|
617
|
+
else if ((comment.replies ?? []).length === 0)
|
|
618
|
+
out.push("Not answered yet.");
|
|
619
|
+
out.push("");
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
/** Threads the agent opened, with what the reviewer said back. */
|
|
624
|
+
function renderThreads(ctx, threads) {
|
|
625
|
+
const out = ["## Threads you started", ""];
|
|
626
|
+
for (const c of threads) {
|
|
627
|
+
const where = whereComment(ctx, c);
|
|
628
|
+
const place = where.path === null ? where.label : `${where.path} · ${where.label}`;
|
|
629
|
+
out.push(`### ${c.id} · ${place}`, "", ...quoteBlock(where.quote));
|
|
630
|
+
if (where.quote.length > 0)
|
|
631
|
+
out.push("");
|
|
632
|
+
out.push(`Agent: ${c.body}`, ...renderReplies(c), "");
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
/** Plan feedback for a split review: what the planner answers through `responses`. */
|
|
637
|
+
function renderPlanFeedback(ctx, comments) {
|
|
638
|
+
const { commits } = ctx;
|
|
639
|
+
const out = [];
|
|
640
|
+
const line = (c) => [`- ${c.id} on ${describeAnchor(c.anchor, ctx)}`];
|
|
641
|
+
const planLevel = comments.filter((c) => c.anchor.kind === "plan");
|
|
304
642
|
if (planLevel.length > 0) {
|
|
305
643
|
out.push("## Whole plan", "");
|
|
306
644
|
for (const c of planLevel)
|
|
307
|
-
out.push(`- ${c.id}: ${c.body}
|
|
645
|
+
out.push(`- ${c.id}: ${c.body}`, ...renderReplies(c));
|
|
308
646
|
out.push("");
|
|
309
647
|
}
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const mine = review.comments.filter((c) => c.anchor.kind !== "plan" && c.anchor.commit === i);
|
|
648
|
+
const orphans = comments.filter((c) => c.anchor.kind !== "plan" && c.anchor.commit >= commits.length);
|
|
649
|
+
commits.forEach((commit, i) => {
|
|
650
|
+
const mine = comments.filter((c) => c.anchor.kind !== "plan" && c.anchor.commit === i);
|
|
314
651
|
if (mine.length === 0)
|
|
315
652
|
return;
|
|
316
653
|
out.push(`## Commit ${i + 1}: ${commit.message.split("\n")[0] ?? ""}`, "");
|
|
317
654
|
for (const c of mine) {
|
|
318
|
-
out.push(
|
|
655
|
+
out.push(...line(c));
|
|
319
656
|
if (c.context.path)
|
|
320
657
|
out.push(` - file: ${c.context.path}`);
|
|
658
|
+
const rows = anchorRows(ctx, c.anchor);
|
|
659
|
+
if (rows.length > 0)
|
|
660
|
+
out.push(" - picked rows are marked >:", ...rows.map((r) => ` ${r}`));
|
|
321
661
|
out.push(` - ${c.body.split("\n").join("\n ")}`);
|
|
662
|
+
for (const reply of renderReplies(c))
|
|
663
|
+
out.push(` - ${reply}`);
|
|
322
664
|
}
|
|
323
665
|
out.push("");
|
|
324
666
|
});
|
|
@@ -328,6 +670,277 @@ export function renderFeedback(review, plan, units) {
|
|
|
328
670
|
out.push(`- ${c.id}: ${c.body}`);
|
|
329
671
|
out.push("");
|
|
330
672
|
}
|
|
331
|
-
out
|
|
673
|
+
return out;
|
|
674
|
+
}
|
|
675
|
+
/** The rows `want` picks, numbered as the quoting code numbers them. */
|
|
676
|
+
function numberedRows(hunk, units, want) {
|
|
677
|
+
let tip = tipStartOf(hunk, units);
|
|
678
|
+
let base = hunk.oldStart + 1;
|
|
679
|
+
const rows = [];
|
|
680
|
+
hunk.body.forEach((line, i) => {
|
|
681
|
+
if (want(i, line.op === " " ? null : line.change))
|
|
682
|
+
rows.push({ op: line.op, tip, base });
|
|
683
|
+
if (line.op !== "-")
|
|
684
|
+
tip += 1;
|
|
685
|
+
if (line.op !== "+")
|
|
686
|
+
base += 1;
|
|
687
|
+
});
|
|
688
|
+
return rows;
|
|
689
|
+
}
|
|
690
|
+
const NO_SPAN = { newLine: null, oldLine: null, startNewLine: null, startOldLine: null };
|
|
691
|
+
/** The last row names the line: both sides for context, the tip for an addition, the base for a removal. */
|
|
692
|
+
function spanOf(rows) {
|
|
693
|
+
const first = rows[0];
|
|
694
|
+
const last = rows.at(-1);
|
|
695
|
+
if (first === undefined || last === undefined)
|
|
696
|
+
return NO_SPAN;
|
|
697
|
+
const range = rows.length > 1;
|
|
698
|
+
return {
|
|
699
|
+
newLine: last.op === "-" ? null : last.tip,
|
|
700
|
+
oldLine: last.op === "+" ? null : last.base,
|
|
701
|
+
startNewLine: range && first.op !== "-" ? first.tip : null,
|
|
702
|
+
startOldLine: range && first.op !== "+" ? first.base : null,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function commentSpan(ctx, c) {
|
|
706
|
+
const { anchor } = c;
|
|
707
|
+
if (anchor.kind !== "hunk")
|
|
708
|
+
return NO_SPAN;
|
|
709
|
+
const hunk = ctx.units.get(anchor.unit);
|
|
710
|
+
if (hunk?.kind !== "hunk")
|
|
711
|
+
return NO_SPAN;
|
|
712
|
+
const { offsets, changes } = anchor;
|
|
713
|
+
if (offsets !== undefined)
|
|
714
|
+
return spanOf(numberedRows(hunk, ctx.units, (i) => offsets.includes(i)));
|
|
715
|
+
return spanOf(numberedRows(hunk, ctx.units, (_, n) => n !== null && (changes === null || changes.includes(n))));
|
|
716
|
+
}
|
|
717
|
+
function concernSpan(ctx, concern) {
|
|
718
|
+
const hunk = concern.unit === undefined ? undefined : ctx.units.get(concern.unit);
|
|
719
|
+
if (hunk?.kind !== "hunk")
|
|
720
|
+
return NO_SPAN;
|
|
721
|
+
const lines = concern.lines;
|
|
722
|
+
return spanOf(numberedRows(hunk, ctx.units, (_, n) => n !== null && (lines === undefined || lines.includes(n))));
|
|
723
|
+
}
|
|
724
|
+
function threadOf(c) {
|
|
725
|
+
return (c.replies ?? []).map((r) => ({ author: r.author, body: r.body }));
|
|
726
|
+
}
|
|
727
|
+
function withSeverity(severity, text) {
|
|
728
|
+
return severity === null ? text : `**${severity}**\n\n${text}`;
|
|
729
|
+
}
|
|
730
|
+
function collectItems(review, commits, units, responses) {
|
|
731
|
+
const ctx = { commits, units };
|
|
732
|
+
const fromReviewer = review.comments.filter((c) => c.author !== "agent");
|
|
733
|
+
const walk = review.mode === "walk";
|
|
734
|
+
const rest = fromReviewer.filter((c) => c.kind !== "question");
|
|
735
|
+
const issues = walk ? rest : rest.filter((c) => c.severity !== undefined);
|
|
736
|
+
const noComments = new Set(review.excluded?.comments ?? []);
|
|
737
|
+
const noConcerns = new Set(review.excluded?.concerns ?? []);
|
|
738
|
+
const resolved = new Set(review.resolved ?? []);
|
|
739
|
+
const out = buildItems(ctx, keptConcerns(review, commits), issues).map((it) => {
|
|
740
|
+
const { kept } = it;
|
|
741
|
+
const first = it.comments[0];
|
|
742
|
+
if (kept !== null) {
|
|
743
|
+
const note = kept.note === null ? "" : `\n\nReviewer: ${kept.note}`;
|
|
744
|
+
const thread = it.comments.flatMap((c) => [
|
|
745
|
+
{ author: "reviewer", body: c.body },
|
|
746
|
+
...threadOf(c),
|
|
747
|
+
]);
|
|
748
|
+
return {
|
|
749
|
+
where: it.where,
|
|
750
|
+
item: {
|
|
751
|
+
...concernSpan(ctx, kept.concern),
|
|
752
|
+
path: it.where.path,
|
|
753
|
+
oldPath: it.where.path,
|
|
754
|
+
severity: it.severity,
|
|
755
|
+
kind: "concern",
|
|
756
|
+
body: withSeverity(it.severity, `${kept.concern.text}${note}`),
|
|
757
|
+
thread,
|
|
758
|
+
resolved: false,
|
|
759
|
+
excluded: noConcerns.has(kept.concern.id),
|
|
760
|
+
},
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
if (first === undefined)
|
|
764
|
+
throw new Error("an item with neither a concern nor a comment");
|
|
765
|
+
return {
|
|
766
|
+
where: it.where,
|
|
767
|
+
item: {
|
|
768
|
+
...commentSpan(ctx, first),
|
|
769
|
+
path: it.where.path,
|
|
770
|
+
oldPath: it.where.path,
|
|
771
|
+
severity: it.severity,
|
|
772
|
+
kind: "flag",
|
|
773
|
+
body: withSeverity(it.severity, first.body),
|
|
774
|
+
thread: threadOf(first),
|
|
775
|
+
resolved: resolved.has(first.id),
|
|
776
|
+
excluded: noComments.has(first.id),
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
});
|
|
780
|
+
for (const q of fromReviewer.filter((c) => c.kind === "question")) {
|
|
781
|
+
const where = whereComment(ctx, q);
|
|
782
|
+
const answer = responses.find((r) => r.comment === q.id)?.note;
|
|
783
|
+
const lastAgent = (q.replies ?? []).findLast((r) => r.author === "agent")?.body;
|
|
784
|
+
const said = answer ?? lastAgent;
|
|
785
|
+
const details = said === undefined ? "" : `\n\n<details><summary>Answer</summary>\n\n${said}\n\n</details>`;
|
|
786
|
+
out.push({
|
|
787
|
+
where,
|
|
788
|
+
item: {
|
|
789
|
+
...commentSpan(ctx, q),
|
|
790
|
+
path: where.path,
|
|
791
|
+
oldPath: where.path,
|
|
792
|
+
severity: q.severity ?? null,
|
|
793
|
+
kind: "question",
|
|
794
|
+
body: `${q.body}${details}`,
|
|
795
|
+
thread: threadOf(q),
|
|
796
|
+
resolved: resolved.has(q.id),
|
|
797
|
+
excluded: noComments.has(q.id),
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
return sortItems(out, units);
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* The items of a review for the merge request, in file order then line order, general items last.
|
|
805
|
+
* Excluded ones are present and marked; whoever posts them skips those.
|
|
806
|
+
*/
|
|
807
|
+
export function reportItems(review, commits, units, opts) {
|
|
808
|
+
return collectItems(review, commits, units, opts.responses).map((c) => c.item);
|
|
809
|
+
}
|
|
810
|
+
/** A short list of what the reviewer left out, so nothing vanishes without a trace. */
|
|
811
|
+
function renderExcluded(collected) {
|
|
812
|
+
const left = collected.filter((c) => c.item.excluded);
|
|
813
|
+
if (left.length === 0)
|
|
814
|
+
return [];
|
|
815
|
+
const out = ["## Excluded", "", "The reviewer left these out of the hand-off:", ""];
|
|
816
|
+
for (const { item, where } of left) {
|
|
817
|
+
const place = where.path === null ? where.label : `${where.path} · ${where.label}`;
|
|
818
|
+
const first = item.body.split("\n").find((l) => l !== "" && !l.startsWith("**")) ?? "";
|
|
819
|
+
out.push(`- ${place} · ${item.kind}${item.severity === null ? "" : ` · ${item.severity}`}: ${first}`);
|
|
820
|
+
}
|
|
821
|
+
out.push("");
|
|
822
|
+
return out;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* The consolidated report of a review, in the voices of both the reviewer and the agent: the
|
|
826
|
+
* reviewer's comments and the agent's concerns the reviewer kept, grouped by file and ordered by
|
|
827
|
+
* line, then the questions with their answers. A walk review is a code review for the author. A
|
|
828
|
+
* split review is plan feedback for the planner, followed by the same code issues and questions.
|
|
829
|
+
* Concerns the reviewer has not decided are left out, as are those dismissed.
|
|
830
|
+
*/
|
|
831
|
+
export function renderReport(full, commits, units, opts) {
|
|
832
|
+
const excluded = full.excluded;
|
|
833
|
+
if (excluded === undefined)
|
|
834
|
+
return renderIncluded(full, commits, units, opts);
|
|
835
|
+
const noComments = new Set(excluded.comments);
|
|
836
|
+
const noConcerns = new Set(excluded.concerns);
|
|
837
|
+
const review = {
|
|
838
|
+
...full,
|
|
839
|
+
comments: full.comments.filter((c) => !noComments.has(c.id)),
|
|
840
|
+
concerns: (full.concerns ?? []).filter((d) => !noConcerns.has(d.concern)),
|
|
841
|
+
};
|
|
842
|
+
const tail = renderExcluded(collectItems(full, commits, units, opts.responses));
|
|
843
|
+
const main = renderIncluded(review, commits, units, opts);
|
|
844
|
+
return tail.length === 0 ? main : `${main}${main.endsWith("\n") ? "" : "\n"}${tail.join("\n")}`;
|
|
845
|
+
}
|
|
846
|
+
function renderIncluded(review, commits, units, opts) {
|
|
847
|
+
const ctx = { commits, units };
|
|
848
|
+
const walk = review.mode === "walk";
|
|
849
|
+
const fromReviewer = review.comments.filter((c) => c.author !== "agent");
|
|
850
|
+
// A thread the agent started matters to it only once the reviewer has answered.
|
|
851
|
+
const started = review.comments.filter((c) => c.author === "agent" && (c.replies ?? []).some((r) => r.author === "reviewer"));
|
|
852
|
+
const questions = fromReviewer.filter((c) => c.kind === "question");
|
|
853
|
+
const rest = fromReviewer.filter((c) => c.kind !== "question");
|
|
854
|
+
const issues = walk ? rest : rest.filter((c) => c.severity !== undefined);
|
|
855
|
+
const feedback = walk ? [] : rest.filter((c) => c.severity === undefined);
|
|
856
|
+
const items = buildItems(ctx, keptConcerns(review, commits), issues);
|
|
857
|
+
const out = [];
|
|
858
|
+
if (walk) {
|
|
859
|
+
out.push(`# Code review: ${opts.branch} (${commits.length === 1 ? "1 commit" : `${commits.length} commits`}, ${summarizeIssues(items)})`, "");
|
|
860
|
+
if (items.length === 0 && questions.length === 0)
|
|
861
|
+
out.push("The reviewer raised no issues.", "");
|
|
862
|
+
out.push(...renderItems(items, units, 2));
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
out.push(`# Review of plan v${review.planVersion}`, "");
|
|
866
|
+
if (feedback.length === 0 &&
|
|
867
|
+
items.length === 0 &&
|
|
868
|
+
questions.length === 0 &&
|
|
869
|
+
started.length === 0) {
|
|
870
|
+
out.push("No comments.", "");
|
|
871
|
+
return out.join("\n");
|
|
872
|
+
}
|
|
873
|
+
out.push(...renderPlanFeedback(ctx, feedback));
|
|
874
|
+
if (items.length > 0) {
|
|
875
|
+
out.push(`## Code issues (${summarizeIssues(items)})`, "", ...renderItems(items, units, 3));
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (questions.length > 0)
|
|
879
|
+
out.push(...renderQuestions(ctx, questions, opts.responses));
|
|
880
|
+
if (started.length > 0)
|
|
881
|
+
out.push(...renderThreads(ctx, started));
|
|
882
|
+
if (walk && items.length > 0) {
|
|
883
|
+
out.push("Address each issue above in the code on this branch, then tell the reviewer what you changed and what you chose not to change and why.", "");
|
|
884
|
+
}
|
|
885
|
+
if (!walk && (feedback.length > 0 || questions.length > 0)) {
|
|
886
|
+
out.push("Answer every comment id above in `responses` of the next draft, then run `follow-me check` and `follow-me apply`.", "");
|
|
887
|
+
}
|
|
888
|
+
const onContext = review.comments.some((c) => pickRows(ctx, c.anchor)?.touchesContext);
|
|
889
|
+
if (onContext)
|
|
890
|
+
out.push(CONTEXT_RULE, "");
|
|
891
|
+
if (!walk && items.length > 0) {
|
|
892
|
+
out.push("Fix the code issues on the original branch, then run `follow-me extract` again.", "");
|
|
893
|
+
}
|
|
332
894
|
return out.join("\n");
|
|
333
895
|
}
|
|
896
|
+
function renderEvent(event, ctx) {
|
|
897
|
+
switch (event.type) {
|
|
898
|
+
case "comment":
|
|
899
|
+
case "comment-updated": {
|
|
900
|
+
const { comment: c } = event;
|
|
901
|
+
const what = c.kind === "question" ? "question" : "comment";
|
|
902
|
+
const verb = event.type === "comment" ? "new" : "edited";
|
|
903
|
+
const lines = [`- ${verb} ${what} ${c.id} on ${describeAnchor(c.anchor, ctx)}`];
|
|
904
|
+
const rows = anchorRows(ctx, c.anchor);
|
|
905
|
+
if (rows.length > 0)
|
|
906
|
+
lines.push(...rows.map((r) => ` ${r}`));
|
|
907
|
+
if (c.context.path)
|
|
908
|
+
lines.push(` - file: ${c.context.path}`);
|
|
909
|
+
if (c.severity)
|
|
910
|
+
lines.push(` - severity: ${c.severity}`);
|
|
911
|
+
if (c.follows)
|
|
912
|
+
lines.push(` - asks again after ${c.follows}`);
|
|
913
|
+
lines.push(` - ${c.body.split("\n").join("\n ")}`);
|
|
914
|
+
return lines;
|
|
915
|
+
}
|
|
916
|
+
case "comment-deleted":
|
|
917
|
+
return [`- comment ${event.id} was deleted`];
|
|
918
|
+
case "reply":
|
|
919
|
+
return [`- ${event.reply.author} replied under ${event.commentId}: ${event.reply.body}`];
|
|
920
|
+
case "concern": {
|
|
921
|
+
const severity = event.severity === undefined ? "" : ` as ${event.severity}`;
|
|
922
|
+
const note = event.note === undefined ? "" : `: ${event.note}`;
|
|
923
|
+
return [`- concern ${event.concernId} ${event.decision}${severity}${note}`];
|
|
924
|
+
}
|
|
925
|
+
case "resolved":
|
|
926
|
+
return [`- question ${event.commentId} marked ${event.resolved ? "resolved" : "open again"}`];
|
|
927
|
+
case "version":
|
|
928
|
+
return [`- plan v${event.version} is now open`];
|
|
929
|
+
case "submitted":
|
|
930
|
+
return ["- the reviewer submitted the review; the held flags follow as new comments"];
|
|
931
|
+
case "ended":
|
|
932
|
+
return [`- the reviewer ended the review; the report is next to ${event.review}`];
|
|
933
|
+
default: {
|
|
934
|
+
const exhaustive = event;
|
|
935
|
+
return exhaustive;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/** A markdown digest of what happened on the review page, for `follow-me watch`. */
|
|
940
|
+
export function renderEvents(events, ctx = null) {
|
|
941
|
+
const first = events[0]?.seq;
|
|
942
|
+
const last = events.at(-1)?.seq;
|
|
943
|
+
const range = first === last ? `event ${first}` : `events ${first}-${last}`;
|
|
944
|
+
const body = events.flatMap((event) => renderEvent(event, ctx));
|
|
945
|
+
return [`# Review activity (${range})`, "", ...body, ""].join("\n");
|
|
946
|
+
}
|