thurview 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,6 +73,7 @@ reads the Agent Skills format:
73
73
 
74
74
  ```sh
75
75
  npx skills add Thurbeen/thurview --skill thurview
76
+ npx skills add Thurbeen/thurview --skill forge-review # optional, see below
76
77
  ```
77
78
 
78
79
  That form tracks this repository's default branch: `skills update` takes
@@ -89,8 +90,8 @@ that matches the command you have installed. Use that when you want the two to
89
90
  move together.
90
91
 
91
92
  The skill drives the `thurview` command, which needs Node 22 or later and
92
- git (`gh` for pull requests). Install it, or let the skill reach it through
93
- `npx`:
93
+ git (`gh` for pull requests, `glab` for merge requests). Install it, or let
94
+ the skill reach it through `npx`:
94
95
 
95
96
  ```sh
96
97
  npm install -g thurview # or: pnpm add -g thurview
@@ -168,19 +169,20 @@ bar.
168
169
 
169
170
  ## CLI
170
171
 
171
- | Command | Purpose |
172
- | --------------------------------------------------------------------- | ---------------------------------------------------------------- |
173
- | `thurview scaffold [--pr N \| --base R --head R]` | Create a review pinned to exact commits (`--update` re-pins) |
174
- | `thurview explain [<path>] [--commit R]` | Create a code explainer of a codebase or subsystem at one commit |
175
- | `thurview info [--all]` | Reviews bound to this worktree |
176
- | `thurview publish --review ID [--view T] [--open]` | Validate the document and map, seal a revision |
177
- | `thurview open --review ID [--view T]` | Start the server if needed and open the browser |
178
- | `thurview wait --review ID [--timeout S]` | Block until the reader needs the agent |
179
- | `thurview threads list\|get\|reply\|resolve` | Read and answer threads |
180
- | `thurview graph interfaces\|impact\|callers\|tests-for\|architecture` | Ask the code graph at the pinned commits |
181
- | `thurview serve` / `thurview stop` | Run the server in the foreground / stop the background one |
182
- | `thurview setup hooks\|skill\|status` | Session hooks, agent skill, install state |
183
- | `thurview update` | Self-update from npm |
172
+ | Command | Purpose |
173
+ | --------------------------------------------------------------------- | ----------------------------------------------------------------- |
174
+ | `thurview scaffold [--pr N \| --base R --head R]` | Create a review pinned to exact commits (`--update` re-pins) |
175
+ | `thurview explain [<path>] [--commit R]` | Create a code explainer of a codebase or subsystem at one commit |
176
+ | `thurview info [--all]` | Reviews bound to this worktree |
177
+ | `thurview publish --review ID [--view T] [--open]` | Validate the document and map, seal a revision |
178
+ | `thurview open --review ID [--view T]` | Start the server if needed and open the browser |
179
+ | `thurview wait --review ID [--timeout S]` | Block until the reader needs the agent |
180
+ | `thurview threads list\|get\|reply\|resolve` | Read and answer threads |
181
+ | `thurview graph interfaces\|impact\|callers\|tests-for\|architecture` | Ask the code graph at the pinned commits |
182
+ | `thurview forge status\|prior\|submit\|reply` | Read a change request through its forge, and post the review back |
183
+ | `thurview serve` / `thurview stop` | Run the server in the foreground / stop the background one |
184
+ | `thurview setup hooks\|skill\|status` | Session hooks, agent skill, install state |
185
+ | `thurview update` | Self-update from npm |
184
186
 
185
187
  thurview is an [AXI](https://axi.md): built for agents that drive it through a
186
188
  shell. Output is [TOON](https://toonformat.dev) on stdout, errors are
@@ -192,6 +194,38 @@ arguments shows live state for the current directory instead of a manual.
192
194
  `thurview <command> --help` is the fallback. Progress and diagnostics go to
193
195
  stderr.
194
196
 
197
+ ## Posting the review to the forge
198
+
199
+ A thurview review is read in the browser. When the change is a pull request on
200
+ GitHub or a merge request on GitLab, the `forge-review` skill posts it there as
201
+ well: inline comments anchored to lines, a summary, and a verdict.
202
+
203
+ ```sh
204
+ thurview forge status --change 123 # what CI actually did, and whether it is a gate at all
205
+ thurview forge prior --change 123 # the previous pass, thread by thread
206
+ thurview forge submit --change 123 --file pass.json --dry-run
207
+ thurview forge reply <threadId> --change 123 --body "<answer>" --resolve --at <head>
208
+ ```
209
+
210
+ `status` counts passed, failed, cancelled, skipped and running checks
211
+ separately, and compares them against what the target branch's own tip runs -
212
+ a change request from a fork typically runs a fraction of them, and a
213
+ cancelled job shows no failure while asserting nothing. `ci.trustworthy` is
214
+ the only field that means the tests really passed.
215
+
216
+ `submit` takes one JSON file so a human can read the pass before it is posted,
217
+ refuses an `approve` without `--confirm`, and warns about comments too long to
218
+ be read. `reply --resolve` takes `--at <sha>` and refuses any commit but the
219
+ current head, so a thread is never closed against code nobody looked at. There
220
+ is no merge, close or push command, deliberately.
221
+
222
+ GitHub goes through `gh`, GitLab through `glab`; hosts other than github.com
223
+ and gitlab.com are matched against what those CLIs are authenticated for, and
224
+ an unmatched host is refused rather than guessed. The differences that survive
225
+ the seam - GitLab has no changes-requested state, no atomic review and no
226
+ multi-line comment anchor - are listed in
227
+ [skills/forge-review/references/forges.md](skills/forge-review/references/forges.md).
228
+
195
229
  ## Authoring format
196
230
 
197
231
  The agent writes three files in `~/.thurview/reviews/<id>/`:
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import { runAxiCli, AxiError, installSessionStartHooks, sessionStartHookStatus,
2
2
  import { encode } from "@toon-format/toon";
3
3
  import { spawn, execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
- import { cp, mkdir, symlink, lstat, rm } from "node:fs/promises";
5
+ import { cp, mkdir, symlink, lstat, rm, readdir } from "node:fs/promises";
6
6
  import { existsSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { join, dirname, resolve } from "node:path";
@@ -18,6 +18,8 @@ import { replyThread, setThreadStatus, needsAgent } from "./threads.js";
18
18
  import { attach } from "./presence.js";
19
19
  import { startServer } from "./server/server.js";
20
20
  import { parseFlags, helpFor, str, bool } from "./flags.js";
21
+ import { forgeFor, repoOf, summariseCi, } from "./forge/index.js";
22
+ import { parseSubmission, longComments } from "./forge/submission.js";
21
23
  import { VERSION } from "./version.js";
22
24
  const execFileP = promisify(execFile);
23
25
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -46,10 +48,27 @@ function targetLabel(t) {
46
48
  return `map ${t.node}`;
47
49
  return "review";
48
50
  }
51
+ /** `PR #12` or `MR !12`, because the forge's own word is what the reader knows. */
52
+ function bindingLabel(b) {
53
+ if (b.kind !== "pr")
54
+ return b.name;
55
+ return b.forge === "gitlab" ? `MR !${b.name}` : `PR #${b.name}`;
56
+ }
49
57
  function lastMessage(t) {
50
58
  const m = t.messages[t.messages.length - 1];
51
59
  return m ? `${m.role}: ${truncate(m.body.replace(/\s+/g, " "), 120)}` : "";
52
60
  }
61
+ function skillsRoot() {
62
+ return resolve(HERE, "..", "skills");
63
+ }
64
+ /** Every skill this package ships, so a new one installs without a code change. */
65
+ async function bundledSkills() {
66
+ const entries = await readdir(skillsRoot(), { withFileTypes: true });
67
+ return entries
68
+ .filter((e) => e.isDirectory())
69
+ .map((e) => e.name)
70
+ .sort();
71
+ }
53
72
  async function worktreeOf(cwd) {
54
73
  try {
55
74
  return await g.repoRoot(cwd);
@@ -84,6 +103,33 @@ async function resolveReview(idOpt) {
84
103
  "Run `thurview scaffold` to create one",
85
104
  ]);
86
105
  }
106
+ /**
107
+ * Which change request, on which forge, read once. `--change` names it
108
+ * outright; otherwise the active review's binding does, which is what keeps a
109
+ * pass anchored to the commits the document was written against.
110
+ */
111
+ async function forgeContext(p) {
112
+ const explicit = str(p, "change");
113
+ const review = !explicit || str(p, "review") ? await resolveReview(str(p, "review")) : null;
114
+ let ref = explicit;
115
+ if (!ref) {
116
+ if (review.binding.kind !== "pr")
117
+ throw new AxiError(`review ${short(review.id)} is bound to ${review.binding.name}, not to a change request`, "VALIDATION_ERROR", [
118
+ "Pass --change <number|url>",
119
+ `Or re-pin it: \`thurview scaffold --pr <ref> --update --review ${short(review.id)}\``,
120
+ ]);
121
+ ref = review.binding.name;
122
+ }
123
+ const repoFlag = str(p, "repo");
124
+ const worktree = review?.worktree ?? (await worktreeOf(process.cwd()));
125
+ if (!worktree && !repoFlag)
126
+ throw new AxiError("not inside a git repository", "VALIDATION_ERROR", [
127
+ "Run inside the source worktree, or pass --repo host/path",
128
+ ]);
129
+ const repo = await repoOf(worktree ?? process.cwd(), repoFlag);
130
+ const forge = await forgeFor(repo.host, str(p, "forge"));
131
+ return { forge, repo, cr: await forge.get(repo, ref), review };
132
+ }
87
133
  async function reviewRow(r, fields) {
88
134
  const t = await readThreads(r.id);
89
135
  const row = {
@@ -96,7 +142,7 @@ async function reviewRow(r, fields) {
96
142
  needsAgent: t.threads.filter(needsAgent).length,
97
143
  };
98
144
  if (fields.has("all") || fields.has("binding"))
99
- row["binding"] = r.binding.kind === "pr" ? `PR #${r.binding.name}` : r.binding.name;
145
+ row["binding"] = bindingLabel(r.binding);
100
146
  if (fields.has("all") || fields.has("pins"))
101
147
  row["pins"] =
102
148
  kindOf(r) === "explainer"
@@ -272,13 +318,21 @@ const SPECS = {
272
318
  scaffold: {
273
319
  description: "Create a review pinned to exact base and head commits, or re-pin one",
274
320
  flags: {
275
- pr: { kind: "string", help: "review a GitHub pull request (number or URL, needs gh)" },
321
+ pr: {
322
+ kind: "string",
323
+ help: "review a pull or merge request (number or URL, needs gh or glab)",
324
+ },
276
325
  base: { kind: "string", help: "base revision (default: trunk fork point)" },
277
326
  head: { kind: "string", help: "head revision (default: current branch)" },
278
327
  title: { kind: "string", help: "initial title" },
279
328
  new: { kind: "boolean", help: "create another review even if one matches the binding" },
280
329
  update: { kind: "boolean", help: "re-pin an existing review from its binding" },
281
330
  review: { kind: "string", help: "review to update (id prefix)" },
331
+ forge: {
332
+ kind: "string",
333
+ help: "github or gitlab, when the host is not one of the two known ones",
334
+ },
335
+ repo: { kind: "string", help: "host/path, when `origin` is not the repository to post to" },
282
336
  },
283
337
  examples: [
284
338
  "thurview scaffold",
@@ -395,6 +449,38 @@ const SPECS = {
395
449
  "thurview graph architecture",
396
450
  ],
397
451
  },
452
+ forge: {
453
+ description: "Read a pull or merge request through its forge, and post the review back to it",
454
+ args: 'status|prior|submit --file <path>|reply <threadId> --body "<text>"',
455
+ flags: {
456
+ review: { kind: "string", help: "review id prefix; its binding names the change request" },
457
+ change: { kind: "string", help: "change request number or URL, instead of a review binding" },
458
+ forge: {
459
+ kind: "string",
460
+ help: "github or gitlab, when the host is not one of the two known ones",
461
+ },
462
+ repo: { kind: "string", help: "host/path, when `origin` is not the repository to post to" },
463
+ file: { kind: "string", help: "submit: the JSON submission to post" },
464
+ body: { kind: "string", help: "reply: the answer text" },
465
+ resolve: { kind: "boolean", help: "reply: resolve the thread as well as answering it" },
466
+ at: { kind: "string", help: "reply --resolve: the commit the point was verified at" },
467
+ confirm: { kind: "boolean", help: "submit: required to post an approve" },
468
+ "dry-run": { kind: "boolean", help: "submit: validate and report, post nothing" },
469
+ "max-lines": {
470
+ kind: "string",
471
+ help: "submit: warn above this many lines per comment",
472
+ default: "5",
473
+ },
474
+ mine: { kind: "boolean", help: "prior: only threads this account wrote" },
475
+ full: { kind: "boolean", help: "prior, status: do not truncate or filter" },
476
+ },
477
+ examples: [
478
+ "thurview forge status --change 123",
479
+ "thurview forge prior --change 123 --mine",
480
+ "thurview forge submit --file pass.json --dry-run",
481
+ 'thurview forge reply <threadId> --body "<answer>" --resolve --at <sha>',
482
+ ],
483
+ },
398
484
  delete: {
399
485
  description: "Delete a review and everything stored for it (the code is untouched)",
400
486
  flags: { review: { kind: "string", help: "review id prefix (required)" } },
@@ -480,31 +566,25 @@ const commands = {
480
566
  const pr = str(p, "pr");
481
567
  if (pr || b?.kind === "pr") {
482
568
  const ref = pr ?? b.name;
483
- let info;
484
- try {
485
- const { stdout } = await execFileP("gh", ["pr", "view", ref, "--json", "number,title,url,baseRefName,headRefOid"], { cwd: worktree });
486
- info = JSON.parse(stdout);
487
- }
488
- catch (e) {
489
- throw new AxiError(`could not read pull request ${ref}: ${e.message.split("\n")[0]}`, "PR_ERROR", [
490
- "Check `gh auth status` and the PR number",
491
- "Or run `thurview scaffold --base <ref> --head <ref>`",
492
- ]);
493
- }
569
+ const repo = await repoOf(worktree, str(p, "repo"));
570
+ const forge = await forgeFor(repo.host, str(p, "forge") ?? b?.forge);
571
+ const cr = await forge.get(repo, ref);
572
+ // A fork's head is not a branch in this checkout, so fetch it by the
573
+ // ref the forge publishes it under before anything tries to resolve it.
494
574
  await g
495
- .fetch(worktree, "origin", `refs/pull/${info.number}/head`, `refs/heads/${info.baseRefName}`)
575
+ .fetch(worktree, "origin", forge.fetchRef(cr), `refs/heads/${cr.baseBranch}`)
496
576
  .catch(() => { });
497
- head = info.headRefOid;
498
- let baseRef = `origin/${info.baseRefName}`;
577
+ head = cr.head;
578
+ let baseRef = `origin/${cr.baseBranch}`;
499
579
  try {
500
580
  await g.revParse(worktree, baseRef);
501
581
  }
502
582
  catch {
503
- baseRef = info.baseRefName;
583
+ baseRef = cr.baseBranch;
504
584
  }
505
585
  base = await g.mergeBase(worktree, baseRef, head);
506
- binding = { kind: "pr", name: String(info.number), url: info.url };
507
- title ||= info.title;
586
+ binding = { kind: "pr", name: cr.number, url: cr.url, forge: forge.id };
587
+ title ||= cr.title;
508
588
  }
509
589
  else if (str(p, "base") || str(p, "head") || b?.kind === "range") {
510
590
  const [bb, hh] = b?.kind === "range" && !str(p, "base") && !str(p, "head")
@@ -602,7 +682,7 @@ const commands = {
602
682
  title: review.title,
603
683
  status: review.status,
604
684
  rev: review.revision,
605
- binding: binding.kind === "pr" ? `PR #${binding.name}` : binding.name,
685
+ binding: bindingLabel(binding),
606
686
  base,
607
687
  head,
608
688
  worktree,
@@ -1375,6 +1455,247 @@ const commands = {
1375
1455
  ],
1376
1456
  };
1377
1457
  },
1458
+ async forge(args) {
1459
+ const sub = args[0];
1460
+ const s = spec("forge").flags;
1461
+ const usage = [
1462
+ "thurview forge status [--change <ref>] [--review <id>]",
1463
+ "thurview forge prior [--change <ref>] [--mine] [--full]",
1464
+ "thurview forge submit --file <path> [--dry-run] [--confirm]",
1465
+ 'thurview forge reply <threadId> --body "<text>" [--resolve --at <sha>]',
1466
+ ];
1467
+ if (!sub || !["status", "prior", "submit", "reply"].includes(sub))
1468
+ throw new AxiError(`unknown forge command${sub ? ` ${sub}` : ""}`, "VALIDATION_ERROR", usage);
1469
+ const common = {
1470
+ review: s["review"],
1471
+ change: s["change"],
1472
+ forge: s["forge"],
1473
+ repo: s["repo"],
1474
+ };
1475
+ const rest = args.slice(1);
1476
+ if (sub === "status") {
1477
+ const p = parseFlags("forge status", rest, { ...common, full: s["full"] });
1478
+ const ctx = await forgeContext(p);
1479
+ const checks = await ctx.forge.checks(ctx.repo, ctx.cr);
1480
+ const baseline = await ctx.forge.baseline(ctx.repo, ctx.cr.baseBranch).catch(() => null);
1481
+ const ci = summariseCi(checks, baseline, ctx.cr.baseBranch);
1482
+ const shown = bool(p, "full") ? checks : checks.filter((c) => c.state !== "passed");
1483
+ const hidden = checks.length - shown.length;
1484
+ const help = [
1485
+ `Quote \`ci.verdict\` in the review; do not read "nothing failed" as "the tests passed"`,
1486
+ `Run \`thurview forge prior --change ${ctx.cr.number}\` to read the previous pass before writing a new one`,
1487
+ ];
1488
+ if (hidden && !bool(p, "full"))
1489
+ help.push(`${hidden} passing checks hidden; pass --full to list them`);
1490
+ if (ctx.review && ctx.review.pins.head !== ctx.cr.head)
1491
+ help.unshift(`The head moved since this review was pinned; run \`thurview scaffold --update --review ${short(ctx.review.id)}\` and diff only what moved`);
1492
+ return {
1493
+ change: {
1494
+ forge: ctx.forge.id,
1495
+ repo: `${ctx.repo.host}/${ctx.repo.path}`,
1496
+ number: ctx.cr.number,
1497
+ title: ctx.cr.title,
1498
+ url: ctx.cr.url,
1499
+ state: ctx.cr.state,
1500
+ author: ctx.cr.author,
1501
+ draft: ctx.cr.draft,
1502
+ fromFork: ctx.cr.fromFork,
1503
+ head: ctx.cr.head,
1504
+ headBranch: ctx.cr.headBranch,
1505
+ baseBranch: ctx.cr.baseBranch,
1506
+ },
1507
+ ci,
1508
+ checks: shown.length
1509
+ ? shown.map((c) => ({ name: c.name, state: c.state, raw: c.raw }))
1510
+ : `0 of ${checks.length} checks need attention`,
1511
+ ...(ctx.review
1512
+ ? {
1513
+ review: {
1514
+ id: short(ctx.review.id),
1515
+ pinnedHead: ctx.review.pins.head,
1516
+ movedSincePin: ctx.review.pins.head !== ctx.cr.head,
1517
+ },
1518
+ }
1519
+ : {}),
1520
+ permalink: ctx.forge.permalink(ctx.repo, ctx.cr.head, "<path>", 10, 20),
1521
+ help,
1522
+ };
1523
+ }
1524
+ if (sub === "prior") {
1525
+ const p = parseFlags("forge prior", rest, {
1526
+ ...common,
1527
+ mine: s["mine"],
1528
+ full: s["full"],
1529
+ });
1530
+ const ctx = await forgeContext(p);
1531
+ const prior = await ctx.forge.prior(ctx.repo, ctx.cr);
1532
+ const { threads } = prior;
1533
+ // A forge wraps inline comments in a pass of its own with an empty
1534
+ // body. That envelope is not something to answer; the thread under it
1535
+ // is, and it is already in `threads`.
1536
+ const passes = prior.passes.filter((x) => x.body.trim() !== "" || x.verdict !== "commented");
1537
+ const me = await ctx.forge.whoami(ctx.repo).catch(() => "");
1538
+ const full = bool(p, "full");
1539
+ const rows = (bool(p, "mine") ? threads.filter((t) => t.author === me) : threads).map((t) => {
1540
+ const last = t.messages[t.messages.length - 1];
1541
+ return {
1542
+ id: t.id,
1543
+ author: t.author,
1544
+ at: t.path ? `${t.path}${t.line ? `:${t.line}` : ""}` : "(change request)",
1545
+ resolved: t.resolved,
1546
+ outdated: t.outdated,
1547
+ atHead: t.commit ? t.commit === ctx.cr.head : null,
1548
+ messages: t.messages.length,
1549
+ last: last ? (full ? last.body : truncate(last.body.replace(/\s+/g, " "), 160)) : "",
1550
+ };
1551
+ });
1552
+ const open = rows.filter((r) => !r.resolved).length;
1553
+ return {
1554
+ summary: {
1555
+ passes: passes.length,
1556
+ threads: rows.length,
1557
+ open,
1558
+ resolved: rows.length - open,
1559
+ mine: me ? threads.filter((t) => t.author === me).length : null,
1560
+ notAtHead: rows.filter((r) => r.atHead === false).length,
1561
+ head: ctx.cr.head,
1562
+ },
1563
+ passes: passes.length
1564
+ ? passes.map((x) => ({
1565
+ author: x.author,
1566
+ verdict: x.verdict,
1567
+ at: x.at,
1568
+ commit: x.commit ?? "",
1569
+ body: full ? x.body : truncate(x.body.replace(/\s+/g, " "), 200),
1570
+ }))
1571
+ : "0 (nobody has reviewed this change request yet)",
1572
+ threads: rows.length ? rows : "0 (no review thread on this change request)",
1573
+ help: passes.length || rows.length
1574
+ ? [
1575
+ "Go through every open thread point by point: addressed, partially addressed, or untouched",
1576
+ 'Answer one with `thurview forge reply <threadId> --body "<text>"`, and add --resolve --at <sha> only once you verified the point at that head',
1577
+ full ? "" : "Pass --full for the untruncated bodies",
1578
+ ].filter(Boolean)
1579
+ : ["This is the first pass; there is no prior review to answer"],
1580
+ };
1581
+ }
1582
+ if (sub === "submit") {
1583
+ const p = parseFlags("forge submit", rest, {
1584
+ ...common,
1585
+ file: s["file"],
1586
+ confirm: s["confirm"],
1587
+ "dry-run": s["dry-run"],
1588
+ "max-lines": s["max-lines"],
1589
+ });
1590
+ const file = str(p, "file");
1591
+ if (!file)
1592
+ throw new AxiError("--file is required", "VALIDATION_ERROR", [
1593
+ "thurview forge submit --file <path>",
1594
+ ]);
1595
+ const text = await readText(resolve(process.cwd(), file));
1596
+ if (text === null)
1597
+ throw new AxiError(`${file} not found`, "NOT_FOUND", [
1598
+ 'Write the pass as JSON: {"verdict": "comment", "body": "<summary>", "comments": []}',
1599
+ ]);
1600
+ const submission = parseSubmission(text, file);
1601
+ const max = Number(str(p, "max-lines"));
1602
+ const warnings = longComments(submission, Number.isFinite(max) && max > 0 ? max : 5);
1603
+ const consequences = submission.verdict === "approve"
1604
+ ? [
1605
+ "Approving dismisses any standing request for changes, which is what makes this mergeable",
1606
+ "Where auto-merge is armed, approving merges the code with no further human read",
1607
+ "Say that to the user before you pass --confirm",
1608
+ ]
1609
+ : [];
1610
+ const dry = bool(p, "dry-run");
1611
+ if (submission.verdict === "approve" && !bool(p, "confirm") && !dry)
1612
+ throw new AxiError("approving is a state change, so it needs --confirm", "VALIDATION_ERROR", [...consequences, "Re-run with --confirm, or submit with verdict comment instead"]);
1613
+ const ctx = await forgeContext(p);
1614
+ if (dry)
1615
+ return {
1616
+ dryRun: {
1617
+ forge: ctx.forge.id,
1618
+ change: `${ctx.repo.path}#${ctx.cr.number}`,
1619
+ head: ctx.cr.head,
1620
+ verdict: submission.verdict,
1621
+ comments: submission.comments.length,
1622
+ bodyLines: submission.body.trimEnd().split("\n").length,
1623
+ },
1624
+ comments: submission.comments.length
1625
+ ? submission.comments.map((c) => ({
1626
+ at: `${c.path}:${c.startLine && c.startLine < c.line ? `${c.startLine}-${c.line}` : c.line}`,
1627
+ side: c.side ?? "head",
1628
+ lines: c.body.trimEnd().split("\n").length,
1629
+ }))
1630
+ : "0 (a summary-only pass)",
1631
+ warnings: warnings.length ? warnings : "0 (every comment is within the line budget)",
1632
+ ...(consequences.length ? { consequences } : {}),
1633
+ help: [
1634
+ "Nothing was posted; re-run without --dry-run to post it",
1635
+ "Check every anchor is a line the diff actually touches, or the forge refuses the comment",
1636
+ ],
1637
+ };
1638
+ const posted = await ctx.forge.submit(ctx.repo, ctx.cr, submission);
1639
+ return {
1640
+ submitted: {
1641
+ forge: ctx.forge.id,
1642
+ change: `${ctx.repo.path}#${ctx.cr.number}`,
1643
+ head: ctx.cr.head,
1644
+ verdict: posted.verdict,
1645
+ comments: posted.posted,
1646
+ url: posted.url ?? ctx.cr.url,
1647
+ },
1648
+ warnings: warnings.length ? warnings : "0 (every comment is within the line budget)",
1649
+ notes: posted.notes.length ? posted.notes : "0 (the forge did exactly what was asked)",
1650
+ help: [
1651
+ `Record ${ctx.cr.head.slice(0, 12)} as the head you reviewed; a later pass diffs against it`,
1652
+ "Never merge, close or push to the change request; that decision is the maintainer's",
1653
+ ],
1654
+ };
1655
+ }
1656
+ const p = parseFlags("forge reply", rest, { ...common, body: s["body"], resolve: s["resolve"], at: s["at"] }, 1);
1657
+ const threadId = p.positional[0];
1658
+ if (!threadId)
1659
+ throw new AxiError("forge reply needs a thread id", "VALIDATION_ERROR", [
1660
+ 'thurview forge reply <threadId> --body "<text>"',
1661
+ "Run `thurview forge prior` for the thread ids",
1662
+ ]);
1663
+ const body = str(p, "body");
1664
+ const wantResolve = bool(p, "resolve");
1665
+ if (!body && !wantResolve)
1666
+ throw new AxiError("forge reply needs --body, --resolve, or both", "VALIDATION_ERROR", [
1667
+ 'thurview forge reply <threadId> --body "<text>" --resolve --at <sha>',
1668
+ ]);
1669
+ const ctx = await forgeContext(p);
1670
+ if (wantResolve) {
1671
+ const at = str(p, "at");
1672
+ if (!at)
1673
+ throw new AxiError("--resolve needs --at <sha>", "VALIDATION_ERROR", [
1674
+ "Resolving tells the author the point is verified; --at is the commit you verified it at",
1675
+ `The current head is ${ctx.cr.head}`,
1676
+ ]);
1677
+ if (at.length < 7 || !ctx.cr.head.startsWith(at))
1678
+ throw new AxiError(`--at ${at} is not the current head of ${ctx.repo.path}#${ctx.cr.number}`, "CONFLICT", [
1679
+ `The head is ${ctx.cr.head}`,
1680
+ "Re-read the point at that head before resolving; a thread resolved against an older head tells the author a point was accepted that nobody checked",
1681
+ ]);
1682
+ }
1683
+ const done = await ctx.forge.reply(ctx.repo, ctx.cr, threadId, body, wantResolve);
1684
+ return {
1685
+ thread: {
1686
+ id: threadId,
1687
+ change: `${ctx.repo.path}#${ctx.cr.number}`,
1688
+ replied: done.replied,
1689
+ resolved: done.resolved,
1690
+ verifiedAt: wantResolve ? ctx.cr.head : "",
1691
+ },
1692
+ notes: done.notes.length ? done.notes : "0 (the forge did exactly what was asked)",
1693
+ help: [
1694
+ "Leave a thread open when the point is only partially addressed, and say which part",
1695
+ `Run \`thurview forge prior --change ${ctx.cr.number}\` to see what is still open`,
1696
+ ],
1697
+ };
1698
+ },
1378
1699
  async delete(args) {
1379
1700
  const p = parseFlags("delete", args, spec("delete").flags);
1380
1701
  const idOpt = str(p, "review");
@@ -1427,7 +1748,7 @@ const commands = {
1427
1748
  }
1428
1749
  if (sub === "skill") {
1429
1750
  const p = parseFlags("setup skill", args.slice(1), { targets: s["targets"] });
1430
- const src = resolve(HERE, "..", "skills", "thurview");
1751
+ const names = await bundledSkills();
1431
1752
  const dirs = {
1432
1753
  claude: join(homedir(), ".claude", "skills"),
1433
1754
  agents: join(homedir(), ".agents", "skills"),
@@ -1444,27 +1765,29 @@ const commands = {
1444
1765
  "thurview setup skill --targets claude,agents,cursor",
1445
1766
  ]);
1446
1767
  await mkdir(d, { recursive: true });
1447
- const dst = join(d, "thurview");
1448
- try {
1449
- const stt = await lstat(dst);
1450
- if (stt.isSymbolicLink())
1451
- await rm(dst);
1452
- else
1453
- throw new AxiError(`${dst} exists and is not a symlink`, "CONFLICT", [
1454
- `Remove ${dst} and run \`thurview setup skill\` again`,
1455
- ]);
1768
+ for (const name of names) {
1769
+ const dst = join(d, name);
1770
+ try {
1771
+ const stt = await lstat(dst);
1772
+ if (stt.isSymbolicLink())
1773
+ await rm(dst);
1774
+ else
1775
+ throw new AxiError(`${dst} exists and is not a symlink`, "CONFLICT", [
1776
+ `Remove ${dst} and run \`thurview setup skill\` again`,
1777
+ ]);
1778
+ }
1779
+ catch (e) {
1780
+ if (e instanceof AxiError)
1781
+ throw e;
1782
+ }
1783
+ await symlink(join(skillsRoot(), name), dst, "dir");
1456
1784
  }
1457
- catch (e) {
1458
- if (e instanceof AxiError)
1459
- throw e;
1460
- }
1461
- await symlink(src, dst, "dir");
1462
- installed[t] = dst;
1785
+ installed[t] = `${join(d, `{${names.join(",")}}`)}`;
1463
1786
  }
1464
1787
  return {
1465
1788
  skill: installed,
1466
1789
  help: [
1467
- "Invoke it as /thurview in Claude Code, or by name in other agents",
1790
+ "Invoke them as /thurview and /forge-review in Claude Code, or by name in other agents",
1468
1791
  "Run `thurview setup hooks` for ambient context at session start",
1469
1792
  ],
1470
1793
  };
@@ -1473,10 +1796,12 @@ const commands = {
1473
1796
  const st = sessionStartHookStatus({ ...identity, scope: "user" });
1474
1797
  const skill = {};
1475
1798
  for (const [t, d] of Object.entries({
1476
- claude: join(homedir(), ".claude", "skills", "thurview"),
1477
- agents: join(homedir(), ".agents", "skills", "thurview"),
1478
- }))
1479
- skill[t] = existsSync(d) ? d : "not installed";
1799
+ claude: join(homedir(), ".claude", "skills"),
1800
+ agents: join(homedir(), ".agents", "skills"),
1801
+ })) {
1802
+ const there = (await bundledSkills()).filter((n) => existsSync(join(d, n)));
1803
+ skill[t] = there.length ? there.map((n) => join(d, n)).join(", ") : "not installed";
1804
+ }
1480
1805
  return {
1481
1806
  hooks: {
1482
1807
  claude: st.claude.installed ? st.claude.path : "not installed",
@@ -1489,7 +1814,14 @@ const commands = {
1489
1814
  },
1490
1815
  async skill(args) {
1491
1816
  parseFlags("skill", args, {});
1492
- return { skill: resolve(HERE, "..", "skills", "thurview", "SKILL.md") };
1817
+ const skills = {};
1818
+ for (const name of await bundledSkills())
1819
+ skills[name] = join(skillsRoot(), name, "SKILL.md");
1820
+ return {
1821
+ skill: skills["thurview"],
1822
+ skills,
1823
+ help: ["Read the SKILL.md of the one that matches the request, and its references beside it"],
1824
+ };
1493
1825
  },
1494
1826
  };
1495
1827
  function topLevelHelp() {