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