@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.
Files changed (150) hide show
  1. package/README.md +119 -69
  2. package/dist/src/cli.js +1069 -140
  3. package/dist/src/comments.js +361 -0
  4. package/dist/src/git.js +32 -3
  5. package/dist/src/host.js +106 -9
  6. package/dist/src/install.js +22 -1
  7. package/dist/src/naive.js +26 -0
  8. package/dist/src/plan.js +282 -26
  9. package/dist/src/prompt.js +711 -98
  10. package/dist/src/server.js +573 -140
  11. package/dist/src/session.js +106 -104
  12. package/dist/src/syntax.js +199 -0
  13. package/dist/src/types.js +3 -1
  14. package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-DcmzRkVZ.js +1 -0
  15. package/dist/ui/dist/assets/arc-T5QB0T1C.js +1 -0
  16. package/dist/ui/dist/assets/architecture-WOLXFQ4H-DUIkQTeE.js +1 -0
  17. package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-BUYi4-bA.js +36 -0
  18. package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
  19. package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-dyJxtT9e.js +140 -0
  20. package/dist/ui/dist/assets/c4Diagram-XDPPPE54-CrAgiaZR.js +38 -0
  21. package/dist/ui/dist/assets/channel-wW7ebm0i.js +1 -0
  22. package/dist/ui/dist/assets/chunk-2E7WHVMD-C6fAf4Vj.js +88 -0
  23. package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  24. package/dist/ui/dist/assets/chunk-4EMNKR2T-BLZjwZ4Q.js +1 -0
  25. package/dist/ui/dist/assets/chunk-55XCU76K-DGLK2Q-L.js +1 -0
  26. package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  27. package/dist/ui/dist/assets/chunk-7S6XSTFO-BablszTX.js +2 -0
  28. package/dist/ui/dist/assets/chunk-AQDTSKZB-Cuk3B_vb.js +72 -0
  29. package/dist/ui/dist/assets/chunk-DSNLSHJZ-DiC78RqE.js +127 -0
  30. package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  31. package/dist/ui/dist/assets/chunk-HAGFOTED-X_RbvixX.js +62 -0
  32. package/dist/ui/dist/assets/chunk-HNE4Q36P-C14_x5ck.js +1 -0
  33. package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  34. package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  35. package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  36. package/dist/ui/dist/assets/chunk-NQOKRVIF-Dxd0dtYR.js +213 -0
  37. package/dist/ui/dist/assets/chunk-UHW7PFBP-BIxePNUS.js +1 -0
  38. package/dist/ui/dist/assets/chunk-VQ5W62QI-CKSKLbj4.js +1 -0
  39. package/dist/ui/dist/assets/chunk-WQQL4CDK-CWNmZGlR.js +10 -0
  40. package/dist/ui/dist/assets/chunk-XXDRQBXY-D-I5QHgG.js +1 -0
  41. package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  42. package/dist/ui/dist/assets/chunk-YZO67HFG-DFP68CFd.js +1 -0
  43. package/dist/ui/dist/assets/chunk-ZAUX2LTX-DY90d4QG.js +1 -0
  44. package/dist/ui/dist/assets/chunk-ZCHMQSOR-Dlpr-5UC.js +2 -0
  45. package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-CH9uhoLJ.js +217 -0
  46. package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-DtYHrp8D.js +1 -0
  47. package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtslbtrY.js +1 -0
  48. package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUU1Jcc7.js +62 -0
  49. package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  50. package/dist/ui/dist/assets/dagre-NK4GIZVX-CDlP-dRW.js +4 -0
  51. package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  52. package/dist/ui/dist/assets/diagram-CBK3MWTC-DiV2fn-r.js +24 -0
  53. package/dist/ui/dist/assets/diagram-I5XIIV2D-Bg5RehGi.js +24 -0
  54. package/dist/ui/dist/assets/diagram-ODIL2UCW-CFmazz3f.js +30 -0
  55. package/dist/ui/dist/assets/diagram-PFBEJUXE-uw3ZBqDp.js +3 -0
  56. package/dist/ui/dist/assets/diagram-RJA3I4VV-uEOYYRYa.js +41 -0
  57. package/dist/ui/dist/assets/diagram-TYUNFSNR-BN58LF_3.js +200 -0
  58. package/dist/ui/dist/assets/dist-tWS_lXDS.js +1 -0
  59. package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-B68Tz-w8.js +1 -0
  60. package/dist/ui/dist/assets/elk-QONTBCTQ-DZZohNea.js +27 -0
  61. package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BH_m74pv.js +99 -0
  62. package/dist/ui/dist/assets/eventmodeling-K75KTNOO-CTp5FHos.js +1 -0
  63. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +1 -0
  64. package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-BDau7_0f.js +292 -0
  65. package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  66. package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  67. package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  68. package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  69. package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  70. package/dist/ui/dist/assets/gitGraph-VSP46ZUC-Bv84IoCm.js +1 -0
  71. package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-DzXcMlm8.js +106 -0
  72. package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
  73. package/dist/ui/dist/assets/index-BTJoqVVN.js +133 -0
  74. package/dist/ui/dist/assets/index-D60U3smc.css +1 -0
  75. package/dist/ui/dist/assets/info-OHQRW6UA-222J0O_j.js +1 -0
  76. package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-DE2sa3Vz.js +2 -0
  77. package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
  78. package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js +70 -0
  79. package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-JSUXX5Hb.js +139 -0
  80. package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-Bg79b49M.js +89 -0
  81. package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
  82. package/dist/ui/dist/assets/line-Bk5qQSIa.js +1 -0
  83. package/dist/ui/dist/assets/linear-PFnVMru5.js +1 -0
  84. package/dist/ui/dist/assets/mermaid-parser.core-Dcxlzc-S.js +7 -0
  85. package/dist/ui/dist/assets/mermaid.core-TeOHQJ00.js +44 -0
  86. package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-IaNk8NnW.js +98 -0
  87. package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  88. package/dist/ui/dist/assets/packet-JDAUHWVQ-DMff5cVK.js +1 -0
  89. package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
  90. package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-66Xu4YRc.js +1 -0
  91. package/dist/ui/dist/assets/pie-XZMESJXO-GEtzLCIM.js +1 -0
  92. package/dist/ui/dist/assets/pieDiagram-GNMPY74T-7jMBi-kU.js +39 -0
  93. package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
  94. package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
  95. package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
  96. package/dist/ui/dist/assets/prism-core-FDHlWQ43.js +1 -0
  97. package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
  98. package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
  99. package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
  100. package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
  101. package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
  102. package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
  103. package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
  104. package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
  105. package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
  106. package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
  107. package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  108. package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
  109. package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
  110. package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
  111. package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
  112. package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
  113. package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
  114. package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
  115. package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
  116. package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
  117. package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
  118. package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-8hIWayC2.js +7 -0
  119. package/dist/ui/dist/assets/radar-ABXABTNO-BTdwUOOF.js +1 -0
  120. package/dist/ui/dist/assets/railroad-I3PHUGI6-DcMbDqBL.js +1 -0
  121. package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-gTzUTWeI.js +1 -0
  122. package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-CNtdUPtk.js +1 -0
  123. package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-DOiExAtE.js +1 -0
  124. package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-2kE_ygvM.js +1 -0
  125. package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-CoIAHRq2.js +84 -0
  126. package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  127. package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-7p8D3wim.js +40 -0
  128. package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-C6QFnMvn.js +169 -0
  129. package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  130. package/dist/ui/dist/assets/src-FgijoBVs.js +1 -0
  131. package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-n9eAb_bT.js +291 -0
  132. package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-U18mtUzX.js +1 -0
  133. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +8 -0
  134. package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-CD2Z8QGP.js +120 -0
  135. package/dist/ui/dist/assets/treeView-D4JQ5SDB-sqUjH5gB.js +1 -0
  136. package/dist/ui/dist/assets/treemap-SAJKECNS-DvZ38FP7.js +1 -0
  137. package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-CqOGidaZ.js +328 -0
  138. package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D1SXSBQ5.js +34 -0
  139. package/dist/ui/dist/assets/wardley-7MLQ67FV-Bpwio-Dx.js +1 -0
  140. package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-JSUXHMnk.js +78 -0
  141. package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-B4IFqQTN.js +7 -0
  142. package/dist/ui/dist/index.html +15 -0
  143. package/dist/ui/dist/style.css +121 -0
  144. package/package.json +31 -8
  145. package/skills/follow-me/SKILL.md +15 -61
  146. package/dist/src/anthropic.js +0 -101
  147. package/dist/ui/app.js +0 -797
  148. package/dist/ui/index.html +0 -40
  149. package/dist/ui/playback.js +0 -175
  150. package/dist/ui/style.css +0 -466
@@ -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 where = anchor.changes === null ? "the whole unit" : `change lines ${formatRanges(anchor.changes)}`;
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
- function renderComment(comment) {
75
- const lines = [`- ${comment.id} on ${describeAnchor(comment.anchor)}`];
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 watch it
106
- being written commit by commit, hear your narration read aloud as each line appears, and
107
- comment on the code itself. The final tree is fixed. You decide how its change lines are
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
- Give every commit a \`narration\`. Add one to a unit too wherever the why of that hunk is not
113
- obvious from the commit's narration. Narration should sound like someone thinking aloud at the
114
- keyboard: first person, present tense, two or three sentences, no markdown. Say why, not what
115
- the lines already show.`,
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": "Now that tokens exist I can finally read them. I start with the happy path.",
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. A unit with narration is written as
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 "deferred", and \`note\` says what you did or why not.`;
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
- - Check your work with \`${checkCommand}\` and fix every issue it prints.`;
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 sections = [
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
- if (input.mode === "walk")
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
- if (review && review.comments.length > 0) {
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
- review.comments.map(renderComment).join("\n"),
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(plan, hunk) {
316
+ function ownersOf(commits, hunk) {
177
317
  const owners = new Array(hunk.changeCount).fill(null);
178
- const rest = [];
179
- plan.commits.forEach((commit, k) => {
180
- for (const ref of commit.units) {
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
- if (ref.select.kind === "rest")
184
- rest.push(k);
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
- const restOwner = rest[0] ?? null;
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
- function renderCodeComment(comment, plan, units) {
236
- const { anchor } = comment;
237
- const hunk = anchor.kind === "hunk" ? units.get(anchor.unit) : undefined;
238
- if (anchor.kind !== "hunk" || hunk?.kind !== "hunk") {
239
- return [`### ${comment.id}`, "", comment.body, ""];
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 at = first === undefined ? undefined : lines[first];
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
- const text = lines.map((l) => l.text);
252
- const f = fence(text);
253
- return [`### ${comment.id}${where}`, "", `${f}diff`, ...text, f, "", comment.body, ""];
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
- /** A reviewer's code review for the author, grouped by file in diff order. */
256
- function renderCodeReview(review, plan, units) {
257
- const out = ["# Code review", ""];
258
- if (review.comments.length === 0) {
259
- out.push("The reviewer left no comments.", "");
260
- return out.join("\n");
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
- out.push(`A reviewer watched this branch being written commit by commit and left ${review.comments.length === 1 ? "1 comment" : `${review.comments.length} comments`}.`, "");
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 byPath = new Map();
268
- const general = [];
269
- for (const c of review.comments) {
270
- const path = c.anchor.kind === "hunk" ? c.context.path : null;
271
- if (path === null)
272
- general.push(c);
273
- else
274
- byPath.set(path, [...(byPath.get(path) ?? []), c]);
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
- * A markdown digest of a review for `follow-me feedback`. A split review is plan feedback for
293
- * the planner; a walk review is a code review for the author.
294
- */
295
- export function renderFeedback(review, plan, units) {
296
- if (review.mode === "walk")
297
- return renderCodeReview(review, plan, units);
298
- const out = [`# Review of plan v${review.planVersion}`, ""];
299
- if (review.comments.length === 0) {
300
- out.push("No comments.", "");
301
- return out.join("\n");
302
- }
303
- const planLevel = review.comments.filter((c) => c.anchor.kind === "plan");
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 placed = plan.commits.length;
311
- const orphans = review.comments.filter((c) => c.anchor.kind !== "plan" && c.anchor.commit >= placed);
312
- plan.commits.forEach((commit, i) => {
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(`- ${c.id} on ${describeAnchor(c.anchor)}`);
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.push("Answer every comment id above in `responses` of the next draft, then run `follow-me check` and `follow-me apply`.", "");
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
+ }