@shiyuphan/follow-me 0.2.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 (154) hide show
  1. package/README.md +44 -55
  2. package/dist/src/cli.js +740 -194
  3. package/dist/src/comments.js +109 -6
  4. package/dist/src/diff.js +11 -0
  5. package/dist/src/evolve.js +123 -0
  6. package/dist/src/git.js +12 -0
  7. package/dist/src/host.js +39 -8
  8. package/dist/src/install.js +21 -0
  9. package/dist/src/plan.js +1 -1
  10. package/dist/src/prompt.js +384 -51
  11. package/dist/src/server.js +195 -67
  12. package/dist/src/session.js +53 -12
  13. package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-byISxePP.js +1 -0
  14. package/dist/ui/dist/assets/arc-CBzsAsP2.js +1 -0
  15. package/dist/ui/dist/assets/architecture-WOLXFQ4H-0OSkIwdB.js +1 -0
  16. package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-ixHybNqO.js +36 -0
  17. package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
  18. package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-1F8XL_Ou.js +140 -0
  19. package/dist/ui/dist/assets/c4Diagram-XDPPPE54-D9PFBXnc.js +38 -0
  20. package/dist/ui/dist/assets/channel-B-fwsHf1.js +1 -0
  21. package/dist/ui/dist/assets/chunk-2E7WHVMD-zZdNfMkj.js +88 -0
  22. package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  23. package/dist/ui/dist/assets/chunk-4EMNKR2T-CTKoxSSj.js +1 -0
  24. package/dist/ui/dist/assets/chunk-55XCU76K-ifFnDlcS.js +1 -0
  25. package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  26. package/dist/ui/dist/assets/chunk-7S6XSTFO-BCMVdi_1.js +2 -0
  27. package/dist/ui/dist/assets/chunk-AQDTSKZB-DC-5Whju.js +72 -0
  28. package/dist/ui/dist/assets/chunk-DSNLSHJZ-DASbe_gb.js +127 -0
  29. package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  30. package/dist/ui/dist/assets/chunk-HAGFOTED-CArytDjj.js +62 -0
  31. package/dist/ui/dist/assets/chunk-HNE4Q36P-Dnl-Wtud.js +1 -0
  32. package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  33. package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  34. package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  35. package/dist/ui/dist/assets/chunk-NQOKRVIF-wvSLaeaK.js +213 -0
  36. package/dist/ui/dist/assets/chunk-UHW7PFBP-CckaOGiQ.js +1 -0
  37. package/dist/ui/dist/assets/chunk-VQ5W62QI-kQBMDJ7V.js +1 -0
  38. package/dist/ui/dist/assets/chunk-WQQL4CDK-CMg6bbLU.js +10 -0
  39. package/dist/ui/dist/assets/chunk-XXDRQBXY-CYlFr19K.js +1 -0
  40. package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  41. package/dist/ui/dist/assets/chunk-YZO67HFG-CqkG1vVk.js +1 -0
  42. package/dist/ui/dist/assets/chunk-ZAUX2LTX-BUNEdx-e.js +1 -0
  43. package/dist/ui/dist/assets/chunk-ZCHMQSOR-CFTbvcrm.js +2 -0
  44. package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-t2OdJ0LA.js +217 -0
  45. package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-FGm030gc.js +1 -0
  46. package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtU33Lsu.js +1 -0
  47. package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUr-I7hS.js +62 -0
  48. package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  49. package/dist/ui/dist/assets/dagre-NK4GIZVX-BWTHwJKt.js +4 -0
  50. package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  51. package/dist/ui/dist/assets/diagram-CBK3MWTC-PcHrsEBn.js +24 -0
  52. package/dist/ui/dist/assets/diagram-I5XIIV2D-CkGtKjYj.js +24 -0
  53. package/dist/ui/dist/assets/diagram-ODIL2UCW-B_HdBB1w.js +30 -0
  54. package/dist/ui/dist/assets/diagram-PFBEJUXE-DL9zayE6.js +3 -0
  55. package/dist/ui/dist/assets/diagram-RJA3I4VV-By5wl8nK.js +41 -0
  56. package/dist/ui/dist/assets/diagram-TYUNFSNR-ziWPVayL.js +200 -0
  57. package/dist/ui/dist/assets/dist-NDVqCM7k.js +1 -0
  58. package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-DH4YGkPc.js +1 -0
  59. package/dist/ui/dist/assets/elk-QONTBCTQ-BL2RlCEv.js +27 -0
  60. package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BKbTMaRp.js +99 -0
  61. package/dist/ui/dist/assets/eventmodeling-K75KTNOO-Dk5RCWc-.js +1 -0
  62. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Dkjb3PCK.js +1 -0
  63. package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-3vM9gxeT.js +292 -0
  64. package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  65. package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  66. package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  67. package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  68. package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  69. package/dist/ui/dist/assets/gitGraph-VSP46ZUC-CXGFhz54.js +1 -0
  70. package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-Cl4fwUuA.js +106 -0
  71. package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
  72. package/dist/ui/dist/assets/index-B8TYcxF8.css +1 -0
  73. package/dist/ui/dist/assets/index-CBbx_pJk.js +134 -0
  74. package/dist/ui/dist/assets/info-OHQRW6UA-DfSsZ8ib.js +1 -0
  75. package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-B_7tyrL5.js +2 -0
  76. package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
  77. package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-DUvHljpg.js +70 -0
  78. package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-BCgqzrb8.js +139 -0
  79. package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-DOzZ3NmE.js +89 -0
  80. package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
  81. package/dist/ui/dist/assets/line-B-mPFVll.js +1 -0
  82. package/dist/ui/dist/assets/linear-k7o0jout.js +1 -0
  83. package/dist/ui/dist/assets/mermaid-parser.core-CAyLSQpH.js +7 -0
  84. package/dist/ui/dist/assets/mermaid.core-vfVMjgx2.js +44 -0
  85. package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-B6y-B_r4.js +98 -0
  86. package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  87. package/dist/ui/dist/assets/packet-JDAUHWVQ-C_r4ed30.js +1 -0
  88. package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
  89. package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-B8VUm3Bh.js +1 -0
  90. package/dist/ui/dist/assets/pie-XZMESJXO-DcwtbH9y.js +1 -0
  91. package/dist/ui/dist/assets/pieDiagram-GNMPY74T-CvUNBi2F.js +39 -0
  92. package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
  93. package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
  94. package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
  95. package/dist/ui/dist/assets/prism-core-_2ju85Ya.js +1 -0
  96. package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
  97. package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
  98. package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
  99. package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
  100. package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
  101. package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
  102. package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
  103. package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
  104. package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
  105. package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
  106. package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  107. package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
  108. package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
  109. package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
  110. package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
  111. package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
  112. package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
  113. package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
  114. package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
  115. package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
  116. package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
  117. package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-Dg2eVS3j.js +7 -0
  118. package/dist/ui/dist/assets/radar-ABXABTNO-xCNTcZE9.js +1 -0
  119. package/dist/ui/dist/assets/railroad-I3PHUGI6-BI-sIL2Q.js +1 -0
  120. package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-Ddvk40Wx.js +1 -0
  121. package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-h35gE183.js +1 -0
  122. package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-Zim9iPdp.js +1 -0
  123. package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-Cz6fdmOQ.js +1 -0
  124. package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-DTJkCrcD.js +84 -0
  125. package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  126. package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-Bg1iiVla.js +40 -0
  127. package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-9XfetsRA.js +169 -0
  128. package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  129. package/dist/ui/dist/assets/src-Dym5afQ5.js +1 -0
  130. package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-CMmcMV8z.js +291 -0
  131. package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-B2PcshTY.js +1 -0
  132. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DfB3XTTn.js +8 -0
  133. package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-Cr1hCNtW.js +120 -0
  134. package/dist/ui/dist/assets/treeView-D4JQ5SDB-jH5ksKnh.js +1 -0
  135. package/dist/ui/dist/assets/treemap-SAJKECNS-BV90e8QL.js +1 -0
  136. package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-BNwq5aJV.js +328 -0
  137. package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D-lLyZCL.js +34 -0
  138. package/dist/ui/dist/assets/wardley-7MLQ67FV-CWfHQUGt.js +1 -0
  139. package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-BL_qNKVd.js +78 -0
  140. package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-BHPK5Snp.js +7 -0
  141. package/dist/ui/dist/index.html +15 -0
  142. package/dist/ui/dist/style.css +121 -0
  143. package/package.json +31 -10
  144. package/skills/follow-me/SKILL.md +19 -89
  145. package/dist/src/anthropic.js +0 -133
  146. package/dist/ui/app.js +0 -2201
  147. package/dist/ui/concerns.js +0 -165
  148. package/dist/ui/highlight.js +0 -221
  149. package/dist/ui/index.html +0 -94
  150. package/dist/ui/live.js +0 -184
  151. package/dist/ui/playback.js +0 -579
  152. package/dist/ui/questions.js +0 -189
  153. package/dist/ui/style.css +0 -1331
  154. package/dist/ui/summary.js +0 -45
package/dist/src/cli.js CHANGED
@@ -1,35 +1,54 @@
1
- import { readFileSync } from "node:fs";
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync, writeFileSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join, relative } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
- import { PlanRequestError, requestPlan } from "./anthropic.js";
6
- import { DiffParseError, formatUnitRef } from "./diff.js";
7
- import { BranchRefusedError, branchHead, GitError, signing, treeOf, writeBranch, } from "./git.js";
8
- import { fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postReply, probeHealth, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
9
- import { runInstall, runUninstall } from "./install.js";
6
+ import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
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";
10
10
  import { naiveDraft } from "./naive.js";
11
- import { serializePlanFile, validatePlan } from "./plan.js";
12
- import { renderEvents, renderPrompt, renderReport } from "./prompt.js";
11
+ import { isFields, serializePlanFile, validatePlan } from "./plan.js";
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";
18
18
  }
19
+ const SHORT_USAGE = `usage: follow-me [--wait <seconds>] [--branch <name>]
20
+
21
+ Run follow-me with no command. Each call does the next thing that is missing and returns: it
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 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.
26
+
27
+ Other commands:
28
+ reply <commentId> --body <text> [--diagram <file>]
29
+ answer under a comment on the page; the diagram is
30
+ Mermaid source
31
+ explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
32
+ start a thread on the page without being asked
33
+ report [--json] [--markdown] print the report of the latest review; --json lists
34
+ the items to post to a merge request
35
+
36
+ help --all lists every command.`;
19
37
  const USAGE = `usage: follow-me <command> [options]
20
38
 
39
+ follow-me with no command is the entry point; see \`follow-me help\`. The commands below are what it
40
+ runs, for when you want one step by hand.
41
+
21
42
  Split your own branch:
22
- split [--base <ref>] [--branch <name>] [--model <id>] [--no-open]
23
- extract, draft (with an Anthropic key), apply and
24
- open the review in one go
43
+ split [--base <ref>] [--branch <name>] [--no-open]
44
+ extract, apply your agent's draft and open the
45
+ review in one go; without a valid draft it writes
46
+ the prompt for your agent and stops
25
47
  extract [--base <ref>] [--branch <name>] anchor a session and list the units
26
48
  prompt [--walk] [--branch <name>] print the planner prompt; your coding agent
27
49
  writes draft.json from it
28
- plan [--walk] [--model <id>] [--branch <name>]
29
- the same draft as prompt, written by the
30
- Anthropic API
31
- plan --naive [--branch <name>] no API, no agent: one "wip: <path>" commit per
32
- touched file, in diff order
50
+ plan --naive [--branch <name>] no agent: one "wip: <path>" commit per touched
51
+ file, in diff order
33
52
  check [path] [--branch <name>] [--syntax "<cmd {file}>"]
34
53
  validate draft.json or the given plan file, and
35
54
  check that every file each commit writes parses;
@@ -40,11 +59,11 @@ Split your own branch:
40
59
  --name; never on <branch> or a checked-out branch;
41
60
  refuses a commit with a file that does not parse
42
61
  unless --allow-syntax-errors
43
- review [--port <n>] [--detach] [--no-open] [--wait [--timeout <seconds>]]
44
- open the plan review, commit by commit; --wait
45
- returns only once the review ends
46
- feedback [--walk] [--branch <name>] print the latest plan review as markdown, or with
47
- --walk the latest code review
62
+ review [--base <ref>] [--branch <name>] [--port <n>] [--detach] [--no-open]
63
+ [--wait [--timeout <seconds>]] open the review, commit by commit; --wait returns
64
+ only once the review ends. After apply it reviews
65
+ your split; otherwise it plans for a reader and
66
+ reviews the code, applying nothing
48
67
  status show the session's plans, reviews, lock and the
49
68
  branch apply writes
50
69
 
@@ -58,15 +77,22 @@ Interactive loop (while the review page is open):
58
77
  reply <commentId> --body <text> [--diagram <file>]
59
78
  answer under a comment on the page; the diagram is
60
79
  Mermaid source
61
- report print the consolidated report of the open review, or
62
- of the last one that ended: issues by file and line
63
- in both voices, then questions with answers
80
+ explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
81
+ start a thread on the page without being asked;
82
+ --lines are change lines of the unit, commits count
83
+ from 1; with no target it is about the whole plan.
84
+ watch leaves these out like your replies
85
+ report [--json] [--markdown] [--branch <name>]
86
+ print the report of the open review, or of the last
87
+ one that ended. Markdown lists issues by file and
88
+ line in both voices, then questions with answers;
89
+ --json lists the items to post to a merge request
90
+ (path, lines, severity, body, thread, excluded)
64
91
  apply, while the page is open, swaps it to the new plan version in place, same URL.
65
92
 
66
- Review someone else's branch:
67
- walk [--base <ref>] [--branch <name>] [--model <id>] [--port <n>] [--detach] [--no-open]
68
- watch a branch being written, narrated, and
69
- review its code; nothing is applied
93
+ Old names, still working:
94
+ walk is review; prints one line saying so
95
+ feedback [--walk] is report; prints one line saying so
70
96
 
71
97
  Review server:
72
98
  server status show the review server and the sessions it hosts
@@ -78,14 +104,17 @@ Setup:
78
104
  install --list show each harness, its skill dirs and docs
79
105
  uninstall [--harness <ids>|--all] [--project] [--dry-run]
80
106
  remove the skill files install writes
107
+ upgrade [--harness <ids>|--all] [--project] [--dry-run]
108
+ update the package with npm, then refresh the
109
+ installed skill copies
81
110
  clean [--all] [--dry-run] remove sessions of deleted branches, finished
82
111
  review drafts and leftovers of dead processes;
83
112
  --all removes .follow-me/ entirely
84
113
  --version print the version
85
114
  help print this
86
115
 
87
- --walk writes the prompt for a reviewer: a story with narration instead of a split.
88
- review and walk share one server per user: a running one hosts the session and the command
116
+ --walk writes the prompt for a reviewer: a story with notes instead of a split.
117
+ review shares one server per user: a running one hosts the session and the command
89
118
  prints its URL; otherwise the command serves until its review ends, or with --detach starts the
90
119
  server in the background. --port taken by another program falls back to a free port.
91
120
  --force lets apply move a branch follow-me did not create; it never allows <branch> itself.
@@ -383,6 +412,7 @@ async function apply(cwd, args) {
383
412
  * write runs under it, and the page is then swapped to the new plan version in place.
384
413
  */
385
414
  async function applyHosted(session, args, open) {
415
+ rememberPort(session, open.port);
386
416
  const code = await applyLocked(session, args);
387
417
  const latest = session.plans().at(-1);
388
418
  if (code !== 0 || latest === undefined)
@@ -454,44 +484,36 @@ async function applyLocked(session, args) {
454
484
  say(`proof: ${result.branch}^{tree} ${tree} ${same ? "equals" : "DIFFERS FROM"} ${session.anchor.branch}^{tree} ${session.anchor.tipTree}`);
455
485
  return same ? 0 : 1;
456
486
  }
457
- function promptInput(session, mode, args) {
487
+ function promptInput(session, mode, args, changes) {
458
488
  const common = {
459
489
  diff: session.diff,
460
490
  draftPath: session.draftPath,
461
491
  checkCommand: `follow-me check${branchFlag(args)}`,
492
+ ...(changes === undefined ? {} : { changes }),
462
493
  };
463
494
  if (mode === "walk")
464
495
  return { ...common, mode };
496
+ const previous = session.plans().at(-1);
465
497
  return {
466
498
  ...common,
467
499
  mode,
468
- previousPlan: session.plans().at(-1)?.file ?? null,
500
+ previousPlan: previous?.file ?? null,
469
501
  review: session.lastReview(),
502
+ previousCommits: previous === undefined ? [] : projectedCommits(session, previous.version),
470
503
  };
471
504
  }
472
- /** Asks the API for draft.json, feeding issues back once. Returns the plan when it validates. */
473
- async function draftWithApi(session, cwd, mode, args) {
474
- const model = args.values.get("model");
475
- const opts = model === undefined ? {} : { model };
476
- let prompt = renderPrompt(promptInput(session, mode, args));
477
- for (let round = 1; round <= 2; round++) {
478
- const draft = await requestPlan(prompt, opts);
479
- session.writeDraft(draft);
480
- const verdict = validate(session, draft);
481
- if (verdict.ok) {
482
- say(`wrote ${relative(cwd, session.draftPath)} (${plural(verdict.plan.commits.length, "commit")})`);
483
- return verdict.plan;
484
- }
485
- const issues = verdict.issues.map((issue) => `- ${formatIssue(issue)}`).join("\n");
486
- prompt = `${prompt}\n\n## Your previous draft had these issues, write a corrected plan\n\n${issues}\n`;
487
- }
488
- say(`wrote ${relative(cwd, session.draftPath)} but it still has issues, repair it and run \`follow-me check${branchFlag(args)}\``);
489
- return null;
505
+ /** The commits of a frozen plan as projected now, or none when it no longer fits the branch. */
506
+ function projectedCommits(session, version) {
507
+ const plan = session.plans().find((p) => p.version === version);
508
+ if (plan === undefined)
509
+ return [];
510
+ const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
511
+ return verdict.ok ? verdict.plan.commits : [];
490
512
  }
491
- /** Writes the one-commit-per-file draft with no API call and no agent. */
513
+ /** Writes the one-commit-per-file draft with no agent. */
492
514
  function naive(session, cwd, args) {
493
- if (args.flags.has("walk") || args.values.has("model")) {
494
- throw new UsageError("--naive writes no story, so --walk and --model do not apply");
515
+ if (args.flags.has("walk")) {
516
+ throw new UsageError("--naive writes no story, so --walk does not apply");
495
517
  }
496
518
  const draft = naiveDraft(session.diff);
497
519
  session.writeDraft(draft);
@@ -503,14 +525,11 @@ function naive(session, cwd, args) {
503
525
  return 0;
504
526
  }
505
527
  async function plan(cwd, args) {
506
- const session = await openSession(cwd, sessionOpts(args));
507
- const mode = modeOf(args);
508
- if (args.flags.has("naive"))
509
- return naive(session, cwd, args);
510
- if ((await draftWithApi(session, cwd, mode, args)) === null)
528
+ if (!args.flags.has("naive")) {
529
+ console.error("follow-me plan: there is no built-in model planner; your coding agent writes the draft from `follow-me prompt`, or use --naive for one commit per file");
511
530
  return 1;
512
- next(mode === "walk" ? `follow-me walk${branchFlag(args)}` : "follow-me apply");
513
- return 0;
531
+ }
532
+ return naive(await openSession(cwd, sessionOpts(args)), cwd, args);
514
533
  }
515
534
  /** The latest frozen plan, or the draft frozen now when it validates. */
516
535
  function latestPlan(session) {
@@ -524,8 +543,8 @@ function latestPlan(session) {
524
543
  return null;
525
544
  return { version: session.freeze(verdict.plan), plan: verdict.plan };
526
545
  }
527
- function announce(mode, url, args) {
528
- say(`follow-me ${mode === "walk" ? "code review" : "review"}: ${url}`);
546
+ function announce(url, args) {
547
+ say(`follow-me review: ${url}`);
529
548
  if (!args.flags.has("no-open"))
530
549
  openBrowser(url);
531
550
  }
@@ -548,10 +567,11 @@ async function hostReview(session, mode, args, prepare, report) {
548
567
  const home = stateDir();
549
568
  const open = await openOnServer(session, mode);
550
569
  if (open !== null) {
551
- announce(mode, sessionUrl(open.port, open.summary), args);
570
+ rememberPort(session, open.port);
571
+ announce(sessionUrl(open.port, open.summary), args);
552
572
  return { kind: "elsewhere", ...open };
553
573
  }
554
- return locked(session, mode === "walk" ? "walk" : "review", async (held) => {
574
+ return locked(session, "review", async (held) => {
555
575
  const prepared = await prepare();
556
576
  if (prepared === null)
557
577
  return { kind: "refused" };
@@ -562,7 +582,7 @@ async function hostReview(session, mode, args, prepare, report) {
562
582
  hash: prepared.plan.hash,
563
583
  lockToken: held.token,
564
584
  };
565
- const port = portOf(args);
585
+ const port = portOf(args) ?? rememberedPort(session);
566
586
  let started;
567
587
  const running = await runningServer(home, port);
568
588
  if (running !== null)
@@ -575,14 +595,15 @@ async function hostReview(session, mode, args, prepare, report) {
575
595
  }
576
596
  else
577
597
  started = await startOrAttach(home, port, say);
598
+ rememberPort(session, started.kind === "attached" ? started.port : started.host.port);
578
599
  if (started.kind === "attached") {
579
600
  const summary = await registerWith(started.port, registration);
580
- announce(mode, sessionUrl(started.port, summary), args);
601
+ announce(sessionUrl(started.port, summary), args);
581
602
  return { kind: "elsewhere", port: started.port, summary };
582
603
  }
583
604
  const { host } = started;
584
605
  const { summary, done } = await host.register(registration);
585
- announce(mode, sessionUrl(host.port, summary), args);
606
+ announce(sessionUrl(host.port, summary), args);
586
607
  const review = await done;
587
608
  if (review === null)
588
609
  return { kind: "stopped" };
@@ -601,13 +622,10 @@ function exitCode(hosted) {
601
622
  }
602
623
  return hosted.kind === "refused" || hosted.kind === "stopped" ? 1 : 0;
603
624
  }
604
- function alreadyReviewed(session, mode, version, args) {
605
- if (!reviewsOf(session, mode).some((r) => r.version === version))
625
+ function alreadyReviewed(session, version, args) {
626
+ if (!reviewsOf(session).some((r) => r.version === version))
606
627
  return false;
607
- const branch = branchFlag(args);
608
- console.error(mode === "walk"
609
- ? `follow-me: plan v${version} already has a code review; read it with \`follow-me feedback --walk${branch}\``
610
- : `follow-me: plan v${version} already has a review; read it with \`follow-me feedback${branch}\`, then run \`follow-me split${branch}\` for the next plan`);
628
+ console.error(`follow-me: plan v${version} already has a review; read it with \`follow-me report${branchFlag(args)}\``);
611
629
  return true;
612
630
  }
613
631
  /** This session's review, of this mode or any, on the user's server when that holds its lock. */
@@ -618,7 +636,52 @@ async function openOnServer(session, mode) {
618
636
  return null;
619
637
  const id = sessionIdOf(session.dir);
620
638
  const summary = (await listSessions(live.port)).find((s) => s.id === id && (mode === null || s.mode === mode));
621
- 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 };
622
685
  }
623
686
  function timeoutOf(args) {
624
687
  const raw = args.values.get("timeout");
@@ -630,13 +693,15 @@ function timeoutOf(args) {
630
693
  }
631
694
  return seconds;
632
695
  }
633
- /** The reviews in this mode's slot, oldest first. */
634
- function reviewsOf(session, mode) {
635
- return mode === "walk" ? session.walkReviews() : session.reviews();
696
+ /** Every finished review, oldest first, including reviewer reviews older builds stored apart. */
697
+ function reviewsOf(session) {
698
+ const seen = new Set();
699
+ return [...session.reviews(), ...session.walkReviews()]
700
+ .filter((r) => !seen.has(r.version) && seen.add(r.version))
701
+ .sort((a, b) => a.version - b.version);
636
702
  }
637
- function savedReview(session, mode, version) {
638
- const stored = reviewsOf(session, mode).find((r) => r.version === version);
639
- return stored?.review ?? null;
703
+ function savedReview(session, version) {
704
+ return reviewsOf(session).find((r) => r.version === version)?.review ?? null;
640
705
  }
641
706
  /**
642
707
  * Polls the server until it no longer hosts the session, then reads the review it saved. Null
@@ -649,31 +714,47 @@ async function waitForEnd(session, hosted, timeout) {
649
714
  return "timeout";
650
715
  await new Promise((resolve) => setTimeout(resolve, 250));
651
716
  }
652
- return savedReview(session, hosted.summary.mode, hosted.summary.version);
717
+ return savedReview(session, hosted.summary.version);
653
718
  }
654
- function reportSplit(review) {
719
+ function reportSaved(review) {
655
720
  say(`review v${review.planVersion} saved with ${plural(review.comments.length, "comment")}`);
656
- next("follow-me feedback");
721
+ next("follow-me report");
657
722
  }
658
723
  function prepareSplit(session, args) {
659
724
  return async () => {
660
725
  const latest = latestPlan(session);
661
- if (latest === null || alreadyReviewed(session, "split", latest.version, args))
726
+ if (latest === null || alreadyReviewed(session, latest.version, args))
662
727
  return null;
663
728
  return latest;
664
729
  };
665
730
  }
731
+ /**
732
+ * Author mode when a split branch was applied for the latest plan: the plan is the author's own
733
+ * and the review is of it. Otherwise the reader is a reviewer meeting the branch, so the plan only
734
+ * orders the reading and nothing is applied. The wire keeps calling that mode "walk".
735
+ */
736
+ function reviewMode(session) {
737
+ const applied = session.applied();
738
+ const latest = session.plans().at(-1);
739
+ return applied !== null && latest !== undefined && applied.version === latest.version
740
+ ? "split"
741
+ : "walk";
742
+ }
743
+ /** Hosts the review of the session: the plan for its author, or the code for a reviewer. */
666
744
  async function review(cwd, args) {
667
- const session = await openSession(cwd);
745
+ const session = await openFresh(cwd, args);
668
746
  const timeout = timeoutOf(args);
669
747
  if (timeout !== undefined && !args.flags.has("wait")) {
670
748
  throw new UsageError("--timeout only applies with --wait");
671
749
  }
672
- const hosted = await hostReview(session, "split", args, prepareSplit(session, args), reportSplit);
750
+ const open = await openOnServer(session, null);
751
+ const mode = open?.summary.mode ?? reviewMode(session);
752
+ const prepare = mode === "split" ? prepareSplit(session, args) : () => walkPlan(session, args);
753
+ const hosted = await hostReview(session, mode, args, prepare, reportSaved);
673
754
  if (hosted.kind !== "elsewhere")
674
755
  return exitCode(hosted);
675
756
  if (!args.flags.has("wait")) {
676
- next("end the review on the page, then follow-me feedback");
757
+ next("end the review on the page, then follow-me report");
677
758
  return 0;
678
759
  }
679
760
  const ended = await waitForEnd(session, hosted, timeout);
@@ -683,7 +764,7 @@ async function review(cwd, args) {
683
764
  }
684
765
  if (ended === null)
685
766
  return exitCode({ kind: "stopped" });
686
- reportSplit(ended);
767
+ reportSaved(ended);
687
768
  return 0;
688
769
  }
689
770
  /** Why split needs a new draft before it can go on, or null when draft.json is ready. */
@@ -721,49 +802,352 @@ function draftNeed(session, cwd, args) {
721
802
  function handOff(session, cwd, args, why) {
722
803
  session.writePrompt(renderPrompt(promptInput(session, "split", args)));
723
804
  const prompt = relative(cwd, session.promptPath);
724
- say(`${why}, and neither ANTHROPIC_API_KEY nor ANTHROPIC_AUTH_TOKEN is set to ask the API for one.`);
805
+ say(`${why}.`);
725
806
  say(`Have your coding agent follow ${prompt} and write ${relative(cwd, session.draftPath)}, then run:`);
726
807
  say(` follow-me check${branchFlag(args)}`);
727
808
  say(` follow-me split${branchFlag(args)}`);
728
809
  next(`hand ${prompt} to your agent`);
729
810
  }
730
811
  /**
731
- * The author's one command: extract, draft through the API when it can, apply, and open the
732
- * review on the user's server. It stops with a handoff whenever an agent has to write the draft.
812
+ * The author's one command: extract, apply the agent's draft, and open the review on the user's
813
+ * server. It stops with a handoff whenever there is no valid draft.
733
814
  */
734
815
  async function split(cwd, args) {
735
816
  const session = await openFresh(cwd, args);
736
817
  say(headline(session));
737
818
  const open = await openOnServer(session, "split");
738
819
  if (open !== null) {
739
- announce("split", sessionUrl(open.port, open.summary), args);
740
- next("end the review on the page, then follow-me feedback");
820
+ rememberPort(session, open.port);
821
+ announce(sessionUrl(open.port, open.summary), args);
822
+ next("end the review on the page, then follow-me report");
741
823
  return 0;
742
824
  }
743
825
  const need = draftNeed(session, cwd, args);
744
826
  if (need === "invalid")
745
827
  return 1;
746
828
  if (need !== null) {
747
- if (!hasApiCredentials()) {
748
- handOff(session, cwd, args, need);
749
- return 1;
750
- }
751
- say(`${need}; asking the Anthropic API for one`);
752
- const drafted = await draftWithApi(session, cwd, "split", args);
753
- if (drafted === null)
754
- return 1;
755
- listPlan(drafted);
829
+ handOff(session, cwd, args, need);
830
+ return 1;
756
831
  }
757
832
  const applied = await locked(session, "apply", () => applyLocked(session, args));
758
833
  if (applied !== 0)
759
834
  return applied;
760
835
  const detached = { ...args, flags: new Set([...args.flags, "detach"]) };
761
- const hosted = await hostReview(session, "split", detached, prepareSplit(session, args), reportSplit);
836
+ const hosted = await hostReview(session, "split", detached, prepareSplit(session, args), reportSaved);
762
837
  if (hosted.kind !== "elsewhere")
763
838
  return exitCode(hosted);
764
- next("end the review on the page, then follow-me feedback");
839
+ next("end the review on the page, then follow-me report");
765
840
  return 0;
766
841
  }
842
+ /** The mode a bare `follow-me` works in: a split already applied, else whoever wrote the branch. */
843
+ async function entryMode(session, args) {
844
+ if (args.flags.has("author") && args.flags.has("reviewer")) {
845
+ throw new UsageError("--author and --reviewer cannot be used together");
846
+ }
847
+ if (args.flags.has("reviewer"))
848
+ return "walk";
849
+ if (args.flags.has("author"))
850
+ return "split";
851
+ if (reviewMode(session) === "split")
852
+ return "split";
853
+ return (await isOwnWork(session.root, session.anchor.base, session.anchor.tip))
854
+ ? "split"
855
+ : "walk";
856
+ }
857
+ function draftState(session) {
858
+ let draft;
859
+ try {
860
+ draft = session.readDraft();
861
+ }
862
+ catch (error) {
863
+ if (error instanceof MissingDraftError)
864
+ return { kind: "missing" };
865
+ throw error;
866
+ }
867
+ const verdict = validatePlan(session.diff, draft, { lastReview: session.lastReview() });
868
+ if (verdict.ok)
869
+ return { kind: "valid", prepared: verdict.plan };
870
+ if (verdict.issues.some((issue) => issue.kind === "stale"))
871
+ return { kind: "stale" };
872
+ for (const issue of verdict.issues)
873
+ say(formatIssue(issue));
874
+ return { kind: "invalid" };
875
+ }
876
+ /** The newest review of a plan that still fits the branch, or null. */
877
+ function reviewedPlan(session) {
878
+ const reviews = reviewsOf(session);
879
+ for (const stored of reviews.toReversed()) {
880
+ const plan = session.plans().find((p) => p.version === stored.version);
881
+ if (plan !== undefined && validatePlan(session.diff, serializePlanFile(plan.file)).ok) {
882
+ return stored;
883
+ }
884
+ }
885
+ return null;
886
+ }
887
+ /** Prints the finished review's report, which is what the agent works from from here on. */
888
+ function finished(session, stored) {
889
+ const text = renderStored(session, stored);
890
+ if (text === null)
891
+ return 1;
892
+ say(`review v${stored.version} ended with ${plural(stored.review.comments.length, "comment")}`);
893
+ process.stdout.write(text);
894
+ say();
895
+ next("done; the user can port it with follow-me report --json");
896
+ return 0;
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
+ }
968
+ /** Writes the prompt for the agent and says where; used whenever there is no plan to review. */
969
+ function promptHandoff(session, cwd, mode, args) {
970
+ session.writePrompt(renderPrompt(promptInput(session, mode, args)));
971
+ const prompt = relative(cwd, session.promptPath);
972
+ const draft = relative(cwd, session.draftPath);
973
+ say(`wrote ${prompt}`);
974
+ next(`write ${draft} from ${prompt}, then run follow-me again`);
975
+ return 0;
976
+ }
977
+ function cursorPath(session) {
978
+ return join(session.dir, "cursor.json");
979
+ }
980
+ function logOf(summary) {
981
+ return `${summary.id}@${summary.openedAt}`;
982
+ }
983
+ function readCursor(session, summary) {
984
+ let raw;
985
+ try {
986
+ raw = JSON.parse(readFileSync(cursorPath(session), "utf8"));
987
+ }
988
+ catch {
989
+ raw = null;
990
+ }
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 };
995
+ }
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;
1005
+ }
1006
+ function waitOf(args) {
1007
+ const raw = args.values.get("wait");
1008
+ if (raw === undefined)
1009
+ return ENTRY_WAIT_SECONDS;
1010
+ const seconds = Number(raw);
1011
+ if (!Number.isFinite(seconds) || seconds < 0) {
1012
+ throw new UsageError(`--wait ${raw} is not a number of seconds`);
1013
+ }
1014
+ return Math.min(seconds, MAX_ENTRY_WAIT_SECONDS);
1015
+ }
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;
1019
+ /**
1020
+ * Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
1021
+ * returns at once with news, and with the report when the reviewer submitted or ended the review.
1022
+ */
1023
+ async function listen(session, open, args) {
1024
+ const { port, summary } = open;
1025
+ const waitMs = waitOf(args) * 1000;
1026
+ const deadline = Date.now() + waitMs;
1027
+ let cursor = readCursor(session, summary);
1028
+ let version = summary.version;
1029
+ for (;;) {
1030
+ const left = Math.max(0, deadline - Date.now());
1031
+ let batch;
1032
+ try {
1033
+ batch = await pollEvents(port, summary, cursor.seq, Math.min(left, POLL_MS));
1034
+ }
1035
+ catch (error) {
1036
+ if (!(error instanceof RegistrationError))
1037
+ throw error;
1038
+ // The log restarted under us (a new server); read it from the start.
1039
+ cursor = { ...cursor, seq: 0 };
1040
+ writeCursor(session, cursor);
1041
+ continue;
1042
+ }
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);
1055
+ for (const event of batch.events)
1056
+ if (event.type === "version")
1057
+ version = event.version;
1058
+ const news = fresh.filter(isNews);
1059
+ if (news.length > 0) {
1060
+ const ctx = { commits: projectedCommits(session, version), units: session.diff.units };
1061
+ process.stdout.write(renderEvents(news, ctx));
1062
+ const ended = news.some((e) => e.type === "ended");
1063
+ if (ended || news.some((e) => e.type === "submitted")) {
1064
+ say();
1065
+ if (ended) {
1066
+ const stored = reviewsOf(session).find((r) => r.version === version);
1067
+ if (stored !== undefined)
1068
+ return finished(session, stored);
1069
+ }
1070
+ process.stdout.write(await fetchReport(port, summary));
1071
+ say();
1072
+ next("done; the user can port it with follow-me report --json");
1073
+ return 0;
1074
+ }
1075
+ next(`${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
1076
+ return 0;
1077
+ }
1078
+ if (left <= 0) {
1079
+ next(`${STILL_WORKING}; run follow-me again to keep listening`);
1080
+ return 0;
1081
+ }
1082
+ }
1083
+ }
1084
+ /**
1085
+ * The agent's one command. Each call does the next thing that is missing and returns: it writes
1086
+ * the prompt, reports a bad draft, applies and opens the review, then listens to the reviewer
1087
+ * and hands back the report once the review is submitted.
1088
+ */
1089
+ async function entry(cwd, args) {
1090
+ syncSkills(homedir(), say);
1091
+ const session = await openFresh(cwd, args);
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
+ }
1102
+ if (hosted !== null)
1103
+ return listen(session, hosted, args);
1104
+ const state = draftState(session);
1105
+ if (state.kind === "invalid") {
1106
+ next("fix the draft, then run follow-me again");
1107
+ return 1;
1108
+ }
1109
+ const done = reviewedPlan(session);
1110
+ if (state.kind === "valid") {
1111
+ const reviewed = reviewsOf(session).find((r) => session.plans().find((p) => p.version === r.version)?.hash === state.prepared.hash);
1112
+ if (reviewed !== undefined)
1113
+ return finished(session, reviewed);
1114
+ }
1115
+ else if (done !== null) {
1116
+ return finished(session, done);
1117
+ }
1118
+ let prepared = null;
1119
+ if (state.kind === "valid") {
1120
+ const syntax = await checkSyntax(state.prepared, syntaxOverride(args));
1121
+ if (syntax.issues.length > 0) {
1122
+ for (const line of formatSyntax(syntax))
1123
+ say(line);
1124
+ next("fix the draft, then run follow-me again");
1125
+ return 1;
1126
+ }
1127
+ if (mode === "split") {
1128
+ const code = await locked(session, "apply", () => applyLocked(session, args));
1129
+ if (code !== 0) {
1130
+ next("fix the draft, then run follow-me again");
1131
+ return code;
1132
+ }
1133
+ prepared = latestPlan(session);
1134
+ }
1135
+ else
1136
+ prepared = { version: session.freeze(state.prepared), plan: state.prepared };
1137
+ }
1138
+ else if (mode === "walk") {
1139
+ prepared = walkablePlan(session);
1140
+ }
1141
+ if (prepared === null)
1142
+ return promptHandoff(session, cwd, mode, args);
1143
+ const prep = prepared;
1144
+ const detached = { ...args, flags: new Set([...args.flags, "detach"]) };
1145
+ const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
1146
+ if (result.kind !== "elsewhere")
1147
+ return exitCode(result);
1148
+ say("follow-me is interactive: keep running it until it prints the report");
1149
+ return listen(session, result, args);
1150
+ }
767
1151
  /** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
768
1152
  async function openFresh(cwd, args) {
769
1153
  const opts = sessionOpts(args);
@@ -801,41 +1185,24 @@ function walkablePlan(session) {
801
1185
  const verdict = validatePlan(session.diff, draft, { lastReview });
802
1186
  return verdict.ok ? { version: session.freeze(verdict.plan), plan: verdict.plan } : null;
803
1187
  }
804
- function hasApiCredentials() {
805
- return Boolean(process.env["ANTHROPIC_API_KEY"] || process.env["ANTHROPIC_AUTH_TOKEN"]);
806
- }
1188
+ /** The old name of review; it prints one line saying so and goes on. */
807
1189
  async function walk(cwd, args) {
808
- const session = await openFresh(cwd, args);
809
- const report = (review) => {
810
- const saved = join(session.reviewsDir("walk"), `v${review.planVersion}.json`);
811
- say(`code review saved to ${relative(cwd, saved)} with ${plural(review.comments.length, "comment")}`);
812
- next(`follow-me feedback --walk${branchFlag(args)}, which prints it as a code review for the author`);
813
- };
814
- const hosted = await hostReview(session, "walk", args, () => walkPlan(session, cwd, args), report);
815
- if (hosted.kind === "elsewhere") {
816
- next(`end the code review on the page, then follow-me feedback --walk${branchFlag(args)}`);
817
- }
818
- return exitCode(hosted);
1190
+ console.error("follow-me: walk is now review; running review");
1191
+ return review(cwd, args);
819
1192
  }
820
- async function walkPlan(session, cwd, args) {
821
- let latest = walkablePlan(session);
1193
+ async function walkPlan(session, args) {
1194
+ const latest = walkablePlan(session);
822
1195
  if (latest === null) {
823
- if (!hasApiCredentials()) {
824
- say(`no plan for ${session.anchor.branch} yet, and neither ANTHROPIC_API_KEY nor ANTHROPIC_AUTH_TOKEN is set to ask the API for one.`);
825
- say("Have a coding agent follow the prompt, then walk again:");
826
- say(` follow-me prompt --walk${branchFlag(args)}`);
827
- say(` follow-me walk${branchFlag(args)}`);
828
- return null;
829
- }
830
- const drafted = await draftWithApi(session, cwd, "walk", args);
831
- if (drafted === null)
832
- return null;
833
- latest = { version: session.freeze(drafted), plan: drafted };
1196
+ say(`no plan for ${session.anchor.branch} yet.`);
1197
+ say("Have a coding agent follow the prompt, then review again:");
1198
+ say(` follow-me prompt --walk${branchFlag(args)}`);
1199
+ say(` follow-me review${branchFlag(args)}`);
1200
+ return null;
834
1201
  }
835
- return alreadyReviewed(session, "walk", latest.version, args) ? null : latest;
1202
+ return alreadyReviewed(session, latest.version, args) ? null : latest;
836
1203
  }
837
1204
  /** The consolidated report of a saved review, or null after saying why it cannot be rendered. */
838
- function renderStored(session, stored) {
1205
+ function renderStored(session, stored, format = "markdown") {
839
1206
  const plan = session.plans().find((p) => p.version === stored.version);
840
1207
  if (plan === undefined) {
841
1208
  console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
@@ -851,59 +1218,54 @@ function renderStored(session, stored) {
851
1218
  .plans()
852
1219
  .filter((p) => p.version > stored.version)
853
1220
  .flatMap((p) => p.file.responses);
854
- return renderReport(stored.review, verdict.plan.commits, session.diff.units, {
855
- branch: session.anchor.branch,
856
- responses,
857
- });
858
- }
859
- async function feedback(cwd, args) {
860
- const session = await openSession(cwd, sessionOpts(args));
861
- const walk = args.flags.has("walk");
862
- const walks = session.walkReviews();
863
- const stored = walk ? walks.at(-1) : session.reviews().findLast((r) => r.review.mode === "split");
864
- if (stored === undefined) {
865
- const branch = branchFlag(args);
866
- console.error(walk
867
- ? `follow-me: no code review yet, run \`follow-me walk${branch}\` first`
868
- : walks.length > 0
869
- ? `follow-me: no plan review yet; \`follow-me feedback --walk${branch}\` prints the code review`
870
- : "follow-me: no review yet, run `follow-me review` first");
871
- return 1;
1221
+ const opts = { branch: session.anchor.branch, responses };
1222
+ if (format === "json") {
1223
+ const items = reportItems(stored.review, verdict.plan.commits, session.diff.units, opts);
1224
+ return `${JSON.stringify(items, null, 2)}\n`;
872
1225
  }
873
- const text = renderStored(session, stored);
874
- if (text === null)
875
- return 1;
876
- process.stdout.write(text);
877
- say();
878
- next(stored.review.mode === "walk"
879
- ? "hand this to the branch's author"
880
- : `hand this to your agent, then follow-me check${branchFlag(args)}`);
881
- return 0;
1226
+ return renderReport(stored.review, verdict.plan.commits, session.diff.units, opts);
882
1227
  }
883
- /** The consolidated report of the hosted review as it stands, else of the last one that ended. */
884
- async function report(cwd, args) {
1228
+ /**
1229
+ * The report of the hosted review as it stands, else of the latest one that ended. --json lists
1230
+ * the items to carry to a merge request; --markdown, the default, reads as the agent's to-do.
1231
+ * --walk keeps reading only reviewer-mode reviews, as `feedback --walk` did.
1232
+ */
1233
+ async function report(cwd, args, walkOnly = false) {
1234
+ if (args.flags.has("json") && args.flags.has("markdown")) {
1235
+ throw new UsageError("--json and --markdown cannot be used together");
1236
+ }
885
1237
  const session = await openSession(cwd, sessionOpts(args));
886
- const open = await openOnServer(session, null);
1238
+ const format = args.flags.has("json") ? "json" : "markdown";
1239
+ const open = walkOnly ? null : await openOnServer(session, null);
887
1240
  if (open !== null) {
888
- process.stdout.write(await fetchReport(open.port, open.summary));
1241
+ const text = await fetchReport(open.port, open.summary, format);
1242
+ process.stdout.write(format === "json" ? `${JSON.stringify(JSON.parse(text), null, 2)}\n` : text);
889
1243
  return 0;
890
1244
  }
891
- const ended = [...session.reviews(), ...session.walkReviews()].sort((a, b) => a.review.endedAt.localeCompare(b.review.endedAt));
1245
+ const ended = (walkOnly ? session.walkReviews() : reviewsOf(session)).sort((a, b) => a.review.endedAt.localeCompare(b.review.endedAt));
892
1246
  const stored = ended.at(-1);
893
1247
  if (stored === undefined) {
894
1248
  console.error("follow-me: no review is open and none has ended; run `follow-me review` first");
895
1249
  return 1;
896
1250
  }
897
- const text = renderStored(session, stored);
1251
+ const text = renderStored(session, stored, format);
898
1252
  if (text === null)
899
1253
  return 1;
900
1254
  process.stdout.write(text);
901
1255
  return 0;
902
1256
  }
903
- /** Events the reviewer caused; the agent's own replies and version swaps are not news to it. */
1257
+ /** The old name of report. */
1258
+ async function feedback(cwd, args) {
1259
+ console.error("follow-me: feedback is now report; printing the same");
1260
+ return report(cwd, args, args.flags.has("walk"));
1261
+ }
1262
+ /** Events the reviewer caused; the agent's own replies, explanations and version swaps are not news. */
904
1263
  function isNews(event) {
905
1264
  if (event.type === "version")
906
1265
  return false;
1266
+ if (event.type === "comment" || event.type === "comment-updated") {
1267
+ return event.comment.author !== "agent";
1268
+ }
907
1269
  return !(event.type === "reply" && event.reply.author === "agent");
908
1270
  }
909
1271
  const POLL_MS = 25_000;
@@ -918,7 +1280,7 @@ function notOpen(session) {
918
1280
  */
919
1281
  async function watch(cwd, args) {
920
1282
  const session = await openSession(cwd, sessionOpts(args));
921
- const open = await openOnServer(session, null);
1283
+ const open = (await openOnServer(session, null)) ?? (await revive(session));
922
1284
  if (open === null)
923
1285
  return notOpen(session);
924
1286
  const sinceRaw = args.values.get("since");
@@ -926,17 +1288,26 @@ async function watch(cwd, args) {
926
1288
  if (!Number.isInteger(since) || since < 0) {
927
1289
  throw new UsageError(`--since ${sinceRaw} is not an event number`);
928
1290
  }
1291
+ // Comments are quoted as the plan they were made on wrote them; a swap changes which that is.
1292
+ let version = open.summary.version;
1293
+ const quoting = (events) => {
1294
+ for (const event of events)
1295
+ if (event.type === "version")
1296
+ version = event.version;
1297
+ return { commits: projectedCommits(session, version), units: session.diff.units };
1298
+ };
929
1299
  if (args.flags.has("follow")) {
930
1300
  let ended = false;
931
1301
  await followEvents(open.port, open.summary, since, (event) => {
932
1302
  if (event.type === "ended")
933
1303
  ended = true;
1304
+ const ctx = quoting([event]);
934
1305
  if (!isNews(event))
935
1306
  return;
936
1307
  if (args.flags.has("json"))
937
1308
  say(JSON.stringify(event));
938
1309
  else
939
- process.stdout.write(renderEvents([event]));
1310
+ process.stdout.write(renderEvents([event], ctx));
940
1311
  });
941
1312
  if (ended)
942
1313
  return 0;
@@ -952,6 +1323,7 @@ async function watch(cwd, args) {
952
1323
  }
953
1324
  const batch = await pollEvents(open.port, open.summary, since, Math.min(left, POLL_MS));
954
1325
  since = batch.seq;
1326
+ const ctx = quoting(batch.events);
955
1327
  const news = batch.events.filter(isNews);
956
1328
  if (news.length === 0)
957
1329
  continue;
@@ -959,7 +1331,7 @@ async function watch(cwd, args) {
959
1331
  for (const event of news)
960
1332
  say(JSON.stringify(event));
961
1333
  else {
962
- process.stdout.write(renderEvents(news));
1334
+ process.stdout.write(renderEvents(news, ctx));
963
1335
  next(news.some((e) => e.type === "ended")
964
1336
  ? "follow-me report"
965
1337
  : `follow-me reply <comment> --body <text>, or write a new draft and apply; then follow-me watch --since ${since}`);
@@ -974,16 +1346,7 @@ async function reply(cwd, args) {
974
1346
  if (commentId === undefined || extra !== undefined || body === undefined) {
975
1347
  throw new UsageError("usage: follow-me reply <commentId> --body <text> [--diagram <file>]");
976
1348
  }
977
- const diagramFile = args.values.get("diagram");
978
- let diagram;
979
- if (diagramFile !== undefined) {
980
- try {
981
- diagram = readFileSync(diagramFile, "utf8");
982
- }
983
- catch (error) {
984
- throw new UsageError(`cannot read ${diagramFile}: ${error instanceof Error ? error.message : error}`);
985
- }
986
- }
1349
+ const diagram = diagramOf(args);
987
1350
  const session = await openSession(cwd, sessionOpts(args));
988
1351
  const open = await openOnServer(session, null);
989
1352
  if (open === null)
@@ -997,6 +1360,123 @@ async function reply(cwd, args) {
997
1360
  next("follow-me watch");
998
1361
  return 0;
999
1362
  }
1363
+ /** The Mermaid source in --diagram's file, or undefined without the flag. */
1364
+ function diagramOf(args) {
1365
+ const file = args.values.get("diagram");
1366
+ if (file === undefined)
1367
+ return undefined;
1368
+ try {
1369
+ return readFileSync(file, "utf8");
1370
+ }
1371
+ catch (error) {
1372
+ throw new UsageError(`cannot read ${file}: ${error instanceof Error ? error.message : error}`);
1373
+ }
1374
+ }
1375
+ /** "3" or "2-5" as the change indices they name. */
1376
+ function parseLineRange(raw) {
1377
+ const m = /^(\d+)(?:-(\d+))?$/.exec(raw);
1378
+ const from = Number(m?.[1]);
1379
+ const to = Number(m?.[2] ?? m?.[1]);
1380
+ if (m === null || to < from) {
1381
+ throw new UsageError(`--lines ${raw} is not a change line or a range like 2-5`);
1382
+ }
1383
+ return Array.from({ length: to - from + 1 }, (_, i) => from + i);
1384
+ }
1385
+ /**
1386
+ * Where an explanation points: --unit with --lines names change lines of a hunk, as the prompt
1387
+ * numbers them, and the page marks their rows. --commit alone is the commit itself; nothing is
1388
+ * the whole plan. Commits count from 1, as the prompt prints them.
1389
+ */
1390
+ function explainTarget(session, version, args) {
1391
+ const commitRaw = args.values.get("commit");
1392
+ const unitRaw = args.values.get("unit");
1393
+ const linesRaw = args.values.get("lines");
1394
+ if (linesRaw !== undefined && unitRaw === undefined) {
1395
+ throw new UsageError("--lines needs --unit");
1396
+ }
1397
+ if (commitRaw === undefined && unitRaw === undefined) {
1398
+ return { anchor: { kind: "plan" }, context: { commitMessage: "", path: null } };
1399
+ }
1400
+ const commits = projectedCommits(session, version);
1401
+ if (commits.length === 0) {
1402
+ throw new UsageError(`plan v${version} no longer fits ${session.anchor.branch}`);
1403
+ }
1404
+ const unit = unitRaw === undefined || !isUnitId(unitRaw) ? undefined : session.diff.units.get(unitRaw);
1405
+ if (unitRaw !== undefined && unit === undefined) {
1406
+ throw new UsageError(`--unit ${unitRaw} is not a unit of this branch`);
1407
+ }
1408
+ let index = -1;
1409
+ if (commitRaw !== undefined) {
1410
+ index = Number(commitRaw) - 1;
1411
+ if (!Number.isInteger(index) || index < 0 || index >= commits.length) {
1412
+ throw new UsageError(`--commit ${commitRaw} is not one of the ${commits.length} commits`);
1413
+ }
1414
+ }
1415
+ else {
1416
+ index = commits.findIndex((c) => c.refs.some((r) => r.unit === unit?.id));
1417
+ }
1418
+ const commit = commits[index];
1419
+ if (commit === undefined)
1420
+ throw new UsageError(`no commit of plan v${version} takes ${unitRaw}`);
1421
+ if (unit === undefined) {
1422
+ return {
1423
+ anchor: { kind: "message", commit: index },
1424
+ context: { commitMessage: commit.message, path: null },
1425
+ };
1426
+ }
1427
+ const ref = commit.refs.find((r) => r.unit === unit.id);
1428
+ if (ref === undefined)
1429
+ throw new UsageError(`commit ${index + 1} does not take ${unit.id}`);
1430
+ const context = { commitMessage: commit.message, path: unit.path };
1431
+ if (linesRaw === undefined) {
1432
+ return { anchor: { kind: "hunk", unit: unit.id, commit: index, changes: null }, context };
1433
+ }
1434
+ if (unit.kind !== "hunk")
1435
+ throw new UsageError(`--lines needs a text hunk, ${unit.id} is not one`);
1436
+ const changes = parseLineRange(linesRaw);
1437
+ const taken = ref.select.kind === "lines" ? ref.select.changes : null;
1438
+ for (const n of changes) {
1439
+ if (n >= unit.changeCount || (taken !== null && !taken.includes(n))) {
1440
+ throw new UsageError(`commit ${index + 1} does not take change line ${n} of ${unit.id}`);
1441
+ }
1442
+ }
1443
+ const rows = unit.body.flatMap((line, offset) => line.op !== " " && changes.includes(line.change) ? [{ line, offset }] : []);
1444
+ return {
1445
+ anchor: {
1446
+ kind: "hunk",
1447
+ unit: unit.id,
1448
+ commit: index,
1449
+ changes,
1450
+ offsets: rows.map((r) => r.offset),
1451
+ },
1452
+ context: {
1453
+ ...context,
1454
+ lines: rows.map(({ line }) => `${line.op}${line.text.replace(/\r?\n$/, "")}`),
1455
+ },
1456
+ };
1457
+ }
1458
+ /** Starts a thread on the hosted page: the agent explains before anyone asks. */
1459
+ async function explain(cwd, args) {
1460
+ const body = args.values.get("body");
1461
+ if (body === undefined || args.positionals.length > 0) {
1462
+ throw new UsageError("usage: follow-me explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]");
1463
+ }
1464
+ const diagram = diagramOf(args);
1465
+ const session = await openSession(cwd, sessionOpts(args));
1466
+ const open = await openOnServer(session, null);
1467
+ if (open === null)
1468
+ return notOpen(session);
1469
+ const target = explainTarget(session, open.summary.version, args);
1470
+ const id = await postComment(open.port, open.summary, {
1471
+ ...target,
1472
+ body,
1473
+ author: "agent",
1474
+ ...(diagram === undefined ? {} : { diagram }),
1475
+ });
1476
+ say(`explained ${id}`);
1477
+ next("follow-me watch");
1478
+ return 0;
1479
+ }
1000
1480
  function describeLock(lock) {
1001
1481
  if (lock.kind === "free")
1002
1482
  return "free";
@@ -1111,17 +1591,74 @@ function skillTargets(args) {
1111
1591
  };
1112
1592
  }
1113
1593
  function missingDraft(error, cwd) {
1114
- return `no draft at ${relative(cwd, error.path)} yet; your coding agent writes it from \`follow-me prompt\`, or \`follow-me plan\` has the Anthropic API write it`;
1594
+ return `no draft at ${relative(cwd, error.path)} yet; your coding agent writes it from \`follow-me prompt\`, or \`follow-me plan --naive\` writes one commit per file`;
1595
+ }
1596
+ const PACKAGE_NAME = "@shiyuphan/follow-me";
1597
+ /**
1598
+ * Updates the global package, then refreshes every installed skill copy, which otherwise
1599
+ * stays at the old version. FOLLOW_ME_UPGRADE_COMMAND replaces the npm call in tests.
1600
+ */
1601
+ function upgrade(targets, cwd) {
1602
+ const override = process.env["FOLLOW_ME_UPGRADE_COMMAND"];
1603
+ const command = override === undefined
1604
+ ? ["npm", "install", "-g", `${PACKAGE_NAME}@latest`]
1605
+ : ["sh", "-c", override];
1606
+ const [file, ...args] = command;
1607
+ if (file === undefined)
1608
+ return 1;
1609
+ say(`${targets.dryRun ? "would run" : "running"}: ${override ?? command.join(" ")}`);
1610
+ if (!targets.dryRun) {
1611
+ try {
1612
+ execFileSync(file, args, { stdio: "inherit" });
1613
+ }
1614
+ catch (error) {
1615
+ console.error(`follow-me: upgrade failed: ${error instanceof Error ? error.message : error}`);
1616
+ return 1;
1617
+ }
1618
+ }
1619
+ const code = runInstall({ ...targets, list: false }, homedir(), cwd, say);
1620
+ if (code === 0)
1621
+ say(`next: follow-me --version`);
1622
+ return code;
1623
+ }
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`);
1115
1645
  }
1116
1646
  export async function main(argv, cwd) {
1117
1647
  const [command, ...rest] = argv;
1118
1648
  try {
1649
+ if (command === undefined || (command.startsWith("--") && !HELP_OR_VERSION.has(command))) {
1650
+ const spec = {
1651
+ values: ["wait", "base", "branch", "port", "syntax", "name"],
1652
+ flags: ["no-open", "author", "reviewer", "allow-syntax-errors", "force", "sign", "no-sign"],
1653
+ };
1654
+ return await entry(cwd, parseArgs(argv, spec));
1655
+ }
1119
1656
  switch (command) {
1120
1657
  case "extract": {
1121
1658
  const args = parseArgs(rest, { values: ["base", "branch"] });
1122
1659
  summarize(await extractSession(cwd, sessionOpts(args)), cwd);
1123
1660
  const branch = branchFlag(args);
1124
- next(`follow-me prompt${branch}, or follow-me plan${branch} with an Anthropic key`);
1661
+ next(`follow-me prompt${branch}, or follow-me plan --naive${branch}`);
1125
1662
  return 0;
1126
1663
  }
1127
1664
  case "check":
@@ -1141,34 +1678,41 @@ export async function main(argv, cwd) {
1141
1678
  return 0;
1142
1679
  }
1143
1680
  case "plan":
1144
- return await plan(cwd, parseArgs(rest, { values: ["model", "branch"], flags: ["walk", "naive"] }));
1681
+ return await plan(cwd, parseArgs(rest, { values: ["branch"], flags: ["walk", "naive"] }));
1145
1682
  case "split": {
1146
1683
  const spec = {
1147
- values: ["base", "branch", "model", "syntax"],
1684
+ values: ["base", "branch", "syntax"],
1148
1685
  flags: ["no-open", "allow-syntax-errors"],
1149
1686
  };
1150
1687
  return await split(cwd, parseArgs(rest, spec));
1151
1688
  }
1152
1689
  case "review": {
1153
- const spec = { values: ["port", "timeout"], flags: ["no-open", "detach", "wait"] };
1690
+ const spec = {
1691
+ values: ["base", "branch", "port", "timeout"],
1692
+ flags: ["no-open", "detach", "wait"],
1693
+ };
1154
1694
  return await review(cwd, parseArgs(rest, spec));
1155
1695
  }
1156
1696
  case "walk": {
1157
- const spec = { values: ["base", "branch", "model", "port"], flags: ["no-open", "detach"] };
1697
+ const spec = { values: ["base", "branch", "port"], flags: ["no-open", "detach"] };
1158
1698
  return await walk(cwd, parseArgs(rest, spec));
1159
1699
  }
1160
1700
  case "server":
1161
1701
  return await serverCommand(rest);
1162
1702
  case "feedback":
1163
- return await feedback(cwd, parseArgs(rest, { values: ["branch"], flags: ["walk"] }));
1703
+ return await feedback(cwd, parseArgs(rest, { values: ["branch"], flags: ["walk", "json", "markdown"] }));
1164
1704
  case "watch": {
1165
1705
  const spec = { values: ["since", "timeout", "branch"], flags: ["json", "follow"] };
1166
1706
  return await watch(cwd, parseArgs(rest, spec));
1167
1707
  }
1168
1708
  case "reply":
1169
1709
  return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "branch"] }));
1710
+ case "explain": {
1711
+ const spec = { values: ["commit", "unit", "lines", "body", "diagram", "branch"] };
1712
+ return await explain(cwd, parseArgs(rest, spec));
1713
+ }
1170
1714
  case "report":
1171
- return await report(cwd, parseArgs(rest, { values: ["branch"] }));
1715
+ return await report(cwd, parseArgs(rest, { values: ["branch"], flags: ["json", "markdown"] }));
1172
1716
  case "clean": {
1173
1717
  const args = parseArgs(rest, { flags: ["all", "dry-run"] });
1174
1718
  const opts = { all: args.flags.has("all"), dryRun: args.flags.has("dry-run") };
@@ -1187,18 +1731,21 @@ export async function main(argv, cwd) {
1187
1731
  const args = parseArgs(rest, { values: ["harness"], flags: ["all", "project", "dry-run"] });
1188
1732
  return runUninstall(skillTargets(args), homedir(), cwd, say);
1189
1733
  }
1190
- case undefined:
1734
+ case "upgrade": {
1735
+ const args = parseArgs(rest, { values: ["harness"], flags: ["all", "project", "dry-run"] });
1736
+ return upgrade(skillTargets(args), cwd);
1737
+ }
1191
1738
  case "help":
1192
1739
  case "-h":
1193
1740
  case "--help":
1194
- say(USAGE);
1195
- return command === undefined ? 1 : 0;
1741
+ say(parseArgs(rest, { flags: ["all"] }).flags.has("all") ? USAGE : SHORT_USAGE);
1742
+ return 0;
1196
1743
  case "-v":
1197
1744
  case "--version":
1198
1745
  say(`follow-me ${version()}`);
1199
1746
  return 0;
1200
1747
  default:
1201
- throw new UsageError(`unknown command "${command}"\n${USAGE}`);
1748
+ throw new UsageError(`unknown command "${command}"\n${SHORT_USAGE}`);
1202
1749
  }
1203
1750
  }
1204
1751
  catch (error) {
@@ -1210,7 +1757,6 @@ export async function main(argv, cwd) {
1210
1757
  SessionLockedError,
1211
1758
  RegistrationError,
1212
1759
  GitError,
1213
- PlanRequestError,
1214
1760
  NotARepositoryError,
1215
1761
  ];
1216
1762
  if (error instanceof MissingDraftError) {