@shiyuphan/follow-me 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/src/cli.js +202 -34
  2. package/dist/src/comments.js +49 -7
  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 }),
@@ -229,6 +223,46 @@ function asCommentId(item) {
229
223
  function parseCommentId(item) {
230
224
  return asCommentId(item) ?? "not a comment id";
231
225
  }
226
+ function parseEarlierReplies(v) {
227
+ if (!isFields(v))
228
+ return "not an object";
229
+ const comment = asCommentId(v.comment);
230
+ if (comment === null)
231
+ return "comment is not a comment id";
232
+ const replies = parseList(v.replies, parseReply, "replies");
233
+ return typeof replies === "string" ? replies : { comment, replies };
234
+ }
235
+ function isLine(v) {
236
+ return typeof v === "number" && Number.isInteger(v) && v >= 1;
237
+ }
238
+ export function parseCarried(v) {
239
+ if (!isFields(v)) {
240
+ return "not an object";
241
+ }
242
+ const comment = parseComment(v.comment);
243
+ if (typeof comment === "string")
244
+ return comment;
245
+ const { unit, offsets, changed, path, newLine } = v;
246
+ if (unit !== null && !(typeof unit === "string" && isUnitId(unit)))
247
+ return "unit is not a unit id";
248
+ if (offsets !== null && !(isArray(offsets) && offsets.every(isIndex))) {
249
+ return "offsets is not null or a list of rows";
250
+ }
251
+ if (typeof changed !== "boolean")
252
+ return "changed is not a boolean";
253
+ if (path !== null && !(typeof path === "string" && isRepoPath(path)))
254
+ return "path is not a path";
255
+ if (newLine !== null && !isLine(newLine))
256
+ return "newLine is not null or a line";
257
+ return {
258
+ comment,
259
+ unit,
260
+ offsets: offsets === null ? null : offsets.filter(isIndex),
261
+ changed,
262
+ path,
263
+ newLine,
264
+ };
265
+ }
232
266
  /** Boundary parse of reviews/vN.json. Files written before walk mode existed have no mode. */
233
267
  export function parseReview(v) {
234
268
  if (!isFields(v)) {
@@ -259,6 +293,9 @@ export function parseReview(v) {
259
293
  if (typeof excluded === "string")
260
294
  return excluded;
261
295
  const anyExcluded = excluded.comments.length + excluded.concerns.length > 0;
296
+ const earlier = parseList(v.earlierReplies, parseEarlierReplies, "earlierReplies");
297
+ if (typeof earlier === "string")
298
+ return earlier;
262
299
  return {
263
300
  schema: v.schema,
264
301
  mode,
@@ -269,6 +306,7 @@ export function parseReview(v) {
269
306
  ...(concerns.length === 0 ? {} : { concerns }),
270
307
  ...(resolved.length === 0 ? {} : { resolved }),
271
308
  ...(anyExcluded ? { excluded } : {}),
309
+ ...(earlier.length === 0 ? {} : { earlierReplies: earlier }),
272
310
  };
273
311
  }
274
312
  function lenient(items, parse) {
@@ -293,6 +331,7 @@ export function parseDraft(v) {
293
331
  concerns: lenient(v.concerns, parseDecision),
294
332
  resolved: lenient(v.resolved, parseCommentId),
295
333
  excluded: typeof excluded === "string" ? { comments: [], concerns: [] } : excluded,
334
+ earlierReplies: lenient(v.earlierReplies, parseEarlierReplies),
296
335
  };
297
336
  }
298
337
  export function parseEvent(v) {
@@ -337,7 +376,10 @@ export function parseEvent(v) {
337
376
  case "version": {
338
377
  if (!isPlanVersion(v.version) || typeof v.hash !== "string")
339
378
  return "malformed version event";
340
- return { seq, type: v.type, version: v.version, hash: v.hash };
379
+ const carried = parseList(v.carried, parseCarried, "carried");
380
+ if (typeof carried === "string")
381
+ return carried;
382
+ return { seq, type: v.type, version: v.version, hash: v.hash, carried };
341
383
  }
342
384
  case "submitted":
343
385
  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
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * How a plan follows its branch when the tip moves. A unit id hashes its path and hunk body, so
3
+ * an id that survives names the same change and keeps its place in the plan; everything else is
4
+ * either gone or new, and the agent decides where the new units go.
5
+ */
6
+ import { tipStartOf } from "./diff.js";
7
+ /** Pure. Responses start empty: they answer the review of the old version, not this one. */
8
+ export function evolvePlan(oldPlan, oldDiff, newDiff) {
9
+ const gone = new Map();
10
+ const commits = [];
11
+ oldPlan.commits.forEach((commit, index) => {
12
+ const units = commit.units.filter((ref) => newDiff.units.has(ref.unit));
13
+ for (const ref of commit.units) {
14
+ const unit = oldDiff.units.get(ref.unit);
15
+ if (newDiff.units.has(ref.unit) || unit === undefined)
16
+ continue;
17
+ const seen = gone.get(ref.unit)?.commits ?? [];
18
+ gone.set(ref.unit, { unit, commits: [...seen, { index, message: commit.message }] });
19
+ }
20
+ if (units.length > 0)
21
+ commits.push({ ...commit, units });
22
+ });
23
+ const taken = new Set(oldPlan.commits.flatMap((c) => c.units.map((ref) => ref.unit)));
24
+ return {
25
+ draft: {
26
+ schema: oldPlan.schema,
27
+ base: newDiff.base,
28
+ tip: newDiff.tip,
29
+ commits,
30
+ responses: [],
31
+ },
32
+ vanished: [...gone.values()],
33
+ added: [...newDiff.units.values()].filter((unit) => !taken.has(unit.id)),
34
+ };
35
+ }
36
+ /** The tip line of each body row; a removed row gets the line that follows it. */
37
+ function tipLines(hunk, units) {
38
+ let line = tipStartOf(hunk, units);
39
+ return hunk.body.map((row) => {
40
+ const at = line;
41
+ if (row.op !== "-")
42
+ line += 1;
43
+ return at;
44
+ });
45
+ }
46
+ /** The body row a hunk comment points at first: its first picked row, else its first change. */
47
+ function firstRow(comment, hunk) {
48
+ const { anchor } = comment;
49
+ if (anchor.kind !== "hunk")
50
+ return 0;
51
+ const picked = anchor.offsets?.[0];
52
+ if (picked !== undefined)
53
+ return picked;
54
+ const change = anchor.changes?.[0];
55
+ const row = hunk.body.findIndex((r) => r.op !== " " && r.change === change);
56
+ return change === undefined || row === -1 ? 0 : row;
57
+ }
58
+ /**
59
+ * Where an old line lands in a replacement hunk: the nearest row with the same text the tip
60
+ * keeps, else the old line clamped into the hunk's tip lines.
61
+ */
62
+ function landing(hunk, units, oldLine, text) {
63
+ const lines = tipLines(hunk, units);
64
+ const same = hunk.body.flatMap((row, i) => row.op !== "-" && row.text === text ? [lines[i] ?? oldLine] : []);
65
+ if (same.length > 0) {
66
+ return same.reduce((a, b) => (Math.abs(b - oldLine) < Math.abs(a - oldLine) ? b : a));
67
+ }
68
+ const [from, to] = span(hunk, units);
69
+ return Math.min(Math.max(oldLine, from), to);
70
+ }
71
+ /** First and last tip line of a hunk; a pure removal spans the one line after it. */
72
+ function span(hunk, units) {
73
+ const from = tipStartOf(hunk, units);
74
+ const kept = hunk.body.filter((row) => row.op !== "-").length;
75
+ return [from, from + Math.max(kept, 1) - 1];
76
+ }
77
+ function distance(hunk, units, line) {
78
+ const [from, to] = span(hunk, units);
79
+ return line < from ? from - line : line > to ? line - to : 0;
80
+ }
81
+ /**
82
+ * Pure. Places each comment of the previous version on the new diff: on its own unit when that
83
+ * hunk is unchanged, else on the hunk of the same path nearest its old tip line.
84
+ */
85
+ export function carryComments(comments, oldUnits, newUnits) {
86
+ return comments.map((comment) => carry(comment, oldUnits, newUnits));
87
+ }
88
+ function carry(comment, oldUnits, newUnits) {
89
+ const { anchor } = comment;
90
+ const nowhere = { comment, unit: null, offsets: null, newLine: null };
91
+ if (anchor.kind !== "hunk")
92
+ return { ...nowhere, changed: false, path: comment.context.path };
93
+ const kept = newUnits.get(anchor.unit);
94
+ if (kept !== undefined) {
95
+ const newLine = kept.kind === "hunk" ? (tipLines(kept, newUnits)[firstRow(comment, kept)] ?? null) : null;
96
+ return {
97
+ comment,
98
+ unit: kept.id,
99
+ offsets: anchor.offsets ?? null,
100
+ changed: false,
101
+ path: kept.path,
102
+ newLine,
103
+ };
104
+ }
105
+ const old = oldUnits.get(anchor.unit);
106
+ const path = old?.path ?? comment.context.path;
107
+ const onPath = [...newUnits.values()].filter((u) => u.path === path);
108
+ const first = onPath[0];
109
+ if (first === undefined)
110
+ return { ...nowhere, changed: true, path };
111
+ const moved = { comment, offsets: null, changed: true, path };
112
+ const hunks = onPath.filter((u) => u.kind === "hunk");
113
+ if (old?.kind !== "hunk" || hunks.length === 0)
114
+ return { ...moved, unit: first.id, newLine: null };
115
+ const row = firstRow(comment, old);
116
+ const oldLine = tipLines(old, oldUnits)[row] ?? tipStartOf(old, oldUnits);
117
+ const nearest = hunks.reduce((a, b) => distance(b, newUnits, oldLine) < distance(a, newUnits, oldLine) ? b : a);
118
+ return {
119
+ ...moved,
120
+ unit: nearest.id,
121
+ newLine: landing(nearest, newUnits, oldLine, old.body[row]?.text),
122
+ };
123
+ }