baesian 0.0.0-stage → 1.0.1

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.
Files changed (69) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +217 -2
  3. package/dist/agents.js +249 -0
  4. package/dist/api.js +13 -0
  5. package/dist/bin.js +14 -0
  6. package/dist/ci.js +82 -0
  7. package/dist/cite.js +66 -0
  8. package/dist/cli.js +831 -0
  9. package/dist/client/assets/alegreya-cyrillic-ext-wght-italic-DzWRXZb0.woff2 +0 -0
  10. package/dist/client/assets/alegreya-cyrillic-ext-wght-normal-CN0-BfWD.woff2 +0 -0
  11. package/dist/client/assets/alegreya-cyrillic-wght-italic-Dhrv2xfk.woff2 +0 -0
  12. package/dist/client/assets/alegreya-cyrillic-wght-normal-CU5lb38f.woff2 +0 -0
  13. package/dist/client/assets/alegreya-greek-ext-wght-italic-CoUI9EEu.woff2 +0 -0
  14. package/dist/client/assets/alegreya-greek-ext-wght-normal-C8PPRmwJ.woff2 +0 -0
  15. package/dist/client/assets/alegreya-greek-wght-italic-CVt9-Jr4.woff2 +0 -0
  16. package/dist/client/assets/alegreya-greek-wght-normal-BPx8yWcZ.woff2 +0 -0
  17. package/dist/client/assets/alegreya-latin-ext-wght-italic-BIuo_2sR.woff2 +0 -0
  18. package/dist/client/assets/alegreya-latin-ext-wght-normal-o8axeAGu.woff2 +0 -0
  19. package/dist/client/assets/alegreya-latin-wght-italic-Bxkcgmge.woff2 +0 -0
  20. package/dist/client/assets/alegreya-latin-wght-normal-BG9rTJo4.woff2 +0 -0
  21. package/dist/client/assets/alegreya-vietnamese-wght-italic-BAPuWgnQ.woff2 +0 -0
  22. package/dist/client/assets/alegreya-vietnamese-wght-normal-DOV9JYup.woff2 +0 -0
  23. package/dist/client/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
  24. package/dist/client/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
  25. package/dist/client/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
  26. package/dist/client/assets/index-9yf70Vqv.css +1 -0
  27. package/dist/client/assets/index-Dwg9e0YR.js +15 -0
  28. package/dist/client/index.html +15 -0
  29. package/dist/facts.js +278 -0
  30. package/dist/folder-storage.js +71 -0
  31. package/dist/frontmatter.js +38 -0
  32. package/dist/gate-paths.js +77 -0
  33. package/dist/gate.js +237 -0
  34. package/dist/git.js +100 -0
  35. package/dist/globs.js +120 -0
  36. package/dist/hookblock.js +146 -0
  37. package/dist/hooks.js +911 -0
  38. package/dist/ids.js +23 -0
  39. package/dist/login.js +301 -0
  40. package/dist/mcp.js +303 -0
  41. package/dist/merges.js +849 -0
  42. package/dist/modes.js +245 -0
  43. package/dist/notify.js +91 -0
  44. package/dist/ops.js +381 -0
  45. package/dist/passkey.js +86 -0
  46. package/dist/personal.js +163 -0
  47. package/dist/practice-answers.js +42 -0
  48. package/dist/proposals.js +197 -0
  49. package/dist/quote.js +28 -0
  50. package/dist/report.js +261 -0
  51. package/dist/routing.js +411 -0
  52. package/dist/schema.js +1178 -0
  53. package/dist/server.js +514 -0
  54. package/dist/similarity.js +45 -0
  55. package/dist/sshsig.js +159 -0
  56. package/dist/state.js +135 -0
  57. package/dist/storage.js +61 -0
  58. package/dist/store.js +2259 -0
  59. package/dist/style.js +408 -0
  60. package/dist/sync.js +797 -0
  61. package/dist/trailers.js +121 -0
  62. package/dist/trust.js +222 -0
  63. package/dist/usage-fields.js +161 -0
  64. package/dist/vault.js +849 -0
  65. package/dist/verify.js +443 -0
  66. package/dist/version.js +70 -0
  67. package/dist/wait.js +449 -0
  68. package/dist/wikilinks.js +25 -0
  69. package/package.json +71 -4
package/dist/cli.js ADDED
@@ -0,0 +1,831 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { Command, CommanderError, InvalidArgumentError } from "commander";
5
+ import { agentName, harnessSession, modelSessions, personalInstructions } from "./agents.js";
6
+ import { ciPin, runProgram, workflowBody, workflowSteps } from "./ci.js";
7
+ import { Told, cite, citeIn, unmark, unmarkJson } from "./cite.js";
8
+ import { CONFIG_PATH, stagedSnapshot } from "./facts.js";
9
+ import { parseDoc } from "./frontmatter.js";
10
+ import { describeReport, detectStaged, evaluate } from "./gate.js";
11
+ import { personalProblems, runHook } from "./hooks.js";
12
+ import { scanHistory } from "./merges.js";
13
+ import { initShared, initSharedMode, setUpPersonal } from "./modes.js";
14
+ import { notifyFiled } from "./notify.js";
15
+ import { openRepo } from "./git.js";
16
+ import { parseTrailers } from "./trailers.js";
17
+ import { serveMcp } from "./mcp.js";
18
+ import { attachOp, deckWaitIds, duplicateLines, failureLines, fileOp, inboxOp, listOp, replyOp, reviseOp, ruleOp, showOp, templateOp, topicsOp, verdictsHeadline, waitAllOp, waitOp, withdrawOp, } from "./ops.js";
19
+ import { DEFAULT_SERVICE, Offline, firstCommitToLink, linkAck, linkRepo, machineName, newProjectId, projectPage, readGrant, removeGrant, revokeGrant, signInMachine, writeGrant, } from "./login.js";
20
+ import { q } from "./quote.js";
21
+ import { Origin } from "./schema.js";
22
+ import { findRunningServer, serverUrl, startServer } from "./server.js";
23
+ import { blank, cmd, dim, failed, fields, head, id, indent, lit, literal, next, note, render, renderFor, settingsOf, strong, table, text, warning, } from "./style.js";
24
+ import { aroundFiling, linkProblem, spareKeys, syncRelay, waitWithRelay } from "./sync.js";
25
+ import { describeVerify, fileConflicts, verify } from "./verify.js";
26
+ import { PossibleDuplicate, Store, ValidationError } from "./store.js";
27
+ import { TRUST_ENV } from "./trust.js";
28
+ import { cleanVaultPath, vaultPathProblem, writeNotes } from "./vault.js";
29
+ import { VERSION } from "./version.js";
30
+ import { DEFAULT_TIMEOUT_MS, SLICE_MS, WaitCancelled } from "./wait.js";
31
+ /** Exit code when a card may duplicate an open card or a resolved decision. */
32
+ export const EXIT_POSSIBLE_DUPLICATE = 3;
33
+ /** Baesian's version, as `baesian --version` prints it: package.json's, kept in src/version.ts. */
34
+ export { VERSION };
35
+ /** How long `baesian inbox --quiet`, a harness's hook, waits for the relay: a hook must stay quick, and never fail. */
36
+ export const QUIET_SYNC_MS = 5000;
37
+ /** The command line that re-runs this CLI, for hooks when `baesian` isn't on PATH (D48). */
38
+ export function selfCommand(argv1 = process.argv[1]) {
39
+ if (!argv1)
40
+ return [];
41
+ // Resolve first: under `npm link` or a global install, argv[1] is the bin symlink (`…/bin/baesian`).
42
+ let script;
43
+ try {
44
+ script = realpathSync(argv1);
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ if (!script.endsWith(".js"))
50
+ return []; // running from source through tsx: rely on PATH
51
+ return [process.execPath, script];
52
+ }
53
+ /**
54
+ * Read stdin as a stream. `readFileSync(0)` fails with EAGAIN when the pipe's writer hasn't written
55
+ * yet (`baesian template | baesian file`), so it can't be used here.
56
+ */
57
+ async function readStdin() {
58
+ if (process.stdin.isTTY)
59
+ throw new Told("no card given: pass a file, or pipe one in", [{ command: "baesian file card.md" }]);
60
+ const chunks = [];
61
+ for await (const chunk of process.stdin)
62
+ chunks.push(chunk);
63
+ return Buffer.concat(chunks).toString("utf8");
64
+ }
65
+ const defaultIo = {
66
+ out: (line) => process.stdout.write(`${line}\n`),
67
+ err: (line) => process.stderr.write(`${line}\n`),
68
+ stdin: readStdin,
69
+ cwd: process.cwd(),
70
+ terminal: { out: process.stdout, err: process.stderr },
71
+ };
72
+ function seconds(value) {
73
+ const n = Number(value);
74
+ if (!Number.isFinite(n) || n < 0)
75
+ throw new InvalidArgumentError("expected a number of seconds");
76
+ return n;
77
+ }
78
+ function port(value) {
79
+ const n = Number(value);
80
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
81
+ throw new InvalidArgumentError("expected a port number");
82
+ return n;
83
+ }
84
+ /** Open an address in the default browser; if that fails, the printed address is enough. */
85
+ function openInBrowser(url) {
86
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
87
+ try {
88
+ spawn(command, args, { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
89
+ }
90
+ catch {
91
+ // no browser to open
92
+ }
93
+ }
94
+ /** A draft with `--secret-reason` on it, which files a card that looks like it holds a secret, in a hosted repo (B-jfkv9). */
95
+ function withSecretReason(meta, reason) {
96
+ return reason === undefined ? meta : { ...meta, secret_reason: reason };
97
+ }
98
+ async function readInput(path, io) {
99
+ return !path || path === "-" ? await io.stdin() : readFileSync(resolve(io.cwd, path), "utf8");
100
+ }
101
+ const files = (n) => `${n} file${n === 1 ? "" : "s"}`;
102
+ const mergesOf = (n) => (n === 1 ? "1 merge" : `${n} merges`);
103
+ /**
104
+ * What the history scan recorded (B-dezkk): a headline, then each merge, oldest first: the branches, its commit, its
105
+ * day, and what it brought. A commit's subject is never shown.
106
+ */
107
+ function scanLines(scan) {
108
+ const known = scan.known ? `; ${mergesOf(scan.known)} it found ${scan.known === 1 ? "was" : "were"} recorded already` : "";
109
+ if (!scan.found.length)
110
+ return [head("done", `git's history holds no merge that isn't recorded already${known}`)];
111
+ const what = (cards, commits) => [cards ? `${cards} card${cards === 1 ? "" : "s"}` : "", commits ? `${commits} commit${commits === 1 ? "" : "s"} citing cards` : ""].filter(Boolean).join(" and ") || "its deck";
112
+ return [
113
+ head("done", `recorded ${mergesOf(scan.found.length)} from git's history that no hook saw${known}`),
114
+ ...table(undefined, scan.found.map((f) => [`${f.branch} merged into ${f.into}`, id(f.commit.slice(0, 7)), dim(f.at.slice(0, 10)), what(f.cards.length, f.commits.length)])),
115
+ ];
116
+ }
117
+ /** The command's lines, as each of the two streams gets them: plain, or as a terminal shows them (B-eyrt5, B-qszqr). */
118
+ function output(io, settings) {
119
+ const env = io.env ?? process.env;
120
+ const write = (stream, lines) => {
121
+ const how = renderFor(io.terminal?.[stream], env, settings);
122
+ for (const l of render(Array.isArray(lines) ? lines : [lines], how))
123
+ io[stream](l);
124
+ };
125
+ return {
126
+ say: (lines) => write("out", lines),
127
+ tell: (lines) => write("err", lines),
128
+ /** What the relay said, on standard error, so a command's output is its own. */
129
+ notes: (ns) => write("err", ns.map((n) => text(n))),
130
+ how: (stream) => renderFor(io.terminal?.[stream], env, settings),
131
+ };
132
+ }
133
+ export function buildProgram(io = defaultIo, state = { code: 0 }, settings = {}) {
134
+ // Help text cites Baesian's decisions only with --verbose (B-728jd).
135
+ const d = (s) => unmark(s, settings.verbose);
136
+ const { say, tell, notes, how } = output(io, settings);
137
+ const program = new Command()
138
+ .name("baesian")
139
+ .description("Swipe review for agent decisions. Agents file cards; a person answers them in the client.")
140
+ .version(VERSION, "-V, --version", "print Baesian's version")
141
+ .option("--agent <name>", d(`who is running this (default: $BAESIAN_AGENT, else the harness's own name, else 'agent'${citeIn("D102")})`))
142
+ .option("--verbose", "also say which of Baesian's own design decisions a message rests on")
143
+ .option("--color", "colour and symbols, even through a pipe or an agent's harness")
144
+ .option("--no-color", "plain text, as an agent reads it, even on a terminal")
145
+ // Throw instead of calling process.exit, and write through io; subcommands inherit both. A usage error prints a
146
+ // short error and where to get help (`main`), not the whole help.
147
+ .exitOverride()
148
+ .configureOutput({ writeOut: (s) => io.out(s.trimEnd()), writeErr: (s) => io.err(s.trimEnd()) });
149
+ const store = () => Store.open(io.cwd);
150
+ const json = (value) => io.out(JSON.stringify(value, unmarkJson, 2));
151
+ const notify = (card) => {
152
+ try {
153
+ (io.notify ?? notifyFiled)(card);
154
+ }
155
+ catch (e) {
156
+ tell(warning(`notification failed (the card is filed): ${e instanceof Error ? e.message : String(e)}`));
157
+ }
158
+ };
159
+ // The sessions the command runs in, so a card it files records its model (B-k0w7d).
160
+ const actor = () => ({ agent: agentName(program.opts().agent), notify, sessions: modelSessions(io.env ?? process.env) });
161
+ /** Print an operation's lines, or with `--json` its data (P18: what the MCP tool returns as structured content). */
162
+ const print = (op, asJson) => (asJson ? json(op.data) : say(op.lines));
163
+ const http = () => io.http ?? {};
164
+ /**
165
+ * A filing command (M9d): in a linked repo, pull first and push after (`aroundFiling()`); what the relay said goes to
166
+ * standard error, so the output is the command's own, as before.
167
+ */
168
+ const filing = async (s, op, asJson) => {
169
+ const { value, notes: said } = await aroundFiling(s, op, http());
170
+ print(value, asJson);
171
+ notes(said);
172
+ };
173
+ // Personal mode (B-4rwyz): every command but the hooks', the MCP server's, `init` (which fixes it) and a harness's quiet
174
+ // inbox (which says it once a session) says what's wrong with the clone's setup, such as a gate that's off because the
175
+ // clone's .git/config no longer includes Baesian's setting.
176
+ program.hook("preAction", (_program, command) => {
177
+ const name = command.name();
178
+ if (name === "hook" || name === "mcp" || name === "init" || (name === "inbox" && command.opts().quiet))
179
+ return;
180
+ let problems = [];
181
+ try {
182
+ problems = personalProblems(store());
183
+ }
184
+ catch {
185
+ // not a repo, or not set up: the command says so itself
186
+ }
187
+ tell(problems.map((p) => warning(p)));
188
+ });
189
+ program.commandsGroup("Setting up:");
190
+ program
191
+ .command("init")
192
+ .description("set up Baesian in this repo: the live layer (.git/baesian/), the record (.baesian/), the gate's block in the repo's hooks and the AGENTS.md instructions; or with --personal, for this clone alone, committing nothing")
193
+ .option("--personal", d(`use Baesian in this clone alone, in a repo whose collaborators don't${cite("B-4rwyz")}: nothing is committed, and the repo's hooks and AGENTS.md are left as they are`))
194
+ .option("--shared", "shared mode, which plain init sets up; in a personal clone, switch to it: this branch's record, the hooks' block and AGENTS.md are written into the repo, for you to commit")
195
+ .option("--vault <path>", "an Obsidian vault for the vault adapter, relative to the repo root (shared mode)")
196
+ .option("--no-agents", "don't write the instructions block in AGENTS.md")
197
+ .option("--no-hooks", "don't install the git gate")
198
+ .action((opts) => {
199
+ if (opts.personal && opts.shared)
200
+ throw new ValidationError("--personal and --shared: choose one");
201
+ const s = store();
202
+ const vault = opts.vault === undefined ? undefined : cleanVaultPath(opts.vault);
203
+ if (vault !== undefined) {
204
+ const problem = vaultPathProblem(s.repo.toplevel, vault);
205
+ if (problem)
206
+ throw new ValidationError(problem);
207
+ }
208
+ const o = { ...(vault !== undefined ? { vault } : {}), agents: opts.agents, hooks: opts.hooks, fallback: io.hookFallback ?? selfCommand() };
209
+ // B-4rwyz: a personal clone stays personal, unless --shared switches it; plain init in any other clone is shared mode.
210
+ const personal = opts.personal || (!opts.shared && s.personal);
211
+ say(personal ? setUpPersonal(s, o) : opts.shared ? initShared(store, o) : initSharedMode(s, o));
212
+ });
213
+ program
214
+ .command("serve")
215
+ .description(d(`serve the client on 127.0.0.1 and open it; one server per repo. In a repo linked to the hosted service, open the project's page on the website instead${cite("D143")}`))
216
+ .option("--port <n>", "listen on this port (by default the last server's, if it's free, or one the system picks)", port)
217
+ .option("--no-open", "don't open the browser")
218
+ .option("--poll", "check the live layer every second instead of watching it, for shared storage")
219
+ .action(async (opts) => {
220
+ const s = store();
221
+ // D143 (B-k216t): in a linked repo, every answer comes from the website, signed; the local client doesn't start.
222
+ const link = s.config();
223
+ if (link.service && link.project) {
224
+ const problem = linkProblem(s, link);
225
+ if (problem) {
226
+ tell(failed(`not serving: ${problem}`));
227
+ state.code = 1;
228
+ return;
229
+ }
230
+ const page = projectPage(link.service, link.project);
231
+ say([head("done", `this repo is linked to ${link.service}: its cards are answered on the website, signed in with your passkey, not here`), ...fields([{ label: "page", value: page }])]);
232
+ if (opts.open)
233
+ openInBrowser(page);
234
+ return;
235
+ }
236
+ const running = await findRunningServer(s);
237
+ if (running) {
238
+ say(head("done", `already serving this repo: ${serverUrl(running)}`));
239
+ if (opts.open)
240
+ openInBrowser(serverUrl(running));
241
+ return;
242
+ }
243
+ const server = await startServer(s, { ...(opts.port ? { port: opts.port } : {}), poll: opts.poll ?? false });
244
+ say([
245
+ head("done", `serving ${s.repo.toplevel} at ${server.url}`),
246
+ indent(2, text(dim(`watching the live layer by ${server.watchMode() === "poll" ? "polling every second" : "file events, with a rescan every 30 seconds"}; Ctrl-C to stop`))),
247
+ ]);
248
+ if (opts.open)
249
+ openInBrowser(server.url);
250
+ await new Promise((done) => {
251
+ process.once("SIGINT", done);
252
+ process.once("SIGTERM", done);
253
+ });
254
+ await server.close();
255
+ say(head("done", "stopped"));
256
+ });
257
+ program
258
+ .command("login")
259
+ .description(d(`sign this machine in to the hosted service for this repo's project, linking the repo first if it isn't${cite("B-62qnz")}`))
260
+ .option("--service <url>", `the service's address, for a repo that isn't linked yet (default: $BAESIAN_SERVICE, else ${DEFAULT_SERVICE})`)
261
+ .option("--machine <name>", "this machine's name, as the website shows it (default: its hostname)")
262
+ .action(async (opts) => {
263
+ const s = store();
264
+ s.requireInit();
265
+ const c = s.config();
266
+ const asked = opts.service ?? (c.service ? undefined : (io.env ?? process.env).BAESIAN_SERVICE);
267
+ if (asked !== undefined && !Origin.safeParse(asked).success)
268
+ throw new ValidationError(`--service: the service's address, an origin such as https://baesian.example, with no path (got ${JSON.stringify(asked)})`);
269
+ if (c.service && asked && asked !== c.service) {
270
+ throw new ValidationError(s.personal
271
+ ? `this clone is linked to ${c.service}, not ${asked}: to move it, change the service and project in its config, .git/baesian/config.json (personal mode${citeIn("B-4rwyz")})`
272
+ : `this repo is linked to ${c.service}, not ${asked}: to move it, change the service and project in .baesian/config.json, a change the commit gate asks a card or an ack for${cite("B-w4g6c")}`);
273
+ }
274
+ // B-f2mfb: with none named, the hosted service. Its approval page names it, and takes a passkey, before any card
275
+ // leaves this machine; someone running their own names it once, and the config keeps it.
276
+ const service = c.service ?? asked ?? DEFAULT_SERVICE;
277
+ // Linking (B-62qnz, B-5gnea): a repo with no project, or a copy of another repo's (its history starts elsewhere),
278
+ // gets a project of its own; one whose config lacks its first commit has it recorded.
279
+ const copy = c.project !== undefined && c.first_commit !== undefined && linkProblem(s, c) !== undefined;
280
+ const link = c.project === undefined || copy
281
+ ? { service, project: newProjectId(), first_commit: firstCommitToLink(s.repo.toplevel) }
282
+ : c.first_commit === undefined
283
+ ? { service, project: c.project, first_commit: firstCommitToLink(s.repo.toplevel) }
284
+ : undefined;
285
+ const project = link?.project ?? c.project;
286
+ if (!link) {
287
+ const held = (() => {
288
+ try {
289
+ return readGrant(project);
290
+ }
291
+ catch {
292
+ return undefined;
293
+ }
294
+ })();
295
+ if (held?.service === service) {
296
+ say([head("done", `this machine is signed in already, to project ${project} on ${service}, as ${held.machine}`), next("baesian logout", "first, to sign in again")]);
297
+ return;
298
+ }
299
+ }
300
+ const fresh = link && link.project !== c.project;
301
+ if (fresh && !c.service) {
302
+ say([head(undefined, strong(`linking this ${s.personal ? "clone" : "repo"} to ${service}`), ", the hosted service"), indent(2, text(dim("to use another, run "), cmd("baesian login --service <url>"), dim(" instead")))]);
303
+ }
304
+ else if (fresh)
305
+ say(head(undefined, strong(`linking this ${s.personal ? "clone" : "repo"} to ${service}`)));
306
+ if (copy)
307
+ say(note(`this checkout is a copy of another repo, not that repo${cite("B-5gnea")}: linking it to a project of its own`));
308
+ const grant = await signInMachine({
309
+ ...http(),
310
+ service,
311
+ project,
312
+ machine: opts.machine ?? machineName(),
313
+ ...(io.sleep ? { sleep: io.sleep } : {}),
314
+ onCode: (code) => {
315
+ // B-zqkwk: a new project is named on that page, by the person; this repo's own name is never sent.
316
+ const asks = fresh ? "It asks you to name the project, then for your passkey." : "It asks for your passkey.";
317
+ say([
318
+ text("Open this page, and check it shows the code ", strong(code.user_code), ":"),
319
+ indent(2, literal(code.verification_uri_complete ?? code.verification_uri)),
320
+ indent(2, text(dim(`${asks} The code lasts ${Math.round(code.expires_in / 60)} minutes.`))),
321
+ text(dim("waiting for it to be approved…")),
322
+ ]);
323
+ },
324
+ });
325
+ writeGrant(grant);
326
+ if (link)
327
+ linkRepo(s, link);
328
+ // B-dezkk: a repo linked here records, once, the merges its history shows that no hook saw (made before it had
329
+ // Baesian's merge hooks), so they go up with the first sync. Linking never fails for it.
330
+ let scanned;
331
+ let unscanned;
332
+ if (link) {
333
+ try {
334
+ scanned = scanHistory(s, { write: true });
335
+ }
336
+ catch (e) {
337
+ unscanned = e instanceof Error ? e.message.split("\n")[0] : String(e);
338
+ }
339
+ }
340
+ // The first sync: the relay's files come down, and everything an agent wrote here goes up (B-6k0rk). A repo with a
341
+ // history of cards sends hundreds of files, so it says how many before it starts.
342
+ const relay = await syncRelay(s, { ...http(), sending: (n) => say(text(dim(`sending ${files(n)} to ${service}…`))) });
343
+ const linkedIn = s.personal
344
+ ? `linked in its own config, .git/baesian/config.json: personal mode commits nothing, so there's nothing to commit${cite("B-62qnz", "B-4rwyz")}`
345
+ : `linked in ${CONFIG_PATH}${cite("B-62qnz", "B-5gnea")}`;
346
+ say([
347
+ head("done", `signed in: ${grant.machine} can file cards and hear answers in this project, never answer them`),
348
+ ...fields([
349
+ { label: "project", value: project, ...(link ? { why: linkedIn } : {}) },
350
+ !!scanned?.found.length && { label: "history", value: mergesOf(scanned.found.length), why: `from git's history, made before this repo had Baesian's merge hooks: recorded once${cite("B-dezkk")}` },
351
+ relay.state === "synced" && { label: "relay", value: `sent ${files(relay.pushed.length)}, received ${relay.pulled.length}` },
352
+ { label: "answer", value: projectPage(service, project) },
353
+ ]),
354
+ ]);
355
+ if (unscanned)
356
+ tell([warning(`couldn't read git's history for merges no hook saw (${unscanned})`), next("baesian sync --history", "to try again")]);
357
+ notes(relay.notes);
358
+ if (link && !s.personal) {
359
+ // The link is a gate fact (B-w4g6c): a command to commit it with its ack, ready to run, the trailer its own paragraph.
360
+ say(next(`git add ${CONFIG_PATH} && git commit -m "Link this repo to Baesian" -m "${linkAck(service, link.project)}"`, "commit the link, with the ack the commit gate asks for where cards go"));
361
+ }
362
+ });
363
+ program
364
+ .command("logout")
365
+ .description("revoke this machine's grant for this repo's project, and delete its tokens")
366
+ .action(async () => {
367
+ const s = store();
368
+ s.requireInit();
369
+ const { project } = s.config();
370
+ if (!project)
371
+ throw new ValidationError("this repo isn't linked to the service, so there's nothing to sign out of");
372
+ let grant;
373
+ try {
374
+ grant = readGrant(project);
375
+ }
376
+ catch {
377
+ removeGrant(project);
378
+ say([head("attention", `deleted this machine's unreadable grant file for project ${project}`), indent(2, text("Revoke the machine on the website's Machines page."))]);
379
+ return;
380
+ }
381
+ if (!grant) {
382
+ say(head("done", `this machine isn't signed in to project ${project}`));
383
+ return;
384
+ }
385
+ let reached = true;
386
+ try {
387
+ await revokeGrant(grant, http());
388
+ }
389
+ catch (e) {
390
+ if (!(e instanceof Offline))
391
+ throw e;
392
+ reached = false;
393
+ notes([`relay: ${e.message}`]);
394
+ }
395
+ removeGrant(project);
396
+ say(reached
397
+ ? head("done", `signed out: revoked this machine's grant for project ${project}, and deleted its tokens`)
398
+ : [
399
+ head("attention", `deleted this machine's tokens for project ${project}, but couldn't revoke its grant`),
400
+ indent(2, text("Revoke it on the website's Machines page, or it lapses after 30 days unused.")),
401
+ ]);
402
+ });
403
+ program
404
+ .command("ci")
405
+ .description(d(`in a linked repo: the lines CI's BAESIAN_TRUST pins, read from the service, and the workflow steps that run baesian verify${cite("B-pt52b")}`))
406
+ .option("--github", "also set the repository variable BAESIAN_TRUST with gh, after printing it")
407
+ .option("--json", "print the result as JSON")
408
+ .action(async (opts) => {
409
+ const s = store();
410
+ s.requireInit();
411
+ // B-4rwyz: CI checks each commit against its committed record, and personal mode commits none.
412
+ if (s.personal) {
413
+ throw new ValidationError(`this clone uses Baesian in personal mode${cite("B-4rwyz")}: nothing of Baesian's is committed, so CI has no record to verify, and no pin to set. To have CI check the repo's decisions, switch it to shared mode, commit, then run \`baesian ci\`.`, [{ command: "baesian init --shared", why: "switch to shared mode" }]);
414
+ }
415
+ const c = s.config();
416
+ if (!c.service || !c.project)
417
+ throw new ValidationError("this repo isn't linked to the hosted service", [{ command: "baesian login", why: "first, then baesian ci" }]);
418
+ // The keys come from the service, through this pull, never from the committed record, which a commit can change.
419
+ const relay = await syncRelay(s, { ...http(), push: false });
420
+ notes(relay.notes);
421
+ if (relay.state === "signed-out")
422
+ throw new ValidationError(`this machine isn't signed in to project ${c.project} on ${c.service}`, [{ command: "baesian login", why: "first, then baesian ci" }]);
423
+ if (relay.state === "stopped")
424
+ throw new ValidationError("this checkout doesn't sync with the project its config names, so it can't read the project's keys (above)");
425
+ if (relay.state !== "synced")
426
+ throw new ValidationError(`couldn't sync with ${c.service}, and CI's keys must come from it: run \`baesian ci\` again once it's reachable`);
427
+ // B-x47ek: the service's spare key, which it never signs with, pinned beside its key so CI passes if it switches.
428
+ const spare = await spareKeys(c.service, http());
429
+ if (spare.problem)
430
+ notes([`relay: ${spare.problem}, so the pin has no spare key${cite("B-x47ek")}: run \`baesian ci\` again later to add it`]);
431
+ const pin = ciPin(s, c.service, c.project, spare.keys);
432
+ if ("problem" in pin)
433
+ throw new ValidationError(pin.problem);
434
+ if (pin.skipped.length)
435
+ notes(pin.skipped.map((k) => `left out ${k.file}: ${k.why}`));
436
+ if (opts.json)
437
+ json({ ...pin, steps: workflowSteps(VERSION).join("\n") });
438
+ else {
439
+ const page = new URL("/account", pin.site).href;
440
+ const count = pin.passkeys.length === 1 ? "1 passkey" : `${pin.passkeys.length} passkeys`;
441
+ const lines = [
442
+ head(undefined, strong(`CI's pin for project ${c.project}`), `, read from ${c.service}`),
443
+ indent(2, warning(`that's the service ${CONFIG_PATH} names${cite("B-pt52b")}. If it isn't the one you meant, stop here: a commit can change the config.`)),
444
+ blank,
445
+ text(strong("1."), ` Set the repository variable ${TRUST_ENV} to these ${pin.lines.length} lines, the project's${cite("B-mar8x")}, the service's key${pin.spare.length ? `, its spare (${pin.spare.join(", ")})` : ""} and ${count} (on GitHub: Settings, Secrets and variables, Actions, Variables), or run `, cmd("baesian ci --github"), ":"),
446
+ blank,
447
+ ...pin.lines.map(literal),
448
+ blank,
449
+ ...(pin.spare.length
450
+ ? [
451
+ indent(3, text(`The spare, ${pin.spare.join(", ")}, is a key the service keeps offline and never signs with${cite("B-x47ek")}: pinned now, CI keeps passing if the service ever has to replace its key with it.`)),
452
+ blank,
453
+ ]
454
+ : []),
455
+ ];
456
+ if (pin.passkeys.length) {
457
+ const whose = pin.passkeys.map((p) => `${p.handle}'s, added ${p.enrolled.slice(0, 10)}`).join("; ");
458
+ lines.push(indent(3, text(`Check ${pin.passkeys.length === 1 ? "the passkey is" : "each passkey is"} yours (${whose}): compare its line with the one the website showed when you added it, and with your account page, ${page}. Leave out a line that isn't yours.`)));
459
+ }
460
+ else {
461
+ lines.push(indent(3, warning(`no passkey yet: CI counts an answer on a hard card only with a passkey it pins${cite("D139", "D159")}. Add one on your account page, ${page}, and run `, cmd("baesian ci"), " again.")));
462
+ }
463
+ lines.push(blank, text(strong("2."), " Add these steps to a workflow that runs on pushes to your main branch and on pull requests, as one job's steps:"), blank, ...workflowBody(VERSION).map(literal));
464
+ say(lines);
465
+ }
466
+ if (opts.github) {
467
+ // Only after the lines are shown (B-pt52b). gh reads the variable's value from its input.
468
+ const r = (io.run ?? runProgram)("gh", ["variable", "set", TRUST_ENV], { input: `${pin.lines.join("\n")}\n`, cwd: s.repo.toplevel });
469
+ if (r.missing) {
470
+ tell([failed(`gh isn't on PATH, so ${TRUST_ENV} wasn't set: set it by hand, or install GitHub's CLI (https://cli.github.com)`), next("baesian ci --github", "again, once gh is installed")]);
471
+ state.code = 1;
472
+ }
473
+ else if (r.status !== 0) {
474
+ tell(failed(`gh variable set ${TRUST_ENV} failed${r.stderr.trim() ? `: ${r.stderr.trim()}` : ""}`));
475
+ state.code = 1;
476
+ }
477
+ else
478
+ (opts.json ? tell : say)(head("done", `set the repository variable ${TRUST_ENV} with gh: ${pin.lines.length} lines`));
479
+ }
480
+ });
481
+ program
482
+ .command("sync")
483
+ .description("in a linked repo, sync with the relay; then copy new verdicts and ledger entries into this branch's record (.baesian/), and write the vault's notes")
484
+ .option("--history", d(`first, record the merges git's history shows that no hook saw: made before this repo had Baesian's merge hooks, or where it isn't installed. Linking a repo does this once${cite("B-dezkk")}`))
485
+ .action(async (opts) => {
486
+ const s = store();
487
+ s.requireInit();
488
+ // B-dezkk: the history scan, as at first link, so what it records goes up with this sync.
489
+ if (opts.history)
490
+ say(scanLines(scanHistory(s, { write: true })));
491
+ // M9d: pull the relay's log, and push what it doesn't have, before the record is brought up to date.
492
+ const relay = await syncRelay(s, http());
493
+ if (relay.state === "synced")
494
+ say(head("done", `synced with the relay: received ${files(relay.pulled.length)}, sent ${relay.pushed.length}`));
495
+ notes(relay.notes);
496
+ // Personal mode (B-4rwyz) has no record and no vault: the live layer is all there is.
497
+ if (s.personal)
498
+ return say(head("done", `personal mode: nothing to bring into a record, since this clone commits none${cite("B-4rwyz")}`));
499
+ const added = s.syncRecord();
500
+ say(added.length ? [head("done", `added ${files(added.length)} to the record (.baesian/)`), ...indent(2, added.map((p) => text(p)))] : head("done", "the record is up to date"));
501
+ // M6 (D170): the vault's notes too, for verdicts that came after the branch's last commit. Not staged.
502
+ const written = writeNotes(s);
503
+ tell(written.warnings.map((w) => warning(w)));
504
+ if (written.vault && !written.unusable) {
505
+ const n = written.written.length;
506
+ say(n ? [head("done", `wrote ${n === 1 ? "1 note" : `${n} notes`} in the vault`), ...indent(2, written.written.map((p) => text(p)))] : head("done", "the vault's notes are up to date"));
507
+ }
508
+ });
509
+ program
510
+ .command("mcp")
511
+ .description("serve the agent commands as MCP tools over stdio, for a harness to start (the plugin registers it)")
512
+ .action(async () => {
513
+ // Only protocol messages go to the output; the server opens the store on every call, in the harness's directory.
514
+ await serveMcp({ cwd: io.cwd, ...(program.opts().agent ? { agent: program.opts().agent } : {}) });
515
+ });
516
+ program.commandsGroup("Filing and answers:");
517
+ program
518
+ .command("template")
519
+ .description("print a card draft to fill in")
520
+ .option("--json", "print it as JSON")
521
+ .action((opts) => print(templateOp(), opts.json));
522
+ program
523
+ .command("topics")
524
+ .description("list the topics in use, to reuse rather than invent near-duplicates")
525
+ .option("--json", "print them as JSON")
526
+ .action((opts) => print(topicsOp(store()), opts.json));
527
+ program
528
+ .command("file")
529
+ .description("file a decision card from a Markdown file (or stdin)")
530
+ .argument("[path]", "the card draft; '-' or omitted reads stdin")
531
+ .option("--new", "file even if an open card or a resolved decision looks like the same decision")
532
+ .option("--secret-reason <text>", d(`in a hosted repo, file a card that looks like it holds a secret anyway, saying why; kept on the card${cite("B-jfkv9")}`))
533
+ .option("--json", "print the filed card as JSON")
534
+ .action(async (path, opts) => {
535
+ const { meta, body } = parseDoc(await readInput(path, io));
536
+ const s = store();
537
+ await filing(s, () => fileOp(s, withSecretReason(meta, opts.secretReason), body, { ...actor(), force: opts.new ?? false }), opts.json);
538
+ });
539
+ program
540
+ .command("wait")
541
+ .description("wait for the reviewer's verdict on a card, or on several; the exit code says what to do next")
542
+ .argument("[ids...]", d(`the cards: with several, it returns when every one is answered, or sooner when one comes back to you${cite("B-xnae9")}`))
543
+ .option("--deck <id>", "wait on your cards in this deck that are still with the reviewer, with any IDs given")
544
+ .option("--until-answered", "no time limit: return only when the cards are answered, or one comes back, for a run in the background that wakes you when it ends")
545
+ .option("--timeout <seconds>", "how long to wait in all, across runs, before falling back; with --until-answered, how long the reviewer sees you waiting", seconds, DEFAULT_TIMEOUT_MS / 1000)
546
+ .option("--slice <seconds>", d(`how long this run lasts before it exits 7 to be run again${cite("D101")}`), seconds, SLICE_MS / 1000)
547
+ .option("--no-presence", "wait the full timeout even if the client says the reviewer is away")
548
+ .option("--json", "print the outcome as JSON")
549
+ .action(async (ids, opts) => {
550
+ if (!ids.length && opts.deck === undefined)
551
+ throw new Told("name a card to wait on, or several, or a deck with --deck", [{ command: "baesian list", why: "the cards still with the reviewer" }]);
552
+ const agent = agentName(program.opts().agent);
553
+ // Ctrl-C stops the wait and removes its markers, since the agent has stopped waiting (P18).
554
+ const stop = new AbortController();
555
+ const cancel = () => stop.abort();
556
+ process.once("SIGINT", cancel);
557
+ try {
558
+ const s = store();
559
+ const options = {
560
+ agent,
561
+ timeoutMs: opts.timeout * 1000,
562
+ sliceMs: opts.slice * 1000,
563
+ usePresence: opts.presence,
564
+ untilAnswered: opts.untilAnswered ?? false,
565
+ signal: stop.signal,
566
+ ...io.waitClock,
567
+ };
568
+ // One card waits as ever; several, or a deck's, wait together (B-xnae9). The deck's cards are read after the first
569
+ // sync, so an answer given while the agent was away is counted. An answer it gives is marked delivered: `inbox`
570
+ // and the hooks shouldn't hand it over again. In a linked repo it listens on the relay's socket, and the waiting
571
+ // markers go up as a heartbeat (M9d, B-bv49e).
572
+ const one = ids.length === 1 && opts.deck === undefined;
573
+ const { value: op, notes: said } = await waitWithRelay(s, (o) => (one ? waitOp(s, ids[0], o) : waitAllOp(s, [...ids, ...(opts.deck === undefined ? [] : deckWaitIds(s, opts.deck, agent))], o)), options, http());
574
+ print(op, opts.json);
575
+ notes(said);
576
+ state.code = op.data.exit;
577
+ }
578
+ finally {
579
+ process.off("SIGINT", cancel);
580
+ }
581
+ });
582
+ program
583
+ .command("inbox")
584
+ .description("verdicts on this branch's cards since you last looked")
585
+ .option("--peek", "don't mark them as seen")
586
+ .option("--json", "print as JSON")
587
+ .option("--quiet", "for a harness hook: print nothing when there's nothing new, or the repo doesn't use Baesian")
588
+ .option("--session <id>", "for a harness hook: the session reading, which keeps its own place, so every session on the branch hears each verdict once")
589
+ .action(async (opts) => {
590
+ // A session starting in a repo without Baesian, or one not set up, isn't an error for a hook.
591
+ let op;
592
+ let starting = [];
593
+ try {
594
+ const s = store();
595
+ // M9d: in a linked repo, pull first, and push what's waiting to go up. A hook's run is brief and silent.
596
+ if (s.isInitialised()) {
597
+ const given = io.http ?? {};
598
+ const synced = await syncRelay(s, opts.quiet ? { ...given, signal: given.signal ?? AbortSignal.timeout(QUIET_SYNC_MS) } : given).catch(() => undefined);
599
+ if (!opts.quiet)
600
+ notes(synced?.notes ?? []);
601
+ }
602
+ // A hook names its session; an agent's own command runs in one too, which the harness says (B-ghhym).
603
+ const session = opts.session ?? harnessSession(io.env ?? process.env);
604
+ // Personal mode (B-4rwyz): AGENTS.md says nothing of Baesian, so a harness's hook hands the agent its instructions
605
+ // at a session's first read, with anything wrong with the clone's setup.
606
+ if (opts.quiet && !opts.json && session && s.isInitialised() && s.personal && !s.sessionKnown(session)) {
607
+ starting = [...personalInstructions().split("\n").map(literal), ...personalProblems(s).map((p) => text(`Baesian: ${p}`))];
608
+ }
609
+ op = inboxOp(s, { advance: !opts.peek, ...(session ? { session } : {}) });
610
+ }
611
+ catch (e) {
612
+ if (opts.quiet)
613
+ return;
614
+ throw e;
615
+ }
616
+ if (opts.json)
617
+ return json(op.data);
618
+ say(starting);
619
+ if (!op.data.length && opts.quiet)
620
+ return;
621
+ // A harness's hook hands the agent these lines under a heading of their own, which says where they came from.
622
+ if (opts.quiet)
623
+ say([text(`Baesian: ${verdictsHeadline(op.data.length)}, since you last looked (`, cmd("baesian inbox"), ")"), ...op.lines.slice(1)]);
624
+ else
625
+ say(op.lines);
626
+ });
627
+ program
628
+ .command("list")
629
+ .description("list cards that still need the reviewer (or all of them)")
630
+ .option("--all", "include resolved, withdrawn and superseded cards")
631
+ .option("--branch", "only this branch's cards")
632
+ .option("--json", "print as JSON")
633
+ .action((opts) => print(listOp(store(), opts), opts.json));
634
+ program
635
+ .command("show")
636
+ .description("show a card, its status and its verdicts")
637
+ .argument("<id>", "the card")
638
+ .option("--json", "print as JSON")
639
+ .action((cardId, opts) => print(showOp(store(), cardId), opts.json));
640
+ program
641
+ .command("revise")
642
+ .description("file the next revision of a returned card: your reply to the reviewer")
643
+ .argument("<id>", "the card")
644
+ .argument("[path]", "the revised card; '-' or omitted reads stdin")
645
+ .option("--secret-reason <text>", d(`in a hosted repo, file a revision that looks like it holds a secret anyway, saying why${cite("B-jfkv9")}`))
646
+ .option("--json", "print the new revision as JSON")
647
+ .action(async (cardId, path, opts) => {
648
+ const { meta, body } = parseDoc(await readInput(path, io));
649
+ const s = store();
650
+ await filing(s, () => reviseOp(s, cardId, withSecretReason(meta, opts.secretReason), body, actor()), opts.json);
651
+ });
652
+ program
653
+ .command("reply")
654
+ .description(d(`reply once to an answer that went against your pick: a fact the reviewer lacked, or a check${cite("D89")}`))
655
+ .argument("<id>", "the card")
656
+ .argument("<text>", "the fact or the check, in a line; not your pick again")
657
+ .option("--json", "print the reply's revision as JSON")
658
+ .action(async (cardId, reply, opts) => {
659
+ const s = store();
660
+ await filing(s, () => replyOp(s, cardId, reply, actor()), opts.json);
661
+ });
662
+ program
663
+ .command("rule")
664
+ .description(d(`draft the rule the reviewer asked for on a decision; it goes to them as a card${cite("D16", "D110")}`))
665
+ .argument("<id>", "the decision")
666
+ .argument("<text>", "the rule, in a line")
667
+ .option("--covers <patterns...>", "the gate facts it answers, as gate reports name them: dep:pytest-*, schema:migrations/*")
668
+ .option("--json", "print the draft's card as JSON")
669
+ .action(async (cardId, rule, opts) => {
670
+ const s = store();
671
+ await filing(s, () => ruleOp(s, cardId, rule, opts.covers ?? [], actor()), opts.json);
672
+ });
673
+ program
674
+ .command("attach")
675
+ .description("wait on an existing card instead of filing a duplicate")
676
+ .argument("<id>", "the card")
677
+ .option("--json", "print the attachment as JSON")
678
+ .action(async (cardId, opts) => {
679
+ const s = store();
680
+ await filing(s, () => attachOp(s, cardId, agentName(program.opts().agent)), opts.json);
681
+ });
682
+ program
683
+ .command("withdraw")
684
+ .description("withdraw your own card")
685
+ .argument("<id>", "the card")
686
+ .requiredOption("--reason <text>", "why it's no longer needed")
687
+ .option("--json", "print the withdrawal as JSON")
688
+ .action(async (cardId, opts) => {
689
+ const s = store();
690
+ await filing(s, () => withdrawOp(s, cardId, opts.reason, agentName(program.opts().agent)), opts.json);
691
+ });
692
+ program
693
+ .command("deck")
694
+ .description("create a deck; its body is the briefing")
695
+ .argument("<id>", "a lowercase slug, e.g. storage-layer")
696
+ .requiredOption("--title <text>", "what the deck decides")
697
+ .option("--from <path>", "use a document (a plan, design note, issue) as the briefing; '-' reads stdin")
698
+ .option("--note <wikilink...>", "vault notes that give context")
699
+ .action(async (deckId, opts) => {
700
+ // A briefing piped in records no `from`: a harness's plan lives outside the repo, and its path would show the
701
+ // machine's layout in the record (as D105 found for worktrees).
702
+ const from = opts.from && opts.from !== "-" ? resolve(io.cwd, opts.from) : undefined;
703
+ const body = opts.from ? await readInput(opts.from, io) : "";
704
+ const s = store();
705
+ const create = () => {
706
+ const deck = s.createDeck({ id: deckId, title: opts.title, ...(from ? { from } : {}), ...(opts.note ? { context_notes: opts.note } : {}) }, body);
707
+ return {
708
+ lines: [head("done", "created deck ", id(deck.meta.id), " ", q(deck.meta.title)), indent(2, text("File cards into it with ", lit(`deck: ${deck.meta.id}`), " in their frontmatter."))],
709
+ data: deck,
710
+ };
711
+ };
712
+ await filing(s, create, false);
713
+ });
714
+ program.commandsGroup("The commit gate:");
715
+ program
716
+ .command("check")
717
+ .description("preview the commit gate on the staged changes: the decisions found, and what covers them")
718
+ .option("--message <file>", "check the trailers in this commit message too")
719
+ .option("--json", "print the report as JSON")
720
+ .action((opts) => {
721
+ const s = store();
722
+ const message = opts.message ? readFileSync(resolve(io.cwd, opts.message), "utf8") : "";
723
+ const report = evaluate(s, detectStaged(s, stagedSnapshot(s.repo.toplevel)), parseTrailers(message, s.repo.toplevel));
724
+ // The exit code is the same with --json: found by rehearse's agent, whose graders read the JSON.
725
+ state.code = report.ok ? 0 : 1;
726
+ if (opts.json)
727
+ return json(report);
728
+ say(describeReport(report, { for: "check", message: opts.message !== undefined }));
729
+ if (s.personal && report.facts.length) {
730
+ say(note(`personal mode${cite("B-4rwyz")}: the commit-msg hook takes these trailers out of the message once it has checked them, into this clone's ledger`));
731
+ }
732
+ });
733
+ program
734
+ .command("verify")
735
+ .description("CI: check every commit since --base against its committed record; --merge adds the merge gate")
736
+ .option("--base <ref>", "the branch being merged into (default: origin/$GITHUB_BASE_REF in GitHub Actions)")
737
+ .option("--merge", "also require every cited card to be resolved or deferred, and no conflicting decisions")
738
+ .option("--file-conflicts", "file a conflict card for each conflict (local only: needs the live layer)")
739
+ .option("--json", "print the result as JSON")
740
+ .action((opts) => {
741
+ const base = opts.base ?? (process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : undefined);
742
+ if (!base)
743
+ throw new Told("verify needs the branch the work merges into", [{ command: "baesian verify --base origin/main", why: "or whichever branch it is" }]);
744
+ try {
745
+ if (store().personal)
746
+ tell(note(`this clone uses Baesian in personal mode${cite("B-4rwyz")}: its decisions aren't committed, so verify, which reads each commit's own record, sees none of them`));
747
+ }
748
+ catch {
749
+ // CI's checkout has no live layer: it's never personal
750
+ }
751
+ const repo = openRepo(io.cwd);
752
+ const result = verify(repo, base, { merge: Boolean(opts.merge || opts.fileConflicts) });
753
+ if (opts.json)
754
+ json(result);
755
+ else
756
+ say(describeVerify(result));
757
+ if (opts.fileConflicts && result.merge?.conflicts.length) {
758
+ const s = store();
759
+ for (const card of fileConflicts(s, result.merge.conflicts)) {
760
+ notify(card);
761
+ say(head("done", "filed conflict card ", id(card.meta.id), " ", q(card.meta.title)));
762
+ }
763
+ }
764
+ state.code = result.ok ? 0 : 1;
765
+ });
766
+ program
767
+ .command("hook", { hidden: true })
768
+ .description("run a git hook (called by the hooks `baesian init` installs)")
769
+ .argument("<name>", "pre-commit, commit-msg, post-commit, post-merge, post-rewrite or pre-push")
770
+ .argument("[args...]", "the hook's arguments")
771
+ .action(async (name, args) => {
772
+ const s = store();
773
+ // Personal mode's post-rewrite hands Baesian the commits it rewrote, on standard input (B-4rwyz); shared mode's
774
+ // doesn't. pre-push hands it the refs being pushed (B-h7rsv).
775
+ let input = "";
776
+ if ((name === "post-rewrite" && s.personal) || name === "pre-push") {
777
+ try {
778
+ input = await io.stdin();
779
+ }
780
+ catch {
781
+ // no input
782
+ }
783
+ }
784
+ state.code = await runHook(s, name, args, { out: io.out, err: io.err, how: how("err") }, http(), input);
785
+ });
786
+ // A usage error (an unknown option, a missing argument) prints a short error and where to get help (`main`), not the
787
+ // whole help: each command notes that it was the one.
788
+ for (const c of [program, ...program.commands]) {
789
+ c.configureOutput({
790
+ outputError: (message) => {
791
+ state.usage = { command: c === program ? "" : c.name(), message: message.trim() };
792
+ },
793
+ });
794
+ }
795
+ return program;
796
+ }
797
+ /** A usage error, as Commander says it, made short: what was wrong, a suggestion if it has one, and where to get help. */
798
+ function usageLines(usage) {
799
+ const [first, ...more] = usage.message.replace(/^error: /, "").split("\n");
800
+ const suggestion = more.join(" ").trim();
801
+ return [failed(first, suggestion ? ` ${suggestion.replace(/^\((.*)\)$/, "($1)").replace("(Did you", "(did you")}` : ""), next(`baesian ${usage.command ? `${usage.command} ` : ""}--help`, usage.command ? "its options" : "the commands")];
802
+ }
803
+ export async function main(argv = process.argv, io = defaultIo) {
804
+ const state = { code: 0 };
805
+ const settings = settingsOf(argv.slice(2));
806
+ const { tell } = output(io, settings);
807
+ try {
808
+ await buildProgram(io, state, settings).parseAsync(argv);
809
+ return state.code;
810
+ }
811
+ catch (e) {
812
+ // Commander has printed help and the version itself; a usage error it noted, for this to print short.
813
+ if (e instanceof CommanderError) {
814
+ if (state.usage && e.exitCode !== 0)
815
+ tell(usageLines(state.usage));
816
+ return e.exitCode;
817
+ }
818
+ if (e instanceof PossibleDuplicate) {
819
+ tell(duplicateLines(e));
820
+ return EXIT_POSSIBLE_DUPLICATE;
821
+ }
822
+ // Ctrl-C during `wait`: its marker is gone, so the reviewer no longer sees the agent waiting.
823
+ if (e instanceof WaitCancelled) {
824
+ tell([failed(e.message), next(`baesian wait ${e.id}`, `to wait on ${e.id.includes(" ") ? "them" : "it"} again`)]);
825
+ return 130;
826
+ }
827
+ tell(failureLines(e));
828
+ return 1;
829
+ }
830
+ }
831
+ //# sourceMappingURL=cli.js.map