@shiyuphan/follow-me 0.5.0 → 0.5.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 (95) hide show
  1. package/dist/src/cli.js +17 -3
  2. package/dist/src/comments.js +62 -9
  3. package/dist/src/evolve.js +145 -29
  4. package/dist/src/host.js +12 -3
  5. package/dist/src/plan.js +14 -0
  6. package/dist/src/prompt.js +100 -12
  7. package/dist/src/server.js +106 -29
  8. package/dist/src/session.js +23 -0
  9. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-BXkLs7mV.js → abnfDiagram-WEYOIA3C-8UsLPznO.js} +1 -1
  10. package/dist/ui/dist/assets/{arc-DNzjw7Ie.js → arc-vovjGalz.js} +1 -1
  11. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-D66kgJJt.js → architecture-WOLXFQ4H-BBMK-PUu.js} +1 -1
  12. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-D8UWuhQr.js → architectureDiagram-ZHVYKT53-Bw_ehaQk.js} +1 -1
  13. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-BLkmr62i.js → blockDiagram-RJAE7QA3-CAmD0wKf.js} +1 -1
  14. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-3rZPYwOc.js → c4Diagram-XDPPPE54-DCP-3zgt.js} +1 -1
  15. package/dist/ui/dist/assets/channel-B7CT_oIp.js +1 -0
  16. package/dist/ui/dist/assets/{chunk-2E7WHVMD-MMnvAgpB.js → chunk-2E7WHVMD-BiHSeJMu.js} +1 -1
  17. package/dist/ui/dist/assets/{chunk-4EMNKR2T-D9FGWA3l.js → chunk-4EMNKR2T-CR3lHKf_.js} +1 -1
  18. package/dist/ui/dist/assets/{chunk-55XCU76K-DuOUaV8F.js → chunk-55XCU76K-CtaoloTT.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-7S6XSTFO-p3YWPv0m.js → chunk-7S6XSTFO-eABdc7fu.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-AQDTSKZB-DWfKYjwk.js → chunk-AQDTSKZB-CPlbJWi-.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-C3CIwpv0.js → chunk-DSNLSHJZ-YiMuxsb-.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-HAGFOTED-Bwfl9gme.js → chunk-HAGFOTED-1VcdL78a.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-HNE4Q36P-0_oeU0le.js → chunk-HNE4Q36P-u-sr_0Qu.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-NQOKRVIF-z_LPPPx5.js → chunk-NQOKRVIF-C_QlWSze.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-UHW7PFBP-BH28V2yu.js → chunk-UHW7PFBP-CNuZhsbU.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-VQ5W62QI-BXDajDf7.js → chunk-VQ5W62QI-DAHWbLqU.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-WQQL4CDK-BphBlfKj.js → chunk-WQQL4CDK-DWQnxFCd.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-XXDRQBXY-BiCT5NwV.js → chunk-XXDRQBXY-B0lGgSOR.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-YZO67HFG-Bg5Pu8BX.js → chunk-YZO67HFG-BDw7efkN.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-C_Tgd8tB.js → chunk-ZAUX2LTX-B5wfHsZv.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-MBRcMi8-.js → chunk-ZCHMQSOR--rWtYtAG.js} +1 -1
  32. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-1wyeCe5y.js → classDiagram-v2-4MYWR3UA-BKuQ_xoS.js} +1 -1
  33. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-BtOYclF6.js → cose-bilkent-JH36ORCC-Dk5fIpU0.js} +1 -1
  34. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-dJmoOG8g.js → cynefin-EF2NZ3EQ-DIykJb2g.js} +1 -1
  35. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-AloyqbEN.js → cynefinDiagram-WF24UDRP-Cg8tBqUb.js} +1 -1
  36. package/dist/ui/dist/assets/{dagre-NK4GIZVX-NCZhdnfb.js → dagre-NK4GIZVX-BC_tnnjz.js} +1 -1
  37. package/dist/ui/dist/assets/{diagram-CBK3MWTC-D8w7x5Gb.js → diagram-CBK3MWTC-B_MQGixU.js} +1 -1
  38. package/dist/ui/dist/assets/{diagram-I5XIIV2D-BUFnCGlp.js → diagram-I5XIIV2D-DO681pZ5.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-ODIL2UCW-BULmHKaW.js → diagram-ODIL2UCW-D14jLzU6.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-PFBEJUXE-B5MqFo0r.js → diagram-PFBEJUXE-cML48gUS.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-RJA3I4VV-BIkDLL8E.js → diagram-RJA3I4VV-DSoq8jFk.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-TYUNFSNR-Ca4gsQV8.js → diagram-TYUNFSNR-CaJ4pvnl.js} +1 -1
  43. package/dist/ui/dist/assets/{dist-G85JKY5k.js → dist-D3j-DODP.js} +1 -1
  44. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-rVjcFFtb.js → ebnfDiagram-V4P3TTPT-CyRpjSix.js} +1 -1
  45. package/dist/ui/dist/assets/{elk-QONTBCTQ-BWPdvULh.js → elk-QONTBCTQ-ICJdzVFL.js} +1 -1
  46. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-BPxJJkRl.js → erDiagram-KYQIGQAA-B57hNTfw.js} +1 -1
  47. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-BSB9WYV3.js → eventmodeling-K75KTNOO-BG8tUVIQ.js} +1 -1
  48. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU--HPNoVQJ.js +1 -0
  49. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-DKps530K.js → ganttDiagram-R7GPNTJY-Czt6Klik.js} +1 -1
  50. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-D1d4UAFV.js → gitGraph-VSP46ZUC-De_ZWJLj.js} +1 -1
  51. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-N55yl6hc.js → gitGraphDiagram-PDYL7XHY-BMCJoOnT.js} +1 -1
  52. package/dist/ui/dist/assets/index-CYKbk4QS.js +134 -0
  53. package/dist/ui/dist/assets/{info-OHQRW6UA-CgxE3gf9.js → info-OHQRW6UA-D75H9kUq.js} +1 -1
  54. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-DOnUrnMf.js → infoDiagram-MVDRKAX5-Bsm_I9dt.js} +1 -1
  55. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-BIY0fFHB.js → ishikawaDiagram-HPEHV2GE-98xZAJ9y.js} +1 -1
  56. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-CqA2Ern0.js → journeyDiagram-BOSHW7O7-BWVGuc0P.js} +1 -1
  57. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-vGrpQKIF.js → kanban-definition-FNSA4HC5-DgUE_KDd.js} +1 -1
  58. package/dist/ui/dist/assets/{line-Brzv3qA6.js → line-i__8Rvas.js} +1 -1
  59. package/dist/ui/dist/assets/{linear-D9uIlqyh.js → linear-BXKj0Fip.js} +1 -1
  60. package/dist/ui/dist/assets/{mermaid-parser.core-Bz9b-Thu.js → mermaid-parser.core-DxvaorTa.js} +3 -3
  61. package/dist/ui/dist/assets/{mermaid.core-BaZipu3b.js → mermaid.core-ujYTsRZz.js} +4 -4
  62. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-D6aie7uH.js → mindmap-definition-6OY57UG3-B7QD6sjk.js} +1 -1
  63. package/dist/ui/dist/assets/{packet-JDAUHWVQ-28r3jVFY.js → packet-JDAUHWVQ-B6aIwfD1.js} +1 -1
  64. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-CbGvD4qP.js → pegDiagram-ECIUJNBX-_RStHj-a.js} +1 -1
  65. package/dist/ui/dist/assets/{pie-XZMESJXO-DHpcGnZd.js → pie-XZMESJXO-CNLC-pTe.js} +1 -1
  66. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-DuhauwnY.js → pieDiagram-GNMPY74T-0uT7MT-u.js} +1 -1
  67. package/dist/ui/dist/assets/{prism-core-CbZMOvrb.js → prism-core-DtxziK1i.js} +1 -1
  68. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-BfyNmVca.js → quadrantDiagram-D57K4MC3-CTMKb5hl.js} +1 -1
  69. package/dist/ui/dist/assets/{radar-ABXABTNO-Xp1TYwaj.js → radar-ABXABTNO-D53h6I0N.js} +1 -1
  70. package/dist/ui/dist/assets/{railroad-I3PHUGI6-c5FY4wwa.js → railroad-I3PHUGI6-CwjhW4mc.js} +1 -1
  71. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-RDyWXqcL.js → railroad-abnf-LNEFI6M7-DDbQ3RmB.js} +1 -1
  72. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-CLD4jVou.js → railroad-ebnf-DWYD2UWJ-Am7YJAqO.js} +1 -1
  73. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-17EYTA-w.js → railroad-peg-JR7BVNR7-Ci32KoVW.js} +1 -1
  74. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-C4p2wtKe.js → railroadDiagram-Y72NRBL7-BPU5A6XW.js} +1 -1
  75. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-DTYIFN8T.js → requirementDiagram-EQYSW5RA-_0Pq4QdV.js} +1 -1
  76. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-YDYhRyJU.js → sankeyDiagram-SY24JHO5-c9h-7uf8.js} +1 -1
  77. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-Crvy6IlU.js → sequenceDiagram-BLEHNHQU-B9-S4Rc9.js} +1 -1
  78. package/dist/ui/dist/assets/{src-BPod-Ype.js → src-BZ3kG7lO.js} +1 -1
  79. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-BWr70YOQ.js → stateDiagram-v2-A6IESKWZ-py1smSen.js} +1 -1
  80. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-DvbG4I9a.js → swimlanes-B3ENRKFQ-C1E90aH4.js} +1 -1
  81. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-I98Q8AqP.js +8 -0
  82. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-CR950Bzb.js → timeline-definition-JXYKGLGB-DNe1To6B.js} +1 -1
  83. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-DSDDXGrk.js → treeView-D4JQ5SDB-D6MG89i6.js} +1 -1
  84. package/dist/ui/dist/assets/{treemap-SAJKECNS-Ct5w2_RR.js → treemap-SAJKECNS-CZpt7Mlh.js} +1 -1
  85. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-_eP0371y.js → usecaseDiagram-WPOLBNNX-CKsv-03M.js} +1 -1
  86. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-Cu-IA57L.js → vennDiagram-Q3Y3IDCW-DGJ1qlyJ.js} +1 -1
  87. package/dist/ui/dist/assets/{wardley-7MLQ67FV-r8yd6frN.js → wardley-7MLQ67FV-ClOzBemC.js} +1 -1
  88. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-CBUUiRQm.js → wardleyDiagram-IVAIDKWA-C68ahPyF.js} +1 -1
  89. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-DEVQjvgG.js → xychartDiagram-4URNHDU4-DWDamCoH.js} +1 -1
  90. package/dist/ui/dist/index.html +1 -1
  91. package/package.json +1 -1
  92. package/dist/ui/dist/assets/channel-B9JVdOBZ.js +0 -1
  93. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-N-Spyk2o.js +0 -1
  94. package/dist/ui/dist/assets/index-BKtWBS7Z.js +0 -134
  95. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-D2wU-jW6.js +0 -8
package/dist/src/cli.js CHANGED
@@ -11,7 +11,7 @@ import { naiveDraft } from "./naive.js";
11
11
  import { isCommentId, isConcernId, isFields, serializePlanFile, validatePlan, } from "./plan.js";
12
12
  import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownUnit, reportItems, } from "./prompt.js";
13
13
  import { RegistrationError, sessionIdOf } from "./server.js";
14
- import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sinceLastPlan, } from "./session.js";
14
+ import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
15
15
  import { checkSyntax } from "./syntax.js";
16
16
  class UsageError extends Error {
17
17
  name = "UsageError";
@@ -909,7 +909,9 @@ function finished(session, stored) {
909
909
  const text = renderStored(session, stored);
910
910
  if (text === null)
911
911
  return 1;
912
- say(`review v${stored.version} ended with ${plural(stored.review.comments.length, "comment")}`);
912
+ // A review that swapped versions holds what its earlier versions said too.
913
+ const said = stored.review.comments.length + (stored.review.carried?.length ?? 0);
914
+ say(`review v${stored.version} ended with ${plural(said, "comment")}`);
913
915
  process.stdout.write(text);
914
916
  say();
915
917
  next("done; the user can port it with follow-me report --json");
@@ -1240,7 +1242,19 @@ function renderStored(session, stored, format = "markdown") {
1240
1242
  .plans()
1241
1243
  .filter((p) => p.version > stored.version)
1242
1244
  .flatMap((p) => p.file.responses);
1243
- const opts = { branch: session.anchor.branch, responses };
1245
+ const { carried } = stored.review;
1246
+ const said = reviewsOf(session)
1247
+ .filter((r) => r.version <= stored.version)
1248
+ .map((r) => r.review);
1249
+ const earlier = carried === undefined
1250
+ ? {}
1251
+ : {
1252
+ earlier: {
1253
+ comments: sessionComments(carried, said),
1254
+ concerns: stored.review.carriedConcerns ?? [],
1255
+ },
1256
+ };
1257
+ const opts = { branch: session.anchor.branch, responses, ...earlier };
1244
1258
  if (format === "json") {
1245
1259
  const items = reportItems(stored.review, verdict.plan.commits, session.diff.units, opts);
1246
1260
  return `${JSON.stringify(items, null, 2)}\n`;
@@ -1,5 +1,5 @@
1
1
  import { isRepoPath, isUnitId } from "./diff.js";
2
- import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isPlanVersion, isSeverity, } from "./plan.js";
2
+ import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isPlanVersion, isSeverity, parsePlacedConcern, } from "./plan.js";
3
3
  /**
4
4
  * The wire formats of review state, parsed once at the boundary: comments, replies, concern
5
5
  * decisions, the review file, its autosaved draft and the events a hosted session logs. Every
@@ -253,13 +253,7 @@ function parseEarlierReplies(v) {
253
253
  function isLine(v) {
254
254
  return typeof v === "number" && Number.isInteger(v) && v >= 1;
255
255
  }
256
- export function parseCarried(v) {
257
- if (!isFields(v)) {
258
- return "not an object";
259
- }
260
- const comment = parseComment(v.comment);
261
- if (typeof comment === "string")
262
- return comment;
256
+ function parsePlacement(v) {
263
257
  const { unit, offsets, changed, path, newLine } = v;
264
258
  if (unit !== null && !(typeof unit === "string" && isUnitId(unit)))
265
259
  return "unit is not a unit id";
@@ -273,7 +267,6 @@ export function parseCarried(v) {
273
267
  if (newLine !== null && !isLine(newLine))
274
268
  return "newLine is not null or a line";
275
269
  return {
276
- comment,
277
270
  unit,
278
271
  offsets: offsets === null ? null : offsets.filter(isIndex),
279
272
  changed,
@@ -281,6 +274,56 @@ export function parseCarried(v) {
281
274
  newLine,
282
275
  };
283
276
  }
277
+ export function parseCarried(v) {
278
+ if (!isFields(v))
279
+ return "not an object";
280
+ const comment = parseComment(v.comment);
281
+ if (typeof comment === "string")
282
+ return comment;
283
+ const placement = parsePlacement(v);
284
+ return typeof placement === "string" ? placement : { comment, ...placement };
285
+ }
286
+ function parseSince(v) {
287
+ if (v === null)
288
+ return null;
289
+ return isPlanVersion(v) ? v : "since is not null or a plan version";
290
+ }
291
+ function parseCarriedRef(v) {
292
+ if (!isFields(v))
293
+ return "not an object";
294
+ const comment = asCommentId(v.comment);
295
+ if (comment === null)
296
+ return "comment is not a comment id";
297
+ if (!isPlanVersion(v.version))
298
+ return "version is not a plan version";
299
+ const since = parseSince(v.since);
300
+ if (typeof since === "string")
301
+ return since;
302
+ const placement = parsePlacement(v);
303
+ if (typeof placement === "string")
304
+ return placement;
305
+ return { ...placement, comment, version: v.version, since };
306
+ }
307
+ function parseCarriedConcern(v) {
308
+ if (!isFields(v)) {
309
+ return "not an object";
310
+ }
311
+ const concern = parsePlacedConcern(v.concern);
312
+ if (typeof concern === "string")
313
+ return `concern: ${concern}`;
314
+ if (!isIndex(v.commit))
315
+ return "commit is not a commit index";
316
+ const decision = parseDecision(v.decision);
317
+ if (typeof decision === "string")
318
+ return `decision: ${decision}`;
319
+ const since = parseSince(v.since);
320
+ if (typeof since === "string")
321
+ return since;
322
+ const placement = parsePlacement(v);
323
+ if (typeof placement === "string")
324
+ return placement;
325
+ return { ...placement, concern, commit: v.commit, decision, since };
326
+ }
284
327
  /** Boundary parse of reviews/vN.json. Files written before walk mode existed have no mode. */
285
328
  export function parseReview(v) {
286
329
  if (!isFields(v)) {
@@ -314,6 +357,12 @@ export function parseReview(v) {
314
357
  const earlier = parseList(v.earlierReplies, parseEarlierReplies, "earlierReplies");
315
358
  if (typeof earlier === "string")
316
359
  return earlier;
360
+ const carried = v.carried === undefined ? null : parseList(v.carried, parseCarriedRef, "carried");
361
+ if (typeof carried === "string")
362
+ return carried;
363
+ const concernsCarried = parseList(v.carriedConcerns, parseCarriedConcern, "carriedConcerns");
364
+ if (typeof concernsCarried === "string")
365
+ return concernsCarried;
317
366
  return {
318
367
  schema: v.schema,
319
368
  mode,
@@ -325,6 +374,8 @@ export function parseReview(v) {
325
374
  ...(resolved.length === 0 ? {} : { resolved }),
326
375
  ...(anyExcluded ? { excluded } : {}),
327
376
  ...(earlier.length === 0 ? {} : { earlierReplies: earlier }),
377
+ ...(carried === null ? {} : { carried }),
378
+ ...(concernsCarried.length === 0 ? {} : { carriedConcerns: concernsCarried }),
328
379
  };
329
380
  }
330
381
  function lenient(items, parse) {
@@ -350,6 +401,8 @@ export function parseDraft(v) {
350
401
  resolved: lenientIds(v.resolved, isCommentIdValue),
351
402
  excluded: typeof excluded === "string" ? { comments: [], concerns: [] } : excluded,
352
403
  earlierReplies: lenient(v.earlierReplies, parseEarlierReplies),
404
+ carried: v.carried === undefined ? null : lenient(v.carried, parseCarriedRef),
405
+ carriedConcerns: lenient(v.carriedConcerns, parseCarriedConcern),
353
406
  };
354
407
  }
355
408
  export function parseEvent(v) {
@@ -78,50 +78,166 @@ function distance(hunk, units, line) {
78
78
  const [from, to] = span(hunk, units);
79
79
  return line < from ? from - line : line > to ? line - to : 0;
80
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));
81
+ /** The body row whose tip line is `line`; a kept row before a removal at the same line. */
82
+ function rowAt(hunk, units, line) {
83
+ if (line === null)
84
+ return 0;
85
+ const lines = tipLines(hunk, units);
86
+ const row = hunk.body.findIndex((r, i) => r.op !== "-" && lines[i] === line);
87
+ return row === -1 ? 0 : row;
87
88
  }
88
- function carry(comment, oldUnits, newUnits) {
89
+ function pinnedComment(comment) {
89
90
  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
91
  // A comment on a whole hunk has no line; inventing one moves its card off the hunk's end.
94
- const whole = anchor.changes === null && anchor.offsets === undefined;
95
- const kept = newUnits.get(anchor.unit);
92
+ const whole = anchor.kind === "hunk" && anchor.changes === null && anchor.offsets === undefined;
93
+ return { whole, row: (hunk) => firstRow(comment, hunk) };
94
+ }
95
+ function pinnedConcern(concern) {
96
+ const { lines } = concern;
97
+ if (lines === undefined)
98
+ return { whole: true, row: () => 0 };
99
+ return {
100
+ whole: false,
101
+ row: (hunk) => {
102
+ const row = hunk.body.findIndex((r) => r.op !== " " && lines.includes(r.change));
103
+ return row === -1 ? 0 : row;
104
+ },
105
+ };
106
+ }
107
+ /** Where a comment points on the version it was made on. */
108
+ function placedOwn(comment) {
109
+ const { anchor } = comment;
110
+ return {
111
+ unit: anchor.kind === "hunk" ? anchor.unit : null,
112
+ offsets: anchor.kind === "hunk" ? (anchor.offsets ?? null) : null,
113
+ changed: false,
114
+ path: comment.context.path,
115
+ newLine: null,
116
+ };
117
+ }
118
+ /**
119
+ * Pure. Moves a placement on the old diff to the new one: a unit the new diff still has keeps its
120
+ * place, else the hunk of the same path nearest its old tip line takes it, marked changed. A
121
+ * placement on no unit stays as it is.
122
+ */
123
+ function move(at, pinned, oldUnits, newUnits) {
124
+ if (at.unit === null)
125
+ return at;
126
+ const kept = newUnits.get(at.unit);
127
+ const old = oldUnits.get(at.unit);
96
128
  if (kept !== undefined) {
97
- const newLine = kept.kind === "hunk" && !whole
98
- ? (tipLines(kept, newUnits)[firstRow(comment, kept)] ?? null)
99
- : null;
100
- return {
101
- comment,
102
- unit: kept.id,
103
- offsets: anchor.offsets ?? null,
104
- changed: false,
105
- path: kept.path,
106
- newLine,
107
- };
129
+ let newLine = null;
130
+ if (kept.kind === "hunk" && !pinned.whole) {
131
+ if (!at.changed)
132
+ newLine = tipLines(kept, newUnits)[pinned.row(kept)] ?? null;
133
+ else if (at.newLine !== null && old?.kind === "hunk") {
134
+ newLine = at.newLine - tipStartOf(old, oldUnits) + tipStartOf(kept, newUnits);
135
+ }
136
+ else
137
+ newLine = at.newLine;
138
+ }
139
+ return { ...at, path: kept.path, newLine };
108
140
  }
109
- const old = oldUnits.get(anchor.unit);
110
- const path = old?.path ?? comment.context.path;
141
+ const path = old?.path ?? at.path;
111
142
  const onPath = [...newUnits.values()].filter((u) => u.path === path);
112
143
  const first = onPath[0];
144
+ const moved = { offsets: null, changed: true, path };
113
145
  if (first === undefined)
114
- return { ...nowhere, changed: true, path };
115
- const moved = { comment, offsets: null, changed: true, path };
146
+ return { ...moved, unit: null, newLine: null };
116
147
  const hunks = onPath.filter((u) => u.kind === "hunk");
117
148
  if (old?.kind !== "hunk" || hunks.length === 0)
118
149
  return { ...moved, unit: first.id, newLine: null };
119
- const row = firstRow(comment, old);
150
+ const row = at.changed ? rowAt(old, oldUnits, at.newLine) : pinned.row(old);
120
151
  const oldLine = tipLines(old, oldUnits)[row] ?? tipStartOf(old, oldUnits);
121
152
  const nearest = hunks.reduce((a, b) => distance(b, newUnits, oldLine) < distance(a, newUnits, oldLine) ? b : a);
122
153
  return {
123
154
  ...moved,
124
155
  unit: nearest.id,
125
- newLine: whole ? null : landing(nearest, newUnits, oldLine, old.body[row]?.text),
156
+ newLine: pinned.whole ? null : landing(nearest, newUnits, oldLine, old.body[row]?.text),
126
157
  };
127
158
  }
159
+ /**
160
+ * Pure. Places each comment of the previous version on the new diff: on its own unit when that
161
+ * hunk is unchanged, else on the hunk of the same path nearest its old tip line.
162
+ */
163
+ export function carryComments(comments, oldUnits, newUnits) {
164
+ return comments.map((comment) => ({
165
+ comment,
166
+ ...move(placedOwn(comment), pinnedComment(comment), oldUnits, newUnits),
167
+ }));
168
+ }
169
+ /** since stays once set; a first change records the version the code still matched. */
170
+ function sinceOf(before, after, since, at) {
171
+ return since ?? (after.changed && !before.changed ? at : null);
172
+ }
173
+ /**
174
+ * Pure. What an open review carries from version `at` into the next: what it carried already,
175
+ * moved again, then the comments said on `at`. Nothing is dropped, so a comment of v1 is still
176
+ * there after the second swap.
177
+ */
178
+ export function carrySession(carried, said, at, oldUnits, newUnits) {
179
+ const again = carried.map((c) => {
180
+ const moved = move(c, pinnedComment(c.comment), oldUnits, newUnits);
181
+ return { ...c, ...moved, since: sinceOf(c, moved, c.since, at) };
182
+ });
183
+ const fresh = said.map((comment) => {
184
+ const own = placedOwn(comment);
185
+ const moved = move(own, pinnedComment(comment), oldUnits, newUnits);
186
+ return { comment, version: at, ...moved, since: sinceOf(own, moved, null, at) };
187
+ });
188
+ return [...again, ...fresh];
189
+ }
190
+ /**
191
+ * Pure. Kept concerns of version `at` that the next plan no longer has, plus those carried before,
192
+ * placed on the new diff. A concern the next plan has again keeps its decision there instead, so
193
+ * it is left out here.
194
+ */
195
+ export function carryConcerns(carried, kept, inNext, at, oldUnits, newUnits) {
196
+ const fresh = kept.map((k) => {
197
+ const unit = k.concern.unit ?? null;
198
+ const path = unit === null ? null : (oldUnits.get(unit)?.path ?? null);
199
+ return { ...k, unit, offsets: null, changed: false, path, newLine: null, since: null };
200
+ });
201
+ return [...carried, ...fresh]
202
+ .filter((c) => !inNext.has(c.concern.id))
203
+ .map((c) => {
204
+ const moved = move(c, pinnedConcern(c.concern), oldUnits, newUnits);
205
+ return { ...c, ...moved, since: sinceOf(c, moved, c.since, at) };
206
+ });
207
+ }
208
+ /**
209
+ * Pure. Where a carried hunk comment points on the diff it was carried to, as an anchor of that
210
+ * diff: its own rows when the hunk is unchanged, the row at its new line when it changed, the
211
+ * whole hunk when it has no line. Null when the code is gone or the comment is not on a hunk.
212
+ */
213
+ export function anchorOn(carried, units, commit) {
214
+ const { anchor } = carried.comment;
215
+ if (anchor.kind !== "hunk" || carried.unit === null)
216
+ return null;
217
+ if (!carried.changed)
218
+ return { ...anchor, commit };
219
+ const hunk = units.get(carried.unit);
220
+ if (hunk?.kind !== "hunk" || carried.newLine === null) {
221
+ return { kind: "hunk", unit: carried.unit, commit, changes: null };
222
+ }
223
+ const row = rowAt(hunk, units, carried.newLine);
224
+ const at = hunk.body[row];
225
+ const changes = at === undefined || at.op === " " ? [] : [at.change];
226
+ return { kind: "hunk", unit: carried.unit, commit, changes, offsets: [row] };
227
+ }
228
+ /** Pure. A carried concern as a concern of the diff it was carried to; see anchorOn. */
229
+ export function concernOn(carried, units) {
230
+ const { concern } = carried;
231
+ if (carried.unit === null)
232
+ return concern;
233
+ if (!carried.changed)
234
+ return { ...concern, unit: carried.unit };
235
+ const { lines: _old, ...rest } = concern;
236
+ const hunk = units.get(carried.unit);
237
+ if (hunk?.kind !== "hunk" || carried.newLine === null)
238
+ return { ...rest, unit: carried.unit };
239
+ const at = hunk.body[rowAt(hunk, units, carried.newLine)];
240
+ return at === undefined || at.op === " "
241
+ ? { ...rest, unit: carried.unit }
242
+ : { ...rest, unit: carried.unit, lines: [at.change] };
243
+ }
package/dist/src/host.js CHANGED
@@ -6,8 +6,8 @@ import { join } from "node:path";
6
6
  import { parseCarried, parseComment, parseEvent, parseEventBatch, } from "./comments.js";
7
7
  import { carryComments } from "./evolve.js";
8
8
  import { isPlanVersion, serializePlanFile, validatePlan } from "./plan.js";
9
- import { HEALTH_APP, isSessionId, loadDraftComments, PortInUseError, RegistrationError, startServer, } from "./server.js";
10
- import { BranchMovedError, diffOfPlan, isAlive, openSessionAt, SessionFileError, SessionLockedError, } from "./session.js";
9
+ import { HEALTH_APP, isSessionId, loadDraft, PortInUseError, RegistrationError, startServer, } from "./server.js";
10
+ import { BranchMovedError, diffOfPlan, isAlive, openSessionAt, SessionFileError, SessionLockedError, sessionComments, } from "./session.js";
11
11
  const RECORD = "server.json";
12
12
  const DEFAULT_IDLE_MS = 30_000;
13
13
  const START_TIMEOUT_MS = 10_000;
@@ -127,7 +127,15 @@ async function loadHosted(registration, held) {
127
127
  throw new RegistrationError(`plan v${version} no longer fits ${branch}; check it again`);
128
128
  }
129
129
  const reviewsDir = session.reviewsDir();
130
- const comments = await loadDraftComments(reviewsDir, version);
130
+ const draft = await loadDraft(reviewsDir, version);
131
+ const { comments } = draft;
132
+ // A restart in the middle of a review that swapped: the draft names what was said before.
133
+ const earlier = draft.carried === null
134
+ ? null
135
+ : {
136
+ comments: sessionComments(draft.carried, reviewed.filter((r) => r.version < version).map((r) => r.review)),
137
+ concerns: draft.carriedConcerns,
138
+ };
131
139
  const prior = session.priorReview(mode, version);
132
140
  const previous = prior === null || prior.comments.length === 0
133
141
  ? null
@@ -151,6 +159,7 @@ async function loadHosted(registration, held) {
151
159
  release,
152
160
  previous,
153
161
  carried,
162
+ earlier,
154
163
  state: {
155
164
  mode,
156
165
  anchor: session.anchor,
package/dist/src/plan.js CHANGED
@@ -90,6 +90,20 @@ function parseConcern(v, onUnit) {
90
90
  return "lines: expected a list of change indices";
91
91
  return { severity, kind, text, lines: [...new Set(indices)].sort((a, b) => a - b) };
92
92
  }
93
+ /** A concern as validation placed it, stored beside a review: with its id and its unit. */
94
+ export function parsePlacedConcern(v) {
95
+ if (!isFields(v))
96
+ return "expected an object";
97
+ if (!isConcernId(v.id))
98
+ return "id: expected a concern id";
99
+ if (v.unit !== undefined && !(typeof v.unit === "string" && isUnitId(v.unit))) {
100
+ return "unit: expected a unit id";
101
+ }
102
+ const concern = parseConcern(v, v.unit !== undefined);
103
+ if (typeof concern === "string")
104
+ return concern;
105
+ return { ...concern, id: v.id, ...(v.unit === undefined ? {} : { unit: v.unit }) };
106
+ }
93
107
  /** Absent, null and [] all mean no concerns, so a planner may always write the field. */
94
108
  function parseConcerns(v, onUnit) {
95
109
  if (v === undefined || v === null)
@@ -1,4 +1,5 @@
1
1
  import { tipStartOf } from "./diff.js";
2
+ import { anchorOn, concernOn } from "./evolve.js";
2
3
  /** Consecutive runs collapse: [0,1,2,3,7] becomes "0-3,7". */
3
4
  function formatRanges(changes) {
4
5
  const sorted = [...new Set(changes)].sort((a, b) => a - b);
@@ -383,10 +384,23 @@ function quoteHunk(units, commits, unit, k, picked) {
383
384
  return { line, text: lines.map((l) => l.text) };
384
385
  }
385
386
  const SEVERITY_RANK = { blocker: 0, major: 1, minor: 2 };
387
+ /** Marks the label of what was said before its code changed, and places what lost its code. */
388
+ function marked(ctx, id, where, quote) {
389
+ const mark = ctx.marks?.get(id);
390
+ if (mark === undefined)
391
+ return where;
392
+ if (mark.gone) {
393
+ return { path: mark.path, line: null, label: `the code is gone after v${mark.since}`, quote };
394
+ }
395
+ return { ...where, label: `${where.label} · the code changed after v${mark.since}` };
396
+ }
386
397
  function lineLabel(line, fallback) {
387
398
  return line === null ? fallback : `line ${line}`;
388
399
  }
389
400
  function whereComment(ctx, c) {
401
+ return marked(ctx, c.id, whereOnTip(ctx, c), c.context.lines ?? []);
402
+ }
403
+ function whereOnTip(ctx, c) {
390
404
  const { anchor } = c;
391
405
  switch (anchor.kind) {
392
406
  case "plan":
@@ -429,6 +443,9 @@ function whereComment(ctx, c) {
429
443
  }
430
444
  }
431
445
  function whereConcern(ctx, kept) {
446
+ return marked(ctx, kept.concern.id, concernOnTip(ctx, kept), []);
447
+ }
448
+ function concernOnTip(ctx, kept) {
432
449
  const { concern, commit } = kept;
433
450
  if (concern.unit === undefined) {
434
451
  return { path: null, line: null, label: `commit ${commit + 1}`, quote: [] };
@@ -727,8 +744,12 @@ function threadOf(c) {
727
744
  function withSeverity(severity, text) {
728
745
  return severity === null ? text : `**${severity}**\n\n${text}`;
729
746
  }
730
- function collectItems(review, commits, units, responses) {
731
- const ctx = { commits, units };
747
+ function changedOf(ctx, id) {
748
+ const mark = ctx.marks?.get(id);
749
+ return mark === undefined ? {} : { changed: { since: mark.since, gone: mark.gone } };
750
+ }
751
+ function collectItems(review, ctx, responses) {
752
+ const { commits, units } = ctx;
732
753
  const fromReviewer = review.comments.filter((c) => c.author !== "agent");
733
754
  const walk = review.mode === "walk";
734
755
  const rest = fromReviewer.filter((c) => c.kind !== "question");
@@ -748,6 +769,7 @@ function collectItems(review, commits, units, responses) {
748
769
  return {
749
770
  where: it.where,
750
771
  item: {
772
+ id: kept.concern.id,
751
773
  ...concernSpan(ctx, kept.concern),
752
774
  path: it.where.path,
753
775
  oldPath: it.where.path,
@@ -757,6 +779,7 @@ function collectItems(review, commits, units, responses) {
757
779
  thread,
758
780
  resolved: false,
759
781
  excluded: noConcerns.has(kept.concern.id),
782
+ ...changedOf(ctx, kept.concern.id),
760
783
  },
761
784
  };
762
785
  }
@@ -765,6 +788,7 @@ function collectItems(review, commits, units, responses) {
765
788
  return {
766
789
  where: it.where,
767
790
  item: {
791
+ id: first.id,
768
792
  ...commentSpan(ctx, first),
769
793
  path: it.where.path,
770
794
  oldPath: it.where.path,
@@ -774,6 +798,7 @@ function collectItems(review, commits, units, responses) {
774
798
  thread: threadOf(first),
775
799
  resolved: resolved.has(first.id),
776
800
  excluded: noComments.has(first.id),
801
+ ...changedOf(ctx, first.id),
777
802
  },
778
803
  };
779
804
  });
@@ -786,6 +811,7 @@ function collectItems(review, commits, units, responses) {
786
811
  out.push({
787
812
  where,
788
813
  item: {
814
+ id: q.id,
789
815
  ...commentSpan(ctx, q),
790
816
  path: where.path,
791
817
  oldPath: where.path,
@@ -795,6 +821,7 @@ function collectItems(review, commits, units, responses) {
795
821
  thread: threadOf(q),
796
822
  resolved: resolved.has(q.id),
797
823
  excluded: noComments.has(q.id),
824
+ ...changedOf(ctx, q.id),
798
825
  },
799
826
  });
800
827
  }
@@ -805,7 +832,8 @@ function collectItems(review, commits, units, responses) {
805
832
  * Excluded ones are present and marked; whoever posts them skips those.
806
833
  */
807
834
  export function reportItems(review, commits, units, opts) {
808
- return collectItems(review, commits, units, opts.responses).map((c) => c.item);
835
+ const session = folded(review, commits, units, opts.earlier);
836
+ return collectItems(session.review, session.ctx, opts.responses).map((c) => c.item);
809
837
  }
810
838
  /** A short list of what the reviewer left out, so nothing vanishes without a trace. */
811
839
  function renderExcluded(collected) {
@@ -821,6 +849,64 @@ function renderExcluded(collected) {
821
849
  out.push("");
822
850
  return out;
823
851
  }
852
+ /**
853
+ * The review of the whole session on this version's diff: the earlier versions' comments on
854
+ * their carried anchors, the concerns kept there that this plan lost added to the commit now
855
+ * taking their unit, and a mark on each whose code changed. Each id appears once; this
856
+ * version's own copy wins.
857
+ */
858
+ function folded(review, commits, units, earlier) {
859
+ if (earlier === undefined)
860
+ return { review, ctx: { commits, units } };
861
+ const marks = new Map();
862
+ const mark = (id, at) => {
863
+ if (at.since !== null)
864
+ marks.set(id, { since: at.since, gone: at.unit === null, path: at.path });
865
+ };
866
+ const owner = (unit, fallback) => {
867
+ const k = commits.findIndex((c) => c.refs.some((r) => r.unit === unit));
868
+ return k === -1 ? Math.min(fallback, Math.max(commits.length - 1, 0)) : k;
869
+ };
870
+ const own = new Set(review.comments.map((c) => c.id));
871
+ const comments = earlier.comments
872
+ .filter((c) => !own.has(c.comment.id))
873
+ .map((c) => {
874
+ mark(c.comment.id, c);
875
+ const { anchor } = c.comment;
876
+ if (anchor.kind !== "hunk")
877
+ return c.comment;
878
+ const moved = anchorOn(c, units, owner(c.unit, anchor.commit));
879
+ return moved === null ? c.comment : { ...c.comment, anchor: moved };
880
+ });
881
+ const inPlan = new Set(commits.flatMap((c) => (c.concerns ?? []).map((k) => k.id)));
882
+ const lost = earlier.concerns.filter((c) => !inPlan.has(c.concern.id));
883
+ for (const c of lost)
884
+ mark(c.concern.id, c);
885
+ const decided = new Set((review.concerns ?? []).map((d) => d.concern));
886
+ return {
887
+ review: {
888
+ ...review,
889
+ comments: [...comments, ...review.comments],
890
+ concerns: [
891
+ ...lost.filter((c) => !decided.has(c.concern.id)).map((c) => c.decision),
892
+ ...(review.concerns ?? []),
893
+ ],
894
+ },
895
+ ctx: {
896
+ units,
897
+ marks,
898
+ commits: commits.map((commit, k) => {
899
+ const mine = lost.filter((c) => owner(c.unit, c.commit) === k);
900
+ if (mine.length === 0)
901
+ return commit;
902
+ return {
903
+ ...commit,
904
+ concerns: [...(commit.concerns ?? []), ...mine.map((c) => concernOn(c, units))],
905
+ };
906
+ }),
907
+ },
908
+ };
909
+ }
824
910
  /**
825
911
  * The consolidated report of a review, in the voices of both the reviewer and the agent: the
826
912
  * reviewer's comments and the agent's concerns the reviewer kept, grouped by file and ordered by
@@ -829,22 +915,24 @@ function renderExcluded(collected) {
829
915
  * Concerns the reviewer has not decided are left out, as are those dismissed.
830
916
  */
831
917
  export function renderReport(full, commits, units, opts) {
832
- const excluded = full.excluded;
918
+ const session = folded(full, commits, units, opts.earlier);
919
+ const { ctx } = session;
920
+ const excluded = session.review.excluded;
833
921
  if (excluded === undefined)
834
- return renderIncluded(full, commits, units, opts);
922
+ return renderIncluded(session.review, ctx, opts);
835
923
  const noComments = new Set(excluded.comments);
836
924
  const noConcerns = new Set(excluded.concerns);
837
925
  const review = {
838
- ...full,
839
- comments: full.comments.filter((c) => !noComments.has(c.id)),
840
- concerns: (full.concerns ?? []).filter((d) => !noConcerns.has(d.concern)),
926
+ ...session.review,
927
+ comments: session.review.comments.filter((c) => !noComments.has(c.id)),
928
+ concerns: (session.review.concerns ?? []).filter((d) => !noConcerns.has(d.concern)),
841
929
  };
842
- const tail = renderExcluded(collectItems(full, commits, units, opts.responses));
843
- const main = renderIncluded(review, commits, units, opts);
930
+ const tail = renderExcluded(collectItems(session.review, ctx, opts.responses));
931
+ const main = renderIncluded(review, ctx, opts);
844
932
  return tail.length === 0 ? main : `${main}${main.endsWith("\n") ? "" : "\n"}${tail.join("\n")}`;
845
933
  }
846
- function renderIncluded(review, commits, units, opts) {
847
- const ctx = { commits, units };
934
+ function renderIncluded(review, ctx, opts) {
935
+ const { commits, units } = ctx;
848
936
  const walk = review.mode === "walk";
849
937
  const fromReviewer = review.comments.filter((c) => c.author !== "agent");
850
938
  // A thread the agent started matters to it only once the reviewer has answered.