@shiyuphan/follow-me 0.1.1 → 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 +127 -66
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +632 -81
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +20 -3
- package/dist/src/host.js +92 -6
- package/dist/src/install.js +1 -1
- 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 +461 -137
- package/dist/src/session.js +104 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +3 -1
- package/dist/ui/app.js +1676 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +61 -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 +955 -90
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -6
package/dist/src/cli.js
CHANGED
|
@@ -1,50 +1,96 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, relative } from "node:path";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { PlanRequestError, requestPlan } from "./anthropic.js";
|
|
5
6
|
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";
|
|
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";
|
|
8
9
|
import { runInstall, runUninstall } from "./install.js";
|
|
10
|
+
import { naiveDraft } from "./naive.js";
|
|
9
11
|
import { serializePlanFile, validatePlan } from "./plan.js";
|
|
10
|
-
import {
|
|
12
|
+
import { renderEvents, renderPrompt, renderReport } from "./prompt.js";
|
|
11
13
|
import { RegistrationError, sessionIdOf } from "./server.js";
|
|
12
|
-
import { BranchMovedError, cleanSessions, extractSession, openSession, SessionFileError, SessionLockedError, } from "./session.js";
|
|
14
|
+
import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, } from "./session.js";
|
|
15
|
+
import { checkSyntax } from "./syntax.js";
|
|
13
16
|
class UsageError extends Error {
|
|
14
17
|
name = "UsageError";
|
|
15
18
|
}
|
|
16
|
-
const USAGE = `usage: follow-me <command>
|
|
19
|
+
const USAGE = `usage: follow-me <command> [options]
|
|
17
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
|
|
18
25
|
extract [--base <ref>] [--branch <name>] anchor a session and list the units
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
prompt [--walk] [--branch <name>] print the planner prompt; your coding agent
|
|
27
|
+
writes draft.json from it
|
|
28
|
+
plan [--walk] [--model <id>] [--branch <name>]
|
|
29
|
+
the same draft as prompt, written by the
|
|
30
|
+
Anthropic API
|
|
31
|
+
plan --naive [--branch <name>] no API, no agent: one "wip: <path>" commit per
|
|
32
|
+
touched file, in diff order
|
|
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
|
|
22
48
|
status show the session's plans, reviews, lock and the
|
|
23
49
|
branch apply writes
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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:
|
|
28
67
|
walk [--base <ref>] [--branch <name>] [--model <id>] [--port <n>] [--detach] [--no-open]
|
|
29
68
|
watch a branch being written, narrated, and
|
|
30
69
|
review its code; nothing is applied
|
|
70
|
+
|
|
71
|
+
Review server:
|
|
31
72
|
server status show the review server and the sessions it hosts
|
|
32
73
|
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]
|
|
74
|
+
|
|
75
|
+
Setup:
|
|
76
|
+
install [--harness <ids>|--all] [--project] [--dry-run]
|
|
38
77
|
copy the skill into coding agents' skill dirs
|
|
39
78
|
install --list show each harness, its skill dirs and docs
|
|
40
|
-
uninstall [--harness <ids>|--all] [--
|
|
79
|
+
uninstall [--harness <ids>|--all] [--project] [--dry-run]
|
|
41
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
|
|
42
86
|
|
|
43
87
|
--walk writes the prompt for a reviewer: a story with narration instead of a split.
|
|
44
88
|
review and walk share one server per user: a running one hosts the session and the command
|
|
45
89
|
prints its URL; otherwise the command serves until its review ends, or with --detach starts the
|
|
46
90
|
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
|
|
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.`;
|
|
48
94
|
function parseArgs(argv, spec) {
|
|
49
95
|
const positionals = [];
|
|
50
96
|
const values = new Map();
|
|
@@ -73,6 +119,34 @@ function parseArgs(argv, spec) {
|
|
|
73
119
|
function say(line = "") {
|
|
74
120
|
process.stdout.write(`${line}\n`);
|
|
75
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
|
+
}
|
|
76
150
|
/** Paths are latin1 so bytes round-trip; show them as the UTF-8 the user typed. */
|
|
77
151
|
function show(path) {
|
|
78
152
|
return Buffer.from(path, "latin1").toString("utf8");
|
|
@@ -108,13 +182,22 @@ function describeFile(diff, units) {
|
|
|
108
182
|
status = text.base === null ? "A" : text.tip === null ? "D" : "M";
|
|
109
183
|
return { status, what: parts.join(", ") };
|
|
110
184
|
}
|
|
111
|
-
function
|
|
112
|
-
const { anchor, diff } = session;
|
|
185
|
+
function unitsByPath(diff) {
|
|
113
186
|
const byPath = new Map();
|
|
114
187
|
for (const unit of diff.units.values()) {
|
|
115
188
|
byPath.set(unit.path, [...(byPath.get(unit.path) ?? []), unit]);
|
|
116
189
|
}
|
|
117
|
-
|
|
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));
|
|
118
201
|
const width = Math.max(0, ...[...byPath.keys()].map((p) => show(p).length));
|
|
119
202
|
for (const [path, units] of byPath) {
|
|
120
203
|
const { status, what } = describeFile(diff, units);
|
|
@@ -149,6 +232,12 @@ function formatIssue(issue) {
|
|
|
149
232
|
return `$.commits[${issue.commit}]: ${show(issue.file)} is a file while ${show(issue.under)} exists`;
|
|
150
233
|
case "unknown-response":
|
|
151
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`;
|
|
152
241
|
case "final-mismatch": {
|
|
153
242
|
const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
|
|
154
243
|
return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
|
|
@@ -160,8 +249,22 @@ function formatIssue(issue) {
|
|
|
160
249
|
}
|
|
161
250
|
}
|
|
162
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
|
+
}
|
|
163
258
|
return `warning: comment ${w.comment} from review v${w.review} has no response`;
|
|
164
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
|
+
}
|
|
165
268
|
function readPlanArg(path) {
|
|
166
269
|
try {
|
|
167
270
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -204,17 +307,53 @@ async function check(cwd, args) {
|
|
|
204
307
|
const session = await openSession(cwd, sessionOpts(args));
|
|
205
308
|
const path = args.positionals[0];
|
|
206
309
|
const verdict = validate(session, path === undefined ? session.readDraft() : readPlanArg(path));
|
|
207
|
-
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)}`);
|
|
208
313
|
return 1;
|
|
209
|
-
|
|
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) {
|
|
210
349
|
say(`ok: ${plural(plan.commits.length, "commit")}, plan ${short(plan.hash)}`);
|
|
211
350
|
for (const c of plan.commits) {
|
|
212
351
|
const subject = c.message.split("\n")[0] ?? "";
|
|
213
352
|
say(` ${c.index + 1} ${subject} (${plural(c.refs.length, "unit")}, ${plural(c.changes.length, "file")})`);
|
|
214
353
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
354
|
+
const tally = concernTally(plan.commits);
|
|
355
|
+
if (tally !== null)
|
|
356
|
+
say(tally);
|
|
218
357
|
}
|
|
219
358
|
/** Runs one command under the session's lock, so two agents never apply or review it at once. */
|
|
220
359
|
async function locked(session, command, run) {
|
|
@@ -231,25 +370,85 @@ function splitBranch(session) {
|
|
|
231
370
|
}
|
|
232
371
|
async function apply(cwd, args) {
|
|
233
372
|
const session = await openSession(cwd);
|
|
234
|
-
|
|
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";
|
|
235
410
|
}
|
|
236
411
|
async function applyLocked(session, args) {
|
|
412
|
+
const mode = signMode(args);
|
|
237
413
|
const verdict = validate(session, session.readDraft());
|
|
238
414
|
if (!verdict.ok)
|
|
239
415
|
return 1;
|
|
240
|
-
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);
|
|
241
428
|
const name = args.values.get("name") ?? splitBranch(session);
|
|
429
|
+
const signed = await signing(session.root, mode);
|
|
242
430
|
const last = session.applied();
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 });
|
|
250
448
|
for (const w of verdict.warnings)
|
|
251
449
|
say(formatWarning(w));
|
|
252
|
-
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)}`);
|
|
253
452
|
const tree = await treeOf(session.root, result.head);
|
|
254
453
|
const same = tree === session.anchor.tipTree;
|
|
255
454
|
say(`proof: ${result.branch}^{tree} ${tree} ${same ? "equals" : "DIFFERS FROM"} ${session.anchor.branch}^{tree} ${session.anchor.tipTree}`);
|
|
@@ -289,9 +488,29 @@ async function draftWithApi(session, cwd, mode, args) {
|
|
|
289
488
|
say(`wrote ${relative(cwd, session.draftPath)} but it still has issues, repair it and run \`follow-me check${branchFlag(args)}\``);
|
|
290
489
|
return null;
|
|
291
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
|
+
}
|
|
292
505
|
async function plan(cwd, args) {
|
|
293
506
|
const session = await openSession(cwd, sessionOpts(args));
|
|
294
|
-
|
|
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;
|
|
295
514
|
}
|
|
296
515
|
/** The latest frozen plan, or the draft frozen now when it validates. */
|
|
297
516
|
function latestPlan(session) {
|
|
@@ -327,15 +546,10 @@ async function runningServer(home, port) {
|
|
|
327
546
|
*/
|
|
328
547
|
async function hostReview(session, mode, args, prepare, report) {
|
|
329
548
|
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
|
-
}
|
|
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 };
|
|
339
553
|
}
|
|
340
554
|
return locked(session, mode === "walk" ? "walk" : "review", async (held) => {
|
|
341
555
|
const prepared = await prepare();
|
|
@@ -364,7 +578,7 @@ async function hostReview(session, mode, args, prepare, report) {
|
|
|
364
578
|
if (started.kind === "attached") {
|
|
365
579
|
const summary = await registerWith(started.port, registration);
|
|
366
580
|
announce(mode, sessionUrl(started.port, summary), args);
|
|
367
|
-
return { kind: "elsewhere" };
|
|
581
|
+
return { kind: "elsewhere", port: started.port, summary };
|
|
368
582
|
}
|
|
369
583
|
const { host } = started;
|
|
370
584
|
const { summary, done } = await host.register(registration);
|
|
@@ -387,25 +601,171 @@ function exitCode(hosted) {
|
|
|
387
601
|
}
|
|
388
602
|
return hosted.kind === "refused" || hosted.kind === "stopped" ? 1 : 0;
|
|
389
603
|
}
|
|
390
|
-
function alreadyReviewed(session, version, args) {
|
|
391
|
-
if (!session
|
|
604
|
+
function alreadyReviewed(session, mode, version, args) {
|
|
605
|
+
if (!reviewsOf(session, mode).some((r) => r.version === version))
|
|
392
606
|
return false;
|
|
393
|
-
|
|
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`);
|
|
394
611
|
return true;
|
|
395
612
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const
|
|
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 () => {
|
|
399
660
|
const latest = latestPlan(session);
|
|
400
|
-
if (latest === null || alreadyReviewed(session, latest.version, args))
|
|
661
|
+
if (latest === null || alreadyReviewed(session, "split", latest.version, args))
|
|
401
662
|
return null;
|
|
402
663
|
return latest;
|
|
403
664
|
};
|
|
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
665
|
}
|
|
407
|
-
|
|
408
|
-
|
|
666
|
+
async function review(cwd, args) {
|
|
667
|
+
const session = await openSession(cwd);
|
|
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`);
|
|
682
|
+
return 1;
|
|
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")
|
|
745
|
+
return 1;
|
|
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");
|
|
765
|
+
return 0;
|
|
766
|
+
}
|
|
767
|
+
/** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
|
|
768
|
+
async function openFresh(cwd, args) {
|
|
409
769
|
const opts = sessionOpts(args);
|
|
410
770
|
try {
|
|
411
771
|
const session = await openSession(cwd, opts);
|
|
@@ -445,13 +805,16 @@ function hasApiCredentials() {
|
|
|
445
805
|
return Boolean(process.env["ANTHROPIC_API_KEY"] || process.env["ANTHROPIC_AUTH_TOKEN"]);
|
|
446
806
|
}
|
|
447
807
|
async function walk(cwd, args) {
|
|
448
|
-
const session = await
|
|
808
|
+
const session = await openFresh(cwd, args);
|
|
449
809
|
const report = (review) => {
|
|
450
|
-
const saved = join(session.
|
|
810
|
+
const saved = join(session.reviewsDir("walk"), `v${review.planVersion}.json`);
|
|
451
811
|
say(`code review saved to ${relative(cwd, saved)} with ${plural(review.comments.length, "comment")}`);
|
|
452
|
-
|
|
812
|
+
next(`follow-me feedback --walk${branchFlag(args)}, which prints it as a code review for the author`);
|
|
453
813
|
};
|
|
454
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
|
+
}
|
|
455
818
|
return exitCode(hosted);
|
|
456
819
|
}
|
|
457
820
|
async function walkPlan(session, cwd, args) {
|
|
@@ -469,21 +832,169 @@ async function walkPlan(session, cwd, args) {
|
|
|
469
832
|
return null;
|
|
470
833
|
latest = { version: session.freeze(drafted), plan: drafted };
|
|
471
834
|
}
|
|
472
|
-
return alreadyReviewed(session, latest.version, args) ? null : latest;
|
|
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
|
+
});
|
|
473
858
|
}
|
|
474
859
|
async function feedback(cwd, args) {
|
|
475
860
|
const session = await openSession(cwd, sessionOpts(args));
|
|
476
|
-
const
|
|
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");
|
|
477
864
|
if (stored === undefined) {
|
|
478
|
-
|
|
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");
|
|
479
871
|
return 1;
|
|
480
872
|
}
|
|
481
|
-
const
|
|
482
|
-
if (
|
|
483
|
-
|
|
873
|
+
const text = renderStored(session, stored);
|
|
874
|
+
if (text === null)
|
|
875
|
+
return 1;
|
|
876
|
+
process.stdout.write(text);
|
|
877
|
+
say();
|
|
878
|
+
next(stored.review.mode === "walk"
|
|
879
|
+
? "hand this to the branch's author"
|
|
880
|
+
: `hand this to your agent, then follow-me check${branchFlag(args)}`);
|
|
881
|
+
return 0;
|
|
882
|
+
}
|
|
883
|
+
/** The consolidated report of the hosted review as it stands, else of the last one that ended. */
|
|
884
|
+
async function report(cwd, args) {
|
|
885
|
+
const session = await openSession(cwd, sessionOpts(args));
|
|
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);
|
|
893
|
+
if (stored === undefined) {
|
|
894
|
+
console.error("follow-me: no review is open and none has ended; run `follow-me review` first");
|
|
895
|
+
return 1;
|
|
896
|
+
}
|
|
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");
|
|
484
944
|
return 1;
|
|
485
945
|
}
|
|
486
|
-
|
|
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");
|
|
487
998
|
return 0;
|
|
488
999
|
}
|
|
489
1000
|
function describeLock(lock) {
|
|
@@ -549,7 +1060,8 @@ async function status(cwd) {
|
|
|
549
1060
|
const session = await openSession(cwd);
|
|
550
1061
|
const { anchor, diff } = session;
|
|
551
1062
|
const plans = session.plans();
|
|
552
|
-
const reviews = session.reviews();
|
|
1063
|
+
const reviews = session.reviews().filter((r) => r.review.mode === "split");
|
|
1064
|
+
const walks = session.walkReviews();
|
|
553
1065
|
say(`${anchor.branch} ${short(anchor.tip)} onto ${anchor.baseRef} ${short(anchor.base)}`);
|
|
554
1066
|
say(`writes to: ${splitBranch(session)}`);
|
|
555
1067
|
const lock = session.lockState();
|
|
@@ -579,6 +1091,11 @@ async function status(cwd) {
|
|
|
579
1091
|
const open = lastReview.review.comments.filter((c) => !answered.has(c.id)).length;
|
|
580
1092
|
say(`reviews: ${reviews.map((r) => `v${r.version}`).join(", ")} (latest has ${plural(lastReview.review.comments.length, "comment")}, ${open} open)`);
|
|
581
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"))}`);
|
|
582
1099
|
const applied = session.applied();
|
|
583
1100
|
say(applied === null
|
|
584
1101
|
? "applied: never"
|
|
@@ -589,10 +1106,13 @@ function skillTargets(args) {
|
|
|
589
1106
|
return {
|
|
590
1107
|
harness: args.values.get("harness"),
|
|
591
1108
|
all: args.flags.has("all"),
|
|
592
|
-
|
|
1109
|
+
project: args.flags.has("project"),
|
|
593
1110
|
dryRun: args.flags.has("dry-run"),
|
|
594
1111
|
};
|
|
595
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
|
+
}
|
|
596
1116
|
export async function main(argv, cwd) {
|
|
597
1117
|
const [command, ...rest] = argv;
|
|
598
1118
|
try {
|
|
@@ -600,12 +1120,17 @@ export async function main(argv, cwd) {
|
|
|
600
1120
|
case "extract": {
|
|
601
1121
|
const args = parseArgs(rest, { values: ["base", "branch"] });
|
|
602
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`);
|
|
603
1125
|
return 0;
|
|
604
1126
|
}
|
|
605
1127
|
case "check":
|
|
606
|
-
return await check(cwd, parseArgs(rest, { values: ["branch"] }));
|
|
1128
|
+
return await check(cwd, parseArgs(rest, { values: ["branch", "syntax"] }));
|
|
607
1129
|
case "apply":
|
|
608
|
-
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
|
+
}));
|
|
609
1134
|
case "status":
|
|
610
1135
|
parseArgs(rest, {});
|
|
611
1136
|
return await status(cwd);
|
|
@@ -616,9 +1141,18 @@ export async function main(argv, cwd) {
|
|
|
616
1141
|
return 0;
|
|
617
1142
|
}
|
|
618
1143
|
case "plan":
|
|
619
|
-
return await plan(cwd, parseArgs(rest, { values: ["model", "branch"], flags: ["walk"] }));
|
|
620
|
-
case "
|
|
621
|
-
|
|
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
|
+
}
|
|
622
1156
|
case "walk": {
|
|
623
1157
|
const spec = { values: ["base", "branch", "model", "port"], flags: ["no-open", "detach"] };
|
|
624
1158
|
return await walk(cwd, parseArgs(rest, spec));
|
|
@@ -626,7 +1160,15 @@ export async function main(argv, cwd) {
|
|
|
626
1160
|
case "server":
|
|
627
1161
|
return await serverCommand(rest);
|
|
628
1162
|
case "feedback":
|
|
629
|
-
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"] }));
|
|
630
1172
|
case "clean": {
|
|
631
1173
|
const args = parseArgs(rest, { flags: ["all", "dry-run"] });
|
|
632
1174
|
const opts = { all: args.flags.has("all"), dryRun: args.flags.has("dry-run") };
|
|
@@ -637,19 +1179,24 @@ export async function main(argv, cwd) {
|
|
|
637
1179
|
case "install": {
|
|
638
1180
|
const args = parseArgs(rest, {
|
|
639
1181
|
values: ["harness"],
|
|
640
|
-
flags: ["all", "
|
|
1182
|
+
flags: ["all", "project", "dry-run", "list"],
|
|
641
1183
|
});
|
|
642
1184
|
return runInstall({ ...skillTargets(args), list: args.flags.has("list") }, homedir(), cwd, say);
|
|
643
1185
|
}
|
|
644
1186
|
case "uninstall": {
|
|
645
|
-
const args = parseArgs(rest, { values: ["harness"], flags: ["all", "
|
|
1187
|
+
const args = parseArgs(rest, { values: ["harness"], flags: ["all", "project", "dry-run"] });
|
|
646
1188
|
return runUninstall(skillTargets(args), homedir(), cwd, say);
|
|
647
1189
|
}
|
|
648
1190
|
case undefined:
|
|
649
1191
|
case "help":
|
|
1192
|
+
case "-h":
|
|
650
1193
|
case "--help":
|
|
651
1194
|
say(USAGE);
|
|
652
1195
|
return command === undefined ? 1 : 0;
|
|
1196
|
+
case "-v":
|
|
1197
|
+
case "--version":
|
|
1198
|
+
say(`follow-me ${version()}`);
|
|
1199
|
+
return 0;
|
|
653
1200
|
default:
|
|
654
1201
|
throw new UsageError(`unknown command "${command}"\n${USAGE}`);
|
|
655
1202
|
}
|
|
@@ -664,8 +1211,12 @@ export async function main(argv, cwd) {
|
|
|
664
1211
|
RegistrationError,
|
|
665
1212
|
GitError,
|
|
666
1213
|
PlanRequestError,
|
|
1214
|
+
NotARepositoryError,
|
|
667
1215
|
];
|
|
668
|
-
if (error instanceof
|
|
1216
|
+
if (error instanceof MissingDraftError) {
|
|
1217
|
+
console.error(`follow-me: ${missingDraft(error, cwd)}`);
|
|
1218
|
+
}
|
|
1219
|
+
else if (error instanceof DiffParseError) {
|
|
669
1220
|
console.error(`follow-me: could not parse the diff, please report this: ${error.message}`);
|
|
670
1221
|
}
|
|
671
1222
|
else if (error instanceof Error && known.some((k) => error instanceof k)) {
|