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/README.md +139 -91
- package/dist/cli.js +300 -120
- package/dist/cli.js.map +1 -1
- package/dist/document/compile.js +97 -13
- package/dist/document/compile.js.map +1 -1
- package/dist/forge/submission.js +123 -0
- package/dist/forge/submission.js.map +1 -1
- package/dist/store.js +12 -1
- package/dist/store.js.map +1 -1
- package/dist/thread-state.js +23 -0
- package/dist/thread-state.js.map +1 -1
- package/dist/ui/app.css +12 -0
- package/dist/ui/app.js +68 -44
- package/dist/ui/app.js.map +2 -2
- package/package.json +1 -1
- package/skills/thurview/SKILL.md +18 -3
- package/skills/thurview/references/lifecycle.md +26 -0
- package/skills/thurview-design/SKILL.md +265 -0
- package/skills/thurview-design/references/anchors-and-proposals.md +90 -0
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
|
|
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
|
-
|
|
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) === "
|
|
149
|
-
? r.pins.head.slice(0, 12)
|
|
150
|
-
:
|
|
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
|
|
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
|
|
729
|
-
const worktree =
|
|
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
|
|
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
|
-
:
|
|
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)
|
|
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
|
|
1347
|
-
//
|
|
1348
|
-
|
|
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
|
-
|
|
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
|
};
|