@shiyuphan/follow-me 0.2.0 → 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 (152) hide show
  1. package/README.md +44 -55
  2. package/dist/src/cli.js +566 -188
  3. package/dist/src/comments.js +66 -5
  4. package/dist/src/git.js +12 -0
  5. package/dist/src/host.js +19 -8
  6. package/dist/src/install.js +21 -0
  7. package/dist/src/plan.js +1 -1
  8. package/dist/src/prompt.js +343 -39
  9. package/dist/src/server.js +176 -67
  10. package/dist/src/session.js +13 -11
  11. package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-DcmzRkVZ.js +1 -0
  12. package/dist/ui/dist/assets/arc-T5QB0T1C.js +1 -0
  13. package/dist/ui/dist/assets/architecture-WOLXFQ4H-DUIkQTeE.js +1 -0
  14. package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-BUYi4-bA.js +36 -0
  15. package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
  16. package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-dyJxtT9e.js +140 -0
  17. package/dist/ui/dist/assets/c4Diagram-XDPPPE54-CrAgiaZR.js +38 -0
  18. package/dist/ui/dist/assets/channel-wW7ebm0i.js +1 -0
  19. package/dist/ui/dist/assets/chunk-2E7WHVMD-C6fAf4Vj.js +88 -0
  20. package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  21. package/dist/ui/dist/assets/chunk-4EMNKR2T-BLZjwZ4Q.js +1 -0
  22. package/dist/ui/dist/assets/chunk-55XCU76K-DGLK2Q-L.js +1 -0
  23. package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  24. package/dist/ui/dist/assets/chunk-7S6XSTFO-BablszTX.js +2 -0
  25. package/dist/ui/dist/assets/chunk-AQDTSKZB-Cuk3B_vb.js +72 -0
  26. package/dist/ui/dist/assets/chunk-DSNLSHJZ-DiC78RqE.js +127 -0
  27. package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  28. package/dist/ui/dist/assets/chunk-HAGFOTED-X_RbvixX.js +62 -0
  29. package/dist/ui/dist/assets/chunk-HNE4Q36P-C14_x5ck.js +1 -0
  30. package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  31. package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  32. package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  33. package/dist/ui/dist/assets/chunk-NQOKRVIF-Dxd0dtYR.js +213 -0
  34. package/dist/ui/dist/assets/chunk-UHW7PFBP-BIxePNUS.js +1 -0
  35. package/dist/ui/dist/assets/chunk-VQ5W62QI-CKSKLbj4.js +1 -0
  36. package/dist/ui/dist/assets/chunk-WQQL4CDK-CWNmZGlR.js +10 -0
  37. package/dist/ui/dist/assets/chunk-XXDRQBXY-D-I5QHgG.js +1 -0
  38. package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  39. package/dist/ui/dist/assets/chunk-YZO67HFG-DFP68CFd.js +1 -0
  40. package/dist/ui/dist/assets/chunk-ZAUX2LTX-DY90d4QG.js +1 -0
  41. package/dist/ui/dist/assets/chunk-ZCHMQSOR-Dlpr-5UC.js +2 -0
  42. package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-CH9uhoLJ.js +217 -0
  43. package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-DtYHrp8D.js +1 -0
  44. package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtslbtrY.js +1 -0
  45. package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUU1Jcc7.js +62 -0
  46. package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  47. package/dist/ui/dist/assets/dagre-NK4GIZVX-CDlP-dRW.js +4 -0
  48. package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  49. package/dist/ui/dist/assets/diagram-CBK3MWTC-DiV2fn-r.js +24 -0
  50. package/dist/ui/dist/assets/diagram-I5XIIV2D-Bg5RehGi.js +24 -0
  51. package/dist/ui/dist/assets/diagram-ODIL2UCW-CFmazz3f.js +30 -0
  52. package/dist/ui/dist/assets/diagram-PFBEJUXE-uw3ZBqDp.js +3 -0
  53. package/dist/ui/dist/assets/diagram-RJA3I4VV-uEOYYRYa.js +41 -0
  54. package/dist/ui/dist/assets/diagram-TYUNFSNR-BN58LF_3.js +200 -0
  55. package/dist/ui/dist/assets/dist-tWS_lXDS.js +1 -0
  56. package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-B68Tz-w8.js +1 -0
  57. package/dist/ui/dist/assets/elk-QONTBCTQ-DZZohNea.js +27 -0
  58. package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BH_m74pv.js +99 -0
  59. package/dist/ui/dist/assets/eventmodeling-K75KTNOO-CTp5FHos.js +1 -0
  60. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +1 -0
  61. package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-BDau7_0f.js +292 -0
  62. package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  63. package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  64. package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  65. package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  66. package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  67. package/dist/ui/dist/assets/gitGraph-VSP46ZUC-Bv84IoCm.js +1 -0
  68. package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-DzXcMlm8.js +106 -0
  69. package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
  70. package/dist/ui/dist/assets/index-BTJoqVVN.js +133 -0
  71. package/dist/ui/dist/assets/index-D60U3smc.css +1 -0
  72. package/dist/ui/dist/assets/info-OHQRW6UA-222J0O_j.js +1 -0
  73. package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-DE2sa3Vz.js +2 -0
  74. package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
  75. package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js +70 -0
  76. package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-JSUXX5Hb.js +139 -0
  77. package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-Bg79b49M.js +89 -0
  78. package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
  79. package/dist/ui/dist/assets/line-Bk5qQSIa.js +1 -0
  80. package/dist/ui/dist/assets/linear-PFnVMru5.js +1 -0
  81. package/dist/ui/dist/assets/mermaid-parser.core-Dcxlzc-S.js +7 -0
  82. package/dist/ui/dist/assets/mermaid.core-TeOHQJ00.js +44 -0
  83. package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-IaNk8NnW.js +98 -0
  84. package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  85. package/dist/ui/dist/assets/packet-JDAUHWVQ-DMff5cVK.js +1 -0
  86. package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
  87. package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-66Xu4YRc.js +1 -0
  88. package/dist/ui/dist/assets/pie-XZMESJXO-GEtzLCIM.js +1 -0
  89. package/dist/ui/dist/assets/pieDiagram-GNMPY74T-7jMBi-kU.js +39 -0
  90. package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
  91. package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
  92. package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
  93. package/dist/ui/dist/assets/prism-core-FDHlWQ43.js +1 -0
  94. package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
  95. package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
  96. package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
  97. package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
  98. package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
  99. package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
  100. package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
  101. package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
  102. package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
  103. package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
  104. package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  105. package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
  106. package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
  107. package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
  108. package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
  109. package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
  110. package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
  111. package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
  112. package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
  113. package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
  114. package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
  115. package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-8hIWayC2.js +7 -0
  116. package/dist/ui/dist/assets/radar-ABXABTNO-BTdwUOOF.js +1 -0
  117. package/dist/ui/dist/assets/railroad-I3PHUGI6-DcMbDqBL.js +1 -0
  118. package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-gTzUTWeI.js +1 -0
  119. package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-CNtdUPtk.js +1 -0
  120. package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-DOiExAtE.js +1 -0
  121. package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-2kE_ygvM.js +1 -0
  122. package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-CoIAHRq2.js +84 -0
  123. package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  124. package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-7p8D3wim.js +40 -0
  125. package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-C6QFnMvn.js +169 -0
  126. package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  127. package/dist/ui/dist/assets/src-FgijoBVs.js +1 -0
  128. package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-n9eAb_bT.js +291 -0
  129. package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-U18mtUzX.js +1 -0
  130. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +8 -0
  131. package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-CD2Z8QGP.js +120 -0
  132. package/dist/ui/dist/assets/treeView-D4JQ5SDB-sqUjH5gB.js +1 -0
  133. package/dist/ui/dist/assets/treemap-SAJKECNS-DvZ38FP7.js +1 -0
  134. package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-CqOGidaZ.js +328 -0
  135. package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D1SXSBQ5.js +34 -0
  136. package/dist/ui/dist/assets/wardley-7MLQ67FV-Bpwio-Dx.js +1 -0
  137. package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-JSUXHMnk.js +78 -0
  138. package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-B4IFqQTN.js +7 -0
  139. package/dist/ui/dist/index.html +15 -0
  140. package/dist/ui/dist/style.css +121 -0
  141. package/package.json +31 -10
  142. package/skills/follow-me/SKILL.md +15 -89
  143. package/dist/src/anthropic.js +0 -133
  144. package/dist/ui/app.js +0 -2201
  145. package/dist/ui/concerns.js +0 -165
  146. package/dist/ui/highlight.js +0 -221
  147. package/dist/ui/index.html +0 -94
  148. package/dist/ui/live.js +0 -184
  149. package/dist/ui/playback.js +0 -579
  150. package/dist/ui/questions.js +0 -189
  151. package/dist/ui/style.css +0 -1331
  152. package/dist/ui/summary.js +0 -45
package/dist/src/cli.js CHANGED
@@ -1,35 +1,53 @@
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 { 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
10
  import { naiveDraft } from "./naive.js";
11
11
  import { serializePlanFile, validatePlan } from "./plan.js";
12
- import { renderEvents, renderPrompt, renderReport } from "./prompt.js";
12
+ import { renderEvents, renderPrompt, renderReport, reportItems, } from "./prompt.js";
13
13
  import { RegistrationError, sessionIdOf } from "./server.js";
14
14
  import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, } 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 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.`;
19
36
  const USAGE = `usage: follow-me <command> [options]
20
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
+
21
41
  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
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
25
46
  extract [--base <ref>] [--branch <name>] anchor a session and list the units
26
47
  prompt [--walk] [--branch <name>] print the planner prompt; your coding agent
27
48
  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
49
+ plan --naive [--branch <name>] no agent: one "wip: <path>" commit per touched
50
+ file, in diff order
33
51
  check [path] [--branch <name>] [--syntax "<cmd {file}>"]
34
52
  validate draft.json or the given plan file, and
35
53
  check that every file each commit writes parses;
@@ -40,11 +58,11 @@ Split your own branch:
40
58
  --name; never on <branch> or a checked-out branch;
41
59
  refuses a commit with a file that does not parse
42
60
  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
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
48
66
  status show the session's plans, reviews, lock and the
49
67
  branch apply writes
50
68
 
@@ -58,15 +76,22 @@ Interactive loop (while the review page is open):
58
76
  reply <commentId> --body <text> [--diagram <file>]
59
77
  answer under a comment on the page; the diagram is
60
78
  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
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)
64
90
  apply, while the page is open, swaps it to the new plan version in place, same URL.
65
91
 
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
92
+ Old names, still working:
93
+ walk is review; prints one line saying so
94
+ feedback [--walk] is report; prints one line saying so
70
95
 
71
96
  Review server:
72
97
  server status show the review server and the sessions it hosts
@@ -78,14 +103,17 @@ Setup:
78
103
  install --list show each harness, its skill dirs and docs
79
104
  uninstall [--harness <ids>|--all] [--project] [--dry-run]
80
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
81
109
  clean [--all] [--dry-run] remove sessions of deleted branches, finished
82
110
  review drafts and leftovers of dead processes;
83
111
  --all removes .follow-me/ entirely
84
112
  --version print the version
85
113
  help print this
86
114
 
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
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
89
117
  prints its URL; otherwise the command serves until its review ends, or with --detach starts the
90
118
  server in the background. --port taken by another program falls back to a free port.
91
119
  --force lets apply move a branch follow-me did not create; it never allows <branch> itself.
@@ -462,36 +490,27 @@ function promptInput(session, mode, args) {
462
490
  };
463
491
  if (mode === "walk")
464
492
  return { ...common, mode };
493
+ const previous = session.plans().at(-1);
465
494
  return {
466
495
  ...common,
467
496
  mode,
468
- previousPlan: session.plans().at(-1)?.file ?? null,
497
+ previousPlan: previous?.file ?? null,
469
498
  review: session.lastReview(),
499
+ previousCommits: previous === undefined ? [] : projectedCommits(session, previous.version),
470
500
  };
471
501
  }
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;
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 : [];
490
509
  }
491
- /** Writes the one-commit-per-file draft with no API call and no agent. */
510
+ /** Writes the one-commit-per-file draft with no agent. */
492
511
  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");
512
+ if (args.flags.has("walk")) {
513
+ throw new UsageError("--naive writes no story, so --walk does not apply");
495
514
  }
496
515
  const draft = naiveDraft(session.diff);
497
516
  session.writeDraft(draft);
@@ -503,14 +522,11 @@ function naive(session, cwd, args) {
503
522
  return 0;
504
523
  }
505
524
  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)
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");
511
527
  return 1;
512
- next(mode === "walk" ? `follow-me walk${branchFlag(args)}` : "follow-me apply");
513
- return 0;
528
+ }
529
+ return naive(await openSession(cwd, sessionOpts(args)), cwd, args);
514
530
  }
515
531
  /** The latest frozen plan, or the draft frozen now when it validates. */
516
532
  function latestPlan(session) {
@@ -524,8 +540,8 @@ function latestPlan(session) {
524
540
  return null;
525
541
  return { version: session.freeze(verdict.plan), plan: verdict.plan };
526
542
  }
527
- function announce(mode, url, args) {
528
- say(`follow-me ${mode === "walk" ? "code review" : "review"}: ${url}`);
543
+ function announce(url, args) {
544
+ say(`follow-me review: ${url}`);
529
545
  if (!args.flags.has("no-open"))
530
546
  openBrowser(url);
531
547
  }
@@ -548,10 +564,10 @@ async function hostReview(session, mode, args, prepare, report) {
548
564
  const home = stateDir();
549
565
  const open = await openOnServer(session, mode);
550
566
  if (open !== null) {
551
- announce(mode, sessionUrl(open.port, open.summary), args);
567
+ announce(sessionUrl(open.port, open.summary), args);
552
568
  return { kind: "elsewhere", ...open };
553
569
  }
554
- return locked(session, mode === "walk" ? "walk" : "review", async (held) => {
570
+ return locked(session, "review", async (held) => {
555
571
  const prepared = await prepare();
556
572
  if (prepared === null)
557
573
  return { kind: "refused" };
@@ -577,12 +593,12 @@ async function hostReview(session, mode, args, prepare, report) {
577
593
  started = await startOrAttach(home, port, say);
578
594
  if (started.kind === "attached") {
579
595
  const summary = await registerWith(started.port, registration);
580
- announce(mode, sessionUrl(started.port, summary), args);
596
+ announce(sessionUrl(started.port, summary), args);
581
597
  return { kind: "elsewhere", port: started.port, summary };
582
598
  }
583
599
  const { host } = started;
584
600
  const { summary, done } = await host.register(registration);
585
- announce(mode, sessionUrl(host.port, summary), args);
601
+ announce(sessionUrl(host.port, summary), args);
586
602
  const review = await done;
587
603
  if (review === null)
588
604
  return { kind: "stopped" };
@@ -601,13 +617,10 @@ function exitCode(hosted) {
601
617
  }
602
618
  return hosted.kind === "refused" || hosted.kind === "stopped" ? 1 : 0;
603
619
  }
604
- function alreadyReviewed(session, mode, version, args) {
605
- if (!reviewsOf(session, mode).some((r) => r.version === version))
620
+ function alreadyReviewed(session, version, args) {
621
+ if (!reviewsOf(session).some((r) => r.version === version))
606
622
  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`);
623
+ console.error(`follow-me: plan v${version} already has a review; read it with \`follow-me report${branchFlag(args)}\``);
611
624
  return true;
612
625
  }
613
626
  /** This session's review, of this mode or any, on the user's server when that holds its lock. */
@@ -630,13 +643,15 @@ function timeoutOf(args) {
630
643
  }
631
644
  return seconds;
632
645
  }
633
- /** The reviews in this mode's slot, oldest first. */
634
- function reviewsOf(session, mode) {
635
- return mode === "walk" ? session.walkReviews() : session.reviews();
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);
636
652
  }
637
- function savedReview(session, mode, version) {
638
- const stored = reviewsOf(session, mode).find((r) => r.version === version);
639
- return stored?.review ?? null;
653
+ function savedReview(session, version) {
654
+ return reviewsOf(session).find((r) => r.version === version)?.review ?? null;
640
655
  }
641
656
  /**
642
657
  * Polls the server until it no longer hosts the session, then reads the review it saved. Null
@@ -649,31 +664,47 @@ async function waitForEnd(session, hosted, timeout) {
649
664
  return "timeout";
650
665
  await new Promise((resolve) => setTimeout(resolve, 250));
651
666
  }
652
- return savedReview(session, hosted.summary.mode, hosted.summary.version);
667
+ return savedReview(session, hosted.summary.version);
653
668
  }
654
- function reportSplit(review) {
669
+ function reportSaved(review) {
655
670
  say(`review v${review.planVersion} saved with ${plural(review.comments.length, "comment")}`);
656
- next("follow-me feedback");
671
+ next("follow-me report");
657
672
  }
658
673
  function prepareSplit(session, args) {
659
674
  return async () => {
660
675
  const latest = latestPlan(session);
661
- if (latest === null || alreadyReviewed(session, "split", latest.version, args))
676
+ if (latest === null || alreadyReviewed(session, latest.version, args))
662
677
  return null;
663
678
  return latest;
664
679
  };
665
680
  }
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. */
666
694
  async function review(cwd, args) {
667
- const session = await openSession(cwd);
695
+ const session = await openFresh(cwd, args);
668
696
  const timeout = timeoutOf(args);
669
697
  if (timeout !== undefined && !args.flags.has("wait")) {
670
698
  throw new UsageError("--timeout only applies with --wait");
671
699
  }
672
- const hosted = await hostReview(session, "split", args, prepareSplit(session, args), reportSplit);
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);
673
704
  if (hosted.kind !== "elsewhere")
674
705
  return exitCode(hosted);
675
706
  if (!args.flags.has("wait")) {
676
- next("end the review on the page, then follow-me feedback");
707
+ next("end the review on the page, then follow-me report");
677
708
  return 0;
678
709
  }
679
710
  const ended = await waitForEnd(session, hosted, timeout);
@@ -683,7 +714,7 @@ async function review(cwd, args) {
683
714
  }
684
715
  if (ended === null)
685
716
  return exitCode({ kind: "stopped" });
686
- reportSplit(ended);
717
+ reportSaved(ended);
687
718
  return 0;
688
719
  }
689
720
  /** Why split needs a new draft before it can go on, or null when draft.json is ready. */
@@ -721,49 +752,255 @@ function draftNeed(session, cwd, args) {
721
752
  function handOff(session, cwd, args, why) {
722
753
  session.writePrompt(renderPrompt(promptInput(session, "split", args)));
723
754
  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.`);
755
+ say(`${why}.`);
725
756
  say(`Have your coding agent follow ${prompt} and write ${relative(cwd, session.draftPath)}, then run:`);
726
757
  say(` follow-me check${branchFlag(args)}`);
727
758
  say(` follow-me split${branchFlag(args)}`);
728
759
  next(`hand ${prompt} to your agent`);
729
760
  }
730
761
  /**
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.
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.
733
764
  */
734
765
  async function split(cwd, args) {
735
766
  const session = await openFresh(cwd, args);
736
767
  say(headline(session));
737
768
  const open = await openOnServer(session, "split");
738
769
  if (open !== null) {
739
- announce("split", sessionUrl(open.port, open.summary), args);
740
- next("end the review on the page, then follow-me feedback");
770
+ announce(sessionUrl(open.port, open.summary), args);
771
+ next("end the review on the page, then follow-me report");
741
772
  return 0;
742
773
  }
743
774
  const need = draftNeed(session, cwd, args);
744
775
  if (need === "invalid")
745
776
  return 1;
746
777
  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);
778
+ handOff(session, cwd, args, need);
779
+ return 1;
756
780
  }
757
781
  const applied = await locked(session, "apply", () => applyLocked(session, args));
758
782
  if (applied !== 0)
759
783
  return applied;
760
784
  const detached = { ...args, flags: new Set([...args.flags, "detach"]) };
761
- const hosted = await hostReview(session, "split", detached, prepareSplit(session, args), reportSplit);
785
+ const hosted = await hostReview(session, "split", detached, prepareSplit(session, args), reportSaved);
762
786
  if (hosted.kind !== "elsewhere")
763
787
  return exitCode(hosted);
764
- next("end the review on the page, then follow-me feedback");
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`);
765
854
  return 0;
766
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
+ }
767
1004
  /** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
768
1005
  async function openFresh(cwd, args) {
769
1006
  const opts = sessionOpts(args);
@@ -801,41 +1038,24 @@ function walkablePlan(session) {
801
1038
  const verdict = validatePlan(session.diff, draft, { lastReview });
802
1039
  return verdict.ok ? { version: session.freeze(verdict.plan), plan: verdict.plan } : null;
803
1040
  }
804
- function hasApiCredentials() {
805
- return Boolean(process.env["ANTHROPIC_API_KEY"] || process.env["ANTHROPIC_AUTH_TOKEN"]);
806
- }
1041
+ /** The old name of review; it prints one line saying so and goes on. */
807
1042
  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);
1043
+ console.error("follow-me: walk is now review; running review");
1044
+ return review(cwd, args);
819
1045
  }
820
- async function walkPlan(session, cwd, args) {
821
- let latest = walkablePlan(session);
1046
+ async function walkPlan(session, args) {
1047
+ const latest = walkablePlan(session);
822
1048
  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 };
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;
834
1054
  }
835
- return alreadyReviewed(session, "walk", latest.version, args) ? null : latest;
1055
+ return alreadyReviewed(session, latest.version, args) ? null : latest;
836
1056
  }
837
1057
  /** The consolidated report of a saved review, or null after saying why it cannot be rendered. */
838
- function renderStored(session, stored) {
1058
+ function renderStored(session, stored, format = "markdown") {
839
1059
  const plan = session.plans().find((p) => p.version === stored.version);
840
1060
  if (plan === undefined) {
841
1061
  console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
@@ -851,59 +1071,54 @@ function renderStored(session, stored) {
851
1071
  .plans()
852
1072
  .filter((p) => p.version > stored.version)
853
1073
  .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;
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`;
872
1078
  }
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;
1079
+ return renderReport(stored.review, verdict.plan.commits, session.diff.units, opts);
882
1080
  }
883
- /** The consolidated report of the hosted review as it stands, else of the last one that ended. */
884
- async function report(cwd, args) {
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
+ }
885
1090
  const session = await openSession(cwd, sessionOpts(args));
886
- const open = await openOnServer(session, null);
1091
+ const format = args.flags.has("json") ? "json" : "markdown";
1092
+ const open = walkOnly ? null : await openOnServer(session, null);
887
1093
  if (open !== null) {
888
- process.stdout.write(await fetchReport(open.port, open.summary));
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);
889
1096
  return 0;
890
1097
  }
891
- const ended = [...session.reviews(), ...session.walkReviews()].sort((a, b) => a.review.endedAt.localeCompare(b.review.endedAt));
1098
+ const ended = (walkOnly ? session.walkReviews() : reviewsOf(session)).sort((a, b) => a.review.endedAt.localeCompare(b.review.endedAt));
892
1099
  const stored = ended.at(-1);
893
1100
  if (stored === undefined) {
894
1101
  console.error("follow-me: no review is open and none has ended; run `follow-me review` first");
895
1102
  return 1;
896
1103
  }
897
- const text = renderStored(session, stored);
1104
+ const text = renderStored(session, stored, format);
898
1105
  if (text === null)
899
1106
  return 1;
900
1107
  process.stdout.write(text);
901
1108
  return 0;
902
1109
  }
903
- /** Events the reviewer caused; the agent's own replies and version swaps are not news to it. */
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. */
904
1116
  function isNews(event) {
905
1117
  if (event.type === "version")
906
1118
  return false;
1119
+ if (event.type === "comment" || event.type === "comment-updated") {
1120
+ return event.comment.author !== "agent";
1121
+ }
907
1122
  return !(event.type === "reply" && event.reply.author === "agent");
908
1123
  }
909
1124
  const POLL_MS = 25_000;
@@ -926,17 +1141,26 @@ async function watch(cwd, args) {
926
1141
  if (!Number.isInteger(since) || since < 0) {
927
1142
  throw new UsageError(`--since ${sinceRaw} is not an event number`);
928
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
+ };
929
1152
  if (args.flags.has("follow")) {
930
1153
  let ended = false;
931
1154
  await followEvents(open.port, open.summary, since, (event) => {
932
1155
  if (event.type === "ended")
933
1156
  ended = true;
1157
+ const ctx = quoting([event]);
934
1158
  if (!isNews(event))
935
1159
  return;
936
1160
  if (args.flags.has("json"))
937
1161
  say(JSON.stringify(event));
938
1162
  else
939
- process.stdout.write(renderEvents([event]));
1163
+ process.stdout.write(renderEvents([event], ctx));
940
1164
  });
941
1165
  if (ended)
942
1166
  return 0;
@@ -952,6 +1176,7 @@ async function watch(cwd, args) {
952
1176
  }
953
1177
  const batch = await pollEvents(open.port, open.summary, since, Math.min(left, POLL_MS));
954
1178
  since = batch.seq;
1179
+ const ctx = quoting(batch.events);
955
1180
  const news = batch.events.filter(isNews);
956
1181
  if (news.length === 0)
957
1182
  continue;
@@ -959,7 +1184,7 @@ async function watch(cwd, args) {
959
1184
  for (const event of news)
960
1185
  say(JSON.stringify(event));
961
1186
  else {
962
- process.stdout.write(renderEvents(news));
1187
+ process.stdout.write(renderEvents(news, ctx));
963
1188
  next(news.some((e) => e.type === "ended")
964
1189
  ? "follow-me report"
965
1190
  : `follow-me reply <comment> --body <text>, or write a new draft and apply; then follow-me watch --since ${since}`);
@@ -974,16 +1199,7 @@ async function reply(cwd, args) {
974
1199
  if (commentId === undefined || extra !== undefined || body === undefined) {
975
1200
  throw new UsageError("usage: follow-me reply <commentId> --body <text> [--diagram <file>]");
976
1201
  }
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
- }
1202
+ const diagram = diagramOf(args);
987
1203
  const session = await openSession(cwd, sessionOpts(args));
988
1204
  const open = await openOnServer(session, null);
989
1205
  if (open === null)
@@ -997,6 +1213,123 @@ async function reply(cwd, args) {
997
1213
  next("follow-me watch");
998
1214
  return 0;
999
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");
1331
+ return 0;
1332
+ }
1000
1333
  function describeLock(lock) {
1001
1334
  if (lock.kind === "free")
1002
1335
  return "free";
@@ -1111,17 +1444,53 @@ function skillTargets(args) {
1111
1444
  };
1112
1445
  }
1113
1446
  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`;
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`;
1115
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"]);
1116
1478
  export async function main(argv, cwd) {
1117
1479
  const [command, ...rest] = argv;
1118
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
+ }
1119
1488
  switch (command) {
1120
1489
  case "extract": {
1121
1490
  const args = parseArgs(rest, { values: ["base", "branch"] });
1122
1491
  summarize(await extractSession(cwd, sessionOpts(args)), cwd);
1123
1492
  const branch = branchFlag(args);
1124
- next(`follow-me prompt${branch}, or follow-me plan${branch} with an Anthropic key`);
1493
+ next(`follow-me prompt${branch}, or follow-me plan --naive${branch}`);
1125
1494
  return 0;
1126
1495
  }
1127
1496
  case "check":
@@ -1141,34 +1510,41 @@ export async function main(argv, cwd) {
1141
1510
  return 0;
1142
1511
  }
1143
1512
  case "plan":
1144
- return await plan(cwd, parseArgs(rest, { values: ["model", "branch"], flags: ["walk", "naive"] }));
1513
+ return await plan(cwd, parseArgs(rest, { values: ["branch"], flags: ["walk", "naive"] }));
1145
1514
  case "split": {
1146
1515
  const spec = {
1147
- values: ["base", "branch", "model", "syntax"],
1516
+ values: ["base", "branch", "syntax"],
1148
1517
  flags: ["no-open", "allow-syntax-errors"],
1149
1518
  };
1150
1519
  return await split(cwd, parseArgs(rest, spec));
1151
1520
  }
1152
1521
  case "review": {
1153
- const spec = { values: ["port", "timeout"], flags: ["no-open", "detach", "wait"] };
1522
+ const spec = {
1523
+ values: ["base", "branch", "port", "timeout"],
1524
+ flags: ["no-open", "detach", "wait"],
1525
+ };
1154
1526
  return await review(cwd, parseArgs(rest, spec));
1155
1527
  }
1156
1528
  case "walk": {
1157
- const spec = { values: ["base", "branch", "model", "port"], flags: ["no-open", "detach"] };
1529
+ const spec = { values: ["base", "branch", "port"], flags: ["no-open", "detach"] };
1158
1530
  return await walk(cwd, parseArgs(rest, spec));
1159
1531
  }
1160
1532
  case "server":
1161
1533
  return await serverCommand(rest);
1162
1534
  case "feedback":
1163
- return await feedback(cwd, parseArgs(rest, { values: ["branch"], flags: ["walk"] }));
1535
+ return await feedback(cwd, parseArgs(rest, { values: ["branch"], flags: ["walk", "json", "markdown"] }));
1164
1536
  case "watch": {
1165
1537
  const spec = { values: ["since", "timeout", "branch"], flags: ["json", "follow"] };
1166
1538
  return await watch(cwd, parseArgs(rest, spec));
1167
1539
  }
1168
1540
  case "reply":
1169
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
+ }
1170
1546
  case "report":
1171
- return await report(cwd, parseArgs(rest, { values: ["branch"] }));
1547
+ return await report(cwd, parseArgs(rest, { values: ["branch"], flags: ["json", "markdown"] }));
1172
1548
  case "clean": {
1173
1549
  const args = parseArgs(rest, { flags: ["all", "dry-run"] });
1174
1550
  const opts = { all: args.flags.has("all"), dryRun: args.flags.has("dry-run") };
@@ -1187,18 +1563,21 @@ export async function main(argv, cwd) {
1187
1563
  const args = parseArgs(rest, { values: ["harness"], flags: ["all", "project", "dry-run"] });
1188
1564
  return runUninstall(skillTargets(args), homedir(), cwd, say);
1189
1565
  }
1190
- case undefined:
1566
+ case "upgrade": {
1567
+ const args = parseArgs(rest, { values: ["harness"], flags: ["all", "project", "dry-run"] });
1568
+ return upgrade(skillTargets(args), cwd);
1569
+ }
1191
1570
  case "help":
1192
1571
  case "-h":
1193
1572
  case "--help":
1194
- say(USAGE);
1195
- return command === undefined ? 1 : 0;
1573
+ say(parseArgs(rest, { flags: ["all"] }).flags.has("all") ? USAGE : SHORT_USAGE);
1574
+ return 0;
1196
1575
  case "-v":
1197
1576
  case "--version":
1198
1577
  say(`follow-me ${version()}`);
1199
1578
  return 0;
1200
1579
  default:
1201
- throw new UsageError(`unknown command "${command}"\n${USAGE}`);
1580
+ throw new UsageError(`unknown command "${command}"\n${SHORT_USAGE}`);
1202
1581
  }
1203
1582
  }
1204
1583
  catch (error) {
@@ -1210,7 +1589,6 @@ export async function main(argv, cwd) {
1210
1589
  SessionLockedError,
1211
1590
  RegistrationError,
1212
1591
  GitError,
1213
- PlanRequestError,
1214
1592
  NotARepositoryError,
1215
1593
  ];
1216
1594
  if (error instanceof MissingDraftError) {