@shiyuphan/follow-me 0.3.0 → 0.4.1

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 (98) hide show
  1. package/dist/src/cli.js +202 -34
  2. package/dist/src/comments.js +77 -17
  3. package/dist/src/diff.js +11 -0
  4. package/dist/src/evolve.js +123 -0
  5. package/dist/src/host.js +24 -4
  6. package/dist/src/prompt.js +43 -14
  7. package/dist/src/server.js +91 -72
  8. package/dist/src/session.js +40 -1
  9. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-DcmzRkVZ.js → abnfDiagram-WEYOIA3C-byISxePP.js} +1 -1
  10. package/dist/ui/dist/assets/{arc-T5QB0T1C.js → arc-CBzsAsP2.js} +1 -1
  11. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-DUIkQTeE.js → architecture-WOLXFQ4H-0OSkIwdB.js} +1 -1
  12. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-BUYi4-bA.js → architectureDiagram-ZHVYKT53-ixHybNqO.js} +1 -1
  13. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-dyJxtT9e.js → blockDiagram-RJAE7QA3-1F8XL_Ou.js} +1 -1
  14. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-CrAgiaZR.js → c4Diagram-XDPPPE54-D9PFBXnc.js} +1 -1
  15. package/dist/ui/dist/assets/channel-B-fwsHf1.js +1 -0
  16. package/dist/ui/dist/assets/{chunk-2E7WHVMD-C6fAf4Vj.js → chunk-2E7WHVMD-zZdNfMkj.js} +1 -1
  17. package/dist/ui/dist/assets/{chunk-4EMNKR2T-BLZjwZ4Q.js → chunk-4EMNKR2T-CTKoxSSj.js} +1 -1
  18. package/dist/ui/dist/assets/{chunk-55XCU76K-DGLK2Q-L.js → chunk-55XCU76K-ifFnDlcS.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-7S6XSTFO-BablszTX.js → chunk-7S6XSTFO-BCMVdi_1.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-AQDTSKZB-Cuk3B_vb.js → chunk-AQDTSKZB-DC-5Whju.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-DiC78RqE.js → chunk-DSNLSHJZ-DASbe_gb.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-HAGFOTED-X_RbvixX.js → chunk-HAGFOTED-CArytDjj.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-HNE4Q36P-C14_x5ck.js → chunk-HNE4Q36P-Dnl-Wtud.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-NQOKRVIF-Dxd0dtYR.js → chunk-NQOKRVIF-wvSLaeaK.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-UHW7PFBP-BIxePNUS.js → chunk-UHW7PFBP-CckaOGiQ.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-VQ5W62QI-CKSKLbj4.js → chunk-VQ5W62QI-kQBMDJ7V.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-WQQL4CDK-CWNmZGlR.js → chunk-WQQL4CDK-CMg6bbLU.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-XXDRQBXY-D-I5QHgG.js → chunk-XXDRQBXY-CYlFr19K.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-YZO67HFG-DFP68CFd.js → chunk-YZO67HFG-CqkG1vVk.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-DY90d4QG.js → chunk-ZAUX2LTX-BUNEdx-e.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-Dlpr-5UC.js → chunk-ZCHMQSOR-CFTbvcrm.js} +1 -1
  32. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-CH9uhoLJ.js → classDiagram-v2-4MYWR3UA-t2OdJ0LA.js} +1 -1
  33. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-DtYHrp8D.js → cose-bilkent-JH36ORCC-FGm030gc.js} +1 -1
  34. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-BtslbtrY.js → cynefin-EF2NZ3EQ-BtU33Lsu.js} +1 -1
  35. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-DUU1Jcc7.js → cynefinDiagram-WF24UDRP-DUr-I7hS.js} +1 -1
  36. package/dist/ui/dist/assets/{dagre-NK4GIZVX-CDlP-dRW.js → dagre-NK4GIZVX-BWTHwJKt.js} +1 -1
  37. package/dist/ui/dist/assets/{diagram-CBK3MWTC-DiV2fn-r.js → diagram-CBK3MWTC-PcHrsEBn.js} +1 -1
  38. package/dist/ui/dist/assets/{diagram-I5XIIV2D-Bg5RehGi.js → diagram-I5XIIV2D-CkGtKjYj.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-ODIL2UCW-CFmazz3f.js → diagram-ODIL2UCW-B_HdBB1w.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-PFBEJUXE-uw3ZBqDp.js → diagram-PFBEJUXE-DL9zayE6.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-RJA3I4VV-uEOYYRYa.js → diagram-RJA3I4VV-By5wl8nK.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-TYUNFSNR-BN58LF_3.js → diagram-TYUNFSNR-ziWPVayL.js} +1 -1
  43. package/dist/ui/dist/assets/{dist-tWS_lXDS.js → dist-NDVqCM7k.js} +1 -1
  44. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-B68Tz-w8.js → ebnfDiagram-V4P3TTPT-DH4YGkPc.js} +1 -1
  45. package/dist/ui/dist/assets/{elk-QONTBCTQ-DZZohNea.js → elk-QONTBCTQ-BL2RlCEv.js} +1 -1
  46. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-BH_m74pv.js → erDiagram-KYQIGQAA-BKbTMaRp.js} +1 -1
  47. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-CTp5FHos.js → eventmodeling-K75KTNOO-Dk5RCWc-.js} +1 -1
  48. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Dkjb3PCK.js +1 -0
  49. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-BDau7_0f.js → ganttDiagram-R7GPNTJY-3vM9gxeT.js} +1 -1
  50. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-Bv84IoCm.js → gitGraph-VSP46ZUC-CXGFhz54.js} +1 -1
  51. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-DzXcMlm8.js → gitGraphDiagram-PDYL7XHY-Cl4fwUuA.js} +1 -1
  52. package/dist/ui/dist/assets/index-B8TYcxF8.css +1 -0
  53. package/dist/ui/dist/assets/index-CBbx_pJk.js +134 -0
  54. package/dist/ui/dist/assets/{info-OHQRW6UA-222J0O_j.js → info-OHQRW6UA-DfSsZ8ib.js} +1 -1
  55. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-DE2sa3Vz.js → infoDiagram-MVDRKAX5-B_7tyrL5.js} +1 -1
  56. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js → ishikawaDiagram-HPEHV2GE-DUvHljpg.js} +1 -1
  57. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-JSUXX5Hb.js → journeyDiagram-BOSHW7O7-BCgqzrb8.js} +1 -1
  58. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-Bg79b49M.js → kanban-definition-FNSA4HC5-DOzZ3NmE.js} +1 -1
  59. package/dist/ui/dist/assets/{line-Bk5qQSIa.js → line-B-mPFVll.js} +1 -1
  60. package/dist/ui/dist/assets/{linear-PFnVMru5.js → linear-k7o0jout.js} +1 -1
  61. package/dist/ui/dist/assets/{mermaid-parser.core-Dcxlzc-S.js → mermaid-parser.core-CAyLSQpH.js} +3 -3
  62. package/dist/ui/dist/assets/{mermaid.core-TeOHQJ00.js → mermaid.core-vfVMjgx2.js} +4 -4
  63. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-IaNk8NnW.js → mindmap-definition-6OY57UG3-B6y-B_r4.js} +1 -1
  64. package/dist/ui/dist/assets/{packet-JDAUHWVQ-DMff5cVK.js → packet-JDAUHWVQ-C_r4ed30.js} +1 -1
  65. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-66Xu4YRc.js → pegDiagram-ECIUJNBX-B8VUm3Bh.js} +1 -1
  66. package/dist/ui/dist/assets/{pie-XZMESJXO-GEtzLCIM.js → pie-XZMESJXO-DcwtbH9y.js} +1 -1
  67. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-7jMBi-kU.js → pieDiagram-GNMPY74T-CvUNBi2F.js} +1 -1
  68. package/dist/ui/dist/assets/{prism-core-FDHlWQ43.js → prism-core-_2ju85Ya.js} +1 -1
  69. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-8hIWayC2.js → quadrantDiagram-D57K4MC3-Dg2eVS3j.js} +1 -1
  70. package/dist/ui/dist/assets/{radar-ABXABTNO-BTdwUOOF.js → radar-ABXABTNO-xCNTcZE9.js} +1 -1
  71. package/dist/ui/dist/assets/{railroad-I3PHUGI6-DcMbDqBL.js → railroad-I3PHUGI6-BI-sIL2Q.js} +1 -1
  72. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-gTzUTWeI.js → railroad-abnf-LNEFI6M7-Ddvk40Wx.js} +1 -1
  73. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-CNtdUPtk.js → railroad-ebnf-DWYD2UWJ-h35gE183.js} +1 -1
  74. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-DOiExAtE.js → railroad-peg-JR7BVNR7-Zim9iPdp.js} +1 -1
  75. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-2kE_ygvM.js → railroadDiagram-Y72NRBL7-Cz6fdmOQ.js} +1 -1
  76. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-CoIAHRq2.js → requirementDiagram-EQYSW5RA-DTJkCrcD.js} +1 -1
  77. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-7p8D3wim.js → sankeyDiagram-SY24JHO5-Bg1iiVla.js} +1 -1
  78. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-C6QFnMvn.js → sequenceDiagram-BLEHNHQU-9XfetsRA.js} +1 -1
  79. package/dist/ui/dist/assets/{src-FgijoBVs.js → src-Dym5afQ5.js} +1 -1
  80. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-n9eAb_bT.js → stateDiagram-v2-A6IESKWZ-CMmcMV8z.js} +1 -1
  81. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-U18mtUzX.js → swimlanes-B3ENRKFQ-B2PcshTY.js} +1 -1
  82. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DfB3XTTn.js +8 -0
  83. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-CD2Z8QGP.js → timeline-definition-JXYKGLGB-Cr1hCNtW.js} +1 -1
  84. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-sqUjH5gB.js → treeView-D4JQ5SDB-jH5ksKnh.js} +1 -1
  85. package/dist/ui/dist/assets/{treemap-SAJKECNS-DvZ38FP7.js → treemap-SAJKECNS-BV90e8QL.js} +1 -1
  86. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-CqOGidaZ.js → usecaseDiagram-WPOLBNNX-BNwq5aJV.js} +1 -1
  87. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-D1SXSBQ5.js → vennDiagram-Q3Y3IDCW-D-lLyZCL.js} +1 -1
  88. package/dist/ui/dist/assets/{wardley-7MLQ67FV-Bpwio-Dx.js → wardley-7MLQ67FV-CWfHQUGt.js} +1 -1
  89. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-JSUXHMnk.js → wardleyDiagram-IVAIDKWA-BL_qNKVd.js} +1 -1
  90. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-B4IFqQTN.js → xychartDiagram-4URNHDU4-BHPK5Snp.js} +1 -1
  91. package/dist/ui/dist/index.html +2 -2
  92. package/package.json +1 -1
  93. package/skills/follow-me/SKILL.md +8 -4
  94. package/dist/ui/dist/assets/channel-wW7ebm0i.js +0 -1
  95. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +0 -1
  96. package/dist/ui/dist/assets/index-BTJoqVVN.js +0 -133
  97. package/dist/ui/dist/assets/index-D60U3smc.css +0 -1
  98. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +0 -8
@@ -0,0 +1,123 @@
1
+ /**
2
+ * How a plan follows its branch when the tip moves. A unit id hashes its path and hunk body, so
3
+ * an id that survives names the same change and keeps its place in the plan; everything else is
4
+ * either gone or new, and the agent decides where the new units go.
5
+ */
6
+ import { tipStartOf } from "./diff.js";
7
+ /** Pure. Responses start empty: they answer the review of the old version, not this one. */
8
+ export function evolvePlan(oldPlan, oldDiff, newDiff) {
9
+ const gone = new Map();
10
+ const commits = [];
11
+ oldPlan.commits.forEach((commit, index) => {
12
+ const units = commit.units.filter((ref) => newDiff.units.has(ref.unit));
13
+ for (const ref of commit.units) {
14
+ const unit = oldDiff.units.get(ref.unit);
15
+ if (newDiff.units.has(ref.unit) || unit === undefined)
16
+ continue;
17
+ const seen = gone.get(ref.unit)?.commits ?? [];
18
+ gone.set(ref.unit, { unit, commits: [...seen, { index, message: commit.message }] });
19
+ }
20
+ if (units.length > 0)
21
+ commits.push({ ...commit, units });
22
+ });
23
+ const taken = new Set(oldPlan.commits.flatMap((c) => c.units.map((ref) => ref.unit)));
24
+ return {
25
+ draft: {
26
+ schema: oldPlan.schema,
27
+ base: newDiff.base,
28
+ tip: newDiff.tip,
29
+ commits,
30
+ responses: [],
31
+ },
32
+ vanished: [...gone.values()],
33
+ added: [...newDiff.units.values()].filter((unit) => !taken.has(unit.id)),
34
+ };
35
+ }
36
+ /** The tip line of each body row; a removed row gets the line that follows it. */
37
+ function tipLines(hunk, units) {
38
+ let line = tipStartOf(hunk, units);
39
+ return hunk.body.map((row) => {
40
+ const at = line;
41
+ if (row.op !== "-")
42
+ line += 1;
43
+ return at;
44
+ });
45
+ }
46
+ /** The body row a hunk comment points at first: its first picked row, else its first change. */
47
+ function firstRow(comment, hunk) {
48
+ const { anchor } = comment;
49
+ if (anchor.kind !== "hunk")
50
+ return 0;
51
+ const picked = anchor.offsets?.[0];
52
+ if (picked !== undefined)
53
+ return picked;
54
+ const change = anchor.changes?.[0];
55
+ const row = hunk.body.findIndex((r) => r.op !== " " && r.change === change);
56
+ return change === undefined || row === -1 ? 0 : row;
57
+ }
58
+ /**
59
+ * Where an old line lands in a replacement hunk: the nearest row with the same text the tip
60
+ * keeps, else the old line clamped into the hunk's tip lines.
61
+ */
62
+ function landing(hunk, units, oldLine, text) {
63
+ const lines = tipLines(hunk, units);
64
+ const same = hunk.body.flatMap((row, i) => row.op !== "-" && row.text === text ? [lines[i] ?? oldLine] : []);
65
+ if (same.length > 0) {
66
+ return same.reduce((a, b) => (Math.abs(b - oldLine) < Math.abs(a - oldLine) ? b : a));
67
+ }
68
+ const [from, to] = span(hunk, units);
69
+ return Math.min(Math.max(oldLine, from), to);
70
+ }
71
+ /** First and last tip line of a hunk; a pure removal spans the one line after it. */
72
+ function span(hunk, units) {
73
+ const from = tipStartOf(hunk, units);
74
+ const kept = hunk.body.filter((row) => row.op !== "-").length;
75
+ return [from, from + Math.max(kept, 1) - 1];
76
+ }
77
+ function distance(hunk, units, line) {
78
+ const [from, to] = span(hunk, units);
79
+ return line < from ? from - line : line > to ? line - to : 0;
80
+ }
81
+ /**
82
+ * Pure. Places each comment of the previous version on the new diff: on its own unit when that
83
+ * hunk is unchanged, else on the hunk of the same path nearest its old tip line.
84
+ */
85
+ export function carryComments(comments, oldUnits, newUnits) {
86
+ return comments.map((comment) => carry(comment, oldUnits, newUnits));
87
+ }
88
+ function carry(comment, oldUnits, newUnits) {
89
+ const { anchor } = comment;
90
+ const nowhere = { comment, unit: null, offsets: null, newLine: null };
91
+ if (anchor.kind !== "hunk")
92
+ return { ...nowhere, changed: false, path: comment.context.path };
93
+ const kept = newUnits.get(anchor.unit);
94
+ if (kept !== undefined) {
95
+ const newLine = kept.kind === "hunk" ? (tipLines(kept, newUnits)[firstRow(comment, kept)] ?? null) : null;
96
+ return {
97
+ comment,
98
+ unit: kept.id,
99
+ offsets: anchor.offsets ?? null,
100
+ changed: false,
101
+ path: kept.path,
102
+ newLine,
103
+ };
104
+ }
105
+ const old = oldUnits.get(anchor.unit);
106
+ const path = old?.path ?? comment.context.path;
107
+ const onPath = [...newUnits.values()].filter((u) => u.path === path);
108
+ const first = onPath[0];
109
+ if (first === undefined)
110
+ return { ...nowhere, changed: true, path };
111
+ const moved = { comment, offsets: null, changed: true, path };
112
+ const hunks = onPath.filter((u) => u.kind === "hunk");
113
+ if (old?.kind !== "hunk" || hunks.length === 0)
114
+ return { ...moved, unit: first.id, newLine: null };
115
+ const row = firstRow(comment, old);
116
+ const oldLine = tipLines(old, oldUnits)[row] ?? tipStartOf(old, oldUnits);
117
+ const nearest = hunks.reduce((a, b) => distance(b, newUnits, oldLine) < distance(a, newUnits, oldLine) ? b : a);
118
+ return {
119
+ ...moved,
120
+ unit: nearest.id,
121
+ newLine: landing(nearest, newUnits, oldLine, old.body[row]?.text),
122
+ };
123
+ }
package/dist/src/host.js CHANGED
@@ -3,10 +3,11 @@ import { randomUUID } from "node:crypto";
3
3
  import { closeSync, linkSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { parseEvent, parseEventBatch } from "./comments.js";
6
+ import { parseCarried, parseEvent, parseEventBatch } from "./comments.js";
7
+ import { carryComments } from "./evolve.js";
7
8
  import { isPlanVersion, serializePlanFile, validatePlan } from "./plan.js";
8
9
  import { HEALTH_APP, isSessionId, loadDraftComments, PortInUseError, RegistrationError, startServer, } from "./server.js";
9
- import { BranchMovedError, isAlive, openSessionAt, SessionFileError, SessionLockedError, } from "./session.js";
10
+ import { BranchMovedError, diffOfPlan, isAlive, openSessionAt, SessionFileError, SessionLockedError, } from "./session.js";
10
11
  const RECORD = "server.json";
11
12
  const DEFAULT_IDLE_MS = 30_000;
12
13
  const START_TIMEOUT_MS = 10_000;
@@ -135,6 +136,11 @@ async function loadHosted(registration, held) {
135
136
  comments: prior.comments,
136
137
  responses: stored.file.responses,
137
138
  };
139
+ const priorPlan = session.plans().find((p) => p.version === previous?.version);
140
+ const before = priorPlan === undefined ? null : await diffOfPlan(session, priorPlan);
141
+ const carried = previous === null
142
+ ? []
143
+ : carryComments(previous.comments, before?.units ?? new Map(), session.diff.units);
138
144
  const command = "review";
139
145
  // A version swap runs inside the server that already owns this session's lock.
140
146
  const release = held ??
@@ -144,6 +150,7 @@ async function loadHosted(registration, held) {
144
150
  reviewsDir,
145
151
  release,
146
152
  previous,
153
+ carried,
147
154
  state: {
148
155
  mode,
149
156
  anchor: session.anchor,
@@ -275,9 +282,22 @@ export async function swapVersion(port, summary, version, hash) {
275
282
  throw new RegistrationError("the server answered with a malformed session");
276
283
  return swapped;
277
284
  }
285
+ /** The previous version's comments as the open page places them on its plan. */
286
+ export async function fetchCarried(port, summary) {
287
+ const state = await call(port, "GET", `${summary.url}api/state`);
288
+ const raw = isFields(state) ? state["carried"] : undefined;
289
+ if (!Array.isArray(raw))
290
+ throw new RegistrationError("the server sent no carried comments");
291
+ return raw.flatMap((item) => {
292
+ const carried = parseCarried(item);
293
+ if (typeof carried === "string")
294
+ throw new RegistrationError(`carried: ${carried}`);
295
+ return [carried];
296
+ });
297
+ }
278
298
  /** One long poll of the session's event log: what happened after `since`, or nothing at timeout. */
279
299
  export async function pollEvents(port, summary, since, timeoutMs) {
280
- const path = `${summary.url}api/events?since=${since}&timeout=${timeoutMs}&audience=agent`;
300
+ const path = `${summary.url}api/events?since=${since}&timeout=${timeoutMs}`;
281
301
  const batch = parseEventBatch(await call(port, "GET", path));
282
302
  if (typeof batch === "string")
283
303
  throw new RegistrationError(`the server sent bad events: ${batch}`);
@@ -288,7 +308,7 @@ export async function pollEvents(port, summary, since, timeoutMs) {
288
308
  * the server closes the stream, which it does after the `ended` event.
289
309
  */
290
310
  export async function followEvents(port, summary, since, onEvent) {
291
- const res = await fetch(`${sessionUrl(port, summary)}api/events/stream?since=${since}&audience=agent`);
311
+ const res = await fetch(`${sessionUrl(port, summary)}api/events/stream?since=${since}`);
292
312
  if (!res.ok || res.body === null) {
293
313
  throw new RegistrationError(`GET api/events/stream failed with ${res.status}`);
294
314
  }
@@ -1,3 +1,4 @@
1
+ import { tipStartOf } from "./diff.js";
1
2
  /** Consecutive runs collapse: [0,1,2,3,7] becomes "0-3,7". */
2
3
  function formatRanges(changes) {
3
4
  const sorted = [...new Set(changes)].sort((a, b) => a - b);
@@ -269,6 +270,35 @@ const RULES = (checkCommand) => `- A unit is "h_…" (a text hunk), "f_…" (a w
269
270
  - Every commit needs a non-empty message and at least one unit.
270
271
  - 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
272
  - Check your work with \`${checkCommand}\` and fix every issue it prints, syntax errors included.`;
273
+ function renderUnit(unit) {
274
+ return unit.kind === "hunk" ? renderHunk(unit) : renderOtherUnit(unit);
275
+ }
276
+ /** The vanished and new units, and the reviewer's comments on hunks that vanished, quoted. */
277
+ function renderChanges(changes, input, ctx) {
278
+ const { since, vanished, added } = changes;
279
+ const gone = new Set(vanished.map((v) => v.unit.id));
280
+ const review = input.mode === "split" ? input.review : null;
281
+ const orphans = (review?.comments ?? []).filter((c) => c.author !== "agent" && c.anchor.kind === "hunk" && gone.has(c.anchor.unit));
282
+ const parts = [
283
+ `## Changes since v${since}\n`,
284
+ `The branch moved after plan v${since}. ${input.draftPath} already holds that plan on the new tip: every unit whose hunk did not change keeps its commit, and any commit left with no unit was dropped. Put each new unit below into a commit, an existing one or a new one, and keep the rest as it is.`,
285
+ ];
286
+ if (vanished.length > 0) {
287
+ parts.push(`\nThese units of v${since} changed, so their ids are gone:`, ...vanished.map(({ unit, commits }) => {
288
+ const where = commits
289
+ .map((c) => `commit ${c.index + 1} ${JSON.stringify(c.message.split("\n")[0] ?? "")}`)
290
+ .join(", ");
291
+ return `- ${unit.id} ${unit.path}, was in ${where}`;
292
+ }));
293
+ }
294
+ if (added.length > 0) {
295
+ parts.push("\nNew units, in no commit yet:\n", added.map(renderUnit).join("\n\n"));
296
+ }
297
+ if (orphans.length > 0) {
298
+ parts.push(`\nThe reviewer commented on hunks that changed. Write a response for each saying what you changed, and post the same with \`follow-me reply <id>\`:\n`, orphans.map((c) => renderComment(c, ctx)).join("\n"));
299
+ }
300
+ return parts.join("\n");
301
+ }
272
302
  /** The planner prompt for a coding agent. Pure and deterministic for the same input. */
273
303
  export function renderPrompt(input) {
274
304
  const { diff, draftPath } = input;
@@ -282,13 +312,24 @@ export function renderPrompt(input) {
282
312
  if (input.mode === "walk") {
283
313
  const sections = [
284
314
  ...head,
315
+ ...(input.changes === undefined
316
+ ? []
317
+ : [renderChanges(input.changes, input, { commits: [], units: diff.units })]),
285
318
  `## Concerns\n\n${CONCERNS}`,
286
319
  `## Questions from the reviewer\n\n${QUESTIONS}\n\n${CONTEXT_RULE}`,
287
320
  units,
288
321
  ];
289
322
  return `${sections.join("\n\n")}\n`;
290
323
  }
291
- const sections = [...head, units];
324
+ const ctx = {
325
+ commits: input.previousCommits ?? [],
326
+ units: diff.units,
327
+ };
328
+ const sections = [
329
+ ...head,
330
+ ...(input.changes === undefined ? [] : [renderChanges(input.changes, input, ctx)]),
331
+ units,
332
+ ];
292
333
  const { previousPlan, review } = input;
293
334
  if (previousPlan) {
294
335
  sections.push(`## Previous plan\n\nCommits are numbered from 1.\n\n${renderPreviousPlan(previousPlan)}`);
@@ -296,7 +337,6 @@ export function renderPrompt(input) {
296
337
  const theirs = review?.comments.filter((c) => c.author !== "agent") ?? [];
297
338
  const asked = theirs.filter((c) => c.kind === "question");
298
339
  const changes = theirs.filter((c) => c.kind !== "question");
299
- const ctx = { commits: input.previousCommits ?? [], units: diff.units };
300
340
  const questions = [`## Questions from the reviewer\n`, QUESTIONS, `\n${CONTEXT_RULE}`];
301
341
  if (review && asked.length > 0) {
302
342
  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"));
@@ -350,17 +390,6 @@ function excerpt(hunk, tipStart, owners, k) {
350
390
  }
351
391
  return lines;
352
392
  }
353
- /** Where the hunk starts in the tip: its base position shifted by earlier hunks of the file. */
354
- function tipStartOf(hunk, units) {
355
- let shift = 0;
356
- for (const u of units.values()) {
357
- if (u.kind !== "hunk" || u.path !== hunk.path || u.oldStart >= hunk.oldStart)
358
- continue;
359
- for (const line of u.body)
360
- shift += line.op === "+" ? 1 : line.op === "-" ? -1 : 0;
361
- }
362
- return hunk.oldStart + shift + 1;
363
- }
364
393
  /** A fence longer than any backtick run in the content, so quoted code cannot close it. */
365
394
  function fence(lines) {
366
395
  const longest = Math.max(0, ...lines.flatMap((l) => (l.match(/`+/g) ?? []).map((r) => r.length)));
@@ -927,7 +956,7 @@ function renderEvent(event, ctx) {
927
956
  case "version":
928
957
  return [`- plan v${event.version} is now open`];
929
958
  case "submitted":
930
- return ["- the reviewer submitted the review; the held flags follow as new comments"];
959
+ return ["- the reviewer submitted the review"];
931
960
  case "ended":
932
961
  return [`- the reviewer ended the review; the report is next to ${event.review}`];
933
962
  default: {
@@ -82,7 +82,13 @@ export async function loadDraft(reviewsDir, version) {
82
82
  }
83
83
  catch (error) {
84
84
  if (isRecord(error) && error["code"] === "ENOENT") {
85
- return { comments: [], concerns: [], resolved: [], excluded: { comments: [], concerns: [] } };
85
+ return {
86
+ comments: [],
87
+ concerns: [],
88
+ resolved: [],
89
+ excluded: { comments: [], concerns: [] },
90
+ earlierReplies: [],
91
+ };
86
92
  }
87
93
  throw error;
88
94
  }
@@ -240,6 +246,7 @@ function viewOf(registration, loaded, draft) {
240
246
  resolved: new Set(draft.resolved),
241
247
  excludedComments: new Set(draft.excluded.comments),
242
248
  excludedConcerns: new Set(draft.excluded.concerns),
249
+ earlierReplies: new Map(draft.earlierReplies.map((e) => [e.comment, e.replies])),
243
250
  unitIds: new Set(state.units.map((u) => u.id)),
244
251
  hunkRows: new Map(state.units.flatMap((u) => u.kind === "hunk" ? [[u.id, u.body.length]] : [])),
245
252
  concernIds: new Set(state.commits.flatMap((c) => (c.concerns ?? []).map((k) => k.id))),
@@ -251,6 +258,47 @@ function withReplies(comment, replies) {
251
258
  const { replies: _dropped, ...rest } = comment;
252
259
  return replies === undefined || replies.length === 0 ? rest : { ...rest, replies };
253
260
  }
261
+ /** A comment of the previous version with the replies it got during this one. */
262
+ function withEarlier(view, comment) {
263
+ const later = view.earlierReplies.get(comment.id) ?? [];
264
+ return later.length === 0
265
+ ? comment
266
+ : withReplies(comment, [...(comment.replies ?? []), ...later]);
267
+ }
268
+ function previousOf(view) {
269
+ const { previous } = view.loaded;
270
+ if (previous === null)
271
+ return null;
272
+ return { ...previous, comments: previous.comments.map((c) => withEarlier(view, c)) };
273
+ }
274
+ function carriedOf(view) {
275
+ return view.loaded.carried.map((c) => ({ ...c, comment: withEarlier(view, c.comment) }));
276
+ }
277
+ function earlierOf(view) {
278
+ return [...view.earlierReplies].map(([comment, replies]) => ({ comment, replies }));
279
+ }
280
+ /**
281
+ * What a restarted server's log begins with: each saved comment and each reply as the event that
282
+ * first carried it, oldest first, so a client that reads from 0 sees the whole conversation.
283
+ */
284
+ function replayOf(view) {
285
+ const said = [];
286
+ for (const comment of view.comments.values()) {
287
+ said.push({
288
+ at: comment.createdAt,
289
+ event: { type: "comment", comment: withReplies(comment, undefined) },
290
+ });
291
+ for (const reply of comment.replies ?? []) {
292
+ said.push({ at: reply.createdAt, event: { type: "reply", commentId: comment.id, reply } });
293
+ }
294
+ }
295
+ for (const [commentId, replies] of view.earlierReplies) {
296
+ for (const reply of replies) {
297
+ said.push({ at: reply.createdAt, event: { type: "reply", commentId, reply } });
298
+ }
299
+ }
300
+ return said.sort((a, b) => a.at.localeCompare(b.at)).map((s) => s.event);
301
+ }
254
302
  function mintReplyId(taken) {
255
303
  for (;;) {
256
304
  let id = "r_";
@@ -260,38 +308,6 @@ function mintReplyId(taken) {
260
308
  return id;
261
309
  }
262
310
  }
263
- /**
264
- * What the agent may see of the log: a pending flag is held back with everything said about it,
265
- * and reaches the agent as an ordinary "comment" event when the reviewer submits.
266
- */
267
- function forAgent(log) {
268
- const held = new Set();
269
- const out = [];
270
- for (const event of log) {
271
- if (event.type === "comment" || event.type === "comment-updated") {
272
- const { comment } = event;
273
- if (comment.pending === true) {
274
- held.add(comment.id);
275
- continue;
276
- }
277
- if (held.delete(comment.id))
278
- out.push({ ...event, type: "comment" });
279
- else
280
- out.push(event);
281
- }
282
- else if (event.type === "comment-deleted") {
283
- if (!held.delete(event.id))
284
- out.push(event);
285
- }
286
- else if (event.type === "reply") {
287
- if (!held.has(event.commentId))
288
- out.push(event);
289
- }
290
- else
291
- out.push(event);
292
- }
293
- return out;
294
- }
295
311
  const DEFAULT_POLL_MS = 25_000;
296
312
  const MAX_POLL_MS = 60_000;
297
313
  const STREAM_PING_MS = 15_000;
@@ -314,9 +330,10 @@ function hostSession(id, registration, loaded, draft, load) {
314
330
  for (const listener of [...listeners])
315
331
  listener();
316
332
  };
317
- const seen = (agent) => (agent ? forAgent(events) : events);
318
- const waitForEvents = (since, timeoutMs, signal, agent) => new Promise((resolve) => {
319
- const news = () => seen(agent).filter((e) => e.seq > since);
333
+ for (const event of replayOf(view))
334
+ emit(event);
335
+ const waitForEvents = (since, timeoutMs, signal) => new Promise((resolve) => {
336
+ const news = () => events.filter((e) => e.seq > since);
320
337
  const answer = () => resolve({ seq: events.length, events: news() });
321
338
  if (news().length > 0 || timeoutMs === 0 || signal.aborted)
322
339
  return answer();
@@ -356,6 +373,7 @@ function hostSession(id, registration, loaded, draft, load) {
356
373
  const concerns = [...view.decisions.values()];
357
374
  const resolved = [...view.resolved];
358
375
  const excluded = excludedOf();
376
+ const earlierReplies = earlierOf(view);
359
377
  const snapshot = JSON.stringify({
360
378
  schema: "follow-me/review-draft@1",
361
379
  planVersion: state.version,
@@ -363,6 +381,7 @@ function hostSession(id, registration, loaded, draft, load) {
363
381
  ...(concerns.length === 0 ? {} : { concerns }),
364
382
  ...(resolved.length === 0 ? {} : { resolved }),
365
383
  ...(excluded === null ? {} : { excluded }),
384
+ ...(earlierReplies.length === 0 ? {} : { earlierReplies }),
366
385
  }, null, 2);
367
386
  const dir = view.loaded.reviewsDir;
368
387
  writes = writes.then(async () => {
@@ -393,33 +412,22 @@ function hostSession(id, registration, loaded, draft, load) {
393
412
  }
394
413
  return parsed;
395
414
  };
396
- /** Clears `pending` on every held flag at once and tells the agent what it missed. */
397
- const release = () => {
398
- const held = [...view.comments.values()].filter((c) => c.pending === true);
399
- if (held.length === 0)
400
- return 0;
401
- emit({ type: "submitted" });
402
- for (const comment of held) {
403
- const { pending: _held, ...open } = comment;
404
- view.comments.set(comment.id, open);
405
- emit({ type: "comment", comment: open });
406
- }
407
- return held.length;
408
- };
409
415
  const review = () => {
410
416
  const concerns = [...view.decisions.values()];
411
417
  const resolved = [...view.resolved];
412
418
  const excluded = excludedOf();
419
+ const earlierReplies = earlierOf(view);
413
420
  return {
414
421
  schema: "follow-me/review@1",
415
422
  mode: view.loaded.state.mode,
416
423
  planVersion: view.loaded.state.version,
417
424
  planHash: view.loaded.state.planHash,
418
425
  endedAt: new Date().toISOString(),
419
- comments: [...view.comments.values()].filter((c) => c.pending !== true),
426
+ comments: [...view.comments.values()],
420
427
  ...(concerns.length === 0 ? {} : { concerns }),
421
428
  ...(resolved.length === 0 ? {} : { resolved }),
422
429
  ...(excluded === null ? {} : { excluded }),
430
+ ...(earlierReplies.length === 0 ? {} : { earlierReplies }),
423
431
  };
424
432
  };
425
433
  const report = (finished) => {
@@ -439,7 +447,7 @@ function hostSession(id, registration, loaded, draft, load) {
439
447
  await rm(view.draftFile, { force: true });
440
448
  return view.reviewFile;
441
449
  };
442
- const hasWork = () => view.comments.size > 0 || view.decisions.size > 0;
450
+ const hasWork = () => view.comments.size > 0 || view.decisions.size > 0 || view.earlierReplies.size > 0;
443
451
  return {
444
452
  id,
445
453
  done,
@@ -464,17 +472,18 @@ function hostSession(id, registration, loaded, draft, load) {
464
472
  resolved: [...view.resolved],
465
473
  excluded: { comments: [...view.excludedComments], concerns: [...view.excludedConcerns] },
466
474
  seq: events.length,
467
- previous: view.loaded.previous,
475
+ previous: previousOf(view),
476
+ carried: carriedOf(view),
468
477
  }),
469
478
  /** Events after `since`, now and as they happen, until unfollowed or the review ends. */
470
479
  latest: () => events.length,
471
- follow: (since, send, stop, agent) => {
480
+ follow: (since, send, stop) => {
472
481
  if (since > events.length) {
473
482
  throw new HttpError(409, `since ${since} is ahead of this session's log (${events.length})`);
474
483
  }
475
484
  let last = since;
476
485
  const flush = () => {
477
- for (const event of seen(agent).filter((e) => e.seq > last)) {
486
+ for (const event of events.filter((e) => e.seq > last)) {
478
487
  send(event);
479
488
  last = event.seq;
480
489
  if (event.type === "ended") {
@@ -488,11 +497,11 @@ function hostSession(id, registration, loaded, draft, load) {
488
497
  flush();
489
498
  return () => listeners.delete(flush);
490
499
  },
491
- poll: (since, timeoutMs, signal, agent) => {
500
+ poll: (since, timeoutMs, signal) => {
492
501
  if (since > events.length) {
493
502
  throw new HttpError(409, `since ${since} is ahead of this session's log (${events.length})`);
494
503
  }
495
- return waitForEvents(since, timeoutMs, signal, agent);
504
+ return waitForEvents(since, timeoutMs, signal);
496
505
  },
497
506
  report: () => report(review()),
498
507
  reportItems: () => {
@@ -536,22 +545,33 @@ function hostSession(id, registration, loaded, draft, load) {
536
545
  emit({ type: "comment-deleted", id: gone.id });
537
546
  await saveDraft();
538
547
  },
548
+ /** Replies under a comment of this version, or of the previous one, as after a swap. */
539
549
  reply: async (target, raw) => {
540
550
  open();
541
551
  const comment = view.comments.get(target);
542
- if (comment === undefined)
552
+ const earlier = view.loaded.previous?.comments.find((c) => c.id === target);
553
+ const owner = comment ?? earlier;
554
+ if (owner === undefined)
543
555
  throw new HttpError(404, "no such comment");
544
556
  const parsed = parseNewReply(raw);
545
557
  if (typeof parsed === "string")
546
558
  throw new HttpError(400, `malformed reply: ${parsed}`);
547
- const taken = new Set([...view.comments.values()].flatMap((c) => c.replies ?? []).map((r) => r.id));
559
+ const taken = new Set([...view.comments.values(), ...(previousOf(view)?.comments ?? [])]
560
+ .flatMap((c) => c.replies ?? [])
561
+ .map((r) => r.id));
548
562
  const reply = {
549
563
  id: mintReplyId(taken),
550
564
  ...parsed,
551
565
  createdAt: new Date().toISOString(),
552
566
  };
553
- view.comments.set(comment.id, { ...comment, replies: [...(comment.replies ?? []), reply] });
554
- emit({ type: "reply", commentId: comment.id, reply });
567
+ if (comment !== undefined) {
568
+ view.comments.set(comment.id, { ...comment, replies: [...(comment.replies ?? []), reply] });
569
+ }
570
+ else if (earlier !== undefined) {
571
+ const later = view.earlierReplies.get(earlier.id) ?? [];
572
+ view.earlierReplies.set(earlier.id, [...later, reply]);
573
+ }
574
+ emit({ type: "reply", commentId: owner.id, reply });
555
575
  await saveDraft();
556
576
  return reply.id;
557
577
  },
@@ -599,12 +619,14 @@ function hostSession(id, registration, loaded, draft, load) {
599
619
  await saveDraft();
600
620
  return { comments: [...view.excludedComments], concerns: [...view.excludedConcerns] };
601
621
  },
602
- /** Releases every held flag in one go; the reviewer pressed Submit. */
603
- submit: async () => {
622
+ /**
623
+ * The reviewer pressed Submit. Every comment already reached the agent, so this only tells it
624
+ * the reviewer is done; `released` stays for pages that still read it.
625
+ */
626
+ submit: () => {
604
627
  open();
605
- const released = release();
606
- await saveDraft();
607
- return { released };
628
+ emit({ type: "submitted" });
629
+ return { released: 0 };
608
630
  },
609
631
  resolve: async (raw) => {
610
632
  open();
@@ -642,13 +664,12 @@ function hostSession(id, registration, loaded, draft, load) {
642
664
  try {
643
665
  await writes;
644
666
  const next = await load({ ...view.registration, version, hash, lockToken: null }, view.loaded.release);
645
- release();
646
667
  if (hasWork())
647
668
  await writeReview(review());
648
669
  else
649
670
  await rm(view.draftFile, { force: true });
650
671
  view = viewOf({ ...view.registration, version, hash }, next, await loadDraft(next.reviewsDir, version));
651
- emit({ type: "version", version, hash });
672
+ emit({ type: "version", version, hash, carried: carriedOf(view) });
652
673
  }
653
674
  finally {
654
675
  swapping = false;
@@ -658,7 +679,6 @@ function hostSession(id, registration, loaded, draft, load) {
658
679
  end: async () => {
659
680
  open();
660
681
  ending = true;
661
- release();
662
682
  const finished = review();
663
683
  const file = await writeReview(finished);
664
684
  view.loaded.release();
@@ -832,7 +852,7 @@ export function startServer(opts) {
832
852
  });
833
853
  res.write(": follow-me\n\n");
834
854
  const ping = setInterval(() => res.write(": ping\n\n"), STREAM_PING_MS);
835
- const unfollow = session.follow(since, (event) => res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`), () => res.end(), url.searchParams.get("audience") === "agent");
855
+ const unfollow = session.follow(since, (event) => res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`), () => res.end());
836
856
  res.on("close", () => {
837
857
  clearInterval(ping);
838
858
  unfollow();
@@ -844,8 +864,7 @@ export function startServer(opts) {
844
864
  res.on("close", () => abort.abort());
845
865
  const since = queryInt(url, "since", 0, Number.MAX_SAFE_INTEGER, 0);
846
866
  const timeout = queryInt(url, "timeout", 0, MAX_POLL_MS, DEFAULT_POLL_MS);
847
- const agent = url.searchParams.get("audience") === "agent";
848
- return sendJson(res, 200, await session.poll(since, timeout, abort.signal, agent));
867
+ return sendJson(res, 200, await session.poll(since, timeout, abort.signal));
849
868
  }
850
869
  if (method === "GET" && rest === "/api/report" && url.searchParams.get("format") === "json") {
851
870
  return sendJson(res, 200, session.reportItems());
@@ -866,7 +885,7 @@ export function startServer(opts) {
866
885
  return sendJson(res, 200, session.summary());
867
886
  }
868
887
  if (method === "POST" && rest === "/api/submit") {
869
- return sendJson(res, 200, await session.submit());
888
+ return sendJson(res, 200, session.submit());
870
889
  }
871
890
  if (method === "POST" && rest === "/api/excluded") {
872
891
  return sendJson(res, 200, await session.exclude(await readJson(req)));