@shiyuphan/follow-me 0.1.0 → 0.2.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 +128 -56
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +836 -82
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +103 -24
- package/dist/src/harness.js +74 -0
- package/dist/src/host.js +379 -0
- package/dist/src/install.js +131 -0
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +281 -25
- package/dist/src/prompt.js +391 -82
- package/dist/src/server.js +710 -160
- package/dist/src/session.js +311 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +5 -2
- package/dist/ui/app.js +1703 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +62 -7
- package/dist/ui/live.js +184 -0
- package/dist/ui/playback.js +480 -76
- package/dist/ui/questions.js +189 -0
- package/dist/ui/style.css +975 -75
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -16
package/dist/src/cli.js
CHANGED
|
@@ -1,31 +1,96 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
3
5
|
import { PlanRequestError, requestPlan } from "./anthropic.js";
|
|
4
6
|
import { DiffParseError, formatUnitRef } from "./diff.js";
|
|
5
|
-
import { BranchRefusedError, GitError, treeOf, writeBranch } from "./git.js";
|
|
7
|
+
import { BranchRefusedError, branchHead, GitError, signing, treeOf, writeBranch, } from "./git.js";
|
|
8
|
+
import { fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postReply, probeHealth, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
|
|
9
|
+
import { runInstall, runUninstall } from "./install.js";
|
|
10
|
+
import { naiveDraft } from "./naive.js";
|
|
6
11
|
import { serializePlanFile, validatePlan } from "./plan.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { BranchMovedError, extractSession, openSession, SessionFileError, } from "./session.js";
|
|
12
|
+
import { renderEvents, renderPrompt, renderReport } from "./prompt.js";
|
|
13
|
+
import { RegistrationError, sessionIdOf } from "./server.js";
|
|
14
|
+
import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, } from "./session.js";
|
|
15
|
+
import { checkSyntax } from "./syntax.js";
|
|
10
16
|
class UsageError extends Error {
|
|
11
17
|
name = "UsageError";
|
|
12
18
|
}
|
|
13
|
-
const USAGE = `usage: follow-me <command>
|
|
19
|
+
const USAGE = `usage: follow-me <command> [options]
|
|
14
20
|
|
|
21
|
+
Split your own branch:
|
|
22
|
+
split [--base <ref>] [--branch <name>] [--model <id>] [--no-open]
|
|
23
|
+
extract, draft (with an Anthropic key), apply and
|
|
24
|
+
open the review in one go
|
|
15
25
|
extract [--base <ref>] [--branch <name>] anchor a session and list the units
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
status show the session's plans, reviews and branch
|
|
19
|
-
prompt [--walk] [--branch <name>] print the planner prompt for a coding agent
|
|
26
|
+
prompt [--walk] [--branch <name>] print the planner prompt; your coding agent
|
|
27
|
+
writes draft.json from it
|
|
20
28
|
plan [--walk] [--model <id>] [--branch <name>]
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
the same draft as prompt, written by the
|
|
30
|
+
Anthropic API
|
|
31
|
+
plan --naive [--branch <name>] no API, no agent: one "wip: <path>" commit per
|
|
32
|
+
touched file, in diff order
|
|
33
|
+
check [path] [--branch <name>] [--syntax "<cmd {file}>"]
|
|
34
|
+
validate draft.json or the given plan file, and
|
|
35
|
+
check that every file each commit writes parses;
|
|
36
|
+
--syntax runs <cmd> on every text file instead of
|
|
37
|
+
the checker for its extension
|
|
38
|
+
apply [--name <branch>] [--force] [--sign|--no-sign] [--syntax "<cmd {file}>"]
|
|
39
|
+
[--allow-syntax-errors] write the draft as commits on <branch>-split or
|
|
40
|
+
--name; never on <branch> or a checked-out branch;
|
|
41
|
+
refuses a commit with a file that does not parse
|
|
42
|
+
unless --allow-syntax-errors
|
|
43
|
+
review [--port <n>] [--detach] [--no-open] [--wait [--timeout <seconds>]]
|
|
44
|
+
open the plan review, commit by commit; --wait
|
|
45
|
+
returns only once the review ends
|
|
46
|
+
feedback [--walk] [--branch <name>] print the latest plan review as markdown, or with
|
|
47
|
+
--walk the latest code review
|
|
48
|
+
status show the session's plans, reviews, lock and the
|
|
49
|
+
branch apply writes
|
|
50
|
+
|
|
51
|
+
Interactive loop (while the review page is open):
|
|
52
|
+
watch [--since <seq>] [--timeout <seconds>] [--json] [--follow]
|
|
53
|
+
wait for the reviewer to comment, ask or decide and
|
|
54
|
+
print it; exits 0 with news, 2 on timeout, and
|
|
55
|
+
reports the end of the review. Your own replies are
|
|
56
|
+
left out. --follow streams every event until the
|
|
57
|
+
review ends instead of stopping at the first
|
|
58
|
+
reply <commentId> --body <text> [--diagram <file>]
|
|
59
|
+
answer under a comment on the page; the diagram is
|
|
60
|
+
Mermaid source
|
|
61
|
+
report print the consolidated report of the open review, or
|
|
62
|
+
of the last one that ended: issues by file and line
|
|
63
|
+
in both voices, then questions with answers
|
|
64
|
+
apply, while the page is open, swaps it to the new plan version in place, same URL.
|
|
65
|
+
|
|
66
|
+
Review someone else's branch:
|
|
67
|
+
walk [--base <ref>] [--branch <name>] [--model <id>] [--port <n>] [--detach] [--no-open]
|
|
24
68
|
watch a branch being written, narrated, and
|
|
25
69
|
review its code; nothing is applied
|
|
26
|
-
feedback [--branch <name>] print the latest review as markdown
|
|
27
70
|
|
|
28
|
-
|
|
71
|
+
Review server:
|
|
72
|
+
server status show the review server and the sessions it hosts
|
|
73
|
+
server stop stop the review server; drafts are kept
|
|
74
|
+
|
|
75
|
+
Setup:
|
|
76
|
+
install [--harness <ids>|--all] [--project] [--dry-run]
|
|
77
|
+
copy the skill into coding agents' skill dirs
|
|
78
|
+
install --list show each harness, its skill dirs and docs
|
|
79
|
+
uninstall [--harness <ids>|--all] [--project] [--dry-run]
|
|
80
|
+
remove the skill files install writes
|
|
81
|
+
clean [--all] [--dry-run] remove sessions of deleted branches, finished
|
|
82
|
+
review drafts and leftovers of dead processes;
|
|
83
|
+
--all removes .follow-me/ entirely
|
|
84
|
+
--version print the version
|
|
85
|
+
help print this
|
|
86
|
+
|
|
87
|
+
--walk writes the prompt for a reviewer: a story with narration instead of a split.
|
|
88
|
+
review and walk share one server per user: a running one hosts the session and the command
|
|
89
|
+
prints its URL; otherwise the command serves until its review ends, or with --detach starts the
|
|
90
|
+
server in the background. --port taken by another program falls back to a free port.
|
|
91
|
+
--force lets apply move a branch follow-me did not create; it never allows <branch> itself.
|
|
92
|
+
apply signs the commits when your git config signs commits (commit.gpgsign, gpg.format);
|
|
93
|
+
--sign signs them anyway and --no-sign never signs.`;
|
|
29
94
|
function parseArgs(argv, spec) {
|
|
30
95
|
const positionals = [];
|
|
31
96
|
const values = new Map();
|
|
@@ -54,6 +119,34 @@ function parseArgs(argv, spec) {
|
|
|
54
119
|
function say(line = "") {
|
|
55
120
|
process.stdout.write(`${line}\n`);
|
|
56
121
|
}
|
|
122
|
+
/** The step that follows; every command that moves the flow forward ends with one. */
|
|
123
|
+
function next(step) {
|
|
124
|
+
say(`next: ${step}`);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The version in the package.json above this module, which sits one directory up from src/ and
|
|
128
|
+
* two up from dist/src/.
|
|
129
|
+
*/
|
|
130
|
+
function version() {
|
|
131
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
132
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
133
|
+
dir = dirname(dir);
|
|
134
|
+
let raw;
|
|
135
|
+
try {
|
|
136
|
+
raw = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (typeof raw !== "object" || raw === null)
|
|
142
|
+
continue;
|
|
143
|
+
const name = "name" in raw ? raw.name : undefined;
|
|
144
|
+
const value = "version" in raw ? raw.version : undefined;
|
|
145
|
+
if (name === "@shiyuphan/follow-me" && typeof value === "string")
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
throw new Error("cannot find the package.json of follow-me");
|
|
149
|
+
}
|
|
57
150
|
/** Paths are latin1 so bytes round-trip; show them as the UTF-8 the user typed. */
|
|
58
151
|
function show(path) {
|
|
59
152
|
return Buffer.from(path, "latin1").toString("utf8");
|
|
@@ -89,13 +182,22 @@ function describeFile(diff, units) {
|
|
|
89
182
|
status = text.base === null ? "A" : text.tip === null ? "D" : "M";
|
|
90
183
|
return { status, what: parts.join(", ") };
|
|
91
184
|
}
|
|
92
|
-
function
|
|
93
|
-
const { anchor, diff } = session;
|
|
185
|
+
function unitsByPath(diff) {
|
|
94
186
|
const byPath = new Map();
|
|
95
187
|
for (const unit of diff.units.values()) {
|
|
96
188
|
byPath.set(unit.path, [...(byPath.get(unit.path) ?? []), unit]);
|
|
97
189
|
}
|
|
98
|
-
|
|
190
|
+
return byPath;
|
|
191
|
+
}
|
|
192
|
+
function headline(session) {
|
|
193
|
+
const { anchor, diff } = session;
|
|
194
|
+
const files = unitsByPath(diff).size;
|
|
195
|
+
return `${anchor.branch} ${short(anchor.tip)} onto ${anchor.baseRef} ${short(anchor.base)}: ${plural(files, "file")}, ${plural(diff.units.size, "unit")}`;
|
|
196
|
+
}
|
|
197
|
+
function summarize(session, cwd) {
|
|
198
|
+
const { diff } = session;
|
|
199
|
+
const byPath = unitsByPath(diff);
|
|
200
|
+
say(headline(session));
|
|
99
201
|
const width = Math.max(0, ...[...byPath.keys()].map((p) => show(p).length));
|
|
100
202
|
for (const [path, units] of byPath) {
|
|
101
203
|
const { status, what } = describeFile(diff, units);
|
|
@@ -130,6 +232,12 @@ function formatIssue(issue) {
|
|
|
130
232
|
return `$.commits[${issue.commit}]: ${show(issue.file)} is a file while ${show(issue.under)} exists`;
|
|
131
233
|
case "unknown-response":
|
|
132
234
|
return `response to unknown comment ${issue.comment}`;
|
|
235
|
+
case "bad-answer":
|
|
236
|
+
return `response to ${issue.comment}: ${issue.reason}`;
|
|
237
|
+
case "bad-concern":
|
|
238
|
+
return `$.commits[${issue.commit}]: concern: ${issue.reason}`;
|
|
239
|
+
case "too-many-concerns":
|
|
240
|
+
return `${issue.unit} has more than one concern, in commits ${issue.commits.join(" and ")}; keep the one that matters`;
|
|
133
241
|
case "final-mismatch": {
|
|
134
242
|
const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
|
|
135
243
|
return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
|
|
@@ -141,8 +249,22 @@ function formatIssue(issue) {
|
|
|
141
249
|
}
|
|
142
250
|
}
|
|
143
251
|
function formatWarning(w) {
|
|
252
|
+
if (w.kind === "long-title") {
|
|
253
|
+
return `warning: $.commits[${w.commit}]: title is ${w.length} characters; keep it to 50`;
|
|
254
|
+
}
|
|
255
|
+
if (w.kind === "question-addressed") {
|
|
256
|
+
return `warning: question ${w.comment} is marked addressed; explain it with "answered" and "at" unless answering it changed the plan`;
|
|
257
|
+
}
|
|
144
258
|
return `warning: comment ${w.comment} from review v${w.review} has no response`;
|
|
145
259
|
}
|
|
260
|
+
function concernTally(commits) {
|
|
261
|
+
const all = commits.flatMap((c) => c.concerns ?? []);
|
|
262
|
+
const parts = ["blocker", "major", "minor"].flatMap((severity) => {
|
|
263
|
+
const n = all.filter((c) => c.severity === severity).length;
|
|
264
|
+
return n === 0 ? [] : [`${n} ${severity}`];
|
|
265
|
+
});
|
|
266
|
+
return parts.length === 0 ? null : `concerns: ${parts.join(", ")}`;
|
|
267
|
+
}
|
|
146
268
|
function readPlanArg(path) {
|
|
147
269
|
try {
|
|
148
270
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -159,12 +281,12 @@ function branchFlag(args) {
|
|
|
159
281
|
function portOf(args) {
|
|
160
282
|
const raw = args.values.get("port");
|
|
161
283
|
if (raw === undefined)
|
|
162
|
-
return
|
|
284
|
+
return undefined;
|
|
163
285
|
const port = Number(raw);
|
|
164
286
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
165
287
|
throw new UsageError(`--port ${raw} is not a port number`);
|
|
166
288
|
}
|
|
167
|
-
return
|
|
289
|
+
return port === 0 ? undefined : port;
|
|
168
290
|
}
|
|
169
291
|
function modeOf(args) {
|
|
170
292
|
return args.flags.has("walk") ? "walk" : "split";
|
|
@@ -185,35 +307,148 @@ async function check(cwd, args) {
|
|
|
185
307
|
const session = await openSession(cwd, sessionOpts(args));
|
|
186
308
|
const path = args.positionals[0];
|
|
187
309
|
const verdict = validate(session, path === undefined ? session.readDraft() : readPlanArg(path));
|
|
188
|
-
if (!verdict.ok)
|
|
310
|
+
if (!verdict.ok) {
|
|
311
|
+
const fix = path ?? relative(cwd, session.draftPath);
|
|
312
|
+
next(`fix ${fix}, then follow-me check${path === undefined ? "" : ` ${path}`}${branchFlag(args)}`);
|
|
189
313
|
return 1;
|
|
190
|
-
|
|
314
|
+
}
|
|
315
|
+
listPlan(verdict.plan);
|
|
316
|
+
for (const w of verdict.warnings)
|
|
317
|
+
say(formatWarning(w));
|
|
318
|
+
const syntax = await checkSyntax(verdict.plan, syntaxOverride(args));
|
|
319
|
+
for (const line of formatSyntax(syntax))
|
|
320
|
+
say(line);
|
|
321
|
+
if (syntax.issues.length > 0) {
|
|
322
|
+
next(`fix the commits above so every file parses, then follow-me check${branchFlag(args)}`);
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
next("follow-me apply");
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
function syntaxOverride(args) {
|
|
329
|
+
const override = args.values.get("syntax");
|
|
330
|
+
if (override === undefined)
|
|
331
|
+
return {};
|
|
332
|
+
if (!override.includes("{file}"))
|
|
333
|
+
throw new UsageError("--syntax needs {file} in the command");
|
|
334
|
+
return { override };
|
|
335
|
+
}
|
|
336
|
+
function formatSyntax(report, mark = "") {
|
|
337
|
+
const out = report.issues.map((i) => `${mark}$.commits[${i.commit}]: ${show(i.path)}: ${i.message}`);
|
|
338
|
+
for (const i of report.tip) {
|
|
339
|
+
out.push(`${mark}${show(i.path)}: ${i.message} (the tip itself does not parse here, so earlier commits are not checked for it)`);
|
|
340
|
+
}
|
|
341
|
+
const reasons = [...new Set(report.skipped.map((s) => s.reason))];
|
|
342
|
+
const skipped = reasons.length === 0
|
|
343
|
+
? ""
|
|
344
|
+
: `, ${plural(report.skipped.length, "file")} skipped (${reasons.join(", ")})`;
|
|
345
|
+
out.push(`syntax: ${plural(report.checked, "write")} checked${skipped}`);
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
function listPlan(plan) {
|
|
191
349
|
say(`ok: ${plural(plan.commits.length, "commit")}, plan ${short(plan.hash)}`);
|
|
192
350
|
for (const c of plan.commits) {
|
|
193
351
|
const subject = c.message.split("\n")[0] ?? "";
|
|
194
352
|
say(` ${c.index + 1} ${subject} (${plural(c.refs.length, "unit")}, ${plural(c.changes.length, "file")})`);
|
|
195
353
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
354
|
+
const tally = concernTally(plan.commits);
|
|
355
|
+
if (tally !== null)
|
|
356
|
+
say(tally);
|
|
357
|
+
}
|
|
358
|
+
/** Runs one command under the session's lock, so two agents never apply or review it at once. */
|
|
359
|
+
async function locked(session, command, run) {
|
|
360
|
+
const held = session.lock(command);
|
|
361
|
+
try {
|
|
362
|
+
return await run(held);
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
held.release();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function splitBranch(session) {
|
|
369
|
+
return `${session.anchor.branch}-split`;
|
|
199
370
|
}
|
|
200
371
|
async function apply(cwd, args) {
|
|
201
372
|
const session = await openSession(cwd);
|
|
373
|
+
const open = await openOnServer(session, "split");
|
|
374
|
+
if (open !== null)
|
|
375
|
+
return applyHosted(session, args, open);
|
|
376
|
+
const code = await locked(session, "apply", () => applyLocked(session, args));
|
|
377
|
+
if (code === 0)
|
|
378
|
+
next("follow-me review");
|
|
379
|
+
return code;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Applies while the review server hosts the session. The server holds the session lock, so the
|
|
383
|
+
* write runs under it, and the page is then swapped to the new plan version in place.
|
|
384
|
+
*/
|
|
385
|
+
async function applyHosted(session, args, open) {
|
|
386
|
+
const code = await applyLocked(session, args);
|
|
387
|
+
const latest = session.plans().at(-1);
|
|
388
|
+
if (code !== 0 || latest === undefined)
|
|
389
|
+
return code;
|
|
390
|
+
if (latest.version === open.summary.version) {
|
|
391
|
+
say(`plan v${latest.version} is already open: ${sessionUrl(open.port, open.summary)}`);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
const swapped = await swapVersion(open.port, open.summary, latest.version, latest.hash);
|
|
395
|
+
say(`follow-me review: ${sessionUrl(open.port, swapped)}`);
|
|
396
|
+
say(`the page now shows plan v${latest.version}; the comments on v${open.summary.version} are saved`);
|
|
397
|
+
}
|
|
398
|
+
next("follow-me watch");
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
function signMode(args) {
|
|
402
|
+
const on = args.flags.has("sign");
|
|
403
|
+
const off = args.flags.has("no-sign");
|
|
404
|
+
if (on && off)
|
|
405
|
+
throw new UsageError("--sign and --no-sign cannot be used together");
|
|
406
|
+
return on ? "on" : off ? "off" : "config";
|
|
407
|
+
}
|
|
408
|
+
function describeSigning(s) {
|
|
409
|
+
return s.on ? `on (${s.format})` : "off";
|
|
410
|
+
}
|
|
411
|
+
async function applyLocked(session, args) {
|
|
412
|
+
const mode = signMode(args);
|
|
202
413
|
const verdict = validate(session, session.readDraft());
|
|
203
414
|
if (!verdict.ok)
|
|
204
415
|
return 1;
|
|
205
|
-
const
|
|
206
|
-
const
|
|
416
|
+
const { plan } = verdict;
|
|
417
|
+
const syntax = await checkSyntax(plan, syntaxOverride(args));
|
|
418
|
+
const allowed = args.flags.has("allow-syntax-errors");
|
|
419
|
+
if (syntax.issues.length > 0) {
|
|
420
|
+
for (const line of formatSyntax(syntax, allowed ? "warning: " : ""))
|
|
421
|
+
say(line);
|
|
422
|
+
if (!allowed) {
|
|
423
|
+
say("refusing to apply: fix the commits above, or pass --allow-syntax-errors");
|
|
424
|
+
return 1;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const version = session.freeze(plan);
|
|
428
|
+
const name = args.values.get("name") ?? splitBranch(session);
|
|
429
|
+
const signed = await signing(session.root, mode);
|
|
207
430
|
const last = session.applied();
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
431
|
+
// Signatures can differ per run, so the record, not SHA equality, decides that nothing changed.
|
|
432
|
+
const recorded = last !== null &&
|
|
433
|
+
last.branch === name &&
|
|
434
|
+
last.version === version &&
|
|
435
|
+
last.hash === plan.hash &&
|
|
436
|
+
last.signed === signed.on &&
|
|
437
|
+
(await branchHead(session.root, name)) === last.head;
|
|
438
|
+
const result = recorded
|
|
439
|
+
? { kind: "unchanged", branch: name, head: last.head }
|
|
440
|
+
: await writeBranch(session.root, session.diff, plan, {
|
|
441
|
+
name,
|
|
442
|
+
source: session.anchor.branch,
|
|
443
|
+
expectedOld: last?.branch === name ? last.head : null,
|
|
444
|
+
force: args.flags.has("force"),
|
|
445
|
+
sign: signed.on,
|
|
446
|
+
});
|
|
447
|
+
session.recordApplied({ version, hash: plan.hash }, { ...result, signed: signed.on });
|
|
214
448
|
for (const w of verdict.warnings)
|
|
215
449
|
say(formatWarning(w));
|
|
216
|
-
say(`${result.kind} ${result.branch} at ${short(result.head)} from plan v${version} (${plural(
|
|
450
|
+
say(`${result.kind} ${result.branch} at ${short(result.head)} from plan v${version} (${plural(plan.commits.length, "commit")})`);
|
|
451
|
+
say(`signing: ${describeSigning(signed)}`);
|
|
217
452
|
const tree = await treeOf(session.root, result.head);
|
|
218
453
|
const same = tree === session.anchor.tipTree;
|
|
219
454
|
say(`proof: ${result.branch}^{tree} ${tree} ${same ? "equals" : "DIFFERS FROM"} ${session.anchor.branch}^{tree} ${session.anchor.tipTree}`);
|
|
@@ -253,9 +488,29 @@ async function draftWithApi(session, cwd, mode, args) {
|
|
|
253
488
|
say(`wrote ${relative(cwd, session.draftPath)} but it still has issues, repair it and run \`follow-me check${branchFlag(args)}\``);
|
|
254
489
|
return null;
|
|
255
490
|
}
|
|
491
|
+
/** Writes the one-commit-per-file draft with no API call and no agent. */
|
|
492
|
+
function naive(session, cwd, args) {
|
|
493
|
+
if (args.flags.has("walk") || args.values.has("model")) {
|
|
494
|
+
throw new UsageError("--naive writes no story, so --walk and --model do not apply");
|
|
495
|
+
}
|
|
496
|
+
const draft = naiveDraft(session.diff);
|
|
497
|
+
session.writeDraft(draft);
|
|
498
|
+
const verdict = validate(session, draft);
|
|
499
|
+
if (!verdict.ok)
|
|
500
|
+
return 1;
|
|
501
|
+
say(`wrote ${relative(cwd, session.draftPath)} (${plural(verdict.plan.commits.length, "commit")})`);
|
|
502
|
+
next("follow-me apply");
|
|
503
|
+
return 0;
|
|
504
|
+
}
|
|
256
505
|
async function plan(cwd, args) {
|
|
257
506
|
const session = await openSession(cwd, sessionOpts(args));
|
|
258
|
-
|
|
507
|
+
const mode = modeOf(args);
|
|
508
|
+
if (args.flags.has("naive"))
|
|
509
|
+
return naive(session, cwd, args);
|
|
510
|
+
if ((await draftWithApi(session, cwd, mode, args)) === null)
|
|
511
|
+
return 1;
|
|
512
|
+
next(mode === "walk" ? `follow-me walk${branchFlag(args)}` : "follow-me apply");
|
|
513
|
+
return 0;
|
|
259
514
|
}
|
|
260
515
|
/** The latest frozen plan, or the draft frozen now when it validates. */
|
|
261
516
|
function latestPlan(session) {
|
|
@@ -269,38 +524,248 @@ function latestPlan(session) {
|
|
|
269
524
|
return null;
|
|
270
525
|
return { version: session.freeze(verdict.plan), plan: verdict.plan };
|
|
271
526
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
527
|
+
function announce(mode, url, args) {
|
|
528
|
+
say(`follow-me ${mode === "walk" ? "code review" : "review"}: ${url}`);
|
|
529
|
+
if (!args.flags.has("no-open"))
|
|
530
|
+
openBrowser(url);
|
|
531
|
+
}
|
|
532
|
+
/** The server that already runs for this user, or one on --port that answers as ours. */
|
|
533
|
+
async function runningServer(home, port) {
|
|
534
|
+
const live = await findServer(home);
|
|
535
|
+
if (live !== null)
|
|
536
|
+
return live.port;
|
|
537
|
+
if (port !== undefined && (await probeHealth(port)) !== null)
|
|
538
|
+
return port;
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Hosts the session on the user's one review server. A running server gets the session and the
|
|
543
|
+
* CLI exits; otherwise this process becomes the server, in the foreground or, with --detach, as
|
|
544
|
+
* a background process. The CLI prepares the plan under the session lock and hands the lock to
|
|
545
|
+
* whichever process hosts it. report runs when a foreground review ends.
|
|
546
|
+
*/
|
|
547
|
+
async function hostReview(session, mode, args, prepare, report) {
|
|
548
|
+
const home = stateDir();
|
|
549
|
+
const open = await openOnServer(session, mode);
|
|
550
|
+
if (open !== null) {
|
|
551
|
+
announce(mode, sessionUrl(open.port, open.summary), args);
|
|
552
|
+
return { kind: "elsewhere", ...open };
|
|
553
|
+
}
|
|
554
|
+
return locked(session, mode === "walk" ? "walk" : "review", async (held) => {
|
|
555
|
+
const prepared = await prepare();
|
|
556
|
+
if (prepared === null)
|
|
557
|
+
return { kind: "refused" };
|
|
558
|
+
const registration = {
|
|
559
|
+
dir: session.dir,
|
|
560
|
+
mode,
|
|
561
|
+
version: prepared.version,
|
|
562
|
+
hash: prepared.plan.hash,
|
|
563
|
+
lockToken: held.token,
|
|
564
|
+
};
|
|
565
|
+
const port = portOf(args);
|
|
566
|
+
let started;
|
|
567
|
+
const running = await runningServer(home, port);
|
|
568
|
+
if (running !== null)
|
|
569
|
+
started = { kind: "attached", port: running };
|
|
570
|
+
else if (args.flags.has("detach")) {
|
|
571
|
+
const server = await spawnDetached(home, port);
|
|
572
|
+
if (port !== undefined && server.port !== port)
|
|
573
|
+
say(portFallbackMessage(port, server.port));
|
|
574
|
+
started = { kind: "attached", port: server.port };
|
|
575
|
+
}
|
|
576
|
+
else
|
|
577
|
+
started = await startOrAttach(home, port, say);
|
|
578
|
+
if (started.kind === "attached") {
|
|
579
|
+
const summary = await registerWith(started.port, registration);
|
|
580
|
+
announce(mode, sessionUrl(started.port, summary), args);
|
|
581
|
+
return { kind: "elsewhere", port: started.port, summary };
|
|
582
|
+
}
|
|
583
|
+
const { host } = started;
|
|
584
|
+
const { summary, done } = await host.register(registration);
|
|
585
|
+
announce(mode, sessionUrl(host.port, summary), args);
|
|
586
|
+
const review = await done;
|
|
587
|
+
if (review === null)
|
|
588
|
+
return { kind: "stopped" };
|
|
589
|
+
report(review);
|
|
590
|
+
const others = host.sessions().length;
|
|
591
|
+
say(others > 0
|
|
592
|
+
? `still serving ${plural(others, "other session")} at ${host.url}; this process exits after they end`
|
|
593
|
+
: `the server exits in ${Math.ceil(idleMs() / 1000)}s unless another review starts; ctrl-c stops it now`);
|
|
594
|
+
await host.closed;
|
|
595
|
+
return { kind: "ended" };
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
function exitCode(hosted) {
|
|
599
|
+
if (hosted.kind === "stopped") {
|
|
600
|
+
console.error("follow-me: the server stopped before the review ended; its comments are kept");
|
|
601
|
+
}
|
|
602
|
+
return hosted.kind === "refused" || hosted.kind === "stopped" ? 1 : 0;
|
|
603
|
+
}
|
|
604
|
+
function alreadyReviewed(session, mode, version, args) {
|
|
605
|
+
if (!reviewsOf(session, mode).some((r) => r.version === version))
|
|
287
606
|
return false;
|
|
288
|
-
|
|
607
|
+
const branch = branchFlag(args);
|
|
608
|
+
console.error(mode === "walk"
|
|
609
|
+
? `follow-me: plan v${version} already has a code review; read it with \`follow-me feedback --walk${branch}\``
|
|
610
|
+
: `follow-me: plan v${version} already has a review; read it with \`follow-me feedback${branch}\`, then run \`follow-me split${branch}\` for the next plan`);
|
|
289
611
|
return true;
|
|
290
612
|
}
|
|
613
|
+
/** This session's review, of this mode or any, on the user's server when that holds its lock. */
|
|
614
|
+
async function openOnServer(session, mode) {
|
|
615
|
+
const lock = session.lockState();
|
|
616
|
+
const live = await findServer(stateDir());
|
|
617
|
+
if (live === null || lock.kind !== "held" || lock.pid !== live.pid)
|
|
618
|
+
return null;
|
|
619
|
+
const id = sessionIdOf(session.dir);
|
|
620
|
+
const summary = (await listSessions(live.port)).find((s) => s.id === id && (mode === null || s.mode === mode));
|
|
621
|
+
return summary === undefined ? null : { port: live.port, summary };
|
|
622
|
+
}
|
|
623
|
+
function timeoutOf(args) {
|
|
624
|
+
const raw = args.values.get("timeout");
|
|
625
|
+
if (raw === undefined)
|
|
626
|
+
return undefined;
|
|
627
|
+
const seconds = Number(raw);
|
|
628
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
629
|
+
throw new UsageError(`--timeout ${raw} is not a number of seconds`);
|
|
630
|
+
}
|
|
631
|
+
return seconds;
|
|
632
|
+
}
|
|
633
|
+
/** The reviews in this mode's slot, oldest first. */
|
|
634
|
+
function reviewsOf(session, mode) {
|
|
635
|
+
return mode === "walk" ? session.walkReviews() : session.reviews();
|
|
636
|
+
}
|
|
637
|
+
function savedReview(session, mode, version) {
|
|
638
|
+
const stored = reviewsOf(session, mode).find((r) => r.version === version);
|
|
639
|
+
return stored?.review ?? null;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Polls the server until it no longer hosts the session, then reads the review it saved. Null
|
|
643
|
+
* when the server stopped without one; "timeout" when the review is still open at the deadline.
|
|
644
|
+
*/
|
|
645
|
+
async function waitForEnd(session, hosted, timeout) {
|
|
646
|
+
const deadline = timeout === undefined ? Number.POSITIVE_INFINITY : Date.now() + timeout * 1000;
|
|
647
|
+
while (await stillHosted(hosted.port, hosted.summary)) {
|
|
648
|
+
if (Date.now() >= deadline)
|
|
649
|
+
return "timeout";
|
|
650
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
651
|
+
}
|
|
652
|
+
return savedReview(session, hosted.summary.mode, hosted.summary.version);
|
|
653
|
+
}
|
|
654
|
+
function reportSplit(review) {
|
|
655
|
+
say(`review v${review.planVersion} saved with ${plural(review.comments.length, "comment")}`);
|
|
656
|
+
next("follow-me feedback");
|
|
657
|
+
}
|
|
658
|
+
function prepareSplit(session, args) {
|
|
659
|
+
return async () => {
|
|
660
|
+
const latest = latestPlan(session);
|
|
661
|
+
if (latest === null || alreadyReviewed(session, "split", latest.version, args))
|
|
662
|
+
return null;
|
|
663
|
+
return latest;
|
|
664
|
+
};
|
|
665
|
+
}
|
|
291
666
|
async function review(cwd, args) {
|
|
292
667
|
const session = await openSession(cwd);
|
|
293
|
-
const
|
|
294
|
-
if (
|
|
668
|
+
const timeout = timeoutOf(args);
|
|
669
|
+
if (timeout !== undefined && !args.flags.has("wait")) {
|
|
670
|
+
throw new UsageError("--timeout only applies with --wait");
|
|
671
|
+
}
|
|
672
|
+
const hosted = await hostReview(session, "split", args, prepareSplit(session, args), reportSplit);
|
|
673
|
+
if (hosted.kind !== "elsewhere")
|
|
674
|
+
return exitCode(hosted);
|
|
675
|
+
if (!args.flags.has("wait")) {
|
|
676
|
+
next("end the review on the page, then follow-me feedback");
|
|
677
|
+
return 0;
|
|
678
|
+
}
|
|
679
|
+
const ended = await waitForEnd(session, hosted, timeout);
|
|
680
|
+
if (ended === "timeout") {
|
|
681
|
+
console.error(`follow-me: the review is still open after ${timeout}s; run \`follow-me review --wait\` again to keep waiting`);
|
|
295
682
|
return 1;
|
|
296
|
-
|
|
683
|
+
}
|
|
684
|
+
if (ended === null)
|
|
685
|
+
return exitCode({ kind: "stopped" });
|
|
686
|
+
reportSplit(ended);
|
|
687
|
+
return 0;
|
|
688
|
+
}
|
|
689
|
+
/** Why split needs a new draft before it can go on, or null when draft.json is ready. */
|
|
690
|
+
function draftNeed(session, cwd, args) {
|
|
691
|
+
let draft;
|
|
692
|
+
try {
|
|
693
|
+
draft = session.readDraft();
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
if (error instanceof MissingDraftError)
|
|
697
|
+
return `no draft for ${session.anchor.branch} yet`;
|
|
698
|
+
throw error;
|
|
699
|
+
}
|
|
700
|
+
const verdict = validatePlan(session.diff, draft, { lastReview: session.lastReview() });
|
|
701
|
+
if (!verdict.ok) {
|
|
702
|
+
if (verdict.issues.some((issue) => issue.kind === "stale")) {
|
|
703
|
+
return `the draft for ${session.anchor.branch} was written for an older tip`;
|
|
704
|
+
}
|
|
705
|
+
for (const issue of verdict.issues)
|
|
706
|
+
say(formatIssue(issue));
|
|
707
|
+
next(`fix ${relative(cwd, session.draftPath)}, then follow-me split${branchFlag(args)}`);
|
|
708
|
+
return "invalid";
|
|
709
|
+
}
|
|
710
|
+
const { hash } = verdict.plan;
|
|
711
|
+
const reviewed = session
|
|
712
|
+
.plans()
|
|
713
|
+
.find((p) => p.hash === hash && session.reviews().some((r) => r.version === p.version));
|
|
714
|
+
if (reviewed !== undefined) {
|
|
715
|
+
return `plan v${reviewed.version} of ${session.anchor.branch} already has a review and needs a new draft that answers it`;
|
|
716
|
+
}
|
|
717
|
+
listPlan(verdict.plan);
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
/** Writes the prompt for an agent and prints what to tell it and what to run after. */
|
|
721
|
+
function handOff(session, cwd, args, why) {
|
|
722
|
+
session.writePrompt(renderPrompt(promptInput(session, "split", args)));
|
|
723
|
+
const prompt = relative(cwd, session.promptPath);
|
|
724
|
+
say(`${why}, and neither ANTHROPIC_API_KEY nor ANTHROPIC_AUTH_TOKEN is set to ask the API for one.`);
|
|
725
|
+
say(`Have your coding agent follow ${prompt} and write ${relative(cwd, session.draftPath)}, then run:`);
|
|
726
|
+
say(` follow-me check${branchFlag(args)}`);
|
|
727
|
+
say(` follow-me split${branchFlag(args)}`);
|
|
728
|
+
next(`hand ${prompt} to your agent`);
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* The author's one command: extract, draft through the API when it can, apply, and open the
|
|
732
|
+
* review on the user's server. It stops with a handoff whenever an agent has to write the draft.
|
|
733
|
+
*/
|
|
734
|
+
async function split(cwd, args) {
|
|
735
|
+
const session = await openFresh(cwd, args);
|
|
736
|
+
say(headline(session));
|
|
737
|
+
const open = await openOnServer(session, "split");
|
|
738
|
+
if (open !== null) {
|
|
739
|
+
announce("split", sessionUrl(open.port, open.summary), args);
|
|
740
|
+
next("end the review on the page, then follow-me feedback");
|
|
741
|
+
return 0;
|
|
742
|
+
}
|
|
743
|
+
const need = draftNeed(session, cwd, args);
|
|
744
|
+
if (need === "invalid")
|
|
297
745
|
return 1;
|
|
298
|
-
|
|
299
|
-
|
|
746
|
+
if (need !== null) {
|
|
747
|
+
if (!hasApiCredentials()) {
|
|
748
|
+
handOff(session, cwd, args, need);
|
|
749
|
+
return 1;
|
|
750
|
+
}
|
|
751
|
+
say(`${need}; asking the Anthropic API for one`);
|
|
752
|
+
const drafted = await draftWithApi(session, cwd, "split", args);
|
|
753
|
+
if (drafted === null)
|
|
754
|
+
return 1;
|
|
755
|
+
listPlan(drafted);
|
|
756
|
+
}
|
|
757
|
+
const applied = await locked(session, "apply", () => applyLocked(session, args));
|
|
758
|
+
if (applied !== 0)
|
|
759
|
+
return applied;
|
|
760
|
+
const detached = { ...args, flags: new Set([...args.flags, "detach"]) };
|
|
761
|
+
const hosted = await hostReview(session, "split", detached, prepareSplit(session, args), reportSplit);
|
|
762
|
+
if (hosted.kind !== "elsewhere")
|
|
763
|
+
return exitCode(hosted);
|
|
764
|
+
next("end the review on the page, then follow-me feedback");
|
|
300
765
|
return 0;
|
|
301
766
|
}
|
|
302
|
-
/** Opens the
|
|
303
|
-
async function
|
|
767
|
+
/** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
|
|
768
|
+
async function openFresh(cwd, args) {
|
|
304
769
|
const opts = sessionOpts(args);
|
|
305
770
|
try {
|
|
306
771
|
const session = await openSession(cwd, opts);
|
|
@@ -340,7 +805,19 @@ function hasApiCredentials() {
|
|
|
340
805
|
return Boolean(process.env["ANTHROPIC_API_KEY"] || process.env["ANTHROPIC_AUTH_TOKEN"]);
|
|
341
806
|
}
|
|
342
807
|
async function walk(cwd, args) {
|
|
343
|
-
const session = await
|
|
808
|
+
const session = await openFresh(cwd, args);
|
|
809
|
+
const report = (review) => {
|
|
810
|
+
const saved = join(session.reviewsDir("walk"), `v${review.planVersion}.json`);
|
|
811
|
+
say(`code review saved to ${relative(cwd, saved)} with ${plural(review.comments.length, "comment")}`);
|
|
812
|
+
next(`follow-me feedback --walk${branchFlag(args)}, which prints it as a code review for the author`);
|
|
813
|
+
};
|
|
814
|
+
const hosted = await hostReview(session, "walk", args, () => walkPlan(session, cwd, args), report);
|
|
815
|
+
if (hosted.kind === "elsewhere") {
|
|
816
|
+
next(`end the code review on the page, then follow-me feedback --walk${branchFlag(args)}`);
|
|
817
|
+
}
|
|
818
|
+
return exitCode(hosted);
|
|
819
|
+
}
|
|
820
|
+
async function walkPlan(session, cwd, args) {
|
|
344
821
|
let latest = walkablePlan(session);
|
|
345
822
|
if (latest === null) {
|
|
346
823
|
if (!hasApiCredentials()) {
|
|
@@ -348,42 +825,250 @@ async function walk(cwd, args) {
|
|
|
348
825
|
say("Have a coding agent follow the prompt, then walk again:");
|
|
349
826
|
say(` follow-me prompt --walk${branchFlag(args)}`);
|
|
350
827
|
say(` follow-me walk${branchFlag(args)}`);
|
|
351
|
-
return
|
|
828
|
+
return null;
|
|
352
829
|
}
|
|
353
830
|
const drafted = await draftWithApi(session, cwd, "walk", args);
|
|
354
831
|
if (drafted === null)
|
|
355
|
-
return
|
|
832
|
+
return null;
|
|
356
833
|
latest = { version: session.freeze(drafted), plan: drafted };
|
|
357
834
|
}
|
|
358
|
-
|
|
835
|
+
return alreadyReviewed(session, "walk", latest.version, args) ? null : latest;
|
|
836
|
+
}
|
|
837
|
+
/** The consolidated report of a saved review, or null after saying why it cannot be rendered. */
|
|
838
|
+
function renderStored(session, stored) {
|
|
839
|
+
const plan = session.plans().find((p) => p.version === stored.version);
|
|
840
|
+
if (plan === undefined) {
|
|
841
|
+
console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
|
|
845
|
+
if (!verdict.ok) {
|
|
846
|
+
console.error(`follow-me: plan v${plan.version} no longer fits ${session.anchor.branch}`);
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
// Later plans answer this review's questions.
|
|
850
|
+
const responses = session
|
|
851
|
+
.plans()
|
|
852
|
+
.filter((p) => p.version > stored.version)
|
|
853
|
+
.flatMap((p) => p.file.responses);
|
|
854
|
+
return renderReport(stored.review, verdict.plan.commits, session.diff.units, {
|
|
855
|
+
branch: session.anchor.branch,
|
|
856
|
+
responses,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
async function feedback(cwd, args) {
|
|
860
|
+
const session = await openSession(cwd, sessionOpts(args));
|
|
861
|
+
const walk = args.flags.has("walk");
|
|
862
|
+
const walks = session.walkReviews();
|
|
863
|
+
const stored = walk ? walks.at(-1) : session.reviews().findLast((r) => r.review.mode === "split");
|
|
864
|
+
if (stored === undefined) {
|
|
865
|
+
const branch = branchFlag(args);
|
|
866
|
+
console.error(walk
|
|
867
|
+
? `follow-me: no code review yet, run \`follow-me walk${branch}\` first`
|
|
868
|
+
: walks.length > 0
|
|
869
|
+
? `follow-me: no plan review yet; \`follow-me feedback --walk${branch}\` prints the code review`
|
|
870
|
+
: "follow-me: no review yet, run `follow-me review` first");
|
|
359
871
|
return 1;
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
872
|
+
}
|
|
873
|
+
const text = renderStored(session, stored);
|
|
874
|
+
if (text === null)
|
|
875
|
+
return 1;
|
|
876
|
+
process.stdout.write(text);
|
|
877
|
+
say();
|
|
878
|
+
next(stored.review.mode === "walk"
|
|
879
|
+
? "hand this to the branch's author"
|
|
880
|
+
: `hand this to your agent, then follow-me check${branchFlag(args)}`);
|
|
364
881
|
return 0;
|
|
365
882
|
}
|
|
366
|
-
|
|
883
|
+
/** The consolidated report of the hosted review as it stands, else of the last one that ended. */
|
|
884
|
+
async function report(cwd, args) {
|
|
367
885
|
const session = await openSession(cwd, sessionOpts(args));
|
|
368
|
-
const
|
|
886
|
+
const open = await openOnServer(session, null);
|
|
887
|
+
if (open !== null) {
|
|
888
|
+
process.stdout.write(await fetchReport(open.port, open.summary));
|
|
889
|
+
return 0;
|
|
890
|
+
}
|
|
891
|
+
const ended = [...session.reviews(), ...session.walkReviews()].sort((a, b) => a.review.endedAt.localeCompare(b.review.endedAt));
|
|
892
|
+
const stored = ended.at(-1);
|
|
369
893
|
if (stored === undefined) {
|
|
370
|
-
console.error("follow-me: no review
|
|
894
|
+
console.error("follow-me: no review is open and none has ended; run `follow-me review` first");
|
|
371
895
|
return 1;
|
|
372
896
|
}
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
375
|
-
|
|
897
|
+
const text = renderStored(session, stored);
|
|
898
|
+
if (text === null)
|
|
899
|
+
return 1;
|
|
900
|
+
process.stdout.write(text);
|
|
901
|
+
return 0;
|
|
902
|
+
}
|
|
903
|
+
/** Events the reviewer caused; the agent's own replies and version swaps are not news to it. */
|
|
904
|
+
function isNews(event) {
|
|
905
|
+
if (event.type === "version")
|
|
906
|
+
return false;
|
|
907
|
+
return !(event.type === "reply" && event.reply.author === "agent");
|
|
908
|
+
}
|
|
909
|
+
const POLL_MS = 25_000;
|
|
910
|
+
const DEFAULT_WATCH_SECONDS = 90;
|
|
911
|
+
function notOpen(session) {
|
|
912
|
+
console.error(`follow-me: ${session.anchor.branch} is not open on the review server; run \`follow-me review\` first`);
|
|
913
|
+
return 1;
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Waits for the reviewer to do something on the hosted page and prints it. Exits 0 with the first
|
|
917
|
+
* batch, or 2 with nothing at the timeout. The log is the server's, so nothing is read from files.
|
|
918
|
+
*/
|
|
919
|
+
async function watch(cwd, args) {
|
|
920
|
+
const session = await openSession(cwd, sessionOpts(args));
|
|
921
|
+
const open = await openOnServer(session, null);
|
|
922
|
+
if (open === null)
|
|
923
|
+
return notOpen(session);
|
|
924
|
+
const sinceRaw = args.values.get("since");
|
|
925
|
+
let since = sinceRaw === undefined ? 0 : Number(sinceRaw);
|
|
926
|
+
if (!Number.isInteger(since) || since < 0) {
|
|
927
|
+
throw new UsageError(`--since ${sinceRaw} is not an event number`);
|
|
928
|
+
}
|
|
929
|
+
if (args.flags.has("follow")) {
|
|
930
|
+
let ended = false;
|
|
931
|
+
await followEvents(open.port, open.summary, since, (event) => {
|
|
932
|
+
if (event.type === "ended")
|
|
933
|
+
ended = true;
|
|
934
|
+
if (!isNews(event))
|
|
935
|
+
return;
|
|
936
|
+
if (args.flags.has("json"))
|
|
937
|
+
say(JSON.stringify(event));
|
|
938
|
+
else
|
|
939
|
+
process.stdout.write(renderEvents([event]));
|
|
940
|
+
});
|
|
941
|
+
if (ended)
|
|
942
|
+
return 0;
|
|
943
|
+
console.error("follow-me: the event stream closed before the review ended");
|
|
376
944
|
return 1;
|
|
377
945
|
}
|
|
378
|
-
|
|
946
|
+
const deadline = Date.now() + (timeoutOf(args) ?? DEFAULT_WATCH_SECONDS) * 1000;
|
|
947
|
+
for (;;) {
|
|
948
|
+
const left = deadline - Date.now();
|
|
949
|
+
if (left <= 0) {
|
|
950
|
+
console.error(`follow-me: nothing new on the review page since event ${since}`);
|
|
951
|
+
return 2;
|
|
952
|
+
}
|
|
953
|
+
const batch = await pollEvents(open.port, open.summary, since, Math.min(left, POLL_MS));
|
|
954
|
+
since = batch.seq;
|
|
955
|
+
const news = batch.events.filter(isNews);
|
|
956
|
+
if (news.length === 0)
|
|
957
|
+
continue;
|
|
958
|
+
if (args.flags.has("json"))
|
|
959
|
+
for (const event of news)
|
|
960
|
+
say(JSON.stringify(event));
|
|
961
|
+
else {
|
|
962
|
+
process.stdout.write(renderEvents(news));
|
|
963
|
+
next(news.some((e) => e.type === "ended")
|
|
964
|
+
? "follow-me report"
|
|
965
|
+
: `follow-me reply <comment> --body <text>, or write a new draft and apply; then follow-me watch --since ${since}`);
|
|
966
|
+
}
|
|
967
|
+
return 0;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/** Posts the agent's answer under a comment on the hosted page. */
|
|
971
|
+
async function reply(cwd, args) {
|
|
972
|
+
const [commentId, extra] = args.positionals;
|
|
973
|
+
const body = args.values.get("body");
|
|
974
|
+
if (commentId === undefined || extra !== undefined || body === undefined) {
|
|
975
|
+
throw new UsageError("usage: follow-me reply <commentId> --body <text> [--diagram <file>]");
|
|
976
|
+
}
|
|
977
|
+
const diagramFile = args.values.get("diagram");
|
|
978
|
+
let diagram;
|
|
979
|
+
if (diagramFile !== undefined) {
|
|
980
|
+
try {
|
|
981
|
+
diagram = readFileSync(diagramFile, "utf8");
|
|
982
|
+
}
|
|
983
|
+
catch (error) {
|
|
984
|
+
throw new UsageError(`cannot read ${diagramFile}: ${error instanceof Error ? error.message : error}`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const session = await openSession(cwd, sessionOpts(args));
|
|
988
|
+
const open = await openOnServer(session, null);
|
|
989
|
+
if (open === null)
|
|
990
|
+
return notOpen(session);
|
|
991
|
+
const id = await postReply(open.port, open.summary, commentId, {
|
|
992
|
+
author: "agent",
|
|
993
|
+
body,
|
|
994
|
+
...(diagram === undefined ? {} : { diagram }),
|
|
995
|
+
});
|
|
996
|
+
say(`replied ${id} to ${commentId}`);
|
|
997
|
+
next("follow-me watch");
|
|
379
998
|
return 0;
|
|
380
999
|
}
|
|
1000
|
+
function describeLock(lock) {
|
|
1001
|
+
if (lock.kind === "free")
|
|
1002
|
+
return "free";
|
|
1003
|
+
const who = `pid ${lock.pid} (follow-me ${lock.command})`;
|
|
1004
|
+
return lock.kind === "held"
|
|
1005
|
+
? `held by ${who}`
|
|
1006
|
+
: `stale, ${who} is gone; follow-me clean removes it`;
|
|
1007
|
+
}
|
|
1008
|
+
/** The URL of this session on the user's server, when that server is what holds its lock. */
|
|
1009
|
+
async function hostedPage(session, holder) {
|
|
1010
|
+
const live = await findServer(stateDir());
|
|
1011
|
+
if (live === null || live.pid !== holder)
|
|
1012
|
+
return null;
|
|
1013
|
+
const id = sessionIdOf(session.dir);
|
|
1014
|
+
const open = (await listSessions(live.port)).find((s) => s.id === id);
|
|
1015
|
+
return open === undefined ? null : sessionUrl(live.port, open);
|
|
1016
|
+
}
|
|
1017
|
+
async function serverCommand(argv) {
|
|
1018
|
+
const [verb, ...rest] = argv;
|
|
1019
|
+
const home = stateDir();
|
|
1020
|
+
switch (verb) {
|
|
1021
|
+
case "status": {
|
|
1022
|
+
parseArgs(rest, {});
|
|
1023
|
+
const live = await findServer(home);
|
|
1024
|
+
if (live === null) {
|
|
1025
|
+
say("no server running");
|
|
1026
|
+
return 0;
|
|
1027
|
+
}
|
|
1028
|
+
say(`server pid ${live.pid} at http://127.0.0.1:${live.port}/ since ${live.startedAt}`);
|
|
1029
|
+
const sessions = await listSessions(live.port);
|
|
1030
|
+
if (sessions.length === 0)
|
|
1031
|
+
say(" no sessions");
|
|
1032
|
+
for (const s of sessions) {
|
|
1033
|
+
const what = s.mode === "walk" ? "walk" : "review";
|
|
1034
|
+
say(` ${sessionUrl(live.port, s)} ${s.branch} ${what} of plan v${s.version}, ${plural(s.comments, "comment")} ${s.repo}`);
|
|
1035
|
+
}
|
|
1036
|
+
return 0;
|
|
1037
|
+
}
|
|
1038
|
+
case "stop": {
|
|
1039
|
+
parseArgs(rest, {});
|
|
1040
|
+
const stopped = await stopServer(home);
|
|
1041
|
+
say(stopped === null ? "no server running" : `stopped server pid ${stopped.pid}`);
|
|
1042
|
+
return 0;
|
|
1043
|
+
}
|
|
1044
|
+
// What `--detach` runs in the background; not meant to be typed.
|
|
1045
|
+
case "run": {
|
|
1046
|
+
const started = await startOrAttach(home, portOf(parseArgs(rest, { values: ["port"] })), say);
|
|
1047
|
+
if (started.kind === "attached") {
|
|
1048
|
+
say(`a follow-me server already runs on port ${started.port}`);
|
|
1049
|
+
return 0;
|
|
1050
|
+
}
|
|
1051
|
+
say(`follow-me server: ${started.host.url}`);
|
|
1052
|
+
await started.host.closed;
|
|
1053
|
+
return 0;
|
|
1054
|
+
}
|
|
1055
|
+
default:
|
|
1056
|
+
throw new UsageError("usage: follow-me server status|stop");
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
381
1059
|
async function status(cwd) {
|
|
382
1060
|
const session = await openSession(cwd);
|
|
383
1061
|
const { anchor, diff } = session;
|
|
384
1062
|
const plans = session.plans();
|
|
385
|
-
const reviews = session.reviews();
|
|
1063
|
+
const reviews = session.reviews().filter((r) => r.review.mode === "split");
|
|
1064
|
+
const walks = session.walkReviews();
|
|
386
1065
|
say(`${anchor.branch} ${short(anchor.tip)} onto ${anchor.baseRef} ${short(anchor.base)}`);
|
|
1066
|
+
say(`writes to: ${splitBranch(session)}`);
|
|
1067
|
+
const lock = session.lockState();
|
|
1068
|
+
say(`lock: ${describeLock(lock)}`);
|
|
1069
|
+
const page = lock.kind === "held" ? await hostedPage(session, lock.pid) : null;
|
|
1070
|
+
if (page !== null)
|
|
1071
|
+
say(`review page: ${page}`);
|
|
387
1072
|
say(`units: ${plural(diff.units.size, "unit")}`);
|
|
388
1073
|
let hasDraft = true;
|
|
389
1074
|
try {
|
|
@@ -406,12 +1091,28 @@ async function status(cwd) {
|
|
|
406
1091
|
const open = lastReview.review.comments.filter((c) => !answered.has(c.id)).length;
|
|
407
1092
|
say(`reviews: ${reviews.map((r) => `v${r.version}`).join(", ")} (latest has ${plural(lastReview.review.comments.length, "comment")}, ${open} open)`);
|
|
408
1093
|
}
|
|
1094
|
+
if (walks.length > 0) {
|
|
1095
|
+
const latest = walks.at(-1)?.review.comments.length ?? 0;
|
|
1096
|
+
say(`code reviews: ${walks.map((r) => `v${r.version}`).join(", ")} (latest has ${plural(latest, "comment")})`);
|
|
1097
|
+
}
|
|
1098
|
+
say(`signing: ${describeSigning(await signing(session.root, "config"))}`);
|
|
409
1099
|
const applied = session.applied();
|
|
410
1100
|
say(applied === null
|
|
411
1101
|
? "applied: never"
|
|
412
1102
|
: `applied: plan v${applied.version} as ${applied.branch} at ${short(applied.head)}`);
|
|
413
1103
|
return 0;
|
|
414
1104
|
}
|
|
1105
|
+
function skillTargets(args) {
|
|
1106
|
+
return {
|
|
1107
|
+
harness: args.values.get("harness"),
|
|
1108
|
+
all: args.flags.has("all"),
|
|
1109
|
+
project: args.flags.has("project"),
|
|
1110
|
+
dryRun: args.flags.has("dry-run"),
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
function missingDraft(error, cwd) {
|
|
1114
|
+
return `no draft at ${relative(cwd, error.path)} yet; your coding agent writes it from \`follow-me prompt\`, or \`follow-me plan\` has the Anthropic API write it`;
|
|
1115
|
+
}
|
|
415
1116
|
export async function main(argv, cwd) {
|
|
416
1117
|
const [command, ...rest] = argv;
|
|
417
1118
|
try {
|
|
@@ -419,12 +1120,17 @@ export async function main(argv, cwd) {
|
|
|
419
1120
|
case "extract": {
|
|
420
1121
|
const args = parseArgs(rest, { values: ["base", "branch"] });
|
|
421
1122
|
summarize(await extractSession(cwd, sessionOpts(args)), cwd);
|
|
1123
|
+
const branch = branchFlag(args);
|
|
1124
|
+
next(`follow-me prompt${branch}, or follow-me plan${branch} with an Anthropic key`);
|
|
422
1125
|
return 0;
|
|
423
1126
|
}
|
|
424
1127
|
case "check":
|
|
425
|
-
return await check(cwd, parseArgs(rest, { values: ["branch"] }));
|
|
1128
|
+
return await check(cwd, parseArgs(rest, { values: ["branch", "syntax"] }));
|
|
426
1129
|
case "apply":
|
|
427
|
-
return await apply(cwd, parseArgs(rest, {
|
|
1130
|
+
return await apply(cwd, parseArgs(rest, {
|
|
1131
|
+
values: ["name", "syntax"],
|
|
1132
|
+
flags: ["force", "sign", "no-sign", "allow-syntax-errors"],
|
|
1133
|
+
}));
|
|
428
1134
|
case "status":
|
|
429
1135
|
parseArgs(rest, {});
|
|
430
1136
|
return await status(cwd);
|
|
@@ -435,20 +1141,62 @@ export async function main(argv, cwd) {
|
|
|
435
1141
|
return 0;
|
|
436
1142
|
}
|
|
437
1143
|
case "plan":
|
|
438
|
-
return await plan(cwd, parseArgs(rest, { values: ["model", "branch"], flags: ["walk"] }));
|
|
439
|
-
case "
|
|
440
|
-
|
|
1144
|
+
return await plan(cwd, parseArgs(rest, { values: ["model", "branch"], flags: ["walk", "naive"] }));
|
|
1145
|
+
case "split": {
|
|
1146
|
+
const spec = {
|
|
1147
|
+
values: ["base", "branch", "model", "syntax"],
|
|
1148
|
+
flags: ["no-open", "allow-syntax-errors"],
|
|
1149
|
+
};
|
|
1150
|
+
return await split(cwd, parseArgs(rest, spec));
|
|
1151
|
+
}
|
|
1152
|
+
case "review": {
|
|
1153
|
+
const spec = { values: ["port", "timeout"], flags: ["no-open", "detach", "wait"] };
|
|
1154
|
+
return await review(cwd, parseArgs(rest, spec));
|
|
1155
|
+
}
|
|
441
1156
|
case "walk": {
|
|
442
|
-
const spec = { values: ["base", "branch", "model", "port"], flags: ["no-open"] };
|
|
1157
|
+
const spec = { values: ["base", "branch", "model", "port"], flags: ["no-open", "detach"] };
|
|
443
1158
|
return await walk(cwd, parseArgs(rest, spec));
|
|
444
1159
|
}
|
|
1160
|
+
case "server":
|
|
1161
|
+
return await serverCommand(rest);
|
|
445
1162
|
case "feedback":
|
|
446
|
-
return await feedback(cwd, parseArgs(rest, { values: ["branch"] }));
|
|
1163
|
+
return await feedback(cwd, parseArgs(rest, { values: ["branch"], flags: ["walk"] }));
|
|
1164
|
+
case "watch": {
|
|
1165
|
+
const spec = { values: ["since", "timeout", "branch"], flags: ["json", "follow"] };
|
|
1166
|
+
return await watch(cwd, parseArgs(rest, spec));
|
|
1167
|
+
}
|
|
1168
|
+
case "reply":
|
|
1169
|
+
return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "branch"] }));
|
|
1170
|
+
case "report":
|
|
1171
|
+
return await report(cwd, parseArgs(rest, { values: ["branch"] }));
|
|
1172
|
+
case "clean": {
|
|
1173
|
+
const args = parseArgs(rest, { flags: ["all", "dry-run"] });
|
|
1174
|
+
const opts = { all: args.flags.has("all"), dryRun: args.flags.has("dry-run") };
|
|
1175
|
+
for (const path of await cleanSessions(cwd, opts))
|
|
1176
|
+
say(relative(cwd, path));
|
|
1177
|
+
return 0;
|
|
1178
|
+
}
|
|
1179
|
+
case "install": {
|
|
1180
|
+
const args = parseArgs(rest, {
|
|
1181
|
+
values: ["harness"],
|
|
1182
|
+
flags: ["all", "project", "dry-run", "list"],
|
|
1183
|
+
});
|
|
1184
|
+
return runInstall({ ...skillTargets(args), list: args.flags.has("list") }, homedir(), cwd, say);
|
|
1185
|
+
}
|
|
1186
|
+
case "uninstall": {
|
|
1187
|
+
const args = parseArgs(rest, { values: ["harness"], flags: ["all", "project", "dry-run"] });
|
|
1188
|
+
return runUninstall(skillTargets(args), homedir(), cwd, say);
|
|
1189
|
+
}
|
|
447
1190
|
case undefined:
|
|
448
1191
|
case "help":
|
|
1192
|
+
case "-h":
|
|
449
1193
|
case "--help":
|
|
450
1194
|
say(USAGE);
|
|
451
1195
|
return command === undefined ? 1 : 0;
|
|
1196
|
+
case "-v":
|
|
1197
|
+
case "--version":
|
|
1198
|
+
say(`follow-me ${version()}`);
|
|
1199
|
+
return 0;
|
|
452
1200
|
default:
|
|
453
1201
|
throw new UsageError(`unknown command "${command}"\n${USAGE}`);
|
|
454
1202
|
}
|
|
@@ -459,10 +1207,16 @@ export async function main(argv, cwd) {
|
|
|
459
1207
|
BranchMovedError,
|
|
460
1208
|
BranchRefusedError,
|
|
461
1209
|
SessionFileError,
|
|
1210
|
+
SessionLockedError,
|
|
1211
|
+
RegistrationError,
|
|
462
1212
|
GitError,
|
|
463
1213
|
PlanRequestError,
|
|
1214
|
+
NotARepositoryError,
|
|
464
1215
|
];
|
|
465
|
-
if (error instanceof
|
|
1216
|
+
if (error instanceof MissingDraftError) {
|
|
1217
|
+
console.error(`follow-me: ${missingDraft(error, cwd)}`);
|
|
1218
|
+
}
|
|
1219
|
+
else if (error instanceof DiffParseError) {
|
|
466
1220
|
console.error(`follow-me: could not parse the diff, please report this: ${error.message}`);
|
|
467
1221
|
}
|
|
468
1222
|
else if (error instanceof Error && known.some((k) => error instanceof k)) {
|