@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
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 { fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postReply, probeHealth, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.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
9
  import { runInstall, runUninstall, syncSkills } from "./install.js";
10
10
  import { naiveDraft } from "./naive.js";
11
- import { serializePlanFile, validatePlan } from "./plan.js";
11
+ import { isFields, serializePlanFile, validatePlan } from "./plan.js";
12
12
  import { renderEvents, renderPrompt, renderReport, reportItems, } from "./prompt.js";
13
13
  import { RegistrationError, sessionIdOf } from "./server.js";
14
- import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, } from "./session.js";
14
+ import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sinceLastPlan, } from "./session.js";
15
15
  import { checkSyntax } from "./syntax.js";
16
16
  class UsageError extends Error {
17
17
  name = "UsageError";
@@ -20,8 +20,9 @@ const SHORT_USAGE = `usage: follow-me [--wait <seconds>] [--branch <name>]
20
20
 
21
21
  Run follow-me with no command. Each call does the next thing that is missing and returns: it
22
22
  writes the prompt for you to answer, reports what is wrong with your draft, opens the review for
23
- the user, then listens to them for up to --wait seconds (default 60, 0 for none) and prints what
24
- they flagged or asked. Do what its "next:" line says. Run it again to keep listening.
23
+ the user, then listens to them for up to --wait seconds (default 90, at most 300, 0 for none) and
24
+ prints what they flagged or asked. It is interactive: do what its "next:" line says and run it
25
+ again until it prints the report.
25
26
 
26
27
  Other commands:
27
28
  reply <commentId> --body <text> [--diagram <file>]
@@ -411,6 +412,7 @@ async function apply(cwd, args) {
411
412
  * write runs under it, and the page is then swapped to the new plan version in place.
412
413
  */
413
414
  async function applyHosted(session, args, open) {
415
+ rememberPort(session, open.port);
414
416
  const code = await applyLocked(session, args);
415
417
  const latest = session.plans().at(-1);
416
418
  if (code !== 0 || latest === undefined)
@@ -482,11 +484,12 @@ async function applyLocked(session, args) {
482
484
  say(`proof: ${result.branch}^{tree} ${tree} ${same ? "equals" : "DIFFERS FROM"} ${session.anchor.branch}^{tree} ${session.anchor.tipTree}`);
483
485
  return same ? 0 : 1;
484
486
  }
485
- function promptInput(session, mode, args) {
487
+ function promptInput(session, mode, args, changes) {
486
488
  const common = {
487
489
  diff: session.diff,
488
490
  draftPath: session.draftPath,
489
491
  checkCommand: `follow-me check${branchFlag(args)}`,
492
+ ...(changes === undefined ? {} : { changes }),
490
493
  };
491
494
  if (mode === "walk")
492
495
  return { ...common, mode };
@@ -564,6 +567,7 @@ async function hostReview(session, mode, args, prepare, report) {
564
567
  const home = stateDir();
565
568
  const open = await openOnServer(session, mode);
566
569
  if (open !== null) {
570
+ rememberPort(session, open.port);
567
571
  announce(sessionUrl(open.port, open.summary), args);
568
572
  return { kind: "elsewhere", ...open };
569
573
  }
@@ -578,7 +582,7 @@ async function hostReview(session, mode, args, prepare, report) {
578
582
  hash: prepared.plan.hash,
579
583
  lockToken: held.token,
580
584
  };
581
- const port = portOf(args);
585
+ const port = portOf(args) ?? rememberedPort(session);
582
586
  let started;
583
587
  const running = await runningServer(home, port);
584
588
  if (running !== null)
@@ -591,6 +595,7 @@ async function hostReview(session, mode, args, prepare, report) {
591
595
  }
592
596
  else
593
597
  started = await startOrAttach(home, port, say);
598
+ rememberPort(session, started.kind === "attached" ? started.port : started.host.port);
594
599
  if (started.kind === "attached") {
595
600
  const summary = await registerWith(started.port, registration);
596
601
  announce(sessionUrl(started.port, summary), args);
@@ -631,7 +636,52 @@ async function openOnServer(session, mode) {
631
636
  return null;
632
637
  const id = sessionIdOf(session.dir);
633
638
  const summary = (await listSessions(live.port)).find((s) => s.id === id && (mode === null || s.mode === mode));
634
- return summary === undefined ? null : { port: live.port, summary };
639
+ if (summary === undefined)
640
+ return null;
641
+ rememberPort(session, live.port);
642
+ return { port: live.port, summary };
643
+ }
644
+ /**
645
+ * The session's review after its server died: the lock still names the dead server. A fresh
646
+ * server takes the port the old one had, so the open page's origin stays valid and it reconnects;
647
+ * the session registers again from disk, where the page autosaved everything. Null when the
648
+ * session was not hosted by a dead server or its review already ended.
649
+ */
650
+ async function revive(session) {
651
+ const home = stateDir();
652
+ const record = readServerRecord(home);
653
+ const lock = session.lockState();
654
+ if (record === null || lock.kind !== "stale" || lock.pid !== record.pid)
655
+ return null;
656
+ const latest = session.plans().at(-1);
657
+ if (latest === undefined || reviewsOf(session).some((r) => r.version === latest.version)) {
658
+ return null;
659
+ }
660
+ const server = (await findServer(home)) ?? (await spawnDetached(home, record.port));
661
+ const registration = {
662
+ dir: session.dir,
663
+ mode: reviewMode(session),
664
+ version: latest.version,
665
+ hash: latest.hash,
666
+ lockToken: null,
667
+ };
668
+ let summary;
669
+ try {
670
+ summary = await registerWith(server.port, registration);
671
+ }
672
+ catch (error) {
673
+ if (error instanceof RegistrationError)
674
+ return null;
675
+ throw error;
676
+ }
677
+ rememberPort(session, server.port);
678
+ say("review server restarted, nothing lost");
679
+ if (server.port !== record.port) {
680
+ say(portFallbackMessage(record.port, server.port));
681
+ say(`follow-me review: ${sessionUrl(server.port, summary)}`);
682
+ say("the open page cannot follow it to the new port; reopen it at that URL");
683
+ }
684
+ return { port: server.port, summary };
635
685
  }
636
686
  function timeoutOf(args) {
637
687
  const raw = args.values.get("timeout");
@@ -767,6 +817,7 @@ async function split(cwd, args) {
767
817
  say(headline(session));
768
818
  const open = await openOnServer(session, "split");
769
819
  if (open !== null) {
820
+ rememberPort(session, open.port);
770
821
  announce(sessionUrl(open.port, open.summary), args);
771
822
  next("end the review on the page, then follow-me report");
772
823
  return 0;
@@ -844,6 +895,76 @@ function finished(session, stored) {
844
895
  next("done; the user can port it with follow-me report --json");
845
896
  return 0;
846
897
  }
898
+ /** Whether draft.json was written for the branch's current tip; a missing draft was not. */
899
+ function draftForTip(session) {
900
+ try {
901
+ const raw = session.readDraft();
902
+ return (typeof raw === "object" && raw !== null && "tip" in raw && raw.tip === session.anchor.tip);
903
+ }
904
+ catch (error) {
905
+ if (error instanceof MissingDraftError)
906
+ return false;
907
+ throw error;
908
+ }
909
+ }
910
+ /**
911
+ * The branch moved after the latest plan. The first call writes that plan carried over to the new
912
+ * tip as draft.json; while the draft does not validate, each call writes the prompt with what
913
+ * changed and hands off. Null when the draft is ready, so the entry applies it as usual.
914
+ */
915
+ function evolveHandoff(session, cwd, mode, args, moved, hosted) {
916
+ if (!draftForTip(session))
917
+ session.writeDraft(serializePlanFile(moved.draft));
918
+ if (draftState(session).kind === "valid")
919
+ return null;
920
+ const { since, vanished, added } = moved;
921
+ session.writePrompt(renderPrompt(promptInput(session, mode, args, { since, vanished, added })));
922
+ say(`wrote ${relative(cwd, session.promptPath)}`);
923
+ const step = `assign the new units in ${relative(cwd, session.draftPath)}, then run follow-me again`;
924
+ next(hosted ? `${step}; ${STILL_WORKING}` : step);
925
+ return 0;
926
+ }
927
+ const STILL_WORKING = "the reviewer is still working";
928
+ /**
929
+ * Applies the draft carried over to the moved tip under the server's lock (only freezes it for a
930
+ * reviewer's walk) and moves the open page onto it, threads and all.
931
+ */
932
+ async function swapOpenPage(session, open, args) {
933
+ const state = draftState(session);
934
+ if (state.kind !== "valid")
935
+ return 1;
936
+ const syntax = await checkSyntax(state.prepared, syntaxOverride(args));
937
+ if (syntax.issues.length > 0) {
938
+ for (const line of formatSyntax(syntax))
939
+ say(line);
940
+ next(`fix the draft, then run follow-me again; ${STILL_WORKING}`);
941
+ return 1;
942
+ }
943
+ if (open.summary.mode === "split") {
944
+ const code = await applyLocked(session, args);
945
+ if (code !== 0) {
946
+ next(`fix the draft, then run follow-me again; ${STILL_WORKING}`);
947
+ return code;
948
+ }
949
+ }
950
+ else
951
+ session.freeze(state.prepared);
952
+ const latest = session.plans().at(-1);
953
+ if (latest === undefined)
954
+ return 1;
955
+ const swapped = latest.version === open.summary.version
956
+ ? open.summary
957
+ : await swapVersion(open.port, open.summary, latest.version, latest.hash);
958
+ const carried = await fetchCarried(open.port, swapped);
959
+ const changed = carried.filter((c) => c.changed);
960
+ say(`swapped the open page to plan v${latest.version}; carried ${plural(carried.length, "comment")}, ${changed.length} changed`);
961
+ for (const { comment, path, newLine } of changed) {
962
+ const where = path === null ? "no file" : newLine === null ? path : `${path}:${newLine}`;
963
+ say(` ${comment.id} now on ${where}`);
964
+ }
965
+ next(`${STILL_WORKING}; reply on each changed thread with follow-me reply <id> --body "<what changed and where>", then run follow-me again`);
966
+ return 0;
967
+ }
847
968
  /** Writes the prompt for the agent and says where; used whenever there is no plan to review. */
848
969
  function promptHandoff(session, cwd, mode, args) {
849
970
  session.writePrompt(renderPrompt(promptInput(session, mode, args)));
@@ -856,25 +977,31 @@ function promptHandoff(session, cwd, mode, args) {
856
977
  function cursorPath(session) {
857
978
  return join(session.dir, "cursor.json");
858
979
  }
859
- /** Where the last `follow-me` stopped reading this hosted session's events; 0 for a new one. */
980
+ function logOf(summary) {
981
+ return `${summary.id}@${summary.openedAt}`;
982
+ }
860
983
  function readCursor(session, summary) {
861
984
  let raw;
862
985
  try {
863
986
  raw = JSON.parse(readFileSync(cursorPath(session), "utf8"));
864
987
  }
865
988
  catch {
866
- return 0;
989
+ raw = null;
867
990
  }
868
- if (typeof raw !== "object" || raw === null)
869
- return 0;
870
- const seen = "session" in raw ? raw.session : undefined;
871
- const seq = "seq" in raw ? raw.seq : undefined;
872
- const same = seen === `${summary.id}@${summary.openedAt}`;
873
- return same && typeof seq === "number" && Number.isInteger(seq) && seq >= 0 ? seq : 0;
991
+ const { session: log, seq, seen: ids } = isFields(raw) ? raw : {};
992
+ const seen = new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : []);
993
+ const counts = log === logOf(summary) && typeof seq === "number" && Number.isInteger(seq) && seq >= 0;
994
+ return { session: logOf(summary), seq: counts ? seq : 0, seen };
874
995
  }
875
- function writeCursor(session, summary, seq) {
876
- const cursor = { session: `${summary.id}@${summary.openedAt}`, seq };
877
- writeFileSync(cursorPath(session), `${JSON.stringify(cursor)}\n`);
996
+ function writeCursor(session, cursor) {
997
+ const { session: log, seq, seen } = cursor;
998
+ writeFileSync(cursorPath(session), `${JSON.stringify({ session: log, seq, seen: [...seen] })}\n`);
999
+ }
1000
+ /** The comment or reply id an event first carries, or null for events without one. */
1001
+ function saidId(event) {
1002
+ if (event.type === "comment")
1003
+ return event.comment.id;
1004
+ return event.type === "reply" ? event.reply.id : null;
878
1005
  }
879
1006
  function waitOf(args) {
880
1007
  const raw = args.values.get("wait");
@@ -884,9 +1011,11 @@ function waitOf(args) {
884
1011
  if (!Number.isFinite(seconds) || seconds < 0) {
885
1012
  throw new UsageError(`--wait ${raw} is not a number of seconds`);
886
1013
  }
887
- return seconds;
1014
+ return Math.min(seconds, MAX_ENTRY_WAIT_SECONDS);
888
1015
  }
889
- const ENTRY_WAIT_SECONDS = 60;
1016
+ /** One call fits inside a harness's tool timeout, so the agent's loop is one command per turn. */
1017
+ const ENTRY_WAIT_SECONDS = 90;
1018
+ const MAX_ENTRY_WAIT_SECONDS = 300;
890
1019
  /**
891
1020
  * Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
892
1021
  * returns at once with news, and with the report when the reviewer submitted or ended the review.
@@ -895,28 +1024,38 @@ async function listen(session, open, args) {
895
1024
  const { port, summary } = open;
896
1025
  const waitMs = waitOf(args) * 1000;
897
1026
  const deadline = Date.now() + waitMs;
898
- let since = readCursor(session, summary);
1027
+ let cursor = readCursor(session, summary);
899
1028
  let version = summary.version;
900
1029
  for (;;) {
901
1030
  const left = Math.max(0, deadline - Date.now());
902
1031
  let batch;
903
1032
  try {
904
- batch = await pollEvents(port, summary, since, Math.min(left, POLL_MS));
1033
+ batch = await pollEvents(port, summary, cursor.seq, Math.min(left, POLL_MS));
905
1034
  }
906
1035
  catch (error) {
907
1036
  if (!(error instanceof RegistrationError))
908
1037
  throw error;
909
1038
  // The log restarted under us (a new server); read it from the start.
910
- since = 0;
911
- writeCursor(session, summary, 0);
1039
+ cursor = { ...cursor, seq: 0 };
1040
+ writeCursor(session, cursor);
912
1041
  continue;
913
1042
  }
914
- since = batch.seq;
915
- writeCursor(session, summary, since);
1043
+ const fresh = batch.events.filter((e) => {
1044
+ const id = saidId(e);
1045
+ return id === null || !cursor.seen.has(id);
1046
+ });
1047
+ const seen = new Set(cursor.seen);
1048
+ for (const event of batch.events) {
1049
+ const id = saidId(event);
1050
+ if (id !== null)
1051
+ seen.add(id);
1052
+ }
1053
+ cursor = { ...cursor, seq: batch.seq, seen };
1054
+ writeCursor(session, cursor);
916
1055
  for (const event of batch.events)
917
1056
  if (event.type === "version")
918
1057
  version = event.version;
919
- const news = batch.events.filter(isNews);
1058
+ const news = fresh.filter(isNews);
920
1059
  if (news.length > 0) {
921
1060
  const ctx = { commits: projectedCommits(session, version), units: session.diff.units };
922
1061
  process.stdout.write(renderEvents(news, ctx));
@@ -933,12 +1072,11 @@ async function listen(session, open, args) {
933
1072
  next("done; the user can port it with follow-me report --json");
934
1073
  return 0;
935
1074
  }
936
- next('answer with follow-me reply <id> --body "<markdown>" [--diagram file], or run follow-me again to keep listening');
1075
+ next(`${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
937
1076
  return 0;
938
1077
  }
939
1078
  if (left <= 0) {
940
- say(`nothing new on the review page in ${Math.round(waitMs / 1000)}s`);
941
- next("run follow-me again to keep listening");
1079
+ next(`${STILL_WORKING}; run follow-me again to keep listening`);
942
1080
  return 0;
943
1081
  }
944
1082
  }
@@ -951,10 +1089,18 @@ async function listen(session, open, args) {
951
1089
  async function entry(cwd, args) {
952
1090
  syncSkills(homedir(), say);
953
1091
  const session = await openFresh(cwd, args);
954
- const hosted = await openOnServer(session, null);
1092
+ const hosted = (await openOnServer(session, null)) ?? (await revive(session));
1093
+ const mode = hosted?.summary.mode ?? (await entryMode(session, args));
1094
+ const moved = await sinceLastPlan(session);
1095
+ if (moved !== null) {
1096
+ const handed = evolveHandoff(session, cwd, mode, args, moved, hosted !== null);
1097
+ if (handed !== null)
1098
+ return handed;
1099
+ if (hosted !== null)
1100
+ return swapOpenPage(session, hosted, args);
1101
+ }
955
1102
  if (hosted !== null)
956
1103
  return listen(session, hosted, args);
957
- const mode = await entryMode(session, args);
958
1104
  const state = draftState(session);
959
1105
  if (state.kind === "invalid") {
960
1106
  next("fix the draft, then run follow-me again");
@@ -999,6 +1145,7 @@ async function entry(cwd, args) {
999
1145
  const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
1000
1146
  if (result.kind !== "elsewhere")
1001
1147
  return exitCode(result);
1148
+ say("follow-me is interactive: keep running it until it prints the report");
1002
1149
  return listen(session, result, args);
1003
1150
  }
1004
1151
  /** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
@@ -1133,7 +1280,7 @@ function notOpen(session) {
1133
1280
  */
1134
1281
  async function watch(cwd, args) {
1135
1282
  const session = await openSession(cwd, sessionOpts(args));
1136
- const open = await openOnServer(session, null);
1283
+ const open = (await openOnServer(session, null)) ?? (await revive(session));
1137
1284
  if (open === null)
1138
1285
  return notOpen(session);
1139
1286
  const sinceRaw = args.values.get("since");
@@ -1475,6 +1622,27 @@ function upgrade(targets, cwd) {
1475
1622
  return code;
1476
1623
  }
1477
1624
  const HELP_OR_VERSION = new Set(["--help", "--version"]);
1625
+ /**
1626
+ * The port the page was last served on for this session. A server that died or exited idle
1627
+ * takes its record with it; reusing the port keeps an open page on a live origin.
1628
+ */
1629
+ function rememberedPort(session) {
1630
+ try {
1631
+ const raw = JSON.parse(readFileSync(join(session.dir, "page.json"), "utf8"));
1632
+ if (typeof raw === "object" && raw !== null && "port" in raw) {
1633
+ const port = raw.port;
1634
+ if (typeof port === "number" && Number.isInteger(port) && port > 0)
1635
+ return port;
1636
+ }
1637
+ }
1638
+ catch {
1639
+ return undefined;
1640
+ }
1641
+ return undefined;
1642
+ }
1643
+ function rememberPort(session, port) {
1644
+ writeFileSync(join(session.dir, "page.json"), `${JSON.stringify({ port })}\n`);
1645
+ }
1478
1646
  export async function main(argv, cwd) {
1479
1647
  const [command, ...rest] = argv;
1480
1648
  try {
@@ -133,11 +133,6 @@ export function parseComment(v) {
133
133
  const severity = v.severity;
134
134
  if (severity !== undefined && !isSeverity(severity))
135
135
  return "severity is unknown";
136
- if (v.pending !== undefined && v.pending !== true)
137
- return "pending is not true";
138
- if (v.pending === true && (v.kind === "question" || v.author === "agent")) {
139
- return "only a reviewer's flag can be pending";
140
- }
141
136
  const replies = parseList(v.replies, parseReply, "replies");
142
137
  if (typeof replies === "string")
143
138
  return replies;
@@ -154,7 +149,6 @@ export function parseComment(v) {
154
149
  ...(v.kind === "question" ? { kind: "question" } : {}),
155
150
  ...(follows === undefined ? {} : { follows }),
156
151
  ...(severity === undefined ? {} : { severity }),
157
- ...(v.pending === true ? { pending: true } : {}),
158
152
  ...(replies.length === 0 ? {} : { replies }),
159
153
  ...(v.author === "agent" ? { author: "agent" } : {}),
160
154
  ...(v.diagram === undefined ? {} : { diagram: v.diagram }),
@@ -193,7 +187,10 @@ export function parseNewDecision(concern, v) {
193
187
  return "the decision is not an object";
194
188
  return withDecision(concern, { ...v, decidedAt: undefined });
195
189
  }
196
- /** Absent reads as none. The first item that does not parse names its index and reason. */
190
+ /**
191
+ * Absent reads as none. The first item that does not parse names its index and reason. Items
192
+ * are objects so a string result can only mean a failure; string values go through parseIds.
193
+ */
197
194
  function parseList(raw, parse, what) {
198
195
  if (raw === undefined)
199
196
  return [];
@@ -208,8 +205,26 @@ function parseList(raw, parse, what) {
208
205
  }
209
206
  return out;
210
207
  }
211
- function parseConcernId(item) {
212
- return isConcernId(item) ? item : "not a concern id";
208
+ /** Absent reads as none. The first item that is not an id of the kind names its index. */
209
+ function parseIds(raw, is, what, kind) {
210
+ if (raw === undefined)
211
+ return [];
212
+ if (!isArray(raw))
213
+ return `${what} is not an array`;
214
+ const out = [];
215
+ for (const [i, item] of raw.entries()) {
216
+ if (!is(item))
217
+ return `${what}[${i}]: not a ${kind} id`;
218
+ out.push(item);
219
+ }
220
+ return out;
221
+ }
222
+ /** Keeps only the items that are ids of the kind. */
223
+ function lenientIds(raw, is) {
224
+ return isArray(raw) ? raw.filter(is) : [];
225
+ }
226
+ function isCommentIdValue(item) {
227
+ return typeof item === "string" && isCommentId(item);
213
228
  }
214
229
  /** Absent reads as nothing excluded. */
215
230
  export function parseExcluded(v) {
@@ -217,17 +232,54 @@ export function parseExcluded(v) {
217
232
  return { comments: [], concerns: [] };
218
233
  if (!isFields(v))
219
234
  return "excluded is not an object";
220
- const comments = parseList(v.comments, parseCommentId, "excluded.comments");
235
+ const comments = parseIds(v.comments, isCommentIdValue, "excluded.comments", "comment");
221
236
  if (typeof comments === "string")
222
237
  return comments;
223
- const concerns = parseList(v.concerns, parseConcernId, "excluded.concerns");
238
+ const concerns = parseIds(v.concerns, isConcernId, "excluded.concerns", "concern");
224
239
  return typeof concerns === "string" ? concerns : { comments, concerns };
225
240
  }
226
241
  function asCommentId(item) {
227
- return typeof item === "string" && isCommentId(item) ? item : null;
242
+ return isCommentIdValue(item) ? item : null;
243
+ }
244
+ function parseEarlierReplies(v) {
245
+ if (!isFields(v))
246
+ return "not an object";
247
+ const comment = asCommentId(v.comment);
248
+ if (comment === null)
249
+ return "comment is not a comment id";
250
+ const replies = parseList(v.replies, parseReply, "replies");
251
+ return typeof replies === "string" ? replies : { comment, replies };
252
+ }
253
+ function isLine(v) {
254
+ return typeof v === "number" && Number.isInteger(v) && v >= 1;
228
255
  }
229
- function parseCommentId(item) {
230
- return asCommentId(item) ?? "not a comment id";
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;
263
+ const { unit, offsets, changed, path, newLine } = v;
264
+ if (unit !== null && !(typeof unit === "string" && isUnitId(unit)))
265
+ return "unit is not a unit id";
266
+ if (offsets !== null && !(isArray(offsets) && offsets.every(isIndex))) {
267
+ return "offsets is not null or a list of rows";
268
+ }
269
+ if (typeof changed !== "boolean")
270
+ return "changed is not a boolean";
271
+ if (path !== null && !(typeof path === "string" && isRepoPath(path)))
272
+ return "path is not a path";
273
+ if (newLine !== null && !isLine(newLine))
274
+ return "newLine is not null or a line";
275
+ return {
276
+ comment,
277
+ unit,
278
+ offsets: offsets === null ? null : offsets.filter(isIndex),
279
+ changed,
280
+ path,
281
+ newLine,
282
+ };
231
283
  }
232
284
  /** Boundary parse of reviews/vN.json. Files written before walk mode existed have no mode. */
233
285
  export function parseReview(v) {
@@ -252,13 +304,16 @@ export function parseReview(v) {
252
304
  const concerns = parseList(v.concerns, parseDecision, "concerns");
253
305
  if (typeof concerns === "string")
254
306
  return concerns;
255
- const resolved = parseList(v.resolved, parseCommentId, "resolved");
307
+ const resolved = parseIds(v.resolved, isCommentIdValue, "resolved", "comment");
256
308
  if (typeof resolved === "string")
257
309
  return resolved;
258
310
  const excluded = parseExcluded(v.excluded);
259
311
  if (typeof excluded === "string")
260
312
  return excluded;
261
313
  const anyExcluded = excluded.comments.length + excluded.concerns.length > 0;
314
+ const earlier = parseList(v.earlierReplies, parseEarlierReplies, "earlierReplies");
315
+ if (typeof earlier === "string")
316
+ return earlier;
262
317
  return {
263
318
  schema: v.schema,
264
319
  mode,
@@ -269,6 +324,7 @@ export function parseReview(v) {
269
324
  ...(concerns.length === 0 ? {} : { concerns }),
270
325
  ...(resolved.length === 0 ? {} : { resolved }),
271
326
  ...(anyExcluded ? { excluded } : {}),
327
+ ...(earlier.length === 0 ? {} : { earlierReplies: earlier }),
272
328
  };
273
329
  }
274
330
  function lenient(items, parse) {
@@ -291,8 +347,9 @@ export function parseDraft(v) {
291
347
  return {
292
348
  comments: lenient(v.comments, parseComment),
293
349
  concerns: lenient(v.concerns, parseDecision),
294
- resolved: lenient(v.resolved, parseCommentId),
350
+ resolved: lenientIds(v.resolved, isCommentIdValue),
295
351
  excluded: typeof excluded === "string" ? { comments: [], concerns: [] } : excluded,
352
+ earlierReplies: lenient(v.earlierReplies, parseEarlierReplies),
296
353
  };
297
354
  }
298
355
  export function parseEvent(v) {
@@ -337,7 +394,10 @@ export function parseEvent(v) {
337
394
  case "version": {
338
395
  if (!isPlanVersion(v.version) || typeof v.hash !== "string")
339
396
  return "malformed version event";
340
- return { seq, type: v.type, version: v.version, hash: v.hash };
397
+ const carried = parseList(v.carried, parseCarried, "carried");
398
+ if (typeof carried === "string")
399
+ return carried;
400
+ return { seq, type: v.type, version: v.version, hash: v.hash, carried };
341
401
  }
342
402
  case "submitted":
343
403
  return { seq, type: v.type };
package/dist/src/diff.js CHANGED
@@ -357,3 +357,14 @@ export function parseUnitRef(s) {
357
357
  }
358
358
  return { unit, select: { kind: "lines", changes } };
359
359
  }
360
+ /** Where the hunk starts in the tip: its base position shifted by earlier hunks of the file. */
361
+ export function tipStartOf(hunk, units) {
362
+ let shift = 0;
363
+ for (const u of units.values()) {
364
+ if (u.kind !== "hunk" || u.path !== hunk.path || u.oldStart >= hunk.oldStart)
365
+ continue;
366
+ for (const line of u.body)
367
+ shift += line.op === "+" ? 1 : line.op === "-" ? -1 : 0;
368
+ }
369
+ return hunk.oldStart + shift + 1;
370
+ }