@shiyuphan/follow-me 0.1.0 → 0.2.0

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