thurview 0.11.0 → 0.12.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
@@ -1,83 +1,45 @@
1
1
  # thurview
2
2
 
3
- Guided, evidence-anchored reviews of agent-written code.
4
-
5
- A coding agent studies a branch, pull request or commit range and writes a
6
- short document in which every claim is anchored to an exact file and line
7
- range at a pinned commit. thurview validates the anchors, seals a revision,
8
- and serves it in your browser: the walkthrough, live code peeks, the diff,
9
- commits, and a software map. You ask questions, leave anchored comments, and
10
- approve or request changes. The agent answers and republishes.
11
-
12
- It also explains a codebase. A **code explainer** is the same document over a
13
- different unit: one pinned commit instead of a range, so a reader can see the
14
- architecture well enough to spot design problems themselves. It has no diff and
15
- nothing to approve, it surfaces structure rather than grading it, and it states
16
- what it did not examine.
17
-
18
- It does not review the code for you. It helps you understand it fast enough
19
- to review it yourself.
20
-
21
- ![thurview demo: the agent publishes and answers in the terminal, the reader
22
- peeks, comments and decides in the browser](./media/thurview-demo.gif)
23
-
24
- The clip is the `/thurview` skill's loop end to end: `thurview publish`, a
25
- question arriving in `thurview wait`, the reply, then the reader following an
26
- anchor into the code, commenting on a line range in the diff, reading the
27
- answer in the threads panel and requesting changes.
28
-
29
- ## What the reader sees
30
-
31
- Prose with every claim anchored to code, opened beside the text:
32
-
33
- ![The review document, with a call stack diff, a storage view and an anchored
34
- peek open in the side panel](./media/review-review.png)
35
-
36
- The diff at the pinned commits, commenting on a selected line range:
37
-
38
- ![The Files tab, split diff, with a comment on lines 9 to 12 of
39
- src/auth.ts](./media/review-files.png)
40
-
41
- The software map: where the change landed in the system, and what sits next to
42
- it — the question the diff cannot answer:
43
-
44
- ![The Map tab: the parts the change touched, drawn first, the links between
45
- them, and the selected node's files, code and neighbours](./media/review-map.png)
46
-
47
- Threads: a question the agent already answered, and a comment held for the
48
- decision:
49
-
50
- ![The threads panel, one answered question and one pending
51
- comment](./media/review-threads.png)
52
-
53
- Approve, or send it back with the comments:
54
-
55
- ![The submit dialog, one pending comment, Approve or Request
56
- changes](./media/review-decision.png)
57
-
58
- ```mermaid
59
- flowchart LR
60
- A[Branch, PR or range] --> B[Agent pins base and head]
61
- B --> C[Agent authors review.md + data.yaml + map.yaml]
62
- C --> D[thurview publish: validate, seal revision]
63
- D --> E[You read, ask, comment in the browser]
64
- E -->|Request changes| C
65
- E -->|Approve| F[Done]
66
- ```
3
+ Guided, evidence-anchored reviews of agent-written code. A coding agent studies
4
+ a branch, pull request or commit range and writes a short document in which
5
+ every claim is anchored to an exact file and line range at a pinned commit. You
6
+ read it in your browser, ask the agent questions, comment on the code, and
7
+ approve or send it back.
67
8
 
68
9
  ## Install
69
10
 
70
11
  Give your agent the skill, with the [skills](https://github.com/vercel-labs/skills)
71
- CLI. It works with Claude Code, Codex, Cursor, OpenCode and every agent that
12
+ CLI - it works with Claude Code, Codex, Cursor, OpenCode and every agent that
72
13
  reads the Agent Skills format:
73
14
 
74
15
  ```sh
75
16
  npx skills@latest add https://github.com/Thurbeen/thurview \
76
17
  --skill thurview --agent universal claude-code --global --yes
77
- npx skills@latest add https://github.com/Thurbeen/thurview \
78
- --skill review-fix --agent universal claude-code --global --yes # optional, see below
79
18
  ```
80
19
 
20
+ The skill reaches the `thurview` command through `npx`, so the requirements
21
+ are Node 22 or later and git (`gh` for pull requests, `glab` for merge
22
+ requests). Then, in any repository, ask your agent:
23
+
24
+ ```text
25
+ Use the thurview skill to review my current branch against up-to-date main
26
+ and open it.
27
+ ```
28
+
29
+ ![thurview demo: the reader follows an anchor into the code, comments on a line
30
+ range in the diff, reads the agent's answer and requests changes](./media/thurview-demo.gif)
31
+
32
+ The clip is the reader's half, the agent working off camera: following an anchor
33
+ from the prose into the code, opening a call stack frame, commenting on a line
34
+ range of the diff, seeing on the map what the change added, reading in the
35
+ threads panel the answer to a question already asked, and sending the review
36
+ back with changes requested.
37
+
38
+ <details>
39
+ <summary><b>Other ways to install</b> - pin the skill to a release, install the
40
+ command from npm, run from a checkout, session hooks, the review-fix
41
+ skill</summary>
42
+
81
43
  `--global` installs for your user, so one install covers every repository.
82
44
  `universal` puts the one real copy in `~/.agents/skills/thurview`, the directory
83
45
  no single agent owns, and every other agent you name gets a symlink to it, such
@@ -103,13 +65,11 @@ The npm package ships the same skill, so `thurview setup skill` links the copy
103
65
  that matches the command you have installed. Use that when you want the two to
104
66
  move together.
105
67
 
106
- The skill drives the `thurview` command, which needs Node 22 or later and
107
- git (`gh` for pull requests, `glab` for merge requests). Install it, or let
108
- the skill reach it through `npx`:
68
+ Install the command itself, rather than leaving the skill to reach it through
69
+ `npx` on every run:
109
70
 
110
71
  ```sh
111
72
  npm install -g thurview # or: pnpm add -g thurview
112
- npx -y thurview # no install; the skill falls back to this
113
73
  ```
114
74
 
115
75
  The review reasons over a code graph thurview builds itself from the pinned
@@ -132,14 +92,72 @@ with the reviews of its working directory. `thurview setup skill` links the
132
92
  skill from this checkout instead of the `skills` CLI copy; use one or the
133
93
  other.
134
94
 
135
- Then, in any repository, ask your agent:
95
+ Also available: `review-fix`, the browserless companion skill described
96
+ [below](#review-and-fix).
136
97
 
137
- ```text
138
- Use the thurview skill to review my current branch against up-to-date main
139
- and open it.
98
+ ```sh
99
+ npx skills@latest add https://github.com/Thurbeen/thurview \
100
+ --skill review-fix --agent universal claude-code --global --yes
101
+ ```
102
+
103
+ </details>
104
+
105
+ ## How it works
106
+
107
+ thurview validates every anchor against the pinned commits, seals a revision,
108
+ and serves it in your browser: the walkthrough, live code peeks, the diff,
109
+ commits, and a software map. You ask questions, leave anchored comments, and
110
+ approve or request changes. The agent answers and republishes.
111
+
112
+ It also explains a codebase. A **code explainer** is the same document over a
113
+ different unit: one pinned commit instead of a range, so a reader can see the
114
+ architecture well enough to spot design problems themselves. It has no diff and
115
+ nothing to approve, it surfaces structure rather than grading it, and it states
116
+ what it did not examine.
117
+
118
+ It does not review the code for you. It helps you understand it fast enough
119
+ to review it yourself.
120
+
121
+ ```mermaid
122
+ flowchart LR
123
+ A[Branch, PR or range] --> B[Agent pins base and head]
124
+ B --> C[Agent authors review.md + data.yaml + map.yaml]
125
+ C --> D[thurview publish: validate, seal revision]
126
+ D --> E[You read, ask, comment in the browser]
127
+ E -->|Request changes| C
128
+ E -->|Approve| F[Done]
140
129
  ```
141
130
 
142
- ## What the reader gets
131
+ ## What the reader sees
132
+
133
+ Prose with every claim anchored to code, opened beside the text:
134
+
135
+ ![The review document, with a call stack diff, a storage view and an anchored
136
+ peek open in the side panel](./media/review-review.png)
137
+
138
+ The diff at the pinned commits, commenting on a selected line range:
139
+
140
+ ![The Files tab, split diff, with a comment on lines 9 to 12 of
141
+ src/auth.ts](./media/review-files.png)
142
+
143
+ The software map: where the change landed in the system, and what sits next to
144
+ it — the question the diff cannot answer:
145
+
146
+ ![The Map tab: the parts the change touched, drawn first, the links between
147
+ them, and the selected node's files, code and neighbours](./media/review-map.png)
148
+
149
+ Threads: a question the agent already answered, and a comment held for the
150
+ decision:
151
+
152
+ ![The threads panel, one answered question and one pending
153
+ comment](./media/review-threads.png)
154
+
155
+ Approve, or send it back with the comments:
156
+
157
+ ![The submit dialog, one pending comment, Approve or Request
158
+ changes](./media/review-decision.png)
159
+
160
+ ## What's in a review
143
161
 
144
162
  - **Interface delta**: above the document, what the change added to, changed
145
163
  in or removed from the surfaces other code can reach - exported functions
@@ -193,7 +211,7 @@ bar.
193
211
  | `thurview wait --review ID [--timeout S]` | Block until the reader needs the agent |
194
212
  | `thurview threads list\|get\|reply\|resolve` | Read and answer threads |
195
213
  | `thurview graph interfaces\|impact\|callers\|tests-for\|architecture` | Ask the code graph at a review's pins, or at `--base`/`--head` |
196
- | `thurview forge status\|prior\|submit\|reply` | Read a change request through its forge, and post the review back |
214
+ | `thurview forge status\|prior\|pass\|submit\|reply` | Read a change request through its forge, and post the review back |
197
215
  | `thurview serve` / `thurview stop` | Run the server in the foreground / stop the background one |
198
216
  | `thurview setup hooks\|skill\|status` | Session hooks, agent skill, install state |
199
217
  | `thurview update` | Self-update from npm |
@@ -232,6 +250,7 @@ on the change request, through `thurview forge`:
232
250
  ```sh
233
251
  thurview forge status --change 123 # what CI actually did, and whether it is a gate at all
234
252
  thurview forge prior --change 123 # the previous pass, thread by thread
253
+ thurview forge pass --review <id> # the reader's submitted threads, as the file below takes
235
254
  thurview forge submit --change 123 --file pass.json --dry-run
236
255
  thurview forge reply <threadId> --change 123 --body "<answer>" --resolve --at <head>
237
256
  ```
@@ -242,6 +261,14 @@ a change request from a fork typically runs a fraction of them, and a
242
261
  cancelled job shows no failure while asserting nothing. `ci.trustworthy` is
243
262
  the only field that means the tests really passed.
244
263
 
264
+ `pass` goes the other way, from a review a reader submitted in the browser to
265
+ that same file: one inline comment per thread anchored to a line, questions and
266
+ resolved threads left out, and threads with no line - a document block, a map
267
+ node, a whole file - gathered into the summary and named in the output, so
268
+ nobody assumes their comment was posted where they wrote it. The verdict comes
269
+ from the reader's decision, `close` becomes a `comment`, and an approve with
270
+ threads still open is refused rather than quietly downgraded.
271
+
245
272
  `submit` takes one JSON file so a human can read the pass before it is posted,
246
273
  refuses an `approve` without `--confirm`, and warns about comments too long to
247
274
  be read. `reply --resolve` takes `--at <sha>` and refuses any commit but the
package/dist/cli.js CHANGED
@@ -9,17 +9,18 @@ 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));
@@ -30,24 +31,6 @@ function note(msg) {
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)
@@ -478,7 +467,7 @@ const SPECS = {
478
467
  },
479
468
  forge: {
480
469
  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>"',
470
+ args: 'status|prior|pass|submit --file <path>|reply <threadId> --body "<text>"',
482
471
  flags: {
483
472
  review: { kind: "string", help: "review id prefix; its binding names the change request" },
484
473
  change: { kind: "string", help: "change request number or URL, instead of a review binding" },
@@ -488,6 +477,10 @@ const SPECS = {
488
477
  },
489
478
  repo: { kind: "string", help: "host/path, when `origin` is not the repository to post to" },
490
479
  file: { kind: "string", help: "submit: the JSON submission to post" },
480
+ out: {
481
+ kind: "string",
482
+ help: "pass: where to write the submission (default: ~/.thurview/passes/<id>.json)",
483
+ },
491
484
  body: { kind: "string", help: "reply: the answer text" },
492
485
  resolve: { kind: "boolean", help: "reply: resolve the thread as well as answering it" },
493
486
  at: { kind: "string", help: "reply --resolve: the commit the point was verified at" },
@@ -504,6 +497,7 @@ const SPECS = {
504
497
  examples: [
505
498
  "thurview forge status --change 123",
506
499
  "thurview forge prior --change 123 --mine",
500
+ "thurview forge pass --review <id>",
507
501
  "thurview forge submit --file pass.json --dry-run",
508
502
  'thurview forge reply <threadId> --body "<answer>" --resolve --at <sha>',
509
503
  ],
@@ -1500,10 +1494,11 @@ const commands = {
1500
1494
  const usage = [
1501
1495
  "thurview forge status [--change <ref>] [--review <id>]",
1502
1496
  "thurview forge prior [--change <ref>] [--mine] [--full]",
1497
+ "thurview forge pass [--review <id>] [--out <path>]",
1503
1498
  "thurview forge submit --file <path> [--dry-run] [--confirm]",
1504
1499
  'thurview forge reply <threadId> --body "<text>" [--resolve --at <sha>]',
1505
1500
  ];
1506
- if (!sub || !["status", "prior", "submit", "reply"].includes(sub))
1501
+ if (!sub || !["status", "prior", "pass", "submit", "reply"].includes(sub))
1507
1502
  throw new AxiError(`unknown forge command${sub ? ` ${sub}` : ""}`, "VALIDATION_ERROR", usage);
1508
1503
  const common = {
1509
1504
  review: s["review"],
@@ -1618,6 +1613,70 @@ const commands = {
1618
1613
  : ["This is the first pass; there is no prior review to answer"],
1619
1614
  };
1620
1615
  }
1616
+ if (sub === "pass") {
1617
+ const p = parseFlags("forge pass", rest, { review: s["review"], out: s["out"] });
1618
+ // Approve and close both end the review, so a pass has to be able to
1619
+ // reach a finished one - but only when no active review answers first,
1620
+ // or a review the reader finished last week shadows the one in hand.
1621
+ const review = await resolveReview(str(p, "review")).catch((e) => {
1622
+ if (e instanceof AxiError && e.code === "NOT_FOUND")
1623
+ return resolveReview(str(p, "review"), { terminal: true });
1624
+ throw e;
1625
+ });
1626
+ const t = await readThreads(review.id);
1627
+ const id = short(review.id);
1628
+ const decision = t.decisions[t.decisions.length - 1];
1629
+ if (!decision)
1630
+ throw new AxiError(`review ${id} has no decision to carry to the forge`, "NOT_FOUND", [
1631
+ "The reader decides in the browser; nothing is posted before they submit",
1632
+ `Run \`thurview wait --review ${id}\` to block until they do`,
1633
+ ]);
1634
+ const plan = buildPass(t.threads, decision);
1635
+ const out = resolve(process.cwd(), str(p, "out") ?? passFile(review.id));
1636
+ const text = JSON.stringify(plan.submission, null, 2) + "\n";
1637
+ // Checked by the parser `submit` reads it with, so a file this wrote is
1638
+ // never one that command refuses.
1639
+ parseSubmission(text, out);
1640
+ await writeText(out, text);
1641
+ return {
1642
+ pass: {
1643
+ review: id,
1644
+ file: out,
1645
+ decision: plan.decision,
1646
+ verdict: plan.submission.verdict,
1647
+ ...(plan.verdictReason ? { why: plan.verdictReason } : {}),
1648
+ inline: plan.inline.length,
1649
+ summary: plan.summary.length,
1650
+ skipped: plan.skipped.length,
1651
+ anchoredAt: review.pins.head,
1652
+ },
1653
+ comments: plan.inline.length
1654
+ ? plan.inline.map((c) => ({ thread: c.thread, at: c.at, side: c.side }))
1655
+ : "0 (a summary-only pass)",
1656
+ summary: plan.summary.length
1657
+ ? plan.summary.map((x) => ({ thread: x.thread, target: x.target, why: x.why }))
1658
+ : "0 (every comment is anchored to a line)",
1659
+ skipped: plan.skipped.length
1660
+ ? plan.skipped.map((x) => ({ thread: x.thread, target: x.target, why: x.why }))
1661
+ : "0 (no question, resolved or held thread to leave out)",
1662
+ help: [
1663
+ `Read ${out} before it is posted; the file is the thing a human checks`,
1664
+ `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`,
1665
+ `Run \`thurview forge submit --review ${id} --file ${out} --dry-run\` to see what would reach the change request`,
1666
+ "Tell the reader which comments went to the summary, and why they are not on their line",
1667
+ ...(decision.revision === review.revision
1668
+ ? []
1669
+ : [
1670
+ `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`,
1671
+ ]),
1672
+ ...(review.binding.kind === "pr"
1673
+ ? []
1674
+ : [
1675
+ `This review is bound to ${review.binding.name}, not to a change request; \`forge submit\` will need --change <ref>`,
1676
+ ]),
1677
+ ],
1678
+ };
1679
+ }
1621
1680
  if (sub === "submit") {
1622
1681
  const p = parseFlags("forge submit", rest, {
1623
1682
  ...common,