@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/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 { renderFeedback, renderPrompt } from "./prompt.js";
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
- check [path] [--branch <name>] validate draft.json or the given plan file
20
- apply [--name <branch>] [--force] write the draft as commits on <branch>-split or
21
- --name; never on <branch> or a checked-out branch
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
- prompt [--walk] [--branch <name>] print the planner prompt for a coding agent
25
- plan [--walk] [--model <id>] [--branch <name>]
26
- ask the Anthropic API to write draft.json
27
- review [--port <n>] [--detach] [--no-open] review the latest plan commit by commit
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
- feedback [--branch <name>] print the latest review as markdown
34
- clean [--all] [--dry-run] remove sessions of deleted branches, finished
35
- review drafts and leftovers of dead processes;
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] [--user] [--dry-run]
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 summarize(session, cwd) {
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
- say(`${anchor.branch} ${short(anchor.tip)} onto ${anchor.baseRef} ${short(anchor.base)}: ${plural(byPath.size, "file")}, ${plural(diff.units.size, "unit")}`);
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
- const { plan } = verdict;
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
- for (const w of verdict.warnings)
216
- say(formatWarning(w));
217
- return 0;
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
- return locked(session, "apply", () => applyLocked(session, args));
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 version = session.freeze(verdict.plan);
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
- const result = await writeBranch(session.root, session.diff, verdict.plan, {
244
- name,
245
- source: session.anchor.branch,
246
- expectedOld: last?.branch === name ? last.head : null,
247
- force: args.flags.has("force"),
248
- });
249
- session.recordApplied(version, result);
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(result.commits.length, "commit")})`);
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
- return (await draftWithApi(session, cwd, modeOf(args), args)) === null ? 1 : 0;
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 lock = session.lockState();
331
- const live = await findServer(home);
332
- if (live !== null && lock.kind === "held" && lock.pid === live.pid) {
333
- const id = sessionIdOf(session.dir);
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.reviews().some((r) => r.version === version))
604
+ function alreadyReviewed(session, mode, version, args) {
605
+ if (!reviewsOf(session, mode).some((r) => r.version === version))
392
606
  return false;
393
- console.error(`follow-me: plan v${version} already has a review; read it with \`follow-me feedback${branchFlag(args)}\` or plan again first`);
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
- async function review(cwd, args) {
397
- const session = await openSession(cwd);
398
- const prepare = async () => {
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
- /** Opens the reviewer's session, re-extracting instead of failing when the branch moved. */
408
- async function openForWalk(cwd, args) {
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 openForWalk(cwd, args);
808
+ const session = await openFresh(cwd, args);
449
809
  const report = (review) => {
450
- const saved = join(session.dir, "reviews", `v${review.planVersion}.json`);
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
- say(`\`follow-me feedback${branchFlag(args)}\` prints it as a code review for the author`);
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 stored = session.reviews().at(-1);
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
- console.error("follow-me: no review yet, run `follow-me review` first");
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 plan = session.plans().find((p) => p.version === stored.version);
482
- if (plan === undefined) {
483
- console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
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
- process.stdout.write(renderFeedback(stored.review, plan.file, session.diff.units));
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
- user: args.flags.has("user"),
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, { values: ["name"], flags: ["force"] }));
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 "review":
621
- return await review(cwd, parseArgs(rest, { values: ["port"], flags: ["no-open", "detach"] }));
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", "user", "dry-run", "list"],
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", "user", "dry-run"] });
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 DiffParseError) {
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)) {