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