@shiyuphan/follow-me 0.1.1 → 0.3.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 (150) hide show
  1. package/README.md +119 -69
  2. package/dist/src/cli.js +1069 -140
  3. package/dist/src/comments.js +361 -0
  4. package/dist/src/git.js +32 -3
  5. package/dist/src/host.js +106 -9
  6. package/dist/src/install.js +22 -1
  7. package/dist/src/naive.js +26 -0
  8. package/dist/src/plan.js +282 -26
  9. package/dist/src/prompt.js +711 -98
  10. package/dist/src/server.js +573 -140
  11. package/dist/src/session.js +106 -104
  12. package/dist/src/syntax.js +199 -0
  13. package/dist/src/types.js +3 -1
  14. package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-DcmzRkVZ.js +1 -0
  15. package/dist/ui/dist/assets/arc-T5QB0T1C.js +1 -0
  16. package/dist/ui/dist/assets/architecture-WOLXFQ4H-DUIkQTeE.js +1 -0
  17. package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-BUYi4-bA.js +36 -0
  18. package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
  19. package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-dyJxtT9e.js +140 -0
  20. package/dist/ui/dist/assets/c4Diagram-XDPPPE54-CrAgiaZR.js +38 -0
  21. package/dist/ui/dist/assets/channel-wW7ebm0i.js +1 -0
  22. package/dist/ui/dist/assets/chunk-2E7WHVMD-C6fAf4Vj.js +88 -0
  23. package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  24. package/dist/ui/dist/assets/chunk-4EMNKR2T-BLZjwZ4Q.js +1 -0
  25. package/dist/ui/dist/assets/chunk-55XCU76K-DGLK2Q-L.js +1 -0
  26. package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  27. package/dist/ui/dist/assets/chunk-7S6XSTFO-BablszTX.js +2 -0
  28. package/dist/ui/dist/assets/chunk-AQDTSKZB-Cuk3B_vb.js +72 -0
  29. package/dist/ui/dist/assets/chunk-DSNLSHJZ-DiC78RqE.js +127 -0
  30. package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  31. package/dist/ui/dist/assets/chunk-HAGFOTED-X_RbvixX.js +62 -0
  32. package/dist/ui/dist/assets/chunk-HNE4Q36P-C14_x5ck.js +1 -0
  33. package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  34. package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  35. package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  36. package/dist/ui/dist/assets/chunk-NQOKRVIF-Dxd0dtYR.js +213 -0
  37. package/dist/ui/dist/assets/chunk-UHW7PFBP-BIxePNUS.js +1 -0
  38. package/dist/ui/dist/assets/chunk-VQ5W62QI-CKSKLbj4.js +1 -0
  39. package/dist/ui/dist/assets/chunk-WQQL4CDK-CWNmZGlR.js +10 -0
  40. package/dist/ui/dist/assets/chunk-XXDRQBXY-D-I5QHgG.js +1 -0
  41. package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  42. package/dist/ui/dist/assets/chunk-YZO67HFG-DFP68CFd.js +1 -0
  43. package/dist/ui/dist/assets/chunk-ZAUX2LTX-DY90d4QG.js +1 -0
  44. package/dist/ui/dist/assets/chunk-ZCHMQSOR-Dlpr-5UC.js +2 -0
  45. package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-CH9uhoLJ.js +217 -0
  46. package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-DtYHrp8D.js +1 -0
  47. package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtslbtrY.js +1 -0
  48. package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUU1Jcc7.js +62 -0
  49. package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  50. package/dist/ui/dist/assets/dagre-NK4GIZVX-CDlP-dRW.js +4 -0
  51. package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  52. package/dist/ui/dist/assets/diagram-CBK3MWTC-DiV2fn-r.js +24 -0
  53. package/dist/ui/dist/assets/diagram-I5XIIV2D-Bg5RehGi.js +24 -0
  54. package/dist/ui/dist/assets/diagram-ODIL2UCW-CFmazz3f.js +30 -0
  55. package/dist/ui/dist/assets/diagram-PFBEJUXE-uw3ZBqDp.js +3 -0
  56. package/dist/ui/dist/assets/diagram-RJA3I4VV-uEOYYRYa.js +41 -0
  57. package/dist/ui/dist/assets/diagram-TYUNFSNR-BN58LF_3.js +200 -0
  58. package/dist/ui/dist/assets/dist-tWS_lXDS.js +1 -0
  59. package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-B68Tz-w8.js +1 -0
  60. package/dist/ui/dist/assets/elk-QONTBCTQ-DZZohNea.js +27 -0
  61. package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BH_m74pv.js +99 -0
  62. package/dist/ui/dist/assets/eventmodeling-K75KTNOO-CTp5FHos.js +1 -0
  63. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +1 -0
  64. package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-BDau7_0f.js +292 -0
  65. package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  66. package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  67. package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  68. package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  69. package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  70. package/dist/ui/dist/assets/gitGraph-VSP46ZUC-Bv84IoCm.js +1 -0
  71. package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-DzXcMlm8.js +106 -0
  72. package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
  73. package/dist/ui/dist/assets/index-BTJoqVVN.js +133 -0
  74. package/dist/ui/dist/assets/index-D60U3smc.css +1 -0
  75. package/dist/ui/dist/assets/info-OHQRW6UA-222J0O_j.js +1 -0
  76. package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-DE2sa3Vz.js +2 -0
  77. package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
  78. package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js +70 -0
  79. package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-JSUXX5Hb.js +139 -0
  80. package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-Bg79b49M.js +89 -0
  81. package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
  82. package/dist/ui/dist/assets/line-Bk5qQSIa.js +1 -0
  83. package/dist/ui/dist/assets/linear-PFnVMru5.js +1 -0
  84. package/dist/ui/dist/assets/mermaid-parser.core-Dcxlzc-S.js +7 -0
  85. package/dist/ui/dist/assets/mermaid.core-TeOHQJ00.js +44 -0
  86. package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-IaNk8NnW.js +98 -0
  87. package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  88. package/dist/ui/dist/assets/packet-JDAUHWVQ-DMff5cVK.js +1 -0
  89. package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
  90. package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-66Xu4YRc.js +1 -0
  91. package/dist/ui/dist/assets/pie-XZMESJXO-GEtzLCIM.js +1 -0
  92. package/dist/ui/dist/assets/pieDiagram-GNMPY74T-7jMBi-kU.js +39 -0
  93. package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
  94. package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
  95. package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
  96. package/dist/ui/dist/assets/prism-core-FDHlWQ43.js +1 -0
  97. package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
  98. package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
  99. package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
  100. package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
  101. package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
  102. package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
  103. package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
  104. package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
  105. package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
  106. package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
  107. package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  108. package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
  109. package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
  110. package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
  111. package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
  112. package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
  113. package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
  114. package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
  115. package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
  116. package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
  117. package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
  118. package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-8hIWayC2.js +7 -0
  119. package/dist/ui/dist/assets/radar-ABXABTNO-BTdwUOOF.js +1 -0
  120. package/dist/ui/dist/assets/railroad-I3PHUGI6-DcMbDqBL.js +1 -0
  121. package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-gTzUTWeI.js +1 -0
  122. package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-CNtdUPtk.js +1 -0
  123. package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-DOiExAtE.js +1 -0
  124. package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-2kE_ygvM.js +1 -0
  125. package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-CoIAHRq2.js +84 -0
  126. package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  127. package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-7p8D3wim.js +40 -0
  128. package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-C6QFnMvn.js +169 -0
  129. package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  130. package/dist/ui/dist/assets/src-FgijoBVs.js +1 -0
  131. package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-n9eAb_bT.js +291 -0
  132. package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-U18mtUzX.js +1 -0
  133. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +8 -0
  134. package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-CD2Z8QGP.js +120 -0
  135. package/dist/ui/dist/assets/treeView-D4JQ5SDB-sqUjH5gB.js +1 -0
  136. package/dist/ui/dist/assets/treemap-SAJKECNS-DvZ38FP7.js +1 -0
  137. package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-CqOGidaZ.js +328 -0
  138. package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D1SXSBQ5.js +34 -0
  139. package/dist/ui/dist/assets/wardley-7MLQ67FV-Bpwio-Dx.js +1 -0
  140. package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-JSUXHMnk.js +78 -0
  141. package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-B4IFqQTN.js +7 -0
  142. package/dist/ui/dist/index.html +15 -0
  143. package/dist/ui/dist/style.css +121 -0
  144. package/package.json +31 -8
  145. package/skills/follow-me/SKILL.md +15 -61
  146. package/dist/src/anthropic.js +0 -101
  147. package/dist/ui/app.js +0 -797
  148. package/dist/ui/index.html +0 -40
  149. package/dist/ui/playback.js +0 -175
  150. package/dist/ui/style.css +0 -466
package/dist/src/cli.js CHANGED
@@ -1,50 +1,124 @@
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
- import { join, relative } from "node:path";
4
- import { PlanRequestError, requestPlan } from "./anthropic.js";
5
- import { DiffParseError, formatUnitRef } from "./diff.js";
6
- import { BranchRefusedError, GitError, treeOf, writeBranch } from "./git.js";
7
- import { findServer, idleMs, listSessions, openBrowser, portFallbackMessage, probeHealth, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stopServer, } from "./host.js";
8
- import { runInstall, runUninstall } from "./install.js";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
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";
9
+ import { runInstall, runUninstall, syncSkills } from "./install.js";
10
+ import { naiveDraft } from "./naive.js";
9
11
  import { serializePlanFile, validatePlan } from "./plan.js";
10
- import { renderFeedback, renderPrompt } from "./prompt.js";
12
+ import { renderEvents, renderPrompt, renderReport, reportItems, } from "./prompt.js";
11
13
  import { RegistrationError, sessionIdOf } from "./server.js";
12
- import { BranchMovedError, cleanSessions, extractSession, openSession, SessionFileError, SessionLockedError, } from "./session.js";
14
+ import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, } from "./session.js";
15
+ import { checkSyntax } from "./syntax.js";
13
16
  class UsageError extends Error {
14
17
  name = "UsageError";
15
18
  }
16
- const USAGE = `usage: follow-me <command>
19
+ const SHORT_USAGE = `usage: follow-me [--wait <seconds>] [--branch <name>]
17
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 60, 0 for none) and prints what
24
+ they flagged or asked. Do what its "next:" line says. Run it again to keep listening.
25
+
26
+ Other commands:
27
+ reply <commentId> --body <text> [--diagram <file>]
28
+ answer under a comment on the page; the diagram is
29
+ Mermaid source
30
+ explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
31
+ start a thread on the page without being asked
32
+ report [--json] [--markdown] print the report of the latest review; --json lists
33
+ the items to post to a merge request
34
+
35
+ help --all lists every command.`;
36
+ const USAGE = `usage: follow-me <command> [options]
37
+
38
+ follow-me with no command is the entry point; see \`follow-me help\`. The commands below are what it
39
+ runs, for when you want one step by hand.
40
+
41
+ Split your own branch:
42
+ split [--base <ref>] [--branch <name>] [--no-open]
43
+ extract, apply your agent's draft and open the
44
+ review in one go; without a valid draft it writes
45
+ the prompt for your agent and stops
18
46
  extract [--base <ref>] [--branch <name>] anchor a session and list the units
19
- check [path] [--branch <name>] validate draft.json or the given plan file
20
- apply [--name <branch>] [--force] write the draft as commits on <branch>-split or
21
- --name; never on <branch> or a checked-out branch
47
+ prompt [--walk] [--branch <name>] print the planner prompt; your coding agent
48
+ writes draft.json from it
49
+ plan --naive [--branch <name>] no agent: one "wip: <path>" commit per touched
50
+ file, in diff order
51
+ check [path] [--branch <name>] [--syntax "<cmd {file}>"]
52
+ validate draft.json or the given plan file, and
53
+ check that every file each commit writes parses;
54
+ --syntax runs <cmd> on every text file instead of
55
+ the checker for its extension
56
+ apply [--name <branch>] [--force] [--sign|--no-sign] [--syntax "<cmd {file}>"]
57
+ [--allow-syntax-errors] write the draft as commits on <branch>-split or
58
+ --name; never on <branch> or a checked-out branch;
59
+ refuses a commit with a file that does not parse
60
+ unless --allow-syntax-errors
61
+ review [--base <ref>] [--branch <name>] [--port <n>] [--detach] [--no-open]
62
+ [--wait [--timeout <seconds>]] open the review, commit by commit; --wait returns
63
+ only once the review ends. After apply it reviews
64
+ your split; otherwise it plans for a reader and
65
+ reviews the code, applying nothing
22
66
  status show the session's plans, reviews, lock and the
23
67
  branch apply writes
24
- prompt [--walk] [--branch <name>] print the planner prompt for a coding agent
25
- plan [--walk] [--model <id>] [--branch <name>]
26
- ask the Anthropic API to write draft.json
27
- review [--port <n>] [--detach] [--no-open] review the latest plan commit by commit
28
- walk [--base <ref>] [--branch <name>] [--model <id>] [--port <n>] [--detach] [--no-open]
29
- watch a branch being written, narrated, and
30
- review its code; nothing is applied
68
+
69
+ Interactive loop (while the review page is open):
70
+ watch [--since <seq>] [--timeout <seconds>] [--json] [--follow]
71
+ wait for the reviewer to comment, ask or decide and
72
+ print it; exits 0 with news, 2 on timeout, and
73
+ reports the end of the review. Your own replies are
74
+ left out. --follow streams every event until the
75
+ review ends instead of stopping at the first
76
+ reply <commentId> --body <text> [--diagram <file>]
77
+ answer under a comment on the page; the diagram is
78
+ Mermaid source
79
+ explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
80
+ start a thread on the page without being asked;
81
+ --lines are change lines of the unit, commits count
82
+ from 1; with no target it is about the whole plan.
83
+ watch leaves these out like your replies
84
+ report [--json] [--markdown] [--branch <name>]
85
+ print the report of the open review, or of the last
86
+ one that ended. Markdown lists issues by file and
87
+ line in both voices, then questions with answers;
88
+ --json lists the items to post to a merge request
89
+ (path, lines, severity, body, thread, excluded)
90
+ apply, while the page is open, swaps it to the new plan version in place, same URL.
91
+
92
+ Old names, still working:
93
+ walk is review; prints one line saying so
94
+ feedback [--walk] is report; prints one line saying so
95
+
96
+ Review server:
31
97
  server status show the review server and the sessions it hosts
32
98
  server stop stop the review server; drafts are kept
33
- feedback [--branch <name>] print the latest review as markdown
34
- clean [--all] [--dry-run] remove sessions of deleted branches, finished
35
- review drafts and leftovers of dead processes;
36
- --all removes .follow-me/ entirely
37
- install [--harness <ids>|--all] [--user] [--dry-run]
99
+
100
+ Setup:
101
+ install [--harness <ids>|--all] [--project] [--dry-run]
38
102
  copy the skill into coding agents' skill dirs
39
103
  install --list show each harness, its skill dirs and docs
40
- uninstall [--harness <ids>|--all] [--user] [--dry-run]
104
+ uninstall [--harness <ids>|--all] [--project] [--dry-run]
41
105
  remove the skill files install writes
106
+ upgrade [--harness <ids>|--all] [--project] [--dry-run]
107
+ update the package with npm, then refresh the
108
+ installed skill copies
109
+ clean [--all] [--dry-run] remove sessions of deleted branches, finished
110
+ review drafts and leftovers of dead processes;
111
+ --all removes .follow-me/ entirely
112
+ --version print the version
113
+ help print this
42
114
 
43
- --walk writes the prompt for a reviewer: a story with narration instead of a split.
44
- review and walk share one server per user: a running one hosts the session and the command
115
+ --walk writes the prompt for a reviewer: a story with notes instead of a split.
116
+ review shares one server per user: a running one hosts the session and the command
45
117
  prints its URL; otherwise the command serves until its review ends, or with --detach starts the
46
118
  server in the background. --port taken by another program falls back to a free port.
47
- --force lets apply move a branch follow-me did not create; it never allows <branch> itself.`;
119
+ --force lets apply move a branch follow-me did not create; it never allows <branch> itself.
120
+ apply signs the commits when your git config signs commits (commit.gpgsign, gpg.format);
121
+ --sign signs them anyway and --no-sign never signs.`;
48
122
  function parseArgs(argv, spec) {
49
123
  const positionals = [];
50
124
  const values = new Map();
@@ -73,6 +147,34 @@ function parseArgs(argv, spec) {
73
147
  function say(line = "") {
74
148
  process.stdout.write(`${line}\n`);
75
149
  }
150
+ /** The step that follows; every command that moves the flow forward ends with one. */
151
+ function next(step) {
152
+ say(`next: ${step}`);
153
+ }
154
+ /**
155
+ * The version in the package.json above this module, which sits one directory up from src/ and
156
+ * two up from dist/src/.
157
+ */
158
+ function version() {
159
+ let dir = dirname(fileURLToPath(import.meta.url));
160
+ for (let depth = 0; depth < 3; depth++) {
161
+ dir = dirname(dir);
162
+ let raw;
163
+ try {
164
+ raw = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
165
+ }
166
+ catch {
167
+ continue;
168
+ }
169
+ if (typeof raw !== "object" || raw === null)
170
+ continue;
171
+ const name = "name" in raw ? raw.name : undefined;
172
+ const value = "version" in raw ? raw.version : undefined;
173
+ if (name === "@shiyuphan/follow-me" && typeof value === "string")
174
+ return value;
175
+ }
176
+ throw new Error("cannot find the package.json of follow-me");
177
+ }
76
178
  /** Paths are latin1 so bytes round-trip; show them as the UTF-8 the user typed. */
77
179
  function show(path) {
78
180
  return Buffer.from(path, "latin1").toString("utf8");
@@ -108,13 +210,22 @@ function describeFile(diff, units) {
108
210
  status = text.base === null ? "A" : text.tip === null ? "D" : "M";
109
211
  return { status, what: parts.join(", ") };
110
212
  }
111
- function summarize(session, cwd) {
112
- const { anchor, diff } = session;
213
+ function unitsByPath(diff) {
113
214
  const byPath = new Map();
114
215
  for (const unit of diff.units.values()) {
115
216
  byPath.set(unit.path, [...(byPath.get(unit.path) ?? []), unit]);
116
217
  }
117
- say(`${anchor.branch} ${short(anchor.tip)} onto ${anchor.baseRef} ${short(anchor.base)}: ${plural(byPath.size, "file")}, ${plural(diff.units.size, "unit")}`);
218
+ return byPath;
219
+ }
220
+ function headline(session) {
221
+ const { anchor, diff } = session;
222
+ const files = unitsByPath(diff).size;
223
+ return `${anchor.branch} ${short(anchor.tip)} onto ${anchor.baseRef} ${short(anchor.base)}: ${plural(files, "file")}, ${plural(diff.units.size, "unit")}`;
224
+ }
225
+ function summarize(session, cwd) {
226
+ const { diff } = session;
227
+ const byPath = unitsByPath(diff);
228
+ say(headline(session));
118
229
  const width = Math.max(0, ...[...byPath.keys()].map((p) => show(p).length));
119
230
  for (const [path, units] of byPath) {
120
231
  const { status, what } = describeFile(diff, units);
@@ -149,6 +260,12 @@ function formatIssue(issue) {
149
260
  return `$.commits[${issue.commit}]: ${show(issue.file)} is a file while ${show(issue.under)} exists`;
150
261
  case "unknown-response":
151
262
  return `response to unknown comment ${issue.comment}`;
263
+ case "bad-answer":
264
+ return `response to ${issue.comment}: ${issue.reason}`;
265
+ case "bad-concern":
266
+ return `$.commits[${issue.commit}]: concern: ${issue.reason}`;
267
+ case "too-many-concerns":
268
+ return `${issue.unit} has more than one concern, in commits ${issue.commits.join(" and ")}; keep the one that matters`;
152
269
  case "final-mismatch": {
153
270
  const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
154
271
  return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
@@ -160,8 +277,22 @@ function formatIssue(issue) {
160
277
  }
161
278
  }
162
279
  function formatWarning(w) {
280
+ if (w.kind === "long-title") {
281
+ return `warning: $.commits[${w.commit}]: title is ${w.length} characters; keep it to 50`;
282
+ }
283
+ if (w.kind === "question-addressed") {
284
+ return `warning: question ${w.comment} is marked addressed; explain it with "answered" and "at" unless answering it changed the plan`;
285
+ }
163
286
  return `warning: comment ${w.comment} from review v${w.review} has no response`;
164
287
  }
288
+ function concernTally(commits) {
289
+ const all = commits.flatMap((c) => c.concerns ?? []);
290
+ const parts = ["blocker", "major", "minor"].flatMap((severity) => {
291
+ const n = all.filter((c) => c.severity === severity).length;
292
+ return n === 0 ? [] : [`${n} ${severity}`];
293
+ });
294
+ return parts.length === 0 ? null : `concerns: ${parts.join(", ")}`;
295
+ }
165
296
  function readPlanArg(path) {
166
297
  try {
167
298
  return JSON.parse(readFileSync(path, "utf8"));
@@ -204,17 +335,53 @@ async function check(cwd, args) {
204
335
  const session = await openSession(cwd, sessionOpts(args));
205
336
  const path = args.positionals[0];
206
337
  const verdict = validate(session, path === undefined ? session.readDraft() : readPlanArg(path));
207
- if (!verdict.ok)
338
+ if (!verdict.ok) {
339
+ const fix = path ?? relative(cwd, session.draftPath);
340
+ next(`fix ${fix}, then follow-me check${path === undefined ? "" : ` ${path}`}${branchFlag(args)}`);
208
341
  return 1;
209
- const { plan } = verdict;
342
+ }
343
+ listPlan(verdict.plan);
344
+ for (const w of verdict.warnings)
345
+ say(formatWarning(w));
346
+ const syntax = await checkSyntax(verdict.plan, syntaxOverride(args));
347
+ for (const line of formatSyntax(syntax))
348
+ say(line);
349
+ if (syntax.issues.length > 0) {
350
+ next(`fix the commits above so every file parses, then follow-me check${branchFlag(args)}`);
351
+ return 1;
352
+ }
353
+ next("follow-me apply");
354
+ return 0;
355
+ }
356
+ function syntaxOverride(args) {
357
+ const override = args.values.get("syntax");
358
+ if (override === undefined)
359
+ return {};
360
+ if (!override.includes("{file}"))
361
+ throw new UsageError("--syntax needs {file} in the command");
362
+ return { override };
363
+ }
364
+ function formatSyntax(report, mark = "") {
365
+ const out = report.issues.map((i) => `${mark}$.commits[${i.commit}]: ${show(i.path)}: ${i.message}`);
366
+ for (const i of report.tip) {
367
+ out.push(`${mark}${show(i.path)}: ${i.message} (the tip itself does not parse here, so earlier commits are not checked for it)`);
368
+ }
369
+ const reasons = [...new Set(report.skipped.map((s) => s.reason))];
370
+ const skipped = reasons.length === 0
371
+ ? ""
372
+ : `, ${plural(report.skipped.length, "file")} skipped (${reasons.join(", ")})`;
373
+ out.push(`syntax: ${plural(report.checked, "write")} checked${skipped}`);
374
+ return out;
375
+ }
376
+ function listPlan(plan) {
210
377
  say(`ok: ${plural(plan.commits.length, "commit")}, plan ${short(plan.hash)}`);
211
378
  for (const c of plan.commits) {
212
379
  const subject = c.message.split("\n")[0] ?? "";
213
380
  say(` ${c.index + 1} ${subject} (${plural(c.refs.length, "unit")}, ${plural(c.changes.length, "file")})`);
214
381
  }
215
- for (const w of verdict.warnings)
216
- say(formatWarning(w));
217
- return 0;
382
+ const tally = concernTally(plan.commits);
383
+ if (tally !== null)
384
+ say(tally);
218
385
  }
219
386
  /** Runs one command under the session's lock, so two agents never apply or review it at once. */
220
387
  async function locked(session, command, run) {
@@ -231,25 +398,85 @@ function splitBranch(session) {
231
398
  }
232
399
  async function apply(cwd, args) {
233
400
  const session = await openSession(cwd);
234
- return locked(session, "apply", () => applyLocked(session, args));
401
+ const open = await openOnServer(session, "split");
402
+ if (open !== null)
403
+ return applyHosted(session, args, open);
404
+ const code = await locked(session, "apply", () => applyLocked(session, args));
405
+ if (code === 0)
406
+ next("follow-me review");
407
+ return code;
408
+ }
409
+ /**
410
+ * Applies while the review server hosts the session. The server holds the session lock, so the
411
+ * write runs under it, and the page is then swapped to the new plan version in place.
412
+ */
413
+ async function applyHosted(session, args, open) {
414
+ const code = await applyLocked(session, args);
415
+ const latest = session.plans().at(-1);
416
+ if (code !== 0 || latest === undefined)
417
+ return code;
418
+ if (latest.version === open.summary.version) {
419
+ say(`plan v${latest.version} is already open: ${sessionUrl(open.port, open.summary)}`);
420
+ }
421
+ else {
422
+ const swapped = await swapVersion(open.port, open.summary, latest.version, latest.hash);
423
+ say(`follow-me review: ${sessionUrl(open.port, swapped)}`);
424
+ say(`the page now shows plan v${latest.version}; the comments on v${open.summary.version} are saved`);
425
+ }
426
+ next("follow-me watch");
427
+ return 0;
428
+ }
429
+ function signMode(args) {
430
+ const on = args.flags.has("sign");
431
+ const off = args.flags.has("no-sign");
432
+ if (on && off)
433
+ throw new UsageError("--sign and --no-sign cannot be used together");
434
+ return on ? "on" : off ? "off" : "config";
435
+ }
436
+ function describeSigning(s) {
437
+ return s.on ? `on (${s.format})` : "off";
235
438
  }
236
439
  async function applyLocked(session, args) {
440
+ const mode = signMode(args);
237
441
  const verdict = validate(session, session.readDraft());
238
442
  if (!verdict.ok)
239
443
  return 1;
240
- const version = session.freeze(verdict.plan);
444
+ const { plan } = verdict;
445
+ const syntax = await checkSyntax(plan, syntaxOverride(args));
446
+ const allowed = args.flags.has("allow-syntax-errors");
447
+ if (syntax.issues.length > 0) {
448
+ for (const line of formatSyntax(syntax, allowed ? "warning: " : ""))
449
+ say(line);
450
+ if (!allowed) {
451
+ say("refusing to apply: fix the commits above, or pass --allow-syntax-errors");
452
+ return 1;
453
+ }
454
+ }
455
+ const version = session.freeze(plan);
241
456
  const name = args.values.get("name") ?? splitBranch(session);
457
+ const signed = await signing(session.root, mode);
242
458
  const last = session.applied();
243
- const result = await writeBranch(session.root, session.diff, verdict.plan, {
244
- name,
245
- source: session.anchor.branch,
246
- expectedOld: last?.branch === name ? last.head : null,
247
- force: args.flags.has("force"),
248
- });
249
- session.recordApplied(version, result);
459
+ // Signatures can differ per run, so the record, not SHA equality, decides that nothing changed.
460
+ const recorded = last !== null &&
461
+ last.branch === name &&
462
+ last.version === version &&
463
+ last.hash === plan.hash &&
464
+ last.signed === signed.on &&
465
+ (await branchHead(session.root, name)) === last.head;
466
+ const result = recorded
467
+ ? { kind: "unchanged", branch: name, head: last.head }
468
+ : await writeBranch(session.root, session.diff, plan, {
469
+ name,
470
+ source: session.anchor.branch,
471
+ expectedOld: last?.branch === name ? last.head : null,
472
+ force: args.flags.has("force"),
473
+ sign: signed.on,
474
+ });
475
+ session.recordApplied({ version, hash: plan.hash }, { ...result, signed: signed.on });
250
476
  for (const w of verdict.warnings)
251
477
  say(formatWarning(w));
252
- say(`${result.kind} ${result.branch} at ${short(result.head)} from plan v${version} (${plural(result.commits.length, "commit")})`);
478
+ say(`${result.kind} ${result.branch} at ${short(result.head)} from plan v${version} (${plural(plan.commits.length, "commit")})`);
479
+ say(`signing: ${describeSigning(signed)}`);
253
480
  const tree = await treeOf(session.root, result.head);
254
481
  const same = tree === session.anchor.tipTree;
255
482
  say(`proof: ${result.branch}^{tree} ${tree} ${same ? "equals" : "DIFFERS FROM"} ${session.anchor.branch}^{tree} ${session.anchor.tipTree}`);
@@ -263,35 +490,43 @@ function promptInput(session, mode, args) {
263
490
  };
264
491
  if (mode === "walk")
265
492
  return { ...common, mode };
493
+ const previous = session.plans().at(-1);
266
494
  return {
267
495
  ...common,
268
496
  mode,
269
- previousPlan: session.plans().at(-1)?.file ?? null,
497
+ previousPlan: previous?.file ?? null,
270
498
  review: session.lastReview(),
499
+ previousCommits: previous === undefined ? [] : projectedCommits(session, previous.version),
271
500
  };
272
501
  }
273
- /** Asks the API for draft.json, feeding issues back once. Returns the plan when it validates. */
274
- async function draftWithApi(session, cwd, mode, args) {
275
- const model = args.values.get("model");
276
- const opts = model === undefined ? {} : { model };
277
- let prompt = renderPrompt(promptInput(session, mode, args));
278
- for (let round = 1; round <= 2; round++) {
279
- const draft = await requestPlan(prompt, opts);
280
- session.writeDraft(draft);
281
- const verdict = validate(session, draft);
282
- if (verdict.ok) {
283
- say(`wrote ${relative(cwd, session.draftPath)} (${plural(verdict.plan.commits.length, "commit")})`);
284
- return verdict.plan;
285
- }
286
- const issues = verdict.issues.map((issue) => `- ${formatIssue(issue)}`).join("\n");
287
- prompt = `${prompt}\n\n## Your previous draft had these issues, write a corrected plan\n\n${issues}\n`;
288
- }
289
- say(`wrote ${relative(cwd, session.draftPath)} but it still has issues, repair it and run \`follow-me check${branchFlag(args)}\``);
290
- return null;
502
+ /** The commits of a frozen plan as projected now, or none when it no longer fits the branch. */
503
+ function projectedCommits(session, version) {
504
+ const plan = session.plans().find((p) => p.version === version);
505
+ if (plan === undefined)
506
+ return [];
507
+ const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
508
+ return verdict.ok ? verdict.plan.commits : [];
509
+ }
510
+ /** Writes the one-commit-per-file draft with no agent. */
511
+ function naive(session, cwd, args) {
512
+ if (args.flags.has("walk")) {
513
+ throw new UsageError("--naive writes no story, so --walk does not apply");
514
+ }
515
+ const draft = naiveDraft(session.diff);
516
+ session.writeDraft(draft);
517
+ const verdict = validate(session, draft);
518
+ if (!verdict.ok)
519
+ return 1;
520
+ say(`wrote ${relative(cwd, session.draftPath)} (${plural(verdict.plan.commits.length, "commit")})`);
521
+ next("follow-me apply");
522
+ return 0;
291
523
  }
292
524
  async function plan(cwd, args) {
293
- const session = await openSession(cwd, sessionOpts(args));
294
- return (await draftWithApi(session, cwd, modeOf(args), args)) === null ? 1 : 0;
525
+ if (!args.flags.has("naive")) {
526
+ 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");
527
+ return 1;
528
+ }
529
+ return naive(await openSession(cwd, sessionOpts(args)), cwd, args);
295
530
  }
296
531
  /** The latest frozen plan, or the draft frozen now when it validates. */
297
532
  function latestPlan(session) {
@@ -305,8 +540,8 @@ function latestPlan(session) {
305
540
  return null;
306
541
  return { version: session.freeze(verdict.plan), plan: verdict.plan };
307
542
  }
308
- function announce(mode, url, args) {
309
- say(`follow-me ${mode === "walk" ? "code review" : "review"}: ${url}`);
543
+ function announce(url, args) {
544
+ say(`follow-me review: ${url}`);
310
545
  if (!args.flags.has("no-open"))
311
546
  openBrowser(url);
312
547
  }
@@ -327,17 +562,12 @@ async function runningServer(home, port) {
327
562
  */
328
563
  async function hostReview(session, mode, args, prepare, report) {
329
564
  const home = stateDir();
330
- const lock = session.lockState();
331
- const live = await findServer(home);
332
- if (live !== null && lock.kind === "held" && lock.pid === live.pid) {
333
- const id = sessionIdOf(session.dir);
334
- const open = (await listSessions(live.port)).find((s) => s.id === id && s.mode === mode);
335
- if (open !== undefined) {
336
- announce(mode, sessionUrl(live.port, open), args);
337
- return { kind: "elsewhere" };
338
- }
565
+ const open = await openOnServer(session, mode);
566
+ if (open !== null) {
567
+ announce(sessionUrl(open.port, open.summary), args);
568
+ return { kind: "elsewhere", ...open };
339
569
  }
340
- return locked(session, mode === "walk" ? "walk" : "review", async (held) => {
570
+ return locked(session, "review", async (held) => {
341
571
  const prepared = await prepare();
342
572
  if (prepared === null)
343
573
  return { kind: "refused" };
@@ -363,12 +593,12 @@ async function hostReview(session, mode, args, prepare, report) {
363
593
  started = await startOrAttach(home, port, say);
364
594
  if (started.kind === "attached") {
365
595
  const summary = await registerWith(started.port, registration);
366
- announce(mode, sessionUrl(started.port, summary), args);
367
- return { kind: "elsewhere" };
596
+ announce(sessionUrl(started.port, summary), args);
597
+ return { kind: "elsewhere", port: started.port, summary };
368
598
  }
369
599
  const { host } = started;
370
600
  const { summary, done } = await host.register(registration);
371
- announce(mode, sessionUrl(host.port, summary), args);
601
+ announce(sessionUrl(host.port, summary), args);
372
602
  const review = await done;
373
603
  if (review === null)
374
604
  return { kind: "stopped" };
@@ -388,24 +618,391 @@ function exitCode(hosted) {
388
618
  return hosted.kind === "refused" || hosted.kind === "stopped" ? 1 : 0;
389
619
  }
390
620
  function alreadyReviewed(session, version, args) {
391
- if (!session.reviews().some((r) => r.version === version))
621
+ if (!reviewsOf(session).some((r) => r.version === version))
392
622
  return false;
393
- console.error(`follow-me: plan v${version} already has a review; read it with \`follow-me feedback${branchFlag(args)}\` or plan again first`);
623
+ console.error(`follow-me: plan v${version} already has a review; read it with \`follow-me report${branchFlag(args)}\``);
394
624
  return true;
395
625
  }
396
- async function review(cwd, args) {
397
- const session = await openSession(cwd);
398
- const prepare = async () => {
626
+ /** This session's review, of this mode or any, on the user's server when that holds its lock. */
627
+ async function openOnServer(session, mode) {
628
+ const lock = session.lockState();
629
+ const live = await findServer(stateDir());
630
+ if (live === null || lock.kind !== "held" || lock.pid !== live.pid)
631
+ return null;
632
+ const id = sessionIdOf(session.dir);
633
+ 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 };
635
+ }
636
+ function timeoutOf(args) {
637
+ const raw = args.values.get("timeout");
638
+ if (raw === undefined)
639
+ return undefined;
640
+ const seconds = Number(raw);
641
+ if (!Number.isFinite(seconds) || seconds <= 0) {
642
+ throw new UsageError(`--timeout ${raw} is not a number of seconds`);
643
+ }
644
+ return seconds;
645
+ }
646
+ /** Every finished review, oldest first, including reviewer reviews older builds stored apart. */
647
+ function reviewsOf(session) {
648
+ const seen = new Set();
649
+ return [...session.reviews(), ...session.walkReviews()]
650
+ .filter((r) => !seen.has(r.version) && seen.add(r.version))
651
+ .sort((a, b) => a.version - b.version);
652
+ }
653
+ function savedReview(session, version) {
654
+ return reviewsOf(session).find((r) => r.version === version)?.review ?? null;
655
+ }
656
+ /**
657
+ * Polls the server until it no longer hosts the session, then reads the review it saved. Null
658
+ * when the server stopped without one; "timeout" when the review is still open at the deadline.
659
+ */
660
+ async function waitForEnd(session, hosted, timeout) {
661
+ const deadline = timeout === undefined ? Number.POSITIVE_INFINITY : Date.now() + timeout * 1000;
662
+ while (await stillHosted(hosted.port, hosted.summary)) {
663
+ if (Date.now() >= deadline)
664
+ return "timeout";
665
+ await new Promise((resolve) => setTimeout(resolve, 250));
666
+ }
667
+ return savedReview(session, hosted.summary.version);
668
+ }
669
+ function reportSaved(review) {
670
+ say(`review v${review.planVersion} saved with ${plural(review.comments.length, "comment")}`);
671
+ next("follow-me report");
672
+ }
673
+ function prepareSplit(session, args) {
674
+ return async () => {
399
675
  const latest = latestPlan(session);
400
676
  if (latest === null || alreadyReviewed(session, latest.version, args))
401
677
  return null;
402
678
  return latest;
403
679
  };
404
- const report = (review) => say(`review v${review.planVersion} saved with ${plural(review.comments.length, "comment")}`);
405
- return exitCode(await hostReview(session, "split", args, prepare, report));
406
680
  }
407
- /** Opens the reviewer's session, re-extracting instead of failing when the branch moved. */
408
- async function openForWalk(cwd, args) {
681
+ /**
682
+ * Author mode when a split branch was applied for the latest plan: the plan is the author's own
683
+ * and the review is of it. Otherwise the reader is a reviewer meeting the branch, so the plan only
684
+ * orders the reading and nothing is applied. The wire keeps calling that mode "walk".
685
+ */
686
+ function reviewMode(session) {
687
+ const applied = session.applied();
688
+ const latest = session.plans().at(-1);
689
+ return applied !== null && latest !== undefined && applied.version === latest.version
690
+ ? "split"
691
+ : "walk";
692
+ }
693
+ /** Hosts the review of the session: the plan for its author, or the code for a reviewer. */
694
+ async function review(cwd, args) {
695
+ const session = await openFresh(cwd, args);
696
+ const timeout = timeoutOf(args);
697
+ if (timeout !== undefined && !args.flags.has("wait")) {
698
+ throw new UsageError("--timeout only applies with --wait");
699
+ }
700
+ const open = await openOnServer(session, null);
701
+ const mode = open?.summary.mode ?? reviewMode(session);
702
+ const prepare = mode === "split" ? prepareSplit(session, args) : () => walkPlan(session, args);
703
+ const hosted = await hostReview(session, mode, args, prepare, reportSaved);
704
+ if (hosted.kind !== "elsewhere")
705
+ return exitCode(hosted);
706
+ if (!args.flags.has("wait")) {
707
+ next("end the review on the page, then follow-me report");
708
+ return 0;
709
+ }
710
+ const ended = await waitForEnd(session, hosted, timeout);
711
+ if (ended === "timeout") {
712
+ console.error(`follow-me: the review is still open after ${timeout}s; run \`follow-me review --wait\` again to keep waiting`);
713
+ return 1;
714
+ }
715
+ if (ended === null)
716
+ return exitCode({ kind: "stopped" });
717
+ reportSaved(ended);
718
+ return 0;
719
+ }
720
+ /** Why split needs a new draft before it can go on, or null when draft.json is ready. */
721
+ function draftNeed(session, cwd, args) {
722
+ let draft;
723
+ try {
724
+ draft = session.readDraft();
725
+ }
726
+ catch (error) {
727
+ if (error instanceof MissingDraftError)
728
+ return `no draft for ${session.anchor.branch} yet`;
729
+ throw error;
730
+ }
731
+ const verdict = validatePlan(session.diff, draft, { lastReview: session.lastReview() });
732
+ if (!verdict.ok) {
733
+ if (verdict.issues.some((issue) => issue.kind === "stale")) {
734
+ return `the draft for ${session.anchor.branch} was written for an older tip`;
735
+ }
736
+ for (const issue of verdict.issues)
737
+ say(formatIssue(issue));
738
+ next(`fix ${relative(cwd, session.draftPath)}, then follow-me split${branchFlag(args)}`);
739
+ return "invalid";
740
+ }
741
+ const { hash } = verdict.plan;
742
+ const reviewed = session
743
+ .plans()
744
+ .find((p) => p.hash === hash && session.reviews().some((r) => r.version === p.version));
745
+ if (reviewed !== undefined) {
746
+ return `plan v${reviewed.version} of ${session.anchor.branch} already has a review and needs a new draft that answers it`;
747
+ }
748
+ listPlan(verdict.plan);
749
+ return null;
750
+ }
751
+ /** Writes the prompt for an agent and prints what to tell it and what to run after. */
752
+ function handOff(session, cwd, args, why) {
753
+ session.writePrompt(renderPrompt(promptInput(session, "split", args)));
754
+ const prompt = relative(cwd, session.promptPath);
755
+ say(`${why}.`);
756
+ say(`Have your coding agent follow ${prompt} and write ${relative(cwd, session.draftPath)}, then run:`);
757
+ say(` follow-me check${branchFlag(args)}`);
758
+ say(` follow-me split${branchFlag(args)}`);
759
+ next(`hand ${prompt} to your agent`);
760
+ }
761
+ /**
762
+ * The author's one command: extract, apply the agent's draft, and open the review on the user's
763
+ * server. It stops with a handoff whenever there is no valid draft.
764
+ */
765
+ async function split(cwd, args) {
766
+ const session = await openFresh(cwd, args);
767
+ say(headline(session));
768
+ const open = await openOnServer(session, "split");
769
+ if (open !== null) {
770
+ announce(sessionUrl(open.port, open.summary), args);
771
+ next("end the review on the page, then follow-me report");
772
+ return 0;
773
+ }
774
+ const need = draftNeed(session, cwd, args);
775
+ if (need === "invalid")
776
+ return 1;
777
+ if (need !== null) {
778
+ handOff(session, cwd, args, need);
779
+ return 1;
780
+ }
781
+ const applied = await locked(session, "apply", () => applyLocked(session, args));
782
+ if (applied !== 0)
783
+ return applied;
784
+ const detached = { ...args, flags: new Set([...args.flags, "detach"]) };
785
+ const hosted = await hostReview(session, "split", detached, prepareSplit(session, args), reportSaved);
786
+ if (hosted.kind !== "elsewhere")
787
+ return exitCode(hosted);
788
+ next("end the review on the page, then follow-me report");
789
+ return 0;
790
+ }
791
+ /** The mode a bare `follow-me` works in: a split already applied, else whoever wrote the branch. */
792
+ async function entryMode(session, args) {
793
+ if (args.flags.has("author") && args.flags.has("reviewer")) {
794
+ throw new UsageError("--author and --reviewer cannot be used together");
795
+ }
796
+ if (args.flags.has("reviewer"))
797
+ return "walk";
798
+ if (args.flags.has("author"))
799
+ return "split";
800
+ if (reviewMode(session) === "split")
801
+ return "split";
802
+ return (await isOwnWork(session.root, session.anchor.base, session.anchor.tip))
803
+ ? "split"
804
+ : "walk";
805
+ }
806
+ function draftState(session) {
807
+ let draft;
808
+ try {
809
+ draft = session.readDraft();
810
+ }
811
+ catch (error) {
812
+ if (error instanceof MissingDraftError)
813
+ return { kind: "missing" };
814
+ throw error;
815
+ }
816
+ const verdict = validatePlan(session.diff, draft, { lastReview: session.lastReview() });
817
+ if (verdict.ok)
818
+ return { kind: "valid", prepared: verdict.plan };
819
+ if (verdict.issues.some((issue) => issue.kind === "stale"))
820
+ return { kind: "stale" };
821
+ for (const issue of verdict.issues)
822
+ say(formatIssue(issue));
823
+ return { kind: "invalid" };
824
+ }
825
+ /** The newest review of a plan that still fits the branch, or null. */
826
+ function reviewedPlan(session) {
827
+ const reviews = reviewsOf(session);
828
+ for (const stored of reviews.toReversed()) {
829
+ const plan = session.plans().find((p) => p.version === stored.version);
830
+ if (plan !== undefined && validatePlan(session.diff, serializePlanFile(plan.file)).ok) {
831
+ return stored;
832
+ }
833
+ }
834
+ return null;
835
+ }
836
+ /** Prints the finished review's report, which is what the agent works from from here on. */
837
+ function finished(session, stored) {
838
+ const text = renderStored(session, stored);
839
+ if (text === null)
840
+ return 1;
841
+ say(`review v${stored.version} ended with ${plural(stored.review.comments.length, "comment")}`);
842
+ process.stdout.write(text);
843
+ say();
844
+ next("done; the user can port it with follow-me report --json");
845
+ return 0;
846
+ }
847
+ /** Writes the prompt for the agent and says where; used whenever there is no plan to review. */
848
+ function promptHandoff(session, cwd, mode, args) {
849
+ session.writePrompt(renderPrompt(promptInput(session, mode, args)));
850
+ const prompt = relative(cwd, session.promptPath);
851
+ const draft = relative(cwd, session.draftPath);
852
+ say(`wrote ${prompt}`);
853
+ next(`write ${draft} from ${prompt}, then run follow-me again`);
854
+ return 0;
855
+ }
856
+ function cursorPath(session) {
857
+ return join(session.dir, "cursor.json");
858
+ }
859
+ /** Where the last `follow-me` stopped reading this hosted session's events; 0 for a new one. */
860
+ function readCursor(session, summary) {
861
+ let raw;
862
+ try {
863
+ raw = JSON.parse(readFileSync(cursorPath(session), "utf8"));
864
+ }
865
+ catch {
866
+ return 0;
867
+ }
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;
874
+ }
875
+ function writeCursor(session, summary, seq) {
876
+ const cursor = { session: `${summary.id}@${summary.openedAt}`, seq };
877
+ writeFileSync(cursorPath(session), `${JSON.stringify(cursor)}\n`);
878
+ }
879
+ function waitOf(args) {
880
+ const raw = args.values.get("wait");
881
+ if (raw === undefined)
882
+ return ENTRY_WAIT_SECONDS;
883
+ const seconds = Number(raw);
884
+ if (!Number.isFinite(seconds) || seconds < 0) {
885
+ throw new UsageError(`--wait ${raw} is not a number of seconds`);
886
+ }
887
+ return seconds;
888
+ }
889
+ const ENTRY_WAIT_SECONDS = 60;
890
+ /**
891
+ * Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
892
+ * returns at once with news, and with the report when the reviewer submitted or ended the review.
893
+ */
894
+ async function listen(session, open, args) {
895
+ const { port, summary } = open;
896
+ const waitMs = waitOf(args) * 1000;
897
+ const deadline = Date.now() + waitMs;
898
+ let since = readCursor(session, summary);
899
+ let version = summary.version;
900
+ for (;;) {
901
+ const left = Math.max(0, deadline - Date.now());
902
+ let batch;
903
+ try {
904
+ batch = await pollEvents(port, summary, since, Math.min(left, POLL_MS));
905
+ }
906
+ catch (error) {
907
+ if (!(error instanceof RegistrationError))
908
+ throw error;
909
+ // The log restarted under us (a new server); read it from the start.
910
+ since = 0;
911
+ writeCursor(session, summary, 0);
912
+ continue;
913
+ }
914
+ since = batch.seq;
915
+ writeCursor(session, summary, since);
916
+ for (const event of batch.events)
917
+ if (event.type === "version")
918
+ version = event.version;
919
+ const news = batch.events.filter(isNews);
920
+ if (news.length > 0) {
921
+ const ctx = { commits: projectedCommits(session, version), units: session.diff.units };
922
+ process.stdout.write(renderEvents(news, ctx));
923
+ const ended = news.some((e) => e.type === "ended");
924
+ if (ended || news.some((e) => e.type === "submitted")) {
925
+ say();
926
+ if (ended) {
927
+ const stored = reviewsOf(session).find((r) => r.version === version);
928
+ if (stored !== undefined)
929
+ return finished(session, stored);
930
+ }
931
+ process.stdout.write(await fetchReport(port, summary));
932
+ say();
933
+ next("done; the user can port it with follow-me report --json");
934
+ return 0;
935
+ }
936
+ next('answer with follow-me reply <id> --body "<markdown>" [--diagram file], or run follow-me again to keep listening');
937
+ return 0;
938
+ }
939
+ 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");
942
+ return 0;
943
+ }
944
+ }
945
+ }
946
+ /**
947
+ * The agent's one command. Each call does the next thing that is missing and returns: it writes
948
+ * the prompt, reports a bad draft, applies and opens the review, then listens to the reviewer
949
+ * and hands back the report once the review is submitted.
950
+ */
951
+ async function entry(cwd, args) {
952
+ syncSkills(homedir(), say);
953
+ const session = await openFresh(cwd, args);
954
+ const hosted = await openOnServer(session, null);
955
+ if (hosted !== null)
956
+ return listen(session, hosted, args);
957
+ const mode = await entryMode(session, args);
958
+ const state = draftState(session);
959
+ if (state.kind === "invalid") {
960
+ next("fix the draft, then run follow-me again");
961
+ return 1;
962
+ }
963
+ const done = reviewedPlan(session);
964
+ if (state.kind === "valid") {
965
+ const reviewed = reviewsOf(session).find((r) => session.plans().find((p) => p.version === r.version)?.hash === state.prepared.hash);
966
+ if (reviewed !== undefined)
967
+ return finished(session, reviewed);
968
+ }
969
+ else if (done !== null) {
970
+ return finished(session, done);
971
+ }
972
+ let prepared = null;
973
+ if (state.kind === "valid") {
974
+ const syntax = await checkSyntax(state.prepared, syntaxOverride(args));
975
+ if (syntax.issues.length > 0) {
976
+ for (const line of formatSyntax(syntax))
977
+ say(line);
978
+ next("fix the draft, then run follow-me again");
979
+ return 1;
980
+ }
981
+ if (mode === "split") {
982
+ const code = await locked(session, "apply", () => applyLocked(session, args));
983
+ if (code !== 0) {
984
+ next("fix the draft, then run follow-me again");
985
+ return code;
986
+ }
987
+ prepared = latestPlan(session);
988
+ }
989
+ else
990
+ prepared = { version: session.freeze(state.prepared), plan: state.prepared };
991
+ }
992
+ else if (mode === "walk") {
993
+ prepared = walkablePlan(session);
994
+ }
995
+ if (prepared === null)
996
+ return promptHandoff(session, cwd, mode, args);
997
+ const prep = prepared;
998
+ const detached = { ...args, flags: new Set([...args.flags, "detach"]) };
999
+ const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
1000
+ if (result.kind !== "elsewhere")
1001
+ return exitCode(result);
1002
+ return listen(session, result, args);
1003
+ }
1004
+ /** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
1005
+ async function openFresh(cwd, args) {
409
1006
  const opts = sessionOpts(args);
410
1007
  try {
411
1008
  const session = await openSession(cwd, opts);
@@ -441,49 +1038,296 @@ function walkablePlan(session) {
441
1038
  const verdict = validatePlan(session.diff, draft, { lastReview });
442
1039
  return verdict.ok ? { version: session.freeze(verdict.plan), plan: verdict.plan } : null;
443
1040
  }
444
- function hasApiCredentials() {
445
- return Boolean(process.env["ANTHROPIC_API_KEY"] || process.env["ANTHROPIC_AUTH_TOKEN"]);
446
- }
1041
+ /** The old name of review; it prints one line saying so and goes on. */
447
1042
  async function walk(cwd, args) {
448
- const session = await openForWalk(cwd, args);
449
- const report = (review) => {
450
- const saved = join(session.dir, "reviews", `v${review.planVersion}.json`);
451
- say(`code review saved to ${relative(cwd, saved)} with ${plural(review.comments.length, "comment")}`);
452
- say(`\`follow-me feedback${branchFlag(args)}\` prints it as a code review for the author`);
453
- };
454
- const hosted = await hostReview(session, "walk", args, () => walkPlan(session, cwd, args), report);
455
- return exitCode(hosted);
1043
+ console.error("follow-me: walk is now review; running review");
1044
+ return review(cwd, args);
456
1045
  }
457
- async function walkPlan(session, cwd, args) {
458
- let latest = walkablePlan(session);
1046
+ async function walkPlan(session, args) {
1047
+ const latest = walkablePlan(session);
459
1048
  if (latest === null) {
460
- if (!hasApiCredentials()) {
461
- 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.`);
462
- say("Have a coding agent follow the prompt, then walk again:");
463
- say(` follow-me prompt --walk${branchFlag(args)}`);
464
- say(` follow-me walk${branchFlag(args)}`);
465
- return null;
466
- }
467
- const drafted = await draftWithApi(session, cwd, "walk", args);
468
- if (drafted === null)
469
- return null;
470
- latest = { version: session.freeze(drafted), plan: drafted };
1049
+ say(`no plan for ${session.anchor.branch} yet.`);
1050
+ say("Have a coding agent follow the prompt, then review again:");
1051
+ say(` follow-me prompt --walk${branchFlag(args)}`);
1052
+ say(` follow-me review${branchFlag(args)}`);
1053
+ return null;
471
1054
  }
472
1055
  return alreadyReviewed(session, latest.version, args) ? null : latest;
473
1056
  }
474
- async function feedback(cwd, args) {
1057
+ /** The consolidated report of a saved review, or null after saying why it cannot be rendered. */
1058
+ function renderStored(session, stored, format = "markdown") {
1059
+ const plan = session.plans().find((p) => p.version === stored.version);
1060
+ if (plan === undefined) {
1061
+ console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
1062
+ return null;
1063
+ }
1064
+ const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
1065
+ if (!verdict.ok) {
1066
+ console.error(`follow-me: plan v${plan.version} no longer fits ${session.anchor.branch}`);
1067
+ return null;
1068
+ }
1069
+ // Later plans answer this review's questions.
1070
+ const responses = session
1071
+ .plans()
1072
+ .filter((p) => p.version > stored.version)
1073
+ .flatMap((p) => p.file.responses);
1074
+ const opts = { branch: session.anchor.branch, responses };
1075
+ if (format === "json") {
1076
+ const items = reportItems(stored.review, verdict.plan.commits, session.diff.units, opts);
1077
+ return `${JSON.stringify(items, null, 2)}\n`;
1078
+ }
1079
+ return renderReport(stored.review, verdict.plan.commits, session.diff.units, opts);
1080
+ }
1081
+ /**
1082
+ * The report of the hosted review as it stands, else of the latest one that ended. --json lists
1083
+ * the items to carry to a merge request; --markdown, the default, reads as the agent's to-do.
1084
+ * --walk keeps reading only reviewer-mode reviews, as `feedback --walk` did.
1085
+ */
1086
+ async function report(cwd, args, walkOnly = false) {
1087
+ if (args.flags.has("json") && args.flags.has("markdown")) {
1088
+ throw new UsageError("--json and --markdown cannot be used together");
1089
+ }
475
1090
  const session = await openSession(cwd, sessionOpts(args));
476
- const stored = session.reviews().at(-1);
1091
+ const format = args.flags.has("json") ? "json" : "markdown";
1092
+ const open = walkOnly ? null : await openOnServer(session, null);
1093
+ if (open !== null) {
1094
+ const text = await fetchReport(open.port, open.summary, format);
1095
+ process.stdout.write(format === "json" ? `${JSON.stringify(JSON.parse(text), null, 2)}\n` : text);
1096
+ return 0;
1097
+ }
1098
+ const ended = (walkOnly ? session.walkReviews() : reviewsOf(session)).sort((a, b) => a.review.endedAt.localeCompare(b.review.endedAt));
1099
+ const stored = ended.at(-1);
477
1100
  if (stored === undefined) {
478
- console.error("follow-me: no review yet, run `follow-me review` first");
1101
+ console.error("follow-me: no review is open and none has ended; run `follow-me review` first");
479
1102
  return 1;
480
1103
  }
481
- const plan = session.plans().find((p) => p.version === stored.version);
482
- if (plan === undefined) {
483
- console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
1104
+ const text = renderStored(session, stored, format);
1105
+ if (text === null)
1106
+ return 1;
1107
+ process.stdout.write(text);
1108
+ return 0;
1109
+ }
1110
+ /** The old name of report. */
1111
+ async function feedback(cwd, args) {
1112
+ console.error("follow-me: feedback is now report; printing the same");
1113
+ return report(cwd, args, args.flags.has("walk"));
1114
+ }
1115
+ /** Events the reviewer caused; the agent's own replies, explanations and version swaps are not news. */
1116
+ function isNews(event) {
1117
+ if (event.type === "version")
1118
+ return false;
1119
+ if (event.type === "comment" || event.type === "comment-updated") {
1120
+ return event.comment.author !== "agent";
1121
+ }
1122
+ return !(event.type === "reply" && event.reply.author === "agent");
1123
+ }
1124
+ const POLL_MS = 25_000;
1125
+ const DEFAULT_WATCH_SECONDS = 90;
1126
+ function notOpen(session) {
1127
+ console.error(`follow-me: ${session.anchor.branch} is not open on the review server; run \`follow-me review\` first`);
1128
+ return 1;
1129
+ }
1130
+ /**
1131
+ * Waits for the reviewer to do something on the hosted page and prints it. Exits 0 with the first
1132
+ * batch, or 2 with nothing at the timeout. The log is the server's, so nothing is read from files.
1133
+ */
1134
+ async function watch(cwd, args) {
1135
+ const session = await openSession(cwd, sessionOpts(args));
1136
+ const open = await openOnServer(session, null);
1137
+ if (open === null)
1138
+ return notOpen(session);
1139
+ const sinceRaw = args.values.get("since");
1140
+ let since = sinceRaw === undefined ? 0 : Number(sinceRaw);
1141
+ if (!Number.isInteger(since) || since < 0) {
1142
+ throw new UsageError(`--since ${sinceRaw} is not an event number`);
1143
+ }
1144
+ // Comments are quoted as the plan they were made on wrote them; a swap changes which that is.
1145
+ let version = open.summary.version;
1146
+ const quoting = (events) => {
1147
+ for (const event of events)
1148
+ if (event.type === "version")
1149
+ version = event.version;
1150
+ return { commits: projectedCommits(session, version), units: session.diff.units };
1151
+ };
1152
+ if (args.flags.has("follow")) {
1153
+ let ended = false;
1154
+ await followEvents(open.port, open.summary, since, (event) => {
1155
+ if (event.type === "ended")
1156
+ ended = true;
1157
+ const ctx = quoting([event]);
1158
+ if (!isNews(event))
1159
+ return;
1160
+ if (args.flags.has("json"))
1161
+ say(JSON.stringify(event));
1162
+ else
1163
+ process.stdout.write(renderEvents([event], ctx));
1164
+ });
1165
+ if (ended)
1166
+ return 0;
1167
+ console.error("follow-me: the event stream closed before the review ended");
484
1168
  return 1;
485
1169
  }
486
- process.stdout.write(renderFeedback(stored.review, plan.file, session.diff.units));
1170
+ const deadline = Date.now() + (timeoutOf(args) ?? DEFAULT_WATCH_SECONDS) * 1000;
1171
+ for (;;) {
1172
+ const left = deadline - Date.now();
1173
+ if (left <= 0) {
1174
+ console.error(`follow-me: nothing new on the review page since event ${since}`);
1175
+ return 2;
1176
+ }
1177
+ const batch = await pollEvents(open.port, open.summary, since, Math.min(left, POLL_MS));
1178
+ since = batch.seq;
1179
+ const ctx = quoting(batch.events);
1180
+ const news = batch.events.filter(isNews);
1181
+ if (news.length === 0)
1182
+ continue;
1183
+ if (args.flags.has("json"))
1184
+ for (const event of news)
1185
+ say(JSON.stringify(event));
1186
+ else {
1187
+ process.stdout.write(renderEvents(news, ctx));
1188
+ next(news.some((e) => e.type === "ended")
1189
+ ? "follow-me report"
1190
+ : `follow-me reply <comment> --body <text>, or write a new draft and apply; then follow-me watch --since ${since}`);
1191
+ }
1192
+ return 0;
1193
+ }
1194
+ }
1195
+ /** Posts the agent's answer under a comment on the hosted page. */
1196
+ async function reply(cwd, args) {
1197
+ const [commentId, extra] = args.positionals;
1198
+ const body = args.values.get("body");
1199
+ if (commentId === undefined || extra !== undefined || body === undefined) {
1200
+ throw new UsageError("usage: follow-me reply <commentId> --body <text> [--diagram <file>]");
1201
+ }
1202
+ const diagram = diagramOf(args);
1203
+ const session = await openSession(cwd, sessionOpts(args));
1204
+ const open = await openOnServer(session, null);
1205
+ if (open === null)
1206
+ return notOpen(session);
1207
+ const id = await postReply(open.port, open.summary, commentId, {
1208
+ author: "agent",
1209
+ body,
1210
+ ...(diagram === undefined ? {} : { diagram }),
1211
+ });
1212
+ say(`replied ${id} to ${commentId}`);
1213
+ next("follow-me watch");
1214
+ return 0;
1215
+ }
1216
+ /** The Mermaid source in --diagram's file, or undefined without the flag. */
1217
+ function diagramOf(args) {
1218
+ const file = args.values.get("diagram");
1219
+ if (file === undefined)
1220
+ return undefined;
1221
+ try {
1222
+ return readFileSync(file, "utf8");
1223
+ }
1224
+ catch (error) {
1225
+ throw new UsageError(`cannot read ${file}: ${error instanceof Error ? error.message : error}`);
1226
+ }
1227
+ }
1228
+ /** "3" or "2-5" as the change indices they name. */
1229
+ function parseLineRange(raw) {
1230
+ const m = /^(\d+)(?:-(\d+))?$/.exec(raw);
1231
+ const from = Number(m?.[1]);
1232
+ const to = Number(m?.[2] ?? m?.[1]);
1233
+ if (m === null || to < from) {
1234
+ throw new UsageError(`--lines ${raw} is not a change line or a range like 2-5`);
1235
+ }
1236
+ return Array.from({ length: to - from + 1 }, (_, i) => from + i);
1237
+ }
1238
+ /**
1239
+ * Where an explanation points: --unit with --lines names change lines of a hunk, as the prompt
1240
+ * numbers them, and the page marks their rows. --commit alone is the commit itself; nothing is
1241
+ * the whole plan. Commits count from 1, as the prompt prints them.
1242
+ */
1243
+ function explainTarget(session, version, args) {
1244
+ const commitRaw = args.values.get("commit");
1245
+ const unitRaw = args.values.get("unit");
1246
+ const linesRaw = args.values.get("lines");
1247
+ if (linesRaw !== undefined && unitRaw === undefined) {
1248
+ throw new UsageError("--lines needs --unit");
1249
+ }
1250
+ if (commitRaw === undefined && unitRaw === undefined) {
1251
+ return { anchor: { kind: "plan" }, context: { commitMessage: "", path: null } };
1252
+ }
1253
+ const commits = projectedCommits(session, version);
1254
+ if (commits.length === 0) {
1255
+ throw new UsageError(`plan v${version} no longer fits ${session.anchor.branch}`);
1256
+ }
1257
+ const unit = unitRaw === undefined || !isUnitId(unitRaw) ? undefined : session.diff.units.get(unitRaw);
1258
+ if (unitRaw !== undefined && unit === undefined) {
1259
+ throw new UsageError(`--unit ${unitRaw} is not a unit of this branch`);
1260
+ }
1261
+ let index = -1;
1262
+ if (commitRaw !== undefined) {
1263
+ index = Number(commitRaw) - 1;
1264
+ if (!Number.isInteger(index) || index < 0 || index >= commits.length) {
1265
+ throw new UsageError(`--commit ${commitRaw} is not one of the ${commits.length} commits`);
1266
+ }
1267
+ }
1268
+ else {
1269
+ index = commits.findIndex((c) => c.refs.some((r) => r.unit === unit?.id));
1270
+ }
1271
+ const commit = commits[index];
1272
+ if (commit === undefined)
1273
+ throw new UsageError(`no commit of plan v${version} takes ${unitRaw}`);
1274
+ if (unit === undefined) {
1275
+ return {
1276
+ anchor: { kind: "message", commit: index },
1277
+ context: { commitMessage: commit.message, path: null },
1278
+ };
1279
+ }
1280
+ const ref = commit.refs.find((r) => r.unit === unit.id);
1281
+ if (ref === undefined)
1282
+ throw new UsageError(`commit ${index + 1} does not take ${unit.id}`);
1283
+ const context = { commitMessage: commit.message, path: unit.path };
1284
+ if (linesRaw === undefined) {
1285
+ return { anchor: { kind: "hunk", unit: unit.id, commit: index, changes: null }, context };
1286
+ }
1287
+ if (unit.kind !== "hunk")
1288
+ throw new UsageError(`--lines needs a text hunk, ${unit.id} is not one`);
1289
+ const changes = parseLineRange(linesRaw);
1290
+ const taken = ref.select.kind === "lines" ? ref.select.changes : null;
1291
+ for (const n of changes) {
1292
+ if (n >= unit.changeCount || (taken !== null && !taken.includes(n))) {
1293
+ throw new UsageError(`commit ${index + 1} does not take change line ${n} of ${unit.id}`);
1294
+ }
1295
+ }
1296
+ const rows = unit.body.flatMap((line, offset) => line.op !== " " && changes.includes(line.change) ? [{ line, offset }] : []);
1297
+ return {
1298
+ anchor: {
1299
+ kind: "hunk",
1300
+ unit: unit.id,
1301
+ commit: index,
1302
+ changes,
1303
+ offsets: rows.map((r) => r.offset),
1304
+ },
1305
+ context: {
1306
+ ...context,
1307
+ lines: rows.map(({ line }) => `${line.op}${line.text.replace(/\r?\n$/, "")}`),
1308
+ },
1309
+ };
1310
+ }
1311
+ /** Starts a thread on the hosted page: the agent explains before anyone asks. */
1312
+ async function explain(cwd, args) {
1313
+ const body = args.values.get("body");
1314
+ if (body === undefined || args.positionals.length > 0) {
1315
+ throw new UsageError("usage: follow-me explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]");
1316
+ }
1317
+ const diagram = diagramOf(args);
1318
+ const session = await openSession(cwd, sessionOpts(args));
1319
+ const open = await openOnServer(session, null);
1320
+ if (open === null)
1321
+ return notOpen(session);
1322
+ const target = explainTarget(session, open.summary.version, args);
1323
+ const id = await postComment(open.port, open.summary, {
1324
+ ...target,
1325
+ body,
1326
+ author: "agent",
1327
+ ...(diagram === undefined ? {} : { diagram }),
1328
+ });
1329
+ say(`explained ${id}`);
1330
+ next("follow-me watch");
487
1331
  return 0;
488
1332
  }
489
1333
  function describeLock(lock) {
@@ -549,7 +1393,8 @@ async function status(cwd) {
549
1393
  const session = await openSession(cwd);
550
1394
  const { anchor, diff } = session;
551
1395
  const plans = session.plans();
552
- const reviews = session.reviews();
1396
+ const reviews = session.reviews().filter((r) => r.review.mode === "split");
1397
+ const walks = session.walkReviews();
553
1398
  say(`${anchor.branch} ${short(anchor.tip)} onto ${anchor.baseRef} ${short(anchor.base)}`);
554
1399
  say(`writes to: ${splitBranch(session)}`);
555
1400
  const lock = session.lockState();
@@ -579,6 +1424,11 @@ async function status(cwd) {
579
1424
  const open = lastReview.review.comments.filter((c) => !answered.has(c.id)).length;
580
1425
  say(`reviews: ${reviews.map((r) => `v${r.version}`).join(", ")} (latest has ${plural(lastReview.review.comments.length, "comment")}, ${open} open)`);
581
1426
  }
1427
+ if (walks.length > 0) {
1428
+ const latest = walks.at(-1)?.review.comments.length ?? 0;
1429
+ say(`code reviews: ${walks.map((r) => `v${r.version}`).join(", ")} (latest has ${plural(latest, "comment")})`);
1430
+ }
1431
+ say(`signing: ${describeSigning(await signing(session.root, "config"))}`);
582
1432
  const applied = session.applied();
583
1433
  say(applied === null
584
1434
  ? "applied: never"
@@ -589,23 +1439,67 @@ function skillTargets(args) {
589
1439
  return {
590
1440
  harness: args.values.get("harness"),
591
1441
  all: args.flags.has("all"),
592
- user: args.flags.has("user"),
1442
+ project: args.flags.has("project"),
593
1443
  dryRun: args.flags.has("dry-run"),
594
1444
  };
595
1445
  }
1446
+ function missingDraft(error, cwd) {
1447
+ 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`;
1448
+ }
1449
+ const PACKAGE_NAME = "@shiyuphan/follow-me";
1450
+ /**
1451
+ * Updates the global package, then refreshes every installed skill copy, which otherwise
1452
+ * stays at the old version. FOLLOW_ME_UPGRADE_COMMAND replaces the npm call in tests.
1453
+ */
1454
+ function upgrade(targets, cwd) {
1455
+ const override = process.env["FOLLOW_ME_UPGRADE_COMMAND"];
1456
+ const command = override === undefined
1457
+ ? ["npm", "install", "-g", `${PACKAGE_NAME}@latest`]
1458
+ : ["sh", "-c", override];
1459
+ const [file, ...args] = command;
1460
+ if (file === undefined)
1461
+ return 1;
1462
+ say(`${targets.dryRun ? "would run" : "running"}: ${override ?? command.join(" ")}`);
1463
+ if (!targets.dryRun) {
1464
+ try {
1465
+ execFileSync(file, args, { stdio: "inherit" });
1466
+ }
1467
+ catch (error) {
1468
+ console.error(`follow-me: upgrade failed: ${error instanceof Error ? error.message : error}`);
1469
+ return 1;
1470
+ }
1471
+ }
1472
+ const code = runInstall({ ...targets, list: false }, homedir(), cwd, say);
1473
+ if (code === 0)
1474
+ say(`next: follow-me --version`);
1475
+ return code;
1476
+ }
1477
+ const HELP_OR_VERSION = new Set(["--help", "--version"]);
596
1478
  export async function main(argv, cwd) {
597
1479
  const [command, ...rest] = argv;
598
1480
  try {
1481
+ if (command === undefined || (command.startsWith("--") && !HELP_OR_VERSION.has(command))) {
1482
+ const spec = {
1483
+ values: ["wait", "base", "branch", "port", "syntax", "name"],
1484
+ flags: ["no-open", "author", "reviewer", "allow-syntax-errors", "force", "sign", "no-sign"],
1485
+ };
1486
+ return await entry(cwd, parseArgs(argv, spec));
1487
+ }
599
1488
  switch (command) {
600
1489
  case "extract": {
601
1490
  const args = parseArgs(rest, { values: ["base", "branch"] });
602
1491
  summarize(await extractSession(cwd, sessionOpts(args)), cwd);
1492
+ const branch = branchFlag(args);
1493
+ next(`follow-me prompt${branch}, or follow-me plan --naive${branch}`);
603
1494
  return 0;
604
1495
  }
605
1496
  case "check":
606
- return await check(cwd, parseArgs(rest, { values: ["branch"] }));
1497
+ return await check(cwd, parseArgs(rest, { values: ["branch", "syntax"] }));
607
1498
  case "apply":
608
- return await apply(cwd, parseArgs(rest, { values: ["name"], flags: ["force"] }));
1499
+ return await apply(cwd, parseArgs(rest, {
1500
+ values: ["name", "syntax"],
1501
+ flags: ["force", "sign", "no-sign", "allow-syntax-errors"],
1502
+ }));
609
1503
  case "status":
610
1504
  parseArgs(rest, {});
611
1505
  return await status(cwd);
@@ -616,17 +1510,41 @@ export async function main(argv, cwd) {
616
1510
  return 0;
617
1511
  }
618
1512
  case "plan":
619
- return await plan(cwd, parseArgs(rest, { values: ["model", "branch"], flags: ["walk"] }));
620
- case "review":
621
- return await review(cwd, parseArgs(rest, { values: ["port"], flags: ["no-open", "detach"] }));
1513
+ return await plan(cwd, parseArgs(rest, { values: ["branch"], flags: ["walk", "naive"] }));
1514
+ case "split": {
1515
+ const spec = {
1516
+ values: ["base", "branch", "syntax"],
1517
+ flags: ["no-open", "allow-syntax-errors"],
1518
+ };
1519
+ return await split(cwd, parseArgs(rest, spec));
1520
+ }
1521
+ case "review": {
1522
+ const spec = {
1523
+ values: ["base", "branch", "port", "timeout"],
1524
+ flags: ["no-open", "detach", "wait"],
1525
+ };
1526
+ return await review(cwd, parseArgs(rest, spec));
1527
+ }
622
1528
  case "walk": {
623
- const spec = { values: ["base", "branch", "model", "port"], flags: ["no-open", "detach"] };
1529
+ const spec = { values: ["base", "branch", "port"], flags: ["no-open", "detach"] };
624
1530
  return await walk(cwd, parseArgs(rest, spec));
625
1531
  }
626
1532
  case "server":
627
1533
  return await serverCommand(rest);
628
1534
  case "feedback":
629
- return await feedback(cwd, parseArgs(rest, { values: ["branch"] }));
1535
+ return await feedback(cwd, parseArgs(rest, { values: ["branch"], flags: ["walk", "json", "markdown"] }));
1536
+ case "watch": {
1537
+ const spec = { values: ["since", "timeout", "branch"], flags: ["json", "follow"] };
1538
+ return await watch(cwd, parseArgs(rest, spec));
1539
+ }
1540
+ case "reply":
1541
+ return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "branch"] }));
1542
+ case "explain": {
1543
+ const spec = { values: ["commit", "unit", "lines", "body", "diagram", "branch"] };
1544
+ return await explain(cwd, parseArgs(rest, spec));
1545
+ }
1546
+ case "report":
1547
+ return await report(cwd, parseArgs(rest, { values: ["branch"], flags: ["json", "markdown"] }));
630
1548
  case "clean": {
631
1549
  const args = parseArgs(rest, { flags: ["all", "dry-run"] });
632
1550
  const opts = { all: args.flags.has("all"), dryRun: args.flags.has("dry-run") };
@@ -637,21 +1555,29 @@ export async function main(argv, cwd) {
637
1555
  case "install": {
638
1556
  const args = parseArgs(rest, {
639
1557
  values: ["harness"],
640
- flags: ["all", "user", "dry-run", "list"],
1558
+ flags: ["all", "project", "dry-run", "list"],
641
1559
  });
642
1560
  return runInstall({ ...skillTargets(args), list: args.flags.has("list") }, homedir(), cwd, say);
643
1561
  }
644
1562
  case "uninstall": {
645
- const args = parseArgs(rest, { values: ["harness"], flags: ["all", "user", "dry-run"] });
1563
+ const args = parseArgs(rest, { values: ["harness"], flags: ["all", "project", "dry-run"] });
646
1564
  return runUninstall(skillTargets(args), homedir(), cwd, say);
647
1565
  }
648
- case undefined:
1566
+ case "upgrade": {
1567
+ const args = parseArgs(rest, { values: ["harness"], flags: ["all", "project", "dry-run"] });
1568
+ return upgrade(skillTargets(args), cwd);
1569
+ }
649
1570
  case "help":
1571
+ case "-h":
650
1572
  case "--help":
651
- say(USAGE);
652
- return command === undefined ? 1 : 0;
1573
+ say(parseArgs(rest, { flags: ["all"] }).flags.has("all") ? USAGE : SHORT_USAGE);
1574
+ return 0;
1575
+ case "-v":
1576
+ case "--version":
1577
+ say(`follow-me ${version()}`);
1578
+ return 0;
653
1579
  default:
654
- throw new UsageError(`unknown command "${command}"\n${USAGE}`);
1580
+ throw new UsageError(`unknown command "${command}"\n${SHORT_USAGE}`);
655
1581
  }
656
1582
  }
657
1583
  catch (error) {
@@ -663,9 +1589,12 @@ export async function main(argv, cwd) {
663
1589
  SessionLockedError,
664
1590
  RegistrationError,
665
1591
  GitError,
666
- PlanRequestError,
1592
+ NotARepositoryError,
667
1593
  ];
668
- if (error instanceof DiffParseError) {
1594
+ if (error instanceof MissingDraftError) {
1595
+ console.error(`follow-me: ${missingDraft(error, cwd)}`);
1596
+ }
1597
+ else if (error instanceof DiffParseError) {
669
1598
  console.error(`follow-me: could not parse the diff, please report this: ${error.message}`);
670
1599
  }
671
1600
  else if (error instanceof Error && known.some((k) => error instanceof k)) {