thurview 0.11.1 → 0.13.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/cli.js CHANGED
@@ -9,45 +9,28 @@ import { join, dirname, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import open from "open";
11
11
  import * as g from "./git.js";
12
- import { SCHEMA, home, newId, now, readReview, writeReview, listReviews, reviewsFor, reviewDir, revisionDir, readThreads, readText, writeText, writeJson, readJson, serverStateFile, deleteReview, kindOf, } from "./store.js";
12
+ import { SCHEMA, home, newId, now, readReview, writeReview, listReviews, reviewsFor, reviewDir, revisionDir, passFile, readThreads, readText, writeText, writeJson, readJson, serverStateFile, deleteReview, kindOf, } from "./store.js";
13
13
  import { compileDocument, compileMap, globToRegExp } from "./document/compile.js";
14
14
  import { computeCoverage, scopeGlob, scopeGraph, scopeTruncated, } from "./coverage.js";
15
15
  import { parseTheme, compileTheme } from "./theme.js";
16
16
  import { registerTheme } from "./highlight.js";
17
17
  import { replyThread, setThreadStatus, needsAgent } from "./threads.js";
18
+ import { targetLabel, truncate } from "./thread-state.js";
18
19
  import { attach } from "./presence.js";
19
20
  import { startServer } from "./server/server.js";
20
21
  import { parseFlags, helpFor, str, bool } from "./flags.js";
21
22
  import { forgeFor, repoOf, summariseCi, } from "./forge/index.js";
22
- import { parseSubmission, longComments } from "./forge/submission.js";
23
+ import { parseSubmission, longComments, buildPass } from "./forge/submission.js";
23
24
  import { VERSION } from "./version.js";
24
25
  const execFileP = promisify(execFile);
25
26
  const HERE = dirname(fileURLToPath(import.meta.url));
26
- const DESCRIPTION = "Guided, evidence-anchored reviews of a change and explainers of a codebase, read and answered in the browser";
27
+ const DESCRIPTION = "Guided, evidence-anchored reviews of a change, explainers of a codebase and designs of what to build, read and answered in the browser";
27
28
  function note(msg) {
28
29
  process.stderr.write(msg + "\n");
29
30
  }
30
31
  function short(id) {
31
32
  return id.slice(0, 8);
32
33
  }
33
- function truncate(text, max) {
34
- return text.length > max
35
- ? `${text.slice(0, max)}... (truncated, ${text.length} chars total)`
36
- : text;
37
- }
38
- function targetLabel(t) {
39
- if (t.type === "document")
40
- return `document${t.quote ? ` "${truncate(t.quote, 40)}"` : ""}`;
41
- if (t.type === "file") {
42
- if (!t.line)
43
- return `${t.path} (file)`;
44
- const range = t.endLine && t.endLine > t.line ? `${t.line}-${t.endLine}` : String(t.line);
45
- return `${t.path}:${range}${t.side === "base" ? " (base)" : ""}`;
46
- }
47
- if (t.type === "map")
48
- return `map ${t.node}`;
49
- return "review";
50
- }
51
34
  /** `PR #12` or `MR !12`, because the forge's own word is what the reader knows. */
52
35
  function bindingLabel(b) {
53
36
  if (b.kind !== "pr")
@@ -77,7 +60,13 @@ async function worktreeOf(cwd) {
77
60
  return null;
78
61
  }
79
62
  }
80
- async function resolveReview(idOpt) {
63
+ /**
64
+ * `terminal` keeps an approved or closed review in the search. `forge pass`
65
+ * needs it: approve and close are two of the three decisions it carries, and
66
+ * both of them end the review, so without it the command cannot find the very
67
+ * review it was asked about unless the id is spelled out.
68
+ */
69
+ async function resolveReview(idOpt, opts = {}) {
81
70
  if (idOpt) {
82
71
  const r = await readReview(idOpt);
83
72
  if (r)
@@ -92,7 +81,7 @@ async function resolveReview(idOpt) {
92
81
  throw new AxiError("not inside a git repository", "VALIDATION_ERROR", [
93
82
  "Run inside the source worktree, or pass --review <id>",
94
83
  ]);
95
- const mine = (await reviewsFor(worktree)).filter((r) => !r.dismissed && r.status !== "accepted" && r.status !== "closed");
84
+ const mine = (await reviewsFor(worktree)).filter((r) => !r.dismissed && (opts.terminal || (r.status !== "accepted" && r.status !== "closed")));
96
85
  if (mine.length === 1)
97
86
  return mine[0];
98
87
  if (mine.length)
@@ -145,9 +134,9 @@ async function reviewRow(r, fields) {
145
134
  row["binding"] = bindingLabel(r.binding);
146
135
  if (fields.has("all") || fields.has("pins"))
147
136
  row["pins"] =
148
- kindOf(r) === "explainer"
149
- ? r.pins.head.slice(0, 12)
150
- : `${r.pins.base.slice(0, 12)}..${r.pins.head.slice(0, 12)}`;
137
+ kindOf(r) === "review"
138
+ ? `${r.pins.base.slice(0, 12)}..${r.pins.head.slice(0, 12)}`
139
+ : r.pins.head.slice(0, 12);
151
140
  if (fields.has("all") || fields.has("worktree"))
152
141
  row["worktree"] = r.worktree;
153
142
  if (fields.has("all") || fields.has("inSync"))
@@ -334,6 +323,150 @@ const TEMPLATE_EXPLAIN_MAP = `# The structure of the code at the pinned commit:
334
323
  nodes: []
335
324
  edges: []
336
325
  `;
326
+ const TEMPLATE_DESIGN_MD = (title) => `# ${title}
327
+
328
+ **Summary**
329
+
330
+ - The agent is still writing this design. The page offers the new revision
331
+ when the proposal lands.
332
+ `;
333
+ const TEMPLATE_DESIGN_DATA = `# Typed inputs for the design: actors, anchors, stores, and the proposals it
334
+ # makes. A design is pinned to ONE commit - the code as it stands, which the
335
+ # design changes - so every anchor reads that commit and \`graph: base\` is an
336
+ # error.
337
+ #
338
+ # An anchor is evidence, never a proposal. It points at code that exists today:
339
+ # what the design changes, and what constrains it.
340
+ #
341
+ # anchors:
342
+ # login:
343
+ # title: where a request is authenticated today
344
+ # peek: { file: src/auth.ts, from: 41, to: 58 }
345
+ #
346
+ # interfaces holds what the design WOULD add, change or remove. Each entry names
347
+ # the interface, what it would let a consumer do, and the anchor of the code it
348
+ # lands in, replaces or plugs into today. A design with no entry here proposes
349
+ # nothing, and publish refuses it: that document is a code explainer.
350
+ #
351
+ # interfaces:
352
+ # strict:
353
+ # name: auth.login --strict
354
+ # change: added
355
+ # capability: Rejects an empty user instead of answering false.
356
+ # anchor: login
357
+ actors: {}
358
+ anchors: {}
359
+ stores: {}
360
+ interfaces: {}
361
+ `;
362
+ const TEMPLATE_DESIGN_MAP = `# The structure the design proposes, with the structure as it stands under
363
+ # \`base\`, so the Map tab shows what it adds, changes and removes. A node under
364
+ # \`nodes\` may own files that do not exist yet - that is a proposed part. A node
365
+ # under \`base\` may not: it is a claim about today, and publish warns.
366
+ # Seed base from \`thurview graph architecture\`.
367
+ nodes: []
368
+ edges: []
369
+ `;
370
+ const EXPLAINER = {
371
+ kind: "explainer",
372
+ command: "explain",
373
+ title: (scope, worktree) => scope === "**" ? worktree.split("/").pop() || "Codebase" : scope.replace(/\/\*\*$/, ""),
374
+ templates: { md: TEMPLATE_EXPLAIN_MD, data: TEMPLATE_EXPLAIN_DATA, map: TEMPLATE_EXPLAIN_MAP },
375
+ };
376
+ const DESIGN = {
377
+ kind: "design",
378
+ command: "design",
379
+ title: (scope) => (scope === "**" ? "Design" : `${scope.replace(/\/\*\*$/, "")} design`),
380
+ templates: { md: TEMPLATE_DESIGN_MD, data: TEMPLATE_DESIGN_DATA, map: TEMPLATE_DESIGN_MAP },
381
+ };
382
+ /** The kind with its article, so a generated sentence reads as one: "an explainer". */
383
+ function kindWord(kind) {
384
+ return kind === "explainer" ? "an explainer" : kind === "design" ? "a design" : "a review";
385
+ }
386
+ /** The command that re-pins a document of this kind, for a message that offers it. */
387
+ function repinCommand(kind) {
388
+ return kind === "review"
389
+ ? "thurview scaffold"
390
+ : `thurview ${kind === "design" ? "design" : "explain"}`;
391
+ }
392
+ async function pinOneCommit(p, k) {
393
+ const worktree = await worktreeOf(process.cwd());
394
+ if (!worktree)
395
+ throw new AxiError("not inside a git repository", "VALIDATION_ERROR", [
396
+ `Run \`thurview ${k.command}\` inside the source worktree`,
397
+ ]);
398
+ const existing = bool(p, "update") || str(p, "review") ? await resolveReview(str(p, "review")) : null;
399
+ if (existing && kindOf(existing) !== k.kind) {
400
+ const other = kindOf(existing);
401
+ throw new AxiError(`${short(existing.id)} is ${kindWord(other)}, not ${kindWord(k.kind)}`, "VALIDATION_ERROR", [
402
+ `Run \`${repinCommand(other)} --update --review ${short(existing.id)}\` to re-pin that ${other}`,
403
+ `Run \`thurview ${k.command}\` with no --review to start ${kindWord(k.kind)}`,
404
+ ]);
405
+ }
406
+ const scope = scopeGlob(p.positional[0] ?? existing?.binding.name);
407
+ let commit;
408
+ try {
409
+ commit = await g.revParse(worktree, str(p, "commit") ?? "HEAD");
410
+ }
411
+ catch (e) {
412
+ throw new AxiError(e.message, "VALIDATION_ERROR", [
413
+ `Pass a resolvable ref: \`thurview ${k.command} --commit <ref>\``,
414
+ ]);
415
+ }
416
+ // A scope that matches nothing is a typo, and a document of nothing would
417
+ // still publish and still state honest-looking coverage of zero files.
418
+ const files = await g.listFiles(worktree, commit);
419
+ const inScope = scope === "**" ? files : files.filter((f) => globToRegExp(scope).test(f));
420
+ if (!inScope.length)
421
+ throw new AxiError(`no file matches "${scope}" at ${commit.slice(0, 12)}`, "VALIDATION_ERROR", [
422
+ `Pass a path that exists at that commit: \`thurview ${k.command} src/server\``,
423
+ `Run \`thurview ${k.command}\` with no scope for the whole repository`,
424
+ ]);
425
+ const binding = { kind: "codebase", name: scope };
426
+ if (existing) {
427
+ existing.pins = { base: commit, head: commit };
428
+ existing.binding = binding;
429
+ if (str(p, "title"))
430
+ existing.title = str(p, "title");
431
+ await writeReview(existing);
432
+ return { review: existing, reused: false, scope, commit, worktree, inScope };
433
+ }
434
+ const match = bool(p, "new")
435
+ ? []
436
+ : (await reviewsFor(worktree)).filter((r) => kindOf(r) === k.kind &&
437
+ r.binding.name === scope &&
438
+ r.status !== "accepted" &&
439
+ r.status !== "closed");
440
+ if (match.length) {
441
+ const review = match[0];
442
+ review.pins = { base: commit, head: commit };
443
+ await writeReview(review);
444
+ return { review, reused: true, scope, commit, worktree, inScope };
445
+ }
446
+ const id = newId();
447
+ const review = {
448
+ schema: SCHEMA,
449
+ id,
450
+ kind: k.kind,
451
+ title: str(p, "title") || k.title(scope, worktree),
452
+ worktree,
453
+ repoRoot: worktree,
454
+ binding,
455
+ pins: { base: commit, head: commit },
456
+ status: "draft",
457
+ revision: 0,
458
+ dismissed: false,
459
+ createdAt: now(),
460
+ updatedAt: now(),
461
+ };
462
+ await mkdir(reviewDir(id), { recursive: true });
463
+ await writeText(join(reviewDir(id), "review.md"), k.templates.md(review.title));
464
+ await writeText(join(reviewDir(id), "data.yaml"), k.templates.data);
465
+ await writeText(join(reviewDir(id), "map.yaml"), k.templates.map);
466
+ await writeText(join(reviewDir(id), "theme.yaml"), TEMPLATE_THEME);
467
+ await writeReview(review);
468
+ return { review, reused: false, scope, commit, worktree, inScope };
469
+ }
337
470
  // ---- commands ----
338
471
  const SPECS = {
339
472
  scaffold: {
@@ -379,8 +512,25 @@ const SPECS = {
379
512
  "thurview explain --update --review <id>",
380
513
  ],
381
514
  },
515
+ design: {
516
+ description: "Create a design or architecture document: what to build, argued against the code as it stands",
517
+ args: "[<path scope>]",
518
+ flags: {
519
+ commit: { kind: "string", help: "commit to pin (default: HEAD)" },
520
+ title: { kind: "string", help: "initial title" },
521
+ new: { kind: "boolean", help: "create another design even if one matches the scope" },
522
+ update: { kind: "boolean", help: "re-pin an existing design to a new commit" },
523
+ review: { kind: "string", help: "design to update (id prefix)" },
524
+ },
525
+ examples: [
526
+ "thurview design",
527
+ "thurview design src/server",
528
+ "thurview design --title 'Queue the forge pass'",
529
+ "thurview design --update --review <id>",
530
+ ],
531
+ },
382
532
  info: {
383
- description: "Reviews and explainers bound to this worktree (or all of them with --all)",
533
+ description: "Reviews, explainers and designs bound to this worktree (or all of them with --all)",
384
534
  flags: {
385
535
  all: { kind: "boolean", help: "every review, not only this worktree" },
386
536
  fields: {
@@ -478,7 +628,7 @@ const SPECS = {
478
628
  },
479
629
  forge: {
480
630
  description: "Read a pull or merge request through its forge, and post the review back to it",
481
- args: 'status|prior|submit --file <path>|reply <threadId> --body "<text>"',
631
+ args: 'status|prior|pass|submit --file <path>|reply <threadId> --body "<text>"',
482
632
  flags: {
483
633
  review: { kind: "string", help: "review id prefix; its binding names the change request" },
484
634
  change: { kind: "string", help: "change request number or URL, instead of a review binding" },
@@ -488,6 +638,10 @@ const SPECS = {
488
638
  },
489
639
  repo: { kind: "string", help: "host/path, when `origin` is not the repository to post to" },
490
640
  file: { kind: "string", help: "submit: the JSON submission to post" },
641
+ out: {
642
+ kind: "string",
643
+ help: "pass: where to write the submission (default: ~/.thurview/passes/<id>.json)",
644
+ },
491
645
  body: { kind: "string", help: "reply: the answer text" },
492
646
  resolve: { kind: "boolean", help: "reply: resolve the thread as well as answering it" },
493
647
  at: { kind: "string", help: "reply --resolve: the commit the point was verified at" },
@@ -504,6 +658,7 @@ const SPECS = {
504
658
  examples: [
505
659
  "thurview forge status --change 123",
506
660
  "thurview forge prior --change 123 --mine",
661
+ "thurview forge pass --review <id>",
507
662
  "thurview forge submit --file pass.json --dry-run",
508
663
  'thurview forge reply <threadId> --body "<answer>" --resolve --at <sha>',
509
664
  ],
@@ -559,6 +714,7 @@ async function homeView() {
559
714
  "Run `thurview scaffold` to create a review of the current branch",
560
715
  "Run `thurview scaffold --pr <number>` for a pull request",
561
716
  "Run `thurview explain [<path>]` to explain the codebase at HEAD instead",
717
+ "Run `thurview design [<path>]` to design a change before writing it",
562
718
  ],
563
719
  };
564
720
  const reviews = [];
@@ -725,87 +881,8 @@ const commands = {
725
881
  },
726
882
  async explain(args) {
727
883
  const p = parseFlags("explain", args, spec("explain").flags, 1);
728
- const cwd = process.cwd();
729
- const worktree = await worktreeOf(cwd);
730
- if (!worktree)
731
- throw new AxiError("not inside a git repository", "VALIDATION_ERROR", [
732
- "Run `thurview explain` inside the source worktree",
733
- ]);
734
- const existing = bool(p, "update") || str(p, "review") ? await resolveReview(str(p, "review")) : null;
735
- if (existing && kindOf(existing) !== "explainer")
736
- throw new AxiError(`${short(existing.id)} is a review, not an explainer`, "VALIDATION_ERROR", [
737
- "Run `thurview scaffold --update --review <id>` to re-pin a review",
738
- "Run `thurview explain` with no --review to start an explainer",
739
- ]);
740
- const scope = scopeGlob(p.positional[0] ?? existing?.binding.name);
741
- let commit;
742
- try {
743
- commit = await g.revParse(worktree, str(p, "commit") ?? "HEAD");
744
- }
745
- catch (e) {
746
- throw new AxiError(e.message, "VALIDATION_ERROR", [
747
- "Pass a resolvable ref: `thurview explain --commit <ref>`",
748
- ]);
749
- }
750
- // A scope that matches nothing is a typo, and an explainer of nothing would
751
- // still publish and still state honest-looking coverage of zero files.
752
- const files = await g.listFiles(worktree, commit);
753
- const inScope = scope === "**" ? files : files.filter((f) => globToRegExp(scope).test(f));
754
- if (!inScope.length)
755
- throw new AxiError(`no file matches "${scope}" at ${commit.slice(0, 12)}`, "VALIDATION_ERROR", [
756
- "Pass a path that exists at that commit: `thurview explain src/server`",
757
- "Run `thurview explain` with no scope for the whole repository",
758
- ]);
759
- const binding = { kind: "codebase", name: scope };
760
- const defaultTitle = scope === "**" ? worktree.split("/").pop() || "Codebase" : scope.replace(/\/\*\*$/, "");
761
- let review;
762
- let reused = false;
763
- if (existing) {
764
- review = existing;
765
- review.pins = { base: commit, head: commit };
766
- review.binding = binding;
767
- if (str(p, "title"))
768
- review.title = str(p, "title");
769
- await writeReview(review);
770
- }
771
- else {
772
- const match = bool(p, "new")
773
- ? []
774
- : (await reviewsFor(worktree)).filter((r) => kindOf(r) === "explainer" &&
775
- r.binding.name === scope &&
776
- r.status !== "accepted" &&
777
- r.status !== "closed");
778
- if (match.length) {
779
- review = match[0];
780
- reused = true;
781
- review.pins = { base: commit, head: commit };
782
- await writeReview(review);
783
- }
784
- else {
785
- const id = newId();
786
- review = {
787
- schema: SCHEMA,
788
- id,
789
- kind: "explainer",
790
- title: str(p, "title") || defaultTitle,
791
- worktree,
792
- repoRoot: worktree,
793
- binding,
794
- pins: { base: commit, head: commit },
795
- status: "draft",
796
- revision: 0,
797
- dismissed: false,
798
- createdAt: now(),
799
- updatedAt: now(),
800
- };
801
- await mkdir(reviewDir(id), { recursive: true });
802
- await writeText(join(reviewDir(id), "review.md"), TEMPLATE_EXPLAIN_MD(review.title));
803
- await writeText(join(reviewDir(id), "data.yaml"), TEMPLATE_EXPLAIN_DATA);
804
- await writeText(join(reviewDir(id), "map.yaml"), TEMPLATE_EXPLAIN_MAP);
805
- await writeText(join(reviewDir(id), "theme.yaml"), TEMPLATE_THEME);
806
- await writeReview(review);
807
- }
808
- }
884
+ const pinned = await pinOneCommit(p, EXPLAINER);
885
+ const { review, scope, commit, worktree } = pinned;
809
886
  const dir = reviewDir(review.id);
810
887
  return {
811
888
  explainer: {
@@ -818,7 +895,7 @@ const commands = {
818
895
  scope,
819
896
  commit,
820
897
  worktree,
821
- reused,
898
+ reused: pinned.reused,
822
899
  dir,
823
900
  },
824
901
  files: {
@@ -827,7 +904,7 @@ const commands = {
827
904
  map: join(dir, "map.yaml"),
828
905
  theme: join(dir, "theme.yaml"),
829
906
  },
830
- scale: { filesInScope: inScope.length },
907
+ scale: { filesInScope: pinned.inScope.length },
831
908
  guidance: await guidanceFiles(worktree),
832
909
  help: [
833
910
  `Run \`thurview graph architecture --review ${short(review.id)}\` for the clusters, their hubs and the links between them`,
@@ -836,6 +913,40 @@ const commands = {
836
913
  ],
837
914
  };
838
915
  },
916
+ async design(args) {
917
+ const p = parseFlags("design", args, spec("design").flags, 1);
918
+ const pinned = await pinOneCommit(p, DESIGN);
919
+ const { review, scope, commit, worktree } = pinned;
920
+ const dir = reviewDir(review.id);
921
+ return {
922
+ design: {
923
+ id: short(review.id),
924
+ uuid: review.id,
925
+ kind: "design",
926
+ title: review.title,
927
+ status: review.status,
928
+ rev: review.revision,
929
+ scope,
930
+ commit,
931
+ worktree,
932
+ reused: pinned.reused,
933
+ dir,
934
+ },
935
+ files: {
936
+ document: join(dir, "review.md"),
937
+ data: join(dir, "data.yaml"),
938
+ map: join(dir, "map.yaml"),
939
+ theme: join(dir, "theme.yaml"),
940
+ },
941
+ scale: { filesInScope: pinned.inScope.length },
942
+ guidance: await guidanceFiles(worktree),
943
+ help: [
944
+ `Run \`thurview graph architecture --review ${short(review.id)}\` for the structure the design has to fit`,
945
+ `Declare in ${join(dir, "data.yaml")} what the design would add, change or remove, each anchored to the code it lands in today`,
946
+ `Edit ${join(dir, "review.md")}, then run \`thurview publish --review ${short(review.id)}\``,
947
+ ],
948
+ };
949
+ },
839
950
  async info(args) {
840
951
  const p = parseFlags("info", args, spec("info").flags);
841
952
  const fields = new Set((str(p, "fields") ?? "").split(",").filter(Boolean));
@@ -1009,7 +1120,7 @@ const commands = {
1009
1120
  if (review.binding.kind === "codebase") {
1010
1121
  const tip = await g.revParse(review.worktree, "HEAD").catch(() => null);
1011
1122
  if (tip && tip !== review.pins.head)
1012
- warnings.push(`HEAD has moved past the pinned commit; run \`thurview explain --update --review ${short(review.id)}\` to re-pin`);
1123
+ warnings.push(`HEAD has moved past the pinned commit; run \`${repinCommand(kind)} --update --review ${short(review.id)}\` to re-pin`);
1013
1124
  }
1014
1125
  if (review.binding.kind === "branch") {
1015
1126
  const tip = await g.revParse(review.worktree, review.binding.name).catch(() => null);
@@ -1064,7 +1175,9 @@ const commands = {
1064
1175
  map: !!map,
1065
1176
  ...(kind === "explainer"
1066
1177
  ? { coverage: coverage ? coverage.verdict : "(unavailable)" }
1067
- : { interfaces: doc.document.interfaces?.verdict ?? "(unavailable)" }),
1178
+ : kind === "design"
1179
+ ? { proposes: doc.document.interfaces?.verdict ?? "(unavailable)" }
1180
+ : { interfaces: doc.document.interfaces?.verdict ?? "(unavailable)" }),
1068
1181
  theme: theme?.name ?? "default",
1069
1182
  url: url ?? "(server not running)",
1070
1183
  },
@@ -1269,8 +1382,8 @@ const commands = {
1269
1382
  }
1270
1383
  // A next step has to name the same commits, or it answers about another change.
1271
1384
  const again = review ? "" : ` --base ${short(t.pins.base)} --head ${short(t.pins.head)}`;
1272
- if (review && kindOf(review) === "explainer" && (sub === "interfaces" || sub === "impact"))
1273
- throw new AxiError(`graph ${sub} compares two commits; an explainer is pinned to one`, "VALIDATION_ERROR", [
1385
+ if (review && kindOf(review) !== "review" && (sub === "interfaces" || sub === "impact"))
1386
+ throw new AxiError(`graph ${sub} compares two commits; ${kindOf(review) === "design" ? "a design" : "an explainer"} is pinned to one`, "VALIDATION_ERROR", [
1274
1387
  `Run \`thurview graph architecture --review ${short(review.id)}\` for the structure at that commit`,
1275
1388
  `Run \`thurview graph callers <name> --review ${short(review.id)}\` to follow one symbol`,
1276
1389
  ]);
@@ -1343,9 +1456,11 @@ const commands = {
1343
1456
  ],
1344
1457
  };
1345
1458
  }
1346
- // An explainer is scoped to a path, so its structure is that path's, not the
1347
- // repository's: the same bound the Coverage tab accounts for.
1348
- if (review && kindOf(review) === "explainer") {
1459
+ // An explainer and a design are both scoped to a path and pinned to one
1460
+ // commit, so the structure they get back is that path's, not the
1461
+ // repository's: for an explainer, the same bound the Coverage tab accounts
1462
+ // for; for a design, the structure it has to fit.
1463
+ if (review && kindOf(review) !== "review") {
1349
1464
  const scope = review.binding.name;
1350
1465
  const g0 = scopeGraph(head, scope);
1351
1466
  const allFiles = await g.listFiles(t.worktree, t.pins.head);
@@ -1500,10 +1615,11 @@ const commands = {
1500
1615
  const usage = [
1501
1616
  "thurview forge status [--change <ref>] [--review <id>]",
1502
1617
  "thurview forge prior [--change <ref>] [--mine] [--full]",
1618
+ "thurview forge pass [--review <id>] [--out <path>]",
1503
1619
  "thurview forge submit --file <path> [--dry-run] [--confirm]",
1504
1620
  'thurview forge reply <threadId> --body "<text>" [--resolve --at <sha>]',
1505
1621
  ];
1506
- if (!sub || !["status", "prior", "submit", "reply"].includes(sub))
1622
+ if (!sub || !["status", "prior", "pass", "submit", "reply"].includes(sub))
1507
1623
  throw new AxiError(`unknown forge command${sub ? ` ${sub}` : ""}`, "VALIDATION_ERROR", usage);
1508
1624
  const common = {
1509
1625
  review: s["review"],
@@ -1618,6 +1734,70 @@ const commands = {
1618
1734
  : ["This is the first pass; there is no prior review to answer"],
1619
1735
  };
1620
1736
  }
1737
+ if (sub === "pass") {
1738
+ const p = parseFlags("forge pass", rest, { review: s["review"], out: s["out"] });
1739
+ // Approve and close both end the review, so a pass has to be able to
1740
+ // reach a finished one - but only when no active review answers first,
1741
+ // or a review the reader finished last week shadows the one in hand.
1742
+ const review = await resolveReview(str(p, "review")).catch((e) => {
1743
+ if (e instanceof AxiError && e.code === "NOT_FOUND")
1744
+ return resolveReview(str(p, "review"), { terminal: true });
1745
+ throw e;
1746
+ });
1747
+ const t = await readThreads(review.id);
1748
+ const id = short(review.id);
1749
+ const decision = t.decisions[t.decisions.length - 1];
1750
+ if (!decision)
1751
+ throw new AxiError(`review ${id} has no decision to carry to the forge`, "NOT_FOUND", [
1752
+ "The reader decides in the browser; nothing is posted before they submit",
1753
+ `Run \`thurview wait --review ${id}\` to block until they do`,
1754
+ ]);
1755
+ const plan = buildPass(t.threads, decision);
1756
+ const out = resolve(process.cwd(), str(p, "out") ?? passFile(review.id));
1757
+ const text = JSON.stringify(plan.submission, null, 2) + "\n";
1758
+ // Checked by the parser `submit` reads it with, so a file this wrote is
1759
+ // never one that command refuses.
1760
+ parseSubmission(text, out);
1761
+ await writeText(out, text);
1762
+ return {
1763
+ pass: {
1764
+ review: id,
1765
+ file: out,
1766
+ decision: plan.decision,
1767
+ verdict: plan.submission.verdict,
1768
+ ...(plan.verdictReason ? { why: plan.verdictReason } : {}),
1769
+ inline: plan.inline.length,
1770
+ summary: plan.summary.length,
1771
+ skipped: plan.skipped.length,
1772
+ anchoredAt: review.pins.head,
1773
+ },
1774
+ comments: plan.inline.length
1775
+ ? plan.inline.map((c) => ({ thread: c.thread, at: c.at, side: c.side }))
1776
+ : "0 (a summary-only pass)",
1777
+ summary: plan.summary.length
1778
+ ? plan.summary.map((x) => ({ thread: x.thread, target: x.target, why: x.why }))
1779
+ : "0 (every comment is anchored to a line)",
1780
+ skipped: plan.skipped.length
1781
+ ? plan.skipped.map((x) => ({ thread: x.thread, target: x.target, why: x.why }))
1782
+ : "0 (no question, resolved or held thread to leave out)",
1783
+ help: [
1784
+ `Read ${out} before it is posted; the file is the thing a human checks`,
1785
+ `Every anchor is a line at ${review.pins.head.slice(0, 12)}; run \`thurview forge status --review ${id}\` first, because a forge refuses a comment on a line its current head does not have`,
1786
+ `Run \`thurview forge submit --review ${id} --file ${out} --dry-run\` to see what would reach the change request`,
1787
+ "Tell the reader which comments went to the summary, and why they are not on their line",
1788
+ ...(decision.revision === review.revision
1789
+ ? []
1790
+ : [
1791
+ `The decision is the reader's on revision ${decision.revision} and the review is at ${review.revision}; they have not judged what you published since`,
1792
+ ]),
1793
+ ...(review.binding.kind === "pr"
1794
+ ? []
1795
+ : [
1796
+ `This review is bound to ${review.binding.name}, not to a change request; \`forge submit\` will need --change <ref>`,
1797
+ ]),
1798
+ ],
1799
+ };
1800
+ }
1621
1801
  if (sub === "submit") {
1622
1802
  const p = parseFlags("forge submit", rest, {
1623
1803
  ...common,
@@ -1826,7 +2006,7 @@ const commands = {
1826
2006
  return {
1827
2007
  skill: installed,
1828
2008
  help: [
1829
- "Invoke them as /thurview and /review-fix in Claude Code, or by name in other agents",
2009
+ `Invoke them as ${names.map((n) => `/${n}`).join(", ")} in Claude Code, or by name in other agents`,
1830
2010
  "Run `thurview setup hooks` for ambient context at session start",
1831
2011
  ],
1832
2012
  };