@shiyuphan/follow-me 0.4.1 → 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 (99) hide show
  1. package/dist/src/cli.js +171 -14
  2. package/dist/src/comments.js +62 -9
  3. package/dist/src/evolve.js +146 -26
  4. package/dist/src/harness.js +4 -1
  5. package/dist/src/host.js +29 -4
  6. package/dist/src/install.js +1 -1
  7. package/dist/src/plan.js +14 -0
  8. package/dist/src/prompt.js +270 -178
  9. package/dist/src/server.js +106 -29
  10. package/dist/src/session.js +23 -0
  11. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-byISxePP.js → abnfDiagram-WEYOIA3C-8UsLPznO.js} +1 -1
  12. package/dist/ui/dist/assets/{arc-CBzsAsP2.js → arc-vovjGalz.js} +1 -1
  13. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-0OSkIwdB.js → architecture-WOLXFQ4H-BBMK-PUu.js} +1 -1
  14. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-ixHybNqO.js → architectureDiagram-ZHVYKT53-Bw_ehaQk.js} +1 -1
  15. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-1F8XL_Ou.js → blockDiagram-RJAE7QA3-CAmD0wKf.js} +1 -1
  16. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-D9PFBXnc.js → c4Diagram-XDPPPE54-DCP-3zgt.js} +1 -1
  17. package/dist/ui/dist/assets/channel-B7CT_oIp.js +1 -0
  18. package/dist/ui/dist/assets/{chunk-2E7WHVMD-zZdNfMkj.js → chunk-2E7WHVMD-BiHSeJMu.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-4EMNKR2T-CTKoxSSj.js → chunk-4EMNKR2T-CR3lHKf_.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-55XCU76K-ifFnDlcS.js → chunk-55XCU76K-CtaoloTT.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-7S6XSTFO-BCMVdi_1.js → chunk-7S6XSTFO-eABdc7fu.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-AQDTSKZB-DC-5Whju.js → chunk-AQDTSKZB-CPlbJWi-.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-DASbe_gb.js → chunk-DSNLSHJZ-YiMuxsb-.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-HAGFOTED-CArytDjj.js → chunk-HAGFOTED-1VcdL78a.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-HNE4Q36P-Dnl-Wtud.js → chunk-HNE4Q36P-u-sr_0Qu.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-NQOKRVIF-wvSLaeaK.js → chunk-NQOKRVIF-C_QlWSze.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-UHW7PFBP-CckaOGiQ.js → chunk-UHW7PFBP-CNuZhsbU.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-VQ5W62QI-kQBMDJ7V.js → chunk-VQ5W62QI-DAHWbLqU.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-WQQL4CDK-CMg6bbLU.js → chunk-WQQL4CDK-DWQnxFCd.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-XXDRQBXY-CYlFr19K.js → chunk-XXDRQBXY-B0lGgSOR.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-YZO67HFG-CqkG1vVk.js → chunk-YZO67HFG-BDw7efkN.js} +1 -1
  32. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-BUNEdx-e.js → chunk-ZAUX2LTX-B5wfHsZv.js} +1 -1
  33. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-CFTbvcrm.js → chunk-ZCHMQSOR--rWtYtAG.js} +1 -1
  34. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-t2OdJ0LA.js → classDiagram-v2-4MYWR3UA-BKuQ_xoS.js} +1 -1
  35. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-FGm030gc.js → cose-bilkent-JH36ORCC-Dk5fIpU0.js} +1 -1
  36. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-BtU33Lsu.js → cynefin-EF2NZ3EQ-DIykJb2g.js} +1 -1
  37. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-DUr-I7hS.js → cynefinDiagram-WF24UDRP-Cg8tBqUb.js} +1 -1
  38. package/dist/ui/dist/assets/{dagre-NK4GIZVX-BWTHwJKt.js → dagre-NK4GIZVX-BC_tnnjz.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-CBK3MWTC-PcHrsEBn.js → diagram-CBK3MWTC-B_MQGixU.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-I5XIIV2D-CkGtKjYj.js → diagram-I5XIIV2D-DO681pZ5.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-ODIL2UCW-B_HdBB1w.js → diagram-ODIL2UCW-D14jLzU6.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-PFBEJUXE-DL9zayE6.js → diagram-PFBEJUXE-cML48gUS.js} +1 -1
  43. package/dist/ui/dist/assets/{diagram-RJA3I4VV-By5wl8nK.js → diagram-RJA3I4VV-DSoq8jFk.js} +1 -1
  44. package/dist/ui/dist/assets/{diagram-TYUNFSNR-ziWPVayL.js → diagram-TYUNFSNR-CaJ4pvnl.js} +1 -1
  45. package/dist/ui/dist/assets/{dist-NDVqCM7k.js → dist-D3j-DODP.js} +1 -1
  46. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-DH4YGkPc.js → ebnfDiagram-V4P3TTPT-CyRpjSix.js} +1 -1
  47. package/dist/ui/dist/assets/{elk-QONTBCTQ-BL2RlCEv.js → elk-QONTBCTQ-ICJdzVFL.js} +1 -1
  48. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-BKbTMaRp.js → erDiagram-KYQIGQAA-B57hNTfw.js} +1 -1
  49. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-Dk5RCWc-.js → eventmodeling-K75KTNOO-BG8tUVIQ.js} +1 -1
  50. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU--HPNoVQJ.js +1 -0
  51. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-3vM9gxeT.js → ganttDiagram-R7GPNTJY-Czt6Klik.js} +1 -1
  52. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-CXGFhz54.js → gitGraph-VSP46ZUC-De_ZWJLj.js} +1 -1
  53. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-Cl4fwUuA.js → gitGraphDiagram-PDYL7XHY-BMCJoOnT.js} +1 -1
  54. package/dist/ui/dist/assets/index-CYKbk4QS.js +134 -0
  55. package/dist/ui/dist/assets/{info-OHQRW6UA-DfSsZ8ib.js → info-OHQRW6UA-D75H9kUq.js} +1 -1
  56. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-B_7tyrL5.js → infoDiagram-MVDRKAX5-Bsm_I9dt.js} +1 -1
  57. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-DUvHljpg.js → ishikawaDiagram-HPEHV2GE-98xZAJ9y.js} +1 -1
  58. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-BCgqzrb8.js → journeyDiagram-BOSHW7O7-BWVGuc0P.js} +1 -1
  59. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-DOzZ3NmE.js → kanban-definition-FNSA4HC5-DgUE_KDd.js} +1 -1
  60. package/dist/ui/dist/assets/{line-B-mPFVll.js → line-i__8Rvas.js} +1 -1
  61. package/dist/ui/dist/assets/{linear-k7o0jout.js → linear-BXKj0Fip.js} +1 -1
  62. package/dist/ui/dist/assets/{mermaid-parser.core-CAyLSQpH.js → mermaid-parser.core-DxvaorTa.js} +3 -3
  63. package/dist/ui/dist/assets/{mermaid.core-vfVMjgx2.js → mermaid.core-ujYTsRZz.js} +4 -4
  64. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-B6y-B_r4.js → mindmap-definition-6OY57UG3-B7QD6sjk.js} +1 -1
  65. package/dist/ui/dist/assets/{packet-JDAUHWVQ-C_r4ed30.js → packet-JDAUHWVQ-B6aIwfD1.js} +1 -1
  66. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-B8VUm3Bh.js → pegDiagram-ECIUJNBX-_RStHj-a.js} +1 -1
  67. package/dist/ui/dist/assets/{pie-XZMESJXO-DcwtbH9y.js → pie-XZMESJXO-CNLC-pTe.js} +1 -1
  68. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-CvUNBi2F.js → pieDiagram-GNMPY74T-0uT7MT-u.js} +1 -1
  69. package/dist/ui/dist/assets/{prism-core-_2ju85Ya.js → prism-core-DtxziK1i.js} +1 -1
  70. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-Dg2eVS3j.js → quadrantDiagram-D57K4MC3-CTMKb5hl.js} +1 -1
  71. package/dist/ui/dist/assets/{radar-ABXABTNO-xCNTcZE9.js → radar-ABXABTNO-D53h6I0N.js} +1 -1
  72. package/dist/ui/dist/assets/{railroad-I3PHUGI6-BI-sIL2Q.js → railroad-I3PHUGI6-CwjhW4mc.js} +1 -1
  73. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-Ddvk40Wx.js → railroad-abnf-LNEFI6M7-DDbQ3RmB.js} +1 -1
  74. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-h35gE183.js → railroad-ebnf-DWYD2UWJ-Am7YJAqO.js} +1 -1
  75. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-Zim9iPdp.js → railroad-peg-JR7BVNR7-Ci32KoVW.js} +1 -1
  76. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-Cz6fdmOQ.js → railroadDiagram-Y72NRBL7-BPU5A6XW.js} +1 -1
  77. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-DTJkCrcD.js → requirementDiagram-EQYSW5RA-_0Pq4QdV.js} +1 -1
  78. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-Bg1iiVla.js → sankeyDiagram-SY24JHO5-c9h-7uf8.js} +1 -1
  79. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-9XfetsRA.js → sequenceDiagram-BLEHNHQU-B9-S4Rc9.js} +1 -1
  80. package/dist/ui/dist/assets/{src-Dym5afQ5.js → src-BZ3kG7lO.js} +1 -1
  81. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-CMmcMV8z.js → stateDiagram-v2-A6IESKWZ-py1smSen.js} +1 -1
  82. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-B2PcshTY.js → swimlanes-B3ENRKFQ-C1E90aH4.js} +1 -1
  83. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-I98Q8AqP.js +8 -0
  84. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-Cr1hCNtW.js → timeline-definition-JXYKGLGB-DNe1To6B.js} +1 -1
  85. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-jH5ksKnh.js → treeView-D4JQ5SDB-D6MG89i6.js} +1 -1
  86. package/dist/ui/dist/assets/{treemap-SAJKECNS-BV90e8QL.js → treemap-SAJKECNS-CZpt7Mlh.js} +1 -1
  87. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-BNwq5aJV.js → usecaseDiagram-WPOLBNNX-CKsv-03M.js} +1 -1
  88. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-D-lLyZCL.js → vennDiagram-Q3Y3IDCW-DGJ1qlyJ.js} +1 -1
  89. package/dist/ui/dist/assets/{wardley-7MLQ67FV-CWfHQUGt.js → wardley-7MLQ67FV-ClOzBemC.js} +1 -1
  90. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-BL_qNKVd.js → wardleyDiagram-IVAIDKWA-C68ahPyF.js} +1 -1
  91. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-BHPK5Snp.js → xychartDiagram-4URNHDU4-DWDamCoH.js} +1 -1
  92. package/dist/ui/dist/index.html +1 -1
  93. package/package.json +1 -1
  94. package/skills/follow-me/SKILL.md +69 -7
  95. package/skills/follow-me/porting.md +7 -0
  96. package/dist/ui/dist/assets/channel-B-fwsHf1.js +0 -1
  97. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Dkjb3PCK.js +0 -1
  98. package/dist/ui/dist/assets/index-CBbx_pJk.js +0 -134
  99. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DfB3XTTn.js +0 -8
package/dist/src/cli.js CHANGED
@@ -5,13 +5,13 @@ import { dirname, join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
7
7
  import { BranchRefusedError, branchHead, GitError, isOwnWork, signing, treeOf, writeBranch, } from "./git.js";
8
- import { fetchCarried, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
9
- import { runInstall, runUninstall, syncSkills } from "./install.js";
8
+ import { fetchCarried, fetchComments, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
9
+ import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
10
10
  import { naiveDraft } from "./naive.js";
11
- import { isFields, serializePlanFile, validatePlan } from "./plan.js";
12
- import { renderEvents, renderPrompt, renderReport, reportItems, } from "./prompt.js";
11
+ import { isCommentId, isConcernId, isFields, serializePlanFile, validatePlan, } from "./plan.js";
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";
@@ -30,6 +30,9 @@ Other commands:
30
30
  Mermaid source
31
31
  explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
32
32
  start a thread on the page without being asked
33
+ show <h_…|c_…|k_…|path:line[@n|@base]> print a unit, a comment's thread, a concern, or the
34
+ lines around path:line in commit n, instead of
35
+ reading files
33
36
  report [--json] [--markdown] print the report of the latest review; --json lists
34
37
  the items to post to a merge request
35
38
 
@@ -82,6 +85,11 @@ Interactive loop (while the review page is open):
82
85
  --lines are change lines of the unit, commits count
83
86
  from 1; with no target it is about the whole plan.
84
87
  watch leaves these out like your replies
88
+ show <h_…|c_…|k_…|path:line[@n|@base]> [--branch <name>]
89
+ print a unit with the commits that take it, a
90
+ comment with its rows and thread, a concern with its
91
+ lines, or the lines around path:line at the tip, in
92
+ commit n of the plan, or in the base
85
93
  report [--json] [--markdown] [--branch <name>]
86
94
  print the report of the open review, or of the last
87
95
  one that ended. Markdown lists issues by file and
@@ -425,7 +433,7 @@ async function applyHosted(session, args, open) {
425
433
  say(`follow-me review: ${sessionUrl(open.port, swapped)}`);
426
434
  say(`the page now shows plan v${latest.version}; the comments on v${open.summary.version} are saved`);
427
435
  }
428
- next("follow-me watch");
436
+ next(`run follow-me again; ${STILL_WORKING}`);
429
437
  return 0;
430
438
  }
431
439
  function signMode(args) {
@@ -489,6 +497,7 @@ function promptInput(session, mode, args, changes) {
489
497
  diff: session.diff,
490
498
  draftPath: session.draftPath,
491
499
  checkCommand: `follow-me check${branchFlag(args)}`,
500
+ ...skillFile(),
492
501
  ...(changes === undefined ? {} : { changes }),
493
502
  };
494
503
  if (mode === "walk")
@@ -502,6 +511,17 @@ function promptInput(session, mode, args, changes) {
502
511
  previousCommits: previous === undefined ? [] : projectedCommits(session, previous.version),
503
512
  };
504
513
  }
514
+ /** The skill this package ships, which holds the rules prompt.md points at; none if it is missing. */
515
+ function skillFile() {
516
+ try {
517
+ return { skillPath: join(findSkillDir(), "SKILL.md") };
518
+ }
519
+ catch (error) {
520
+ if (error instanceof InstallError)
521
+ return {};
522
+ throw error;
523
+ }
524
+ }
505
525
  /** The commits of a frozen plan as projected now, or none when it no longer fits the branch. */
506
526
  function projectedCommits(session, version) {
507
527
  const plan = session.plans().find((p) => p.version === version);
@@ -889,7 +909,9 @@ function finished(session, stored) {
889
909
  const text = renderStored(session, stored);
890
910
  if (text === null)
891
911
  return 1;
892
- 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")}`);
893
915
  process.stdout.write(text);
894
916
  say();
895
917
  next("done; the user can port it with follow-me report --json");
@@ -917,8 +939,7 @@ function evolveHandoff(session, cwd, mode, args, moved, hosted) {
917
939
  session.writeDraft(serializePlanFile(moved.draft));
918
940
  if (draftState(session).kind === "valid")
919
941
  return null;
920
- const { since, vanished, added } = moved;
921
- session.writePrompt(renderPrompt(promptInput(session, mode, args, { since, vanished, added })));
942
+ session.writePrompt(renderPrompt(promptInput(session, mode, args, moved)));
922
943
  say(`wrote ${relative(cwd, session.promptPath)}`);
923
944
  const step = `assign the new units in ${relative(cwd, session.draftPath)}, then run follow-me again`;
924
945
  next(hosted ? `${step}; ${STILL_WORKING}` : step);
@@ -1058,9 +1079,12 @@ async function listen(session, open, args) {
1058
1079
  const news = fresh.filter(isNews);
1059
1080
  if (news.length > 0) {
1060
1081
  const ctx = { commits: projectedCommits(session, version), units: session.diff.units };
1061
- process.stdout.write(renderEvents(news, ctx));
1062
1082
  const ended = news.some((e) => e.type === "ended");
1063
- if (ended || news.some((e) => e.type === "submitted")) {
1083
+ const done = ended || news.some((e) => e.type === "submitted");
1084
+ // The report that follows quotes every comment, so the digest only says what else happened.
1085
+ const shown = done ? news.filter((e) => !isCommentEvent(e)) : news;
1086
+ process.stdout.write(renderEvents(shown, ctx));
1087
+ if (done) {
1064
1088
  say();
1065
1089
  if (ended) {
1066
1090
  const stored = reviewsOf(session).find((r) => r.version === version);
@@ -1218,7 +1242,19 @@ function renderStored(session, stored, format = "markdown") {
1218
1242
  .plans()
1219
1243
  .filter((p) => p.version > stored.version)
1220
1244
  .flatMap((p) => p.file.responses);
1221
- 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 };
1222
1258
  if (format === "json") {
1223
1259
  const items = reportItems(stored.review, verdict.plan.commits, session.diff.units, opts);
1224
1260
  return `${JSON.stringify(items, null, 2)}\n`;
@@ -1259,6 +1295,9 @@ async function feedback(cwd, args) {
1259
1295
  console.error("follow-me: feedback is now report; printing the same");
1260
1296
  return report(cwd, args, args.flags.has("walk"));
1261
1297
  }
1298
+ function isCommentEvent(event) {
1299
+ return event.type === "comment" || event.type === "comment-updated";
1300
+ }
1262
1301
  /** Events the reviewer caused; the agent's own replies, explanations and version swaps are not news. */
1263
1302
  function isNews(event) {
1264
1303
  if (event.type === "version")
@@ -1357,7 +1396,7 @@ async function reply(cwd, args) {
1357
1396
  ...(diagram === undefined ? {} : { diagram }),
1358
1397
  });
1359
1398
  say(`replied ${id} to ${commentId}`);
1360
- next("follow-me watch");
1399
+ next(`run follow-me again; ${STILL_WORKING}`);
1361
1400
  return 0;
1362
1401
  }
1363
1402
  /** The Mermaid source in --diagram's file, or undefined without the flag. */
@@ -1474,7 +1513,123 @@ async function explain(cwd, args) {
1474
1513
  ...(diagram === undefined ? {} : { diagram }),
1475
1514
  });
1476
1515
  say(`explained ${id}`);
1477
- next("follow-me watch");
1516
+ next(`run follow-me again; ${STILL_WORKING}`);
1517
+ return 0;
1518
+ }
1519
+ const SHOW_USAGE = "usage: follow-me show <h_…|c_…|k_…|path:line[@<commit>|@base]>";
1520
+ /** Every comment the session has, with the plan version it was made on: live ones first. */
1521
+ async function knownComments(session) {
1522
+ const open = await openOnServer(session, null);
1523
+ const live = open === null ? [] : await fetchComments(open.port, open.summary);
1524
+ const saved = [
1525
+ session.priorReview("split", null),
1526
+ session.priorReview("walk", null),
1527
+ ...reviewsOf(session).map((r) => r.review),
1528
+ ].flatMap((r) => (r === null ? [] : [{ version: r.planVersion, comments: r.comments }]));
1529
+ return [...live, ...saved];
1530
+ }
1531
+ const SHOW_MARGIN = 3;
1532
+ /**
1533
+ * The lines around path:line as commit `at` of the latest plan writes the file (1-based), the
1534
+ * base, or the tip when `at` is undefined. Read from the plan, so it works before any apply.
1535
+ */
1536
+ function showLines(session, raw) {
1537
+ const m = /^(.+):(\d+)(?:@(\d+|base))?$/.exec(raw);
1538
+ const [, typed, lineRaw, at] = m ?? [];
1539
+ if (typed === undefined || lineRaw === undefined)
1540
+ throw new UsageError(SHOW_USAGE);
1541
+ const path = Buffer.from(typed, "utf8").toString("latin1");
1542
+ const file = [...session.diff.textFiles.values()].find((f) => f.path === path);
1543
+ if (file === undefined) {
1544
+ return `${typed} is not a text file this branch changes; it reads the same in every commit, so read it from the working tree\n`;
1545
+ }
1546
+ let text;
1547
+ let label;
1548
+ if (at === undefined) {
1549
+ text = file.tip?.bytes ?? null;
1550
+ label = "the tip";
1551
+ }
1552
+ else if (at === "base") {
1553
+ text = file.base === null ? null : file.base.lines.join("");
1554
+ label = "the base";
1555
+ }
1556
+ else {
1557
+ const latest = session.plans().at(-1);
1558
+ const commits = latest === undefined ? [] : projectedCommits(session, latest.version);
1559
+ const n = Number(at);
1560
+ if (n < 1 || n > commits.length) {
1561
+ throw new UsageError(`@${at} is not one of the ${commits.length} commits of the plan`);
1562
+ }
1563
+ text = file.base === null ? null : file.base.lines.join("");
1564
+ for (const commit of commits.slice(0, n)) {
1565
+ for (const change of commit.changes) {
1566
+ if (change.path !== path)
1567
+ continue;
1568
+ text = change.op === "write" ? change.bytes : null;
1569
+ }
1570
+ }
1571
+ label = `commit ${n}`;
1572
+ }
1573
+ if (text === null)
1574
+ return `${typed} does not exist in ${label}\n`;
1575
+ const lines = Buffer.from(text, "latin1").toString("utf8").split("\n");
1576
+ if (lines.at(-1) === "")
1577
+ lines.pop();
1578
+ const line = Number(lineRaw);
1579
+ if (line < 1 || line > lines.length) {
1580
+ return `${typed} has ${plural(lines.length, "line")} in ${label}\n`;
1581
+ }
1582
+ const from = Math.max(1, line - SHOW_MARGIN);
1583
+ const to = Math.min(lines.length, line + SHOW_MARGIN);
1584
+ const width = String(to).length;
1585
+ const rows = lines.slice(from - 1, to).map((l, i) => {
1586
+ const n = from + i;
1587
+ return `${n === line ? ">" : " "} ${String(n).padStart(width)} ${l}`;
1588
+ });
1589
+ return `${typed}:${line} in ${label}\n${rows.join("\n")}\n`;
1590
+ }
1591
+ /** Expands a ref the agent was given to the lines and thread it names, so no file read is needed. */
1592
+ async function showRef(cwd, args) {
1593
+ const [ref, extra] = args.positionals;
1594
+ if (ref === undefined || extra !== undefined)
1595
+ throw new UsageError(SHOW_USAGE);
1596
+ const session = await openSession(cwd, sessionOpts(args));
1597
+ const { units } = session.diff;
1598
+ const latest = session.plans().at(-1);
1599
+ if (isUnitId(ref)) {
1600
+ const unit = units.get(ref);
1601
+ if (unit === undefined) {
1602
+ throw new UsageError(`${ref} is not a unit of the branch now; its hunk changed`);
1603
+ }
1604
+ const commits = latest === undefined ? [] : projectedCommits(session, latest.version);
1605
+ process.stdout.write(renderShownUnit(unit, commits));
1606
+ return 0;
1607
+ }
1608
+ if (/^c_[a-z2-7]{6}$/.test(ref) && isCommentId(ref)) {
1609
+ for (const { version, comments } of await knownComments(session)) {
1610
+ const comment = comments.find((c) => c.id === ref);
1611
+ if (comment === undefined)
1612
+ continue;
1613
+ const ctx = { commits: projectedCommits(session, version), units };
1614
+ process.stdout.write(renderShownComment(comment, ctx, version));
1615
+ return 0;
1616
+ }
1617
+ throw new UsageError(`no comment ${ref} in this session`);
1618
+ }
1619
+ if (isConcernId(ref)) {
1620
+ for (const plan of session.plans().toReversed()) {
1621
+ const commits = projectedCommits(session, plan.version);
1622
+ for (const commit of commits) {
1623
+ const concern = commit.concerns?.find((k) => k.id === ref);
1624
+ if (concern === undefined)
1625
+ continue;
1626
+ process.stdout.write(renderShownConcern(concern, commit.index, { commits, units }));
1627
+ return 0;
1628
+ }
1629
+ }
1630
+ throw new UsageError(`no concern ${ref} in a plan that fits the branch`);
1631
+ }
1632
+ process.stdout.write(showLines(session, ref));
1478
1633
  return 0;
1479
1634
  }
1480
1635
  function describeLock(lock) {
@@ -1711,6 +1866,8 @@ export async function main(argv, cwd) {
1711
1866
  const spec = { values: ["commit", "unit", "lines", "body", "diagram", "branch"] };
1712
1867
  return await explain(cwd, parseArgs(rest, spec));
1713
1868
  }
1869
+ case "show":
1870
+ return await showRef(cwd, parseArgs(rest, { values: ["branch"] }));
1714
1871
  case "report":
1715
1872
  return await report(cwd, parseArgs(rest, { values: ["branch"], flags: ["json", "markdown"] }));
1716
1873
  case "clean": {
@@ -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,46 +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
- const kept = newUnits.get(anchor.unit);
91
+ // A comment on a whole hunk has no line; inventing one moves its card off the hunk's end.
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);
94
128
  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
- };
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 };
104
140
  }
105
- const old = oldUnits.get(anchor.unit);
106
- const path = old?.path ?? comment.context.path;
141
+ const path = old?.path ?? at.path;
107
142
  const onPath = [...newUnits.values()].filter((u) => u.path === path);
108
143
  const first = onPath[0];
144
+ const moved = { offsets: null, changed: true, path };
109
145
  if (first === undefined)
110
- return { ...nowhere, changed: true, path };
111
- const moved = { comment, offsets: null, changed: true, path };
146
+ return { ...moved, unit: null, newLine: null };
112
147
  const hunks = onPath.filter((u) => u.kind === "hunk");
113
148
  if (old?.kind !== "hunk" || hunks.length === 0)
114
149
  return { ...moved, unit: first.id, newLine: null };
115
- const row = firstRow(comment, old);
150
+ const row = at.changed ? rowAt(old, oldUnits, at.newLine) : pinned.row(old);
116
151
  const oldLine = tipLines(old, oldUnits)[row] ?? tipStartOf(old, oldUnits);
117
152
  const nearest = hunks.reduce((a, b) => distance(b, newUnits, oldLine) < distance(a, newUnits, oldLine) ? b : a);
118
153
  return {
119
154
  ...moved,
120
155
  unit: nearest.id,
121
- newLine: landing(nearest, newUnits, oldLine, old.body[row]?.text),
156
+ newLine: pinned.whole ? null : landing(nearest, newUnits, oldLine, old.body[row]?.text),
122
157
  };
123
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
+ }
@@ -68,7 +68,10 @@ export const HARNESSES = [
68
68
  export function findHarness(id) {
69
69
  return HARNESSES.find((h) => h.id === id);
70
70
  }
71
+ /** The files of the skill folder, SKILL.md first. */
72
+ export const SKILL_FILES = ["SKILL.md", "porting.md"];
71
73
  export function plannedInstall(harness, scope, home, repoRoot) {
72
74
  const root = scope === "user" ? join(home, harness.userDir) : join(repoRoot, harness.projectDir);
73
- return [{ path: join(root, SKILL_NAME, "SKILL.md"), source: "SKILL.md" }];
75
+ // porting.md is read only when the user asks to post a review, so the skill stays short.
76
+ return SKILL_FILES.map((source) => ({ path: join(root, SKILL_NAME, source), source }));
74
77
  }
package/dist/src/host.js CHANGED
@@ -3,11 +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 { parseCarried, parseEvent, parseEventBatch } from "./comments.js";
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,
@@ -295,6 +304,22 @@ export async function fetchCarried(port, summary) {
295
304
  return [carried];
296
305
  });
297
306
  }
307
+ /** The comments of the hosted review, and of the version before a swap, with their versions. */
308
+ export async function fetchComments(port, summary) {
309
+ const state = await call(port, "GET", `${summary.url}api/state`);
310
+ const parse = (raw) => (Array.isArray(raw) ? raw : []).flatMap((item) => {
311
+ const comment = parseComment(item);
312
+ return typeof comment === "string" ? [] : [comment];
313
+ });
314
+ if (!isFields(state))
315
+ throw new RegistrationError("the server sent no state");
316
+ const previous = state["previous"];
317
+ const out = [{ version: summary.version, comments: parse(state["comments"]) }];
318
+ if (isFields(previous) && isPlanVersion(previous["version"])) {
319
+ out.push({ version: previous["version"], comments: parse(previous["comments"]) });
320
+ }
321
+ return out;
322
+ }
298
323
  /** One long poll of the session's event log: what happened after `since`, or nothing at timeout. */
299
324
  export async function pollEvents(port, summary, since, timeoutMs) {
300
325
  const path = `${summary.url}api/events?since=${since}&timeout=${timeoutMs}`;