@shiyuphan/follow-me 0.2.0 → 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 (152) hide show
  1. package/README.md +44 -55
  2. package/dist/src/cli.js +566 -188
  3. package/dist/src/comments.js +66 -5
  4. package/dist/src/git.js +12 -0
  5. package/dist/src/host.js +19 -8
  6. package/dist/src/install.js +21 -0
  7. package/dist/src/plan.js +1 -1
  8. package/dist/src/prompt.js +343 -39
  9. package/dist/src/server.js +176 -67
  10. package/dist/src/session.js +13 -11
  11. package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-DcmzRkVZ.js +1 -0
  12. package/dist/ui/dist/assets/arc-T5QB0T1C.js +1 -0
  13. package/dist/ui/dist/assets/architecture-WOLXFQ4H-DUIkQTeE.js +1 -0
  14. package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-BUYi4-bA.js +36 -0
  15. package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
  16. package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-dyJxtT9e.js +140 -0
  17. package/dist/ui/dist/assets/c4Diagram-XDPPPE54-CrAgiaZR.js +38 -0
  18. package/dist/ui/dist/assets/channel-wW7ebm0i.js +1 -0
  19. package/dist/ui/dist/assets/chunk-2E7WHVMD-C6fAf4Vj.js +88 -0
  20. package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  21. package/dist/ui/dist/assets/chunk-4EMNKR2T-BLZjwZ4Q.js +1 -0
  22. package/dist/ui/dist/assets/chunk-55XCU76K-DGLK2Q-L.js +1 -0
  23. package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  24. package/dist/ui/dist/assets/chunk-7S6XSTFO-BablszTX.js +2 -0
  25. package/dist/ui/dist/assets/chunk-AQDTSKZB-Cuk3B_vb.js +72 -0
  26. package/dist/ui/dist/assets/chunk-DSNLSHJZ-DiC78RqE.js +127 -0
  27. package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  28. package/dist/ui/dist/assets/chunk-HAGFOTED-X_RbvixX.js +62 -0
  29. package/dist/ui/dist/assets/chunk-HNE4Q36P-C14_x5ck.js +1 -0
  30. package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  31. package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  32. package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  33. package/dist/ui/dist/assets/chunk-NQOKRVIF-Dxd0dtYR.js +213 -0
  34. package/dist/ui/dist/assets/chunk-UHW7PFBP-BIxePNUS.js +1 -0
  35. package/dist/ui/dist/assets/chunk-VQ5W62QI-CKSKLbj4.js +1 -0
  36. package/dist/ui/dist/assets/chunk-WQQL4CDK-CWNmZGlR.js +10 -0
  37. package/dist/ui/dist/assets/chunk-XXDRQBXY-D-I5QHgG.js +1 -0
  38. package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  39. package/dist/ui/dist/assets/chunk-YZO67HFG-DFP68CFd.js +1 -0
  40. package/dist/ui/dist/assets/chunk-ZAUX2LTX-DY90d4QG.js +1 -0
  41. package/dist/ui/dist/assets/chunk-ZCHMQSOR-Dlpr-5UC.js +2 -0
  42. package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-CH9uhoLJ.js +217 -0
  43. package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-DtYHrp8D.js +1 -0
  44. package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtslbtrY.js +1 -0
  45. package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUU1Jcc7.js +62 -0
  46. package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  47. package/dist/ui/dist/assets/dagre-NK4GIZVX-CDlP-dRW.js +4 -0
  48. package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  49. package/dist/ui/dist/assets/diagram-CBK3MWTC-DiV2fn-r.js +24 -0
  50. package/dist/ui/dist/assets/diagram-I5XIIV2D-Bg5RehGi.js +24 -0
  51. package/dist/ui/dist/assets/diagram-ODIL2UCW-CFmazz3f.js +30 -0
  52. package/dist/ui/dist/assets/diagram-PFBEJUXE-uw3ZBqDp.js +3 -0
  53. package/dist/ui/dist/assets/diagram-RJA3I4VV-uEOYYRYa.js +41 -0
  54. package/dist/ui/dist/assets/diagram-TYUNFSNR-BN58LF_3.js +200 -0
  55. package/dist/ui/dist/assets/dist-tWS_lXDS.js +1 -0
  56. package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-B68Tz-w8.js +1 -0
  57. package/dist/ui/dist/assets/elk-QONTBCTQ-DZZohNea.js +27 -0
  58. package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BH_m74pv.js +99 -0
  59. package/dist/ui/dist/assets/eventmodeling-K75KTNOO-CTp5FHos.js +1 -0
  60. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +1 -0
  61. package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-BDau7_0f.js +292 -0
  62. package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  63. package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  64. package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  65. package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  66. package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  67. package/dist/ui/dist/assets/gitGraph-VSP46ZUC-Bv84IoCm.js +1 -0
  68. package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-DzXcMlm8.js +106 -0
  69. package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
  70. package/dist/ui/dist/assets/index-BTJoqVVN.js +133 -0
  71. package/dist/ui/dist/assets/index-D60U3smc.css +1 -0
  72. package/dist/ui/dist/assets/info-OHQRW6UA-222J0O_j.js +1 -0
  73. package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-DE2sa3Vz.js +2 -0
  74. package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
  75. package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js +70 -0
  76. package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-JSUXX5Hb.js +139 -0
  77. package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-Bg79b49M.js +89 -0
  78. package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
  79. package/dist/ui/dist/assets/line-Bk5qQSIa.js +1 -0
  80. package/dist/ui/dist/assets/linear-PFnVMru5.js +1 -0
  81. package/dist/ui/dist/assets/mermaid-parser.core-Dcxlzc-S.js +7 -0
  82. package/dist/ui/dist/assets/mermaid.core-TeOHQJ00.js +44 -0
  83. package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-IaNk8NnW.js +98 -0
  84. package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  85. package/dist/ui/dist/assets/packet-JDAUHWVQ-DMff5cVK.js +1 -0
  86. package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
  87. package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-66Xu4YRc.js +1 -0
  88. package/dist/ui/dist/assets/pie-XZMESJXO-GEtzLCIM.js +1 -0
  89. package/dist/ui/dist/assets/pieDiagram-GNMPY74T-7jMBi-kU.js +39 -0
  90. package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
  91. package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
  92. package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
  93. package/dist/ui/dist/assets/prism-core-FDHlWQ43.js +1 -0
  94. package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
  95. package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
  96. package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
  97. package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
  98. package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
  99. package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
  100. package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
  101. package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
  102. package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
  103. package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
  104. package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  105. package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
  106. package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
  107. package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
  108. package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
  109. package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
  110. package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
  111. package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
  112. package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
  113. package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
  114. package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
  115. package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-8hIWayC2.js +7 -0
  116. package/dist/ui/dist/assets/radar-ABXABTNO-BTdwUOOF.js +1 -0
  117. package/dist/ui/dist/assets/railroad-I3PHUGI6-DcMbDqBL.js +1 -0
  118. package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-gTzUTWeI.js +1 -0
  119. package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-CNtdUPtk.js +1 -0
  120. package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-DOiExAtE.js +1 -0
  121. package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-2kE_ygvM.js +1 -0
  122. package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-CoIAHRq2.js +84 -0
  123. package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  124. package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-7p8D3wim.js +40 -0
  125. package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-C6QFnMvn.js +169 -0
  126. package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  127. package/dist/ui/dist/assets/src-FgijoBVs.js +1 -0
  128. package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-n9eAb_bT.js +291 -0
  129. package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-U18mtUzX.js +1 -0
  130. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +8 -0
  131. package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-CD2Z8QGP.js +120 -0
  132. package/dist/ui/dist/assets/treeView-D4JQ5SDB-sqUjH5gB.js +1 -0
  133. package/dist/ui/dist/assets/treemap-SAJKECNS-DvZ38FP7.js +1 -0
  134. package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-CqOGidaZ.js +328 -0
  135. package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D1SXSBQ5.js +34 -0
  136. package/dist/ui/dist/assets/wardley-7MLQ67FV-Bpwio-Dx.js +1 -0
  137. package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-JSUXHMnk.js +78 -0
  138. package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-B4IFqQTN.js +7 -0
  139. package/dist/ui/dist/index.html +15 -0
  140. package/dist/ui/dist/style.css +121 -0
  141. package/package.json +31 -10
  142. package/skills/follow-me/SKILL.md +15 -89
  143. package/dist/src/anthropic.js +0 -133
  144. package/dist/ui/app.js +0 -2201
  145. package/dist/ui/concerns.js +0 -165
  146. package/dist/ui/highlight.js +0 -221
  147. package/dist/ui/index.html +0 -94
  148. package/dist/ui/live.js +0 -184
  149. package/dist/ui/playback.js +0 -579
  150. package/dist/ui/questions.js +0 -189
  151. package/dist/ui/style.css +0 -1331
  152. package/dist/ui/summary.js +0 -45
@@ -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,8 +80,92 @@ 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);
76
169
  if (comment.follows)
77
170
  lines.push(` asks again after ${comment.follows}`);
78
171
  if (comment.context.commitMessage) {
@@ -80,7 +173,10 @@ function renderComment(comment) {
80
173
  }
81
174
  if (comment.context.path)
82
175
  lines.push(` path: ${comment.context.path}`);
83
- if (comment.context.lines && comment.context.lines.length > 0) {
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) {
84
180
  lines.push(" lines asked about:", ...comment.context.lines.map((l) => ` ${l}`));
85
181
  }
86
182
  if (comment.severity)
@@ -91,8 +187,8 @@ function renderComment(comment) {
91
187
  }
92
188
  return lines.join("\n");
93
189
  }
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.
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.
96
192
 
97
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.`;
98
194
  const ORDERING = `Order the commits so the story reads naturally:
@@ -104,7 +200,8 @@ const ORDERING = `Order the commits so the story reads naturally:
104
200
  - The last commit must bring the tree to exactly the tip, which follows from covering every unit.
105
201
 
106
202
  Write each commit message as a short imperative title, then a blank line and a body that
107
- says why. Keep the title to 50 characters or fewer; put detail in the body.`;
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.`;
108
205
  const GOALS = {
109
206
  split: {
110
207
  title: "plan the commits",
@@ -116,17 +213,16 @@ ${ORDERING}`,
116
213
  },
117
214
  walk: {
118
215
  title: "tell the story of this branch",
119
- goal: `The reader is a reviewer meeting this branch for the first time. They will watch it
120
- being written commit by commit, hear your narration read aloud as each line appears, and
121
- comment on the code itself. The final tree is fixed. You decide how its change lines are
122
- 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.
123
219
 
124
220
  ${ORDERING}
125
221
 
126
- Give every commit a \`narration\`. Add one to a unit too wherever the why of that hunk is not
127
- obvious from the commit's narration. Narration should sound like someone thinking aloud at the
128
- keyboard: first person, present tense, two or three sentences, no markdown. Say why, not what
129
- 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.`,
130
226
  },
131
227
  };
132
228
  const FORMAT = (draftPath, base, tip) => `Write the plan as JSON to ${draftPath}. Write only the file, in this format:
@@ -139,7 +235,7 @@ const FORMAT = (draftPath, base, tip) => `Write the plan as JSON to ${draftPath}
139
235
  { "message": "Add the Token type\\n\\nThe parser needs it.", "units": ["h_0123456789ab", "h_ba9876543210:0-3,7"] },
140
236
  {
141
237
  "message": "Parse tokens",
142
- "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.",
143
239
  "units": [{ "unit": "h_ba9876543210", "narration": "The loop is boring on purpose." }, "f_0123456789ab"]
144
240
  }
145
241
  ],
@@ -148,19 +244,18 @@ const FORMAT = (draftPath, base, tip) => `Write the plan as JSON to ${draftPath}
148
244
  ]
149
245
  }
150
246
 
151
- \`narration\` is optional on a commit and on a unit. A unit with narration is written as
152
- \`{ "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.
153
249
 
154
250
  \`diagram\` is optional on a commit and on a unit, written the same way:
155
251
  \`{ "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
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
158
253
  modules, a state machine, a call graph, or the before and after shape of a structure. Most commits
159
254
  need none. The first line names the diagram type, for example:
160
255
 
161
256
  "diagram": "flowchart LR\\n parse --> validate --> apply"
162
257
 
163
- Narration stays plain text even when a diagram is present, because it is read aloud.
258
+ The note stays plain text even when a diagram is present.
164
259
  \`responses\` is [] when there are no review comments. \`status\` is one of "addressed",
165
260
  "declined", "deferred" or, for a question, "answered", and \`note\` says what you did or why not.
166
261
  An "answered" response also carries \`"at": { "commit": 3, "unit": "h_x" }\`: the commit, counted from 1,
@@ -188,7 +283,7 @@ export function renderPrompt(input) {
188
283
  const sections = [
189
284
  ...head,
190
285
  `## Concerns\n\n${CONCERNS}`,
191
- `## Questions from the reviewer\n\n${QUESTIONS}`,
286
+ `## Questions from the reviewer\n\n${QUESTIONS}\n\n${CONTEXT_RULE}`,
192
287
  units,
193
288
  ];
194
289
  return `${sections.join("\n\n")}\n`;
@@ -198,11 +293,13 @@ export function renderPrompt(input) {
198
293
  if (previousPlan) {
199
294
  sections.push(`## Previous plan\n\nCommits are numbered from 1.\n\n${renderPreviousPlan(previousPlan)}`);
200
295
  }
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];
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}`];
204
301
  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"));
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"));
206
303
  }
207
304
  sections.push(questions.join("\n"));
208
305
  if (review && changes.length > 0) {
@@ -210,7 +307,7 @@ export function renderPrompt(input) {
210
307
  `## Open review comments\n`,
211
308
  `A reviewer commented on plan v${review.planVersion}. Commits are numbered from 1.`,
212
309
  "Answer every comment below with one entry in `responses`, changing the plan where you agree.\n",
213
- changes.map(renderComment).join("\n"),
310
+ changes.map((c) => renderComment(c, ctx)).join("\n"),
214
311
  ].join("\n"));
215
312
  }
216
313
  return `${sections.join("\n\n")}\n`;
@@ -299,6 +396,15 @@ function whereComment(ctx, c) {
299
396
  case "position":
300
397
  return { path: null, line: null, label: `commit ${anchor.commit + 1} position`, quote: [] };
301
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
+ }
302
408
  const quoted = quoteHunk(ctx.units, ctx.commits, anchor.unit, anchor.commit, anchor.changes);
303
409
  const path = ctx.units.get(anchor.unit)?.path ?? c.context.path;
304
410
  if (quoted === null) {
@@ -514,10 +620,24 @@ function renderQuestions(ctx, questions, responses) {
514
620
  }
515
621
  return out;
516
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
+ }
517
636
  /** Plan feedback for a split review: what the planner answers through `responses`. */
518
- function renderPlanFeedback(commits, comments) {
637
+ function renderPlanFeedback(ctx, comments) {
638
+ const { commits } = ctx;
519
639
  const out = [];
520
- const line = (c) => [`- ${c.id} on ${describeAnchor(c.anchor)}`];
640
+ const line = (c) => [`- ${c.id} on ${describeAnchor(c.anchor, ctx)}`];
521
641
  const planLevel = comments.filter((c) => c.anchor.kind === "plan");
522
642
  if (planLevel.length > 0) {
523
643
  out.push("## Whole plan", "");
@@ -535,6 +655,9 @@ function renderPlanFeedback(commits, comments) {
535
655
  out.push(...line(c));
536
656
  if (c.context.path)
537
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}`));
538
661
  out.push(` - ${c.body.split("\n").join("\n ")}`);
539
662
  for (const reply of renderReplies(c))
540
663
  out.push(` - ${reply}`);
@@ -549,6 +672,155 @@ function renderPlanFeedback(commits, comments) {
549
672
  }
550
673
  return out;
551
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
+ }
552
824
  /**
553
825
  * The consolidated report of a review, in the voices of both the reviewer and the agent: the
554
826
  * reviewer's comments and the agent's concerns the reviewer kept, grouped by file and ordered by
@@ -556,11 +828,29 @@ function renderPlanFeedback(commits, comments) {
556
828
  * split review is plan feedback for the planner, followed by the same code issues and questions.
557
829
  * Concerns the reviewer has not decided are left out, as are those dismissed.
558
830
  */
559
- export function renderReport(review, commits, units, opts) {
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) {
560
847
  const ctx = { commits, units };
561
848
  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");
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");
564
854
  const issues = walk ? rest : rest.filter((c) => c.severity !== undefined);
565
855
  const feedback = walk ? [] : rest.filter((c) => c.severity === undefined);
566
856
  const items = buildItems(ctx, keptConcerns(review, commits), issues);
@@ -573,36 +863,47 @@ export function renderReport(review, commits, units, opts) {
573
863
  }
574
864
  else {
575
865
  out.push(`# Review of plan v${review.planVersion}`, "");
576
- if (feedback.length === 0 && items.length === 0 && questions.length === 0) {
866
+ if (feedback.length === 0 &&
867
+ items.length === 0 &&
868
+ questions.length === 0 &&
869
+ started.length === 0) {
577
870
  out.push("No comments.", "");
578
871
  return out.join("\n");
579
872
  }
580
- out.push(...renderPlanFeedback(commits, feedback));
873
+ out.push(...renderPlanFeedback(ctx, feedback));
581
874
  if (items.length > 0) {
582
875
  out.push(`## Code issues (${summarizeIssues(items)})`, "", ...renderItems(items, units, 3));
583
876
  }
584
877
  }
585
878
  if (questions.length > 0)
586
879
  out.push(...renderQuestions(ctx, questions, opts.responses));
880
+ if (started.length > 0)
881
+ out.push(...renderThreads(ctx, started));
587
882
  if (walk && items.length > 0) {
588
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.", "");
589
884
  }
590
885
  if (!walk && (feedback.length > 0 || questions.length > 0)) {
591
886
  out.push("Answer every comment id above in `responses` of the next draft, then run `follow-me check` and `follow-me apply`.", "");
592
887
  }
888
+ const onContext = review.comments.some((c) => pickRows(ctx, c.anchor)?.touchesContext);
889
+ if (onContext)
890
+ out.push(CONTEXT_RULE, "");
593
891
  if (!walk && items.length > 0) {
594
892
  out.push("Fix the code issues on the original branch, then run `follow-me extract` again.", "");
595
893
  }
596
894
  return out.join("\n");
597
895
  }
598
- function renderEvent(event) {
896
+ function renderEvent(event, ctx) {
599
897
  switch (event.type) {
600
898
  case "comment":
601
899
  case "comment-updated": {
602
900
  const { comment: c } = event;
603
901
  const what = c.kind === "question" ? "question" : "comment";
604
902
  const verb = event.type === "comment" ? "new" : "edited";
605
- const lines = [`- ${verb} ${what} ${c.id} on ${describeAnchor(c.anchor)}`];
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}`));
606
907
  if (c.context.path)
607
908
  lines.push(` - file: ${c.context.path}`);
608
909
  if (c.severity)
@@ -625,6 +926,8 @@ function renderEvent(event) {
625
926
  return [`- question ${event.commentId} marked ${event.resolved ? "resolved" : "open again"}`];
626
927
  case "version":
627
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"];
628
931
  case "ended":
629
932
  return [`- the reviewer ended the review; the report is next to ${event.review}`];
630
933
  default: {
@@ -634,9 +937,10 @@ function renderEvent(event) {
634
937
  }
635
938
  }
636
939
  /** A markdown digest of what happened on the review page, for `follow-me watch`. */
637
- export function renderEvents(events) {
940
+ export function renderEvents(events, ctx = null) {
638
941
  const first = events[0]?.seq;
639
942
  const last = events.at(-1)?.seq;
640
943
  const range = first === last ? `event ${first}` : `events ${first}-${last}`;
641
- return [`# Review activity (${range})`, "", ...events.flatMap(renderEvent), ""].join("\n");
944
+ const body = events.flatMap((event) => renderEvent(event, ctx));
945
+ return [`# Review activity (${range})`, "", ...body, ""].join("\n");
642
946
  }