@shiyuphan/follow-me 0.1.1 → 0.2.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.
@@ -73,14 +73,28 @@ function describeAnchor(anchor) {
73
73
  }
74
74
  function renderComment(comment) {
75
75
  const lines = [`- ${comment.id} on ${describeAnchor(comment.anchor)}`];
76
+ if (comment.follows)
77
+ lines.push(` asks again after ${comment.follows}`);
76
78
  if (comment.context.commitMessage) {
77
79
  lines.push(` commit message then: ${JSON.stringify(comment.context.commitMessage)}`);
78
80
  }
79
81
  if (comment.context.path)
80
82
  lines.push(` path: ${comment.context.path}`);
83
+ if (comment.context.lines && comment.context.lines.length > 0) {
84
+ lines.push(" lines asked about:", ...comment.context.lines.map((l) => ` ${l}`));
85
+ }
86
+ if (comment.severity)
87
+ lines.push(` severity: ${comment.severity}`);
81
88
  lines.push(` comment: ${JSON.stringify(comment.body)}`);
89
+ for (const reply of comment.replies ?? []) {
90
+ lines.push(` ${reply.author} replied: ${JSON.stringify(reply.body)}`);
91
+ }
82
92
  return lines.join("\n");
83
93
  }
94
+ 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\` of that commit or unit so it answers the question, in plain spoken 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.`;
95
+ 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 narration. 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 narration stays the author's.
96
+
97
+ 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
98
  const ORDERING = `Order the commits so the story reads naturally:
85
99
  - Scaffolding and types come before the code that uses them.
86
100
  - A failing test comes before the fix that makes it pass.
@@ -90,7 +104,7 @@ const ORDERING = `Order the commits so the story reads naturally:
90
104
  - The last commit must bring the tree to exactly the tip, which follows from covering every unit.
91
105
 
92
106
  Write each commit message as a short imperative title, then a blank line and a body that
93
- says why.`;
107
+ says why. Keep the title to 50 characters or fewer; put detail in the body.`;
94
108
  const GOALS = {
95
109
  split: {
96
110
  title: "plan the commits",
@@ -136,8 +150,21 @@ const FORMAT = (draftPath, base, tip) => `Write the plan as JSON to ${draftPath}
136
150
 
137
151
  \`narration\` is optional on a commit and on a unit. A unit with narration is written as
138
152
  \`{ "unit": "<ref>", "narration": "..." }\` instead of the plain ref string.
153
+
154
+ \`diagram\` is optional on a commit and on a unit, written the same way:
155
+ \`{ "unit": "<ref>", "diagram": "..." }\`. It is Mermaid source that the review page draws beside
156
+ the code: a commit's diagram while its commit is on screen, a unit's while playback is on that
157
+ unit. Add one only where a picture says what the code and prose cannot: data flowing between
158
+ modules, a state machine, a call graph, or the before and after shape of a structure. Most commits
159
+ need none. The first line names the diagram type, for example:
160
+
161
+ "diagram": "flowchart LR\\n parse --> validate --> apply"
162
+
163
+ Narration stays plain text even when a diagram is present, because it is read aloud.
139
164
  \`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.`;
165
+ "declined", "deferred" or, for a question, "answered", and \`note\` says what you did or why not.
166
+ An "answered" response also carries \`"at": { "commit": 3, "unit": "h_x" }\`: the commit, counted from 1,
167
+ and optionally the unit that holds the explanation.`;
141
168
  const RULES = (checkCommand) => `- A unit is "h_…" (a text hunk), "f_…" (a whole file) or "m_…" (a mode change).
142
169
  - "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
170
  - A bare "h_x" takes every change line of that hunk that no other commit claimed.
@@ -145,50 +172,61 @@ const RULES = (checkCommand) => `- A unit is "h_…" (a text hunk), "f_…" (a w
145
172
  - Use the unit ids exactly as printed. Do not invent ids.
146
173
  - "f_…" and "m_…" units are taken whole, never with a line list.
147
174
  - Every commit needs a non-empty message and at least one unit.
148
- - Check your work with \`${checkCommand}\` and fix every issue it prints.`;
175
+ - 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.
176
+ - Check your work with \`${checkCommand}\` and fix every issue it prints, syntax errors included.`;
149
177
  /** The planner prompt for a coding agent. Pure and deterministic for the same input. */
150
178
  export function renderPrompt(input) {
151
179
  const { diff, draftPath } = input;
152
180
  const { title, goal } = GOALS[input.mode];
153
- const sections = [
181
+ const head = [
154
182
  `# follow-me: ${title}\n\n${goal}`,
155
183
  `## Output format\n\n${FORMAT(draftPath, diff.base, diff.tip)}`,
156
184
  `## 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
185
  ];
159
- if (input.mode === "walk")
186
+ 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)}`;
187
+ if (input.mode === "walk") {
188
+ const sections = [
189
+ ...head,
190
+ `## Concerns\n\n${CONCERNS}`,
191
+ `## Questions from the reviewer\n\n${QUESTIONS}`,
192
+ units,
193
+ ];
160
194
  return `${sections.join("\n\n")}\n`;
195
+ }
196
+ const sections = [...head, units];
161
197
  const { previousPlan, review } = input;
162
198
  if (previousPlan) {
163
199
  sections.push(`## Previous plan\n\nCommits are numbered from 1.\n\n${renderPreviousPlan(previousPlan)}`);
164
200
  }
165
- if (review && review.comments.length > 0) {
201
+ const asked = review?.comments.filter((c) => c.kind === "question") ?? [];
202
+ const changes = review?.comments.filter((c) => c.kind !== "question") ?? [];
203
+ const questions = [`## Questions from the reviewer\n`, QUESTIONS];
204
+ if (review && asked.length > 0) {
205
+ 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(renderComment).join("\n"));
206
+ }
207
+ sections.push(questions.join("\n"));
208
+ if (review && changes.length > 0) {
166
209
  sections.push([
167
210
  `## Open review comments\n`,
168
211
  `A reviewer commented on plan v${review.planVersion}. Commits are numbered from 1.`,
169
212
  "Answer every comment below with one entry in `responses`, changing the plan where you agree.\n",
170
- review.comments.map(renderComment).join("\n"),
213
+ changes.map(renderComment).join("\n"),
171
214
  ].join("\n"));
172
215
  }
173
216
  return `${sections.join("\n\n")}\n`;
174
217
  }
175
218
  /** The owning commit of each change line of a hunk, or null when the plan leaves it out. */
176
- function ownersOf(plan, hunk) {
219
+ function ownersOf(commits, hunk) {
177
220
  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)
221
+ commits.forEach((commit, k) => {
222
+ for (const ref of commit.refs) {
223
+ if (ref.unit !== hunk.id || ref.select.kind !== "lines")
182
224
  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;
225
+ for (const c of ref.select.changes)
226
+ owners[c] = k;
188
227
  }
189
228
  });
190
- const restOwner = rest[0] ?? null;
191
- return owners.map((o) => o ?? restOwner);
229
+ return owners;
192
230
  }
193
231
  /**
194
232
  * The hunk as it reads in commit `k`: context, lines earlier commits left in place, and this
@@ -232,93 +270,274 @@ function fence(lines) {
232
270
  return "`".repeat(Math.max(3, longest + 1));
233
271
  }
234
272
  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;
273
+ /** The lines of a hunk as commit `k` writes them, cut to the picked change lines. Null if no hunk. */
274
+ function quoteHunk(units, commits, unit, k, picked) {
275
+ const hunk = units.get(unit);
276
+ if (hunk?.kind !== "hunk")
277
+ return null;
278
+ let lines = excerpt(hunk, tipStartOf(hunk, units), ownersOf(commits, hunk), k);
243
279
  const marked = lines.flatMap((l, i) => l.change !== null && (picked === null || picked.includes(l.change)) ? [i] : []);
244
280
  const first = marked[0];
245
281
  const last = marked.at(-1);
246
- const at = first === undefined ? undefined : lines[first];
247
- const where = at === undefined ? "" : `, line ${at.tipLine}`;
282
+ const line = first === undefined ? null : (lines[first]?.tipLine ?? null);
248
283
  if (picked !== null && first !== undefined && last !== undefined) {
249
284
  lines = lines.slice(Math.max(0, first - EXCERPT_CONTEXT), last + EXCERPT_CONTEXT + 1);
250
285
  }
251
- const text = lines.map((l) => l.text);
252
- const f = fence(text);
253
- return [`### ${comment.id}${where}`, "", `${f}diff`, ...text, f, "", comment.body, ""];
254
- }
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");
286
+ return { line, text: lines.map((l) => l.text) };
287
+ }
288
+ const SEVERITY_RANK = { blocker: 0, major: 1, minor: 2 };
289
+ function lineLabel(line, fallback) {
290
+ return line === null ? fallback : `line ${line}`;
291
+ }
292
+ function whereComment(ctx, c) {
293
+ const { anchor } = c;
294
+ switch (anchor.kind) {
295
+ case "plan":
296
+ return { path: null, line: null, label: "whole plan", quote: [] };
297
+ case "message":
298
+ return { path: null, line: null, label: `commit ${anchor.commit + 1} message`, quote: [] };
299
+ case "position":
300
+ return { path: null, line: null, label: `commit ${anchor.commit + 1} position`, quote: [] };
301
+ case "hunk": {
302
+ const quoted = quoteHunk(ctx.units, ctx.commits, anchor.unit, anchor.commit, anchor.changes);
303
+ const path = ctx.units.get(anchor.unit)?.path ?? c.context.path;
304
+ if (quoted === null) {
305
+ return {
306
+ path,
307
+ line: null,
308
+ label: "code that has since changed",
309
+ quote: c.context.lines ?? [],
310
+ };
311
+ }
312
+ return {
313
+ path,
314
+ line: quoted.line,
315
+ label: lineLabel(quoted.line, anchor.unit),
316
+ quote: quoted.text,
317
+ };
318
+ }
319
+ default: {
320
+ const exhaustive = anchor;
321
+ return exhaustive;
322
+ }
261
323
  }
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`}.`, "");
324
+ }
325
+ function whereConcern(ctx, kept) {
326
+ const { concern, commit } = kept;
327
+ if (concern.unit === undefined) {
328
+ return { path: null, line: null, label: `commit ${commit + 1}`, quote: [] };
329
+ }
330
+ const path = ctx.units.get(concern.unit)?.path ?? null;
331
+ const quoted = quoteHunk(ctx.units, ctx.commits, concern.unit, commit, concern.lines ?? null);
332
+ if (quoted === null)
333
+ return { path, line: null, label: concern.unit, quote: [] };
334
+ return {
335
+ path,
336
+ line: quoted.line,
337
+ label: lineLabel(quoted.line, concern.unit),
338
+ quote: quoted.text,
339
+ };
340
+ }
341
+ /** Whether a comment is about the same lines as a concern: same unit, and lines that intersect. */
342
+ function overlaps(kept, c) {
343
+ const { anchor } = c;
344
+ const { concern } = kept;
345
+ if (concern.unit === undefined) {
346
+ return ((anchor.kind === "message" || anchor.kind === "position") && anchor.commit === kept.commit);
347
+ }
348
+ if (anchor.kind !== "hunk" || anchor.unit !== concern.unit)
349
+ return false;
350
+ if (anchor.changes === null || concern.lines === undefined)
351
+ return true;
352
+ const mine = concern.lines;
353
+ return anchor.changes.some((n) => mine.includes(n));
354
+ }
355
+ function keptConcerns(review, commits) {
356
+ const decisions = new Map((review.concerns ?? []).map((d) => [d.concern, d]));
357
+ return commits.flatMap((commit, k) => (commit.concerns ?? []).flatMap((concern) => {
358
+ const decision = decisions.get(concern.id);
359
+ if (decision?.decision !== "kept")
360
+ return [];
361
+ return [
362
+ {
363
+ concern,
364
+ commit: k,
365
+ severity: decision.severity ?? concern.severity,
366
+ note: decision.note ?? null,
367
+ },
368
+ ];
369
+ }));
370
+ }
371
+ function strongest(severities) {
372
+ return severities.reduce((best, s) => {
373
+ if (s === null)
374
+ return best;
375
+ return best === null || SEVERITY_RANK[s] < SEVERITY_RANK[best] ? s : best;
376
+ }, null);
377
+ }
378
+ /** Kept concerns and rated or code comments become items; a comment joins the concern it overlaps. */
379
+ function buildItems(ctx, kept, comments) {
380
+ const items = kept.map((k) => ({
381
+ where: whereConcern(ctx, k),
382
+ severity: k.severity,
383
+ kept: k,
384
+ comments: [],
385
+ }));
386
+ const merged = new Map();
387
+ const alone = [];
388
+ for (const c of comments) {
389
+ const home = items.find((item) => item.kept !== null && overlaps(item.kept, c));
390
+ if (home !== undefined)
391
+ merged.set(home, [...(merged.get(home) ?? []), c]);
392
+ else {
393
+ alone.push({
394
+ where: whereComment(ctx, c),
395
+ severity: c.severity ?? null,
396
+ kept: null,
397
+ comments: [c],
398
+ });
399
+ }
400
+ }
401
+ const joined = items.map((item) => {
402
+ const mine = merged.get(item) ?? [];
403
+ return {
404
+ ...item,
405
+ severity: strongest([item.severity, ...mine.map((c) => c.severity ?? null)]),
406
+ comments: mine,
407
+ };
408
+ });
409
+ return [...joined, ...alone];
410
+ }
411
+ /** Files in diff order, then by line on the branch; items without a line close their file. */
412
+ function sortItems(items, units) {
263
413
  const order = new Map();
264
414
  for (const u of units.values())
265
415
  if (!order.has(u.path))
266
416
  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));
417
+ const rank = (path) => path === null ? order.size + 1 : (order.get(path) ?? order.size);
418
+ return items
419
+ .map((item, i) => ({ item, i }))
420
+ .sort((a, b) => rank(a.item.where.path) - rank(b.item.where.path) ||
421
+ (a.item.where.line ?? Number.MAX_SAFE_INTEGER) -
422
+ (b.item.where.line ?? Number.MAX_SAFE_INTEGER) ||
423
+ a.i - b.i)
424
+ .map(({ item }) => item);
425
+ }
426
+ function quoteBlock(quote) {
427
+ if (quote.length === 0)
428
+ return [];
429
+ const f = fence(quote);
430
+ return [`${f}diff`, ...quote, f];
431
+ }
432
+ function voices(item) {
433
+ const { kept } = item;
434
+ const out = [];
435
+ if (kept !== null)
436
+ out.push(`Agent: ${kept.concern.text}`);
437
+ if (kept?.note)
438
+ out.push(`Reviewer: ${kept.note}`);
439
+ for (const c of item.comments) {
440
+ out.push(`Reviewer: ${c.body}`, ...renderReplies(c));
287
441
  }
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");
442
+ return out;
290
443
  }
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");
444
+ function renderReplies(c) {
445
+ return (c.replies ?? []).flatMap((r) => {
446
+ const label = r.author === "agent" ? "Agent replied" : "Reviewer replied";
447
+ return [
448
+ `${label}: ${r.body}`,
449
+ ...(r.diagram === undefined ? [] : ["```mermaid", r.diagram.trimEnd(), "```"]),
450
+ ];
451
+ });
452
+ }
453
+ function itemHeading(item, level) {
454
+ const { where, kept } = item;
455
+ const parts = [where.label, item.severity ?? "unrated"];
456
+ if (kept !== null)
457
+ parts.push(kept.concern.kind);
458
+ parts.push(kept === null ? "raised by the reviewer" : "raised by the agent, kept by the reviewer");
459
+ return `${"#".repeat(level)} ${parts.join(" · ")}`;
460
+ }
461
+ function renderItems(items, units, level) {
462
+ const out = [];
463
+ const sorted = sortItems(items, units);
464
+ const paths = [...new Set(sorted.flatMap((i) => (i.where.path === null ? [] : [i.where.path])))];
465
+ const group = (title, members) => {
466
+ if (members.length === 0)
467
+ return;
468
+ out.push(`${"#".repeat(level)} ${title}`, "");
469
+ for (const item of members) {
470
+ out.push(itemHeading(item, level + 1), "", ...quoteBlock(item.where.quote));
471
+ if (item.where.quote.length > 0)
472
+ out.push("");
473
+ out.push(...voices(item), "");
474
+ }
475
+ };
476
+ for (const path of paths)
477
+ group(path, sorted.filter((i) => i.where.path === path));
478
+ group("General", sorted.filter((i) => i.where.path === null));
479
+ return out;
480
+ }
481
+ function summarizeIssues(items) {
482
+ if (items.length === 0)
483
+ return "no issues";
484
+ const count = (s) => items.filter((i) => i.severity === s).length;
485
+ const tally = [
486
+ [count("blocker"), "blocker"],
487
+ [count("major"), "major"],
488
+ [count("minor"), "minor"],
489
+ [count(null), "unrated"],
490
+ ];
491
+ const parts = tally.flatMap(([n, label]) => (n === 0 ? [] : [`${n} ${label}`]));
492
+ return `${items.length} ${items.length === 1 ? "issue" : "issues"}: ${parts.join(", ")}`;
493
+ }
494
+ function renderQuestions(ctx, questions, responses) {
495
+ const items = sortItems(questions.map((c) => ({ where: whereComment(ctx, c), comment: c })), ctx.units);
496
+ const out = ["## Questions", ""];
497
+ for (const { where, comment } of items) {
498
+ const place = where.path === null ? where.label : `${where.path} · ${where.label}`;
499
+ out.push(`### ${comment.id} · ${place}`, "", ...quoteBlock(where.quote));
500
+ if (where.quote.length > 0)
501
+ out.push("");
502
+ out.push(`Reviewer asked: ${comment.body}`, ...renderReplies(comment));
503
+ const answer = responses.find((r) => r.comment === comment.id);
504
+ if (answer !== undefined) {
505
+ const at = answer.at;
506
+ const explained = at === undefined
507
+ ? ""
508
+ : `, explained in commit ${at.commit}${at.unit === undefined ? "" : ` (${at.unit})`}`;
509
+ out.push(`Answer (${answer.status}${explained}): ${answer.note}`);
510
+ }
511
+ else if ((comment.replies ?? []).length === 0)
512
+ out.push("Not answered yet.");
513
+ out.push("");
302
514
  }
303
- const planLevel = review.comments.filter((c) => c.anchor.kind === "plan");
515
+ return out;
516
+ }
517
+ /** Plan feedback for a split review: what the planner answers through `responses`. */
518
+ function renderPlanFeedback(commits, comments) {
519
+ const out = [];
520
+ const line = (c) => [`- ${c.id} on ${describeAnchor(c.anchor)}`];
521
+ const planLevel = comments.filter((c) => c.anchor.kind === "plan");
304
522
  if (planLevel.length > 0) {
305
523
  out.push("## Whole plan", "");
306
524
  for (const c of planLevel)
307
- out.push(`- ${c.id}: ${c.body}`);
525
+ out.push(`- ${c.id}: ${c.body}`, ...renderReplies(c));
308
526
  out.push("");
309
527
  }
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);
528
+ const orphans = comments.filter((c) => c.anchor.kind !== "plan" && c.anchor.commit >= commits.length);
529
+ commits.forEach((commit, i) => {
530
+ const mine = comments.filter((c) => c.anchor.kind !== "plan" && c.anchor.commit === i);
314
531
  if (mine.length === 0)
315
532
  return;
316
533
  out.push(`## Commit ${i + 1}: ${commit.message.split("\n")[0] ?? ""}`, "");
317
534
  for (const c of mine) {
318
- out.push(`- ${c.id} on ${describeAnchor(c.anchor)}`);
535
+ out.push(...line(c));
319
536
  if (c.context.path)
320
537
  out.push(` - file: ${c.context.path}`);
321
538
  out.push(` - ${c.body.split("\n").join("\n ")}`);
539
+ for (const reply of renderReplies(c))
540
+ out.push(` - ${reply}`);
322
541
  }
323
542
  out.push("");
324
543
  });
@@ -328,6 +547,96 @@ export function renderFeedback(review, plan, units) {
328
547
  out.push(`- ${c.id}: ${c.body}`);
329
548
  out.push("");
330
549
  }
331
- out.push("Answer every comment id above in `responses` of the next draft, then run `follow-me check` and `follow-me apply`.", "");
550
+ return out;
551
+ }
552
+ /**
553
+ * The consolidated report of a review, in the voices of both the reviewer and the agent: the
554
+ * reviewer's comments and the agent's concerns the reviewer kept, grouped by file and ordered by
555
+ * line, then the questions with their answers. A walk review is a code review for the author. A
556
+ * split review is plan feedback for the planner, followed by the same code issues and questions.
557
+ * Concerns the reviewer has not decided are left out, as are those dismissed.
558
+ */
559
+ export function renderReport(review, commits, units, opts) {
560
+ const ctx = { commits, units };
561
+ const walk = review.mode === "walk";
562
+ const questions = review.comments.filter((c) => c.kind === "question");
563
+ const rest = review.comments.filter((c) => c.kind !== "question");
564
+ const issues = walk ? rest : rest.filter((c) => c.severity !== undefined);
565
+ const feedback = walk ? [] : rest.filter((c) => c.severity === undefined);
566
+ const items = buildItems(ctx, keptConcerns(review, commits), issues);
567
+ const out = [];
568
+ if (walk) {
569
+ out.push(`# Code review: ${opts.branch} (${commits.length === 1 ? "1 commit" : `${commits.length} commits`}, ${summarizeIssues(items)})`, "");
570
+ if (items.length === 0 && questions.length === 0)
571
+ out.push("The reviewer raised no issues.", "");
572
+ out.push(...renderItems(items, units, 2));
573
+ }
574
+ else {
575
+ out.push(`# Review of plan v${review.planVersion}`, "");
576
+ if (feedback.length === 0 && items.length === 0 && questions.length === 0) {
577
+ out.push("No comments.", "");
578
+ return out.join("\n");
579
+ }
580
+ out.push(...renderPlanFeedback(commits, feedback));
581
+ if (items.length > 0) {
582
+ out.push(`## Code issues (${summarizeIssues(items)})`, "", ...renderItems(items, units, 3));
583
+ }
584
+ }
585
+ if (questions.length > 0)
586
+ out.push(...renderQuestions(ctx, questions, opts.responses));
587
+ if (walk && items.length > 0) {
588
+ 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.", "");
589
+ }
590
+ if (!walk && (feedback.length > 0 || questions.length > 0)) {
591
+ out.push("Answer every comment id above in `responses` of the next draft, then run `follow-me check` and `follow-me apply`.", "");
592
+ }
593
+ if (!walk && items.length > 0) {
594
+ out.push("Fix the code issues on the original branch, then run `follow-me extract` again.", "");
595
+ }
332
596
  return out.join("\n");
333
597
  }
598
+ function renderEvent(event) {
599
+ switch (event.type) {
600
+ case "comment":
601
+ case "comment-updated": {
602
+ const { comment: c } = event;
603
+ const what = c.kind === "question" ? "question" : "comment";
604
+ const verb = event.type === "comment" ? "new" : "edited";
605
+ const lines = [`- ${verb} ${what} ${c.id} on ${describeAnchor(c.anchor)}`];
606
+ if (c.context.path)
607
+ lines.push(` - file: ${c.context.path}`);
608
+ if (c.severity)
609
+ lines.push(` - severity: ${c.severity}`);
610
+ if (c.follows)
611
+ lines.push(` - asks again after ${c.follows}`);
612
+ lines.push(` - ${c.body.split("\n").join("\n ")}`);
613
+ return lines;
614
+ }
615
+ case "comment-deleted":
616
+ return [`- comment ${event.id} was deleted`];
617
+ case "reply":
618
+ return [`- ${event.reply.author} replied under ${event.commentId}: ${event.reply.body}`];
619
+ case "concern": {
620
+ const severity = event.severity === undefined ? "" : ` as ${event.severity}`;
621
+ const note = event.note === undefined ? "" : `: ${event.note}`;
622
+ return [`- concern ${event.concernId} ${event.decision}${severity}${note}`];
623
+ }
624
+ case "resolved":
625
+ return [`- question ${event.commentId} marked ${event.resolved ? "resolved" : "open again"}`];
626
+ case "version":
627
+ return [`- plan v${event.version} is now open`];
628
+ case "ended":
629
+ return [`- the reviewer ended the review; the report is next to ${event.review}`];
630
+ default: {
631
+ const exhaustive = event;
632
+ return exhaustive;
633
+ }
634
+ }
635
+ }
636
+ /** A markdown digest of what happened on the review page, for `follow-me watch`. */
637
+ export function renderEvents(events) {
638
+ const first = events[0]?.seq;
639
+ const last = events.at(-1)?.seq;
640
+ const range = first === last ? `event ${first}` : `events ${first}-${last}`;
641
+ return [`# Review activity (${range})`, "", ...events.flatMap(renderEvent), ""].join("\n");
642
+ }