baesian 0.0.0-stage → 1.0.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.
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-B3CFhLg-.css +1 -0
  27. package/dist/client/assets/index-DUcPUPI_.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/verify.js ADDED
@@ -0,0 +1,443 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { cite, citeIn } from "./cite.js";
6
+ import { commitSnapshot } from "./facts.js";
7
+ import { blockedBody, configWarnings, detectStaged, evaluate, named } from "./gate.js";
8
+ import { HANG, cmd, dim, failed, failure, fields, head, id, indent, next, note, ok, strong, text, trailer, warning } from "./style.js";
9
+ import { git } from "./git.js";
10
+ import { byLine, cardAuthor, q, reviewerOf, said } from "./quote.js";
11
+ import { TODAY, levelInForce, route } from "./routing.js";
12
+ import { HARD_BY_DEFAULT } from "./schema.js";
13
+ import { BAESIAN, RECORD_DIR, SHALLOW_HISTORY, Store, drawOf, normalise, recordAtLinking, settingsOf } from "./store.js";
14
+ import { ACK_TRAILER, CARD_TRAILER, parseTrailers } from "./trailers.js";
15
+ import { TRUST_ENV, drawOfProof, pinComplete, pinProblems, pinnedTrust } from "./trust.js";
16
+ import { DECISIONS, RULES, cleanVaultPath, vaultWarnings } from "./vault.js";
17
+ // M3b: `baesian verify`, the same gate in CI, plus the merge gate (D31) and conflicts (D35, D38).
18
+ // CI has no live layer, so everything here reads committed records (D34).
19
+ /** A commit's `.baesian/`, extracted to a temporary directory and read as a store. */
20
+ export class RecordAt {
21
+ repo;
22
+ trust;
23
+ dirs = [];
24
+ /** `trust` holds CI's pinned keys, which every commit's record is checked with whatever it says (D142). */
25
+ constructor(repo, trust = {}) {
26
+ this.repo = repo;
27
+ this.trust = trust;
28
+ }
29
+ store(rev) {
30
+ const dir = mkdtempSync(join(tmpdir(), "baesian-record-"));
31
+ this.dirs.push(dir);
32
+ const out = join(dir, RECORD_DIR);
33
+ mkdirSync(out, { recursive: true });
34
+ let present = true;
35
+ try {
36
+ git(this.repo.toplevel, ["cat-file", "-e", `${rev}:${RECORD_DIR}`]);
37
+ }
38
+ catch {
39
+ present = false; // this commit predates Baesian in the repo: an empty record
40
+ }
41
+ if (present) {
42
+ const tar = execFileSync("git", ["archive", "--format=tar", rev, RECORD_DIR], {
43
+ cwd: this.repo.toplevel,
44
+ maxBuffer: 256 << 20,
45
+ });
46
+ execFileSync("tar", ["-x", "-C", dir], { input: tar });
47
+ }
48
+ return Store.overRecord(this.repo, out, this.trust, rev);
49
+ }
50
+ dispose() {
51
+ for (const d of this.dirs.splice(0))
52
+ rmSync(d, { recursive: true, force: true });
53
+ }
54
+ }
55
+ /** Whether a commit has the configured vault, and the paths of its decision and rule notes, relative to the vault. */
56
+ function vaultFilesAt(repo, record, rev) {
57
+ const vault = record.config().vault;
58
+ if (!vault)
59
+ return { vaultExists: false, paths: [] };
60
+ const rel = cleanVaultPath(vault);
61
+ const prefix = rel === "." ? "" : `${rel}/`;
62
+ const exists = rel === "." || git(repo.toplevel, ["ls-tree", "-d", "--name-only", rev, "--", rel]) === rel;
63
+ if (!exists)
64
+ return { vaultExists: false, paths: [] };
65
+ const out = git(repo.toplevel, ["ls-tree", "-r", "--name-only", "-z", rev, "--", `${prefix}${DECISIONS}`, `${prefix}${RULES}`]);
66
+ return { vaultExists: true, paths: out.split("\0").filter(Boolean).map((p) => p.slice(prefix.length)) };
67
+ }
68
+ function commitsIn(repo, base) {
69
+ const out = git(repo.toplevel, ["rev-list", "--reverse", "--no-merges", `${base}..HEAD`]);
70
+ return out ? out.split("\n") : [];
71
+ }
72
+ /**
73
+ * Check one commit the way the local commit-msg hook did: its diff, its message, its own record. In a hosted repo the
74
+ * record's answers count only when signed (D138), and with keys pinned in CI, the record's copy must match them (D142),
75
+ * and its config may name no project but the pinned one (B-mar8x).
76
+ */
77
+ export function checkCommit(repo, records, sha, pinned) {
78
+ const record = records.store(sha);
79
+ const message = git(repo.toplevel, ["log", "-1", "--format=%B", sha]);
80
+ const trailers = parseTrailers(message, repo.toplevel);
81
+ return record.view(() => {
82
+ const report = evaluate(record, detectStaged(record, commitSnapshot(repo.toplevel, sha), { configWarning: false }), trailers);
83
+ const keys = record.trustFiles();
84
+ const trust = pinned ? [...otherProject(record, pinned), ...pinProblems(pinned, [...keys.service, ...keys.passkeys])] : [];
85
+ return { sha, subject: message.split("\n")[0] ?? "", cards: trailers.cards, report, trust, ok: report.ok && trust.length === 0 };
86
+ });
87
+ }
88
+ /**
89
+ * Why a record's config names the wrong project, if it does (B-mar8x): with keys pinned, CI pins the repo's project too,
90
+ * so a commit can't link the repo to a project of its own making, whose answers it could write. A config that names no
91
+ * project is from before the link, and is checked against the pinned one.
92
+ */
93
+ function otherProject(record, pinned) {
94
+ const named = record.config().project;
95
+ if (named === undefined || pinned.project === undefined || named === pinned.project)
96
+ return [];
97
+ return [
98
+ `.baesian/config.json names project ${named}, but CI pins project ${pinned.project}${cite("B-mar8x")}: a commit can't link the repo to another project. If the repo was linked again on purpose, run \`baesian ci\` and update ${TRUST_ENV}.`,
99
+ ];
100
+ }
101
+ /** The merge gate (D31): every card the branch cites is resolved or deferred, in the record at HEAD. */
102
+ export function checkMerge(head, baseRecord, cited) {
103
+ const problems = [];
104
+ const notes = [];
105
+ for (const id of cited) {
106
+ const card = head.card(id);
107
+ if (!card) {
108
+ problems.push({ card: id, problem: "missing", message: `${id} is cited but isn't in the record at HEAD: run \`baesian sync\` and commit.` });
109
+ continue;
110
+ }
111
+ const status = head.status(id);
112
+ const last = head.verdicts(id).filter((v) => v.revision === card.meta.revision).at(-1);
113
+ // The gate's records must recheck against the rules and the preference file each names (P16 section 3), like a fact:
114
+ // the CLI's, which set the card's level, and in a hosted repo the relay's signed one, which alone holds it (D158).
115
+ const records = [head.gateRecordBy(id, card.meta.revision, "cli"), head.hosted() ? head.gateRecordBy(id, card.meta.revision, "relay") : undefined];
116
+ const wrong = records.map((g) => g && gateProblem(head, card, g)).find(Boolean);
117
+ if (wrong) {
118
+ problems.push({ card: id, problem: "gate", message: `${id}'s gate record doesn't recheck: ${wrong}. Answer the card, or file it again.` });
119
+ continue;
120
+ }
121
+ if (status === "resolved")
122
+ continue;
123
+ if (status === "held") {
124
+ notes.push(`${id} is held: the gate let its agent go ahead${cite("D150")}, so it counts as settled; the reviewer may still answer it.`);
125
+ continue;
126
+ }
127
+ if (status === "deferred") {
128
+ notes.push(last?.comment ? `${id} is deferred; ${said("comment", reviewerOf(last), last.comment)}` : `${id} is deferred: no reason given`);
129
+ continue;
130
+ }
131
+ if (status === "withdrawn" || status === "superseded") {
132
+ notes.push(`${id} is ${status}; commits that cite it may need another look.`);
133
+ continue;
134
+ }
135
+ if (status === "returned" && last?.response === "reject") {
136
+ problems.push({
137
+ card: id,
138
+ problem: "rejected",
139
+ message: `${id} was rejected by ${byLine(reviewerOf(last))}${last.comment ? `: ${q(last.comment)}` : ""}. Rework what builds on it before merging.`,
140
+ });
141
+ continue;
142
+ }
143
+ problems.push({
144
+ card: id,
145
+ problem: "unanswered",
146
+ message: `${id} (${said("title", cardAuthor(card.meta), card.meta.title)}) is ${status} in the record at HEAD. Answer it, or defer it with a reason. If it's already answered, run \`baesian sync\` and commit.`,
147
+ });
148
+ }
149
+ return { problems, conflicts: findConflicts(head, baseRecord), notes };
150
+ }
151
+ /**
152
+ * Why a gate record doesn't recheck, if it doesn't (P16 section 3): the preference file it names must be in the record,
153
+ * and the rules must give the same outcome, level and draw from the card and that file. The burn-in can't be recounted
154
+ * from one branch's record, since a row's earlier held cards may be on any branch: the record keeps the count it saw
155
+ * (`held_before`), and the recheck uses it. Locally that count is the CLI's word, as a verdict is (D62); in a hosted repo
156
+ * the relay's own record holds a card back (D158), with the relay's count, and a draw recomputed from its signed proof.
157
+ * The relay's record doesn't set the card's level, which the CLI wrote at filing: it can only raise the level in force.
158
+ */
159
+ export function gateProblem(s, card, g) {
160
+ let settings = TODAY;
161
+ if (g.preferences) {
162
+ let file;
163
+ try {
164
+ file = s.preferenceFile(g.preferences);
165
+ }
166
+ catch (e) {
167
+ return `its preference file, preferences/${g.preferences}, can't be read (${e instanceof Error ? e.message.split("\n")[0] : String(e)})`;
168
+ }
169
+ if (!file) {
170
+ const judged = existsSync(join(s.live, "preferences", g.preferences)) ? s.judge(`preferences/${g.preferences}`) : undefined;
171
+ return judged && !judged.ok ? `it names preferences/${g.preferences}, which doesn't count: ${judged.why}` : `it names preferences/${g.preferences}, which isn't in the record`;
172
+ }
173
+ settings = settingsOf(file);
174
+ }
175
+ if (g.mode !== settings.mode)
176
+ return `it says ${g.mode}, but its preferences say ${settings.mode}`;
177
+ const m = card.meta;
178
+ const facts = (x) => [...x].sort().join(",");
179
+ if (g.card !== m.id || g.revision !== m.revision || facts(g.touches) !== facts(m.touches) || g.reverse !== m.reverse)
180
+ return "it doesn't match the card's facts";
181
+ if (g.source === "agent" && m.source_agent === BAESIAN)
182
+ return "it gated Baesian's own card as an agent's";
183
+ const relay = g.by === "relay";
184
+ if (g.reason === "error") {
185
+ const floor = m.touches.some((f) => HARD_BY_DEFAULT.has(f));
186
+ return g.outcome === "shown" && (relay || g.level === m.level) && (!floor || g.level === "hard") ? undefined : "it says the gate failed, but didn't show the card as a failure must";
187
+ }
188
+ let draw = drawOf(m.id, m.revision);
189
+ if (relay && g.outcome === "held") {
190
+ // The relay's draw is its own signature over the card (D158): checked, then read as the CLI's is.
191
+ const root = s.hosted()?.root;
192
+ if (!root || !g.draw_proof)
193
+ return "it holds the card back without the relay's draw proof";
194
+ const proved = drawOfProof(root, g.draw_proof, m.id, m.revision, s.bytes(`cards/${m.id}/r${m.revision}.md`), g.at);
195
+ if ("problem" in proved)
196
+ return proved.problem;
197
+ draw = proved.draw;
198
+ }
199
+ const d = route({ touches: m.touches, reverse: m.reverse, asked: g.asked, source: g.source, draw, heldBefore: () => g.held_before ?? 0 }, settings);
200
+ const same = d.row === g.row && d.position === g.position && d.outcome === g.outcome && d.level === g.level && d.reason === g.reason;
201
+ if (!same)
202
+ return `the rules give ${d.outcome}${d.level ? ` at ${d.level}` : ""} (${d.row}, ${d.position}, ${d.reason}), not ${g.outcome}${g.level ? ` at ${g.level}` : ""} (${g.row}, ${g.position}, ${g.reason})`;
203
+ if (d.outcome === "held" && (d.p !== g.p || d.draw !== g.draw || d.burn_in !== g.burn_in || d.drawn !== g.drawn))
204
+ return "its audit draw doesn't recompute";
205
+ const level = levelInForce(d, settings.mode, g.asked);
206
+ if (!relay && m.level !== level)
207
+ return `the card's level is ${m.level}, but the gate gave it ${level}`;
208
+ return undefined;
209
+ }
210
+ /**
211
+ * Decisions this branch resolved that answer a topic the base branch answered differently (D38).
212
+ * A card that `supersedes` the other, or a resolved conflict card over the pair, settles it.
213
+ */
214
+ export function findConflicts(head, baseRecord) {
215
+ const resolved = (s) => s
216
+ .cards()
217
+ .filter((c) => c.meta.topic && s.status(c.meta.id) === "resolved")
218
+ .map((c) => ({ card: c, topic: head.canonicalTopic(c.meta.topic), decision: s.decision(c.meta.id) }));
219
+ const onBase = resolved(baseRecord);
220
+ const baseIds = new Set(onBase.map((d) => d.card.meta.id));
221
+ const settled = (a, b) => head
222
+ .cards()
223
+ .some((c) => c.meta.conflict_of?.includes(a) && c.meta.conflict_of.includes(b) && head.status(c.meta.id) === "resolved");
224
+ const out = [];
225
+ for (const mine of resolved(head)) {
226
+ if (baseIds.has(mine.card.meta.id))
227
+ continue; // not new on this branch
228
+ for (const theirs of onBase) {
229
+ if (theirs.topic !== mine.topic)
230
+ continue;
231
+ if (normalise(theirs.decision) === normalise(mine.decision))
232
+ continue;
233
+ if (mine.card.meta.supersedes === theirs.card.meta.id)
234
+ continue;
235
+ if (head.status(theirs.card.meta.id) === "superseded")
236
+ continue;
237
+ if (settled(mine.card.meta.id, theirs.card.meta.id))
238
+ continue;
239
+ out.push({
240
+ topic: mine.topic,
241
+ base: { id: theirs.card.meta.id, decision: theirs.decision },
242
+ branch: { id: mine.card.meta.id, decision: mine.decision },
243
+ });
244
+ }
245
+ }
246
+ return out;
247
+ }
248
+ /**
249
+ * Run every check for `base..HEAD`. `pinned` is CI's pin (D142, D159): the keys, and the repo's project beside them
250
+ * (B-mar8x); by default it's read from CI's settings (`BAESIAN_TRUST`, `BAESIAN_TRUST_FILE`), and `null` pins none. With
251
+ * a pin, every commit is checked as hosted, whatever its record says, in the pinned project, against one linking commit
252
+ * found from HEAD; without, a commit is hosted when its record holds the service's keys.
253
+ */
254
+ export function verify(repo, base, opts) {
255
+ const given = opts.pinned !== undefined ? (opts.pinned ?? undefined) : pinnedTrust();
256
+ const pinned = given && pinComplete(given, TRUST_ENV);
257
+ // The commit that linked the repo to the pinned project, found once, from the tip, by ancestry (B-mar8x): every commit's
258
+ // record counts its answers from before linking against that record, a commit from before the link included, and no
259
+ // commit can make up a linking commit of its own.
260
+ const linking = pinned ? (recordAtLinking(repo.toplevel, "HEAD", pinned.project, { standIn: false }) ?? null) : undefined;
261
+ const records = new RecordAt(repo, { pinned: pinned ?? null, ...(linking !== undefined ? { linking } : {}) });
262
+ try {
263
+ git(repo.toplevel, ["rev-parse", "--verify", "-q", `${base}^{commit}`]);
264
+ }
265
+ catch {
266
+ throw new Error(`unknown base "${base}": in CI, check out with full history (fetch-depth: 0)`);
267
+ }
268
+ try {
269
+ const commits = commitsIn(repo, base).map((sha) => checkCommit(repo, records, sha, pinned));
270
+ const head = records.store("HEAD");
271
+ return head.view(() => {
272
+ let merge;
273
+ if (opts.merge) {
274
+ const cited = [...new Set(commits.flatMap((c) => c.cards))];
275
+ const baseRecord = records.store(base);
276
+ merge = baseRecord.view(() => checkMerge(head, baseRecord, cited));
277
+ }
278
+ const ok = commits.every((c) => c.ok) && (!merge || (merge.problems.length === 0 && merge.conflicts.length === 0));
279
+ // M6: the vault's notes are a view, so a missing note or vault is a warning, never a failure.
280
+ const warnings = [...vaultWarnings(head, vaultFilesAt(repo, head, "HEAD")), ...configWarnings(head.config())];
281
+ const h = head.hosted();
282
+ if (h && !h.pinned) {
283
+ warnings.push(`the service's keys come from the record, which a commit can change: in CI, pin them in its protected settings (${TRUST_ENV}${citeIn("D142")})`);
284
+ }
285
+ const skipped = h ? head.uncounted() : [];
286
+ // Answers from before the repo was linked count as they are (B-45xz8), which takes the commit that linked it: a
287
+ // shallow clone may not have it, and then the run fails, saying to fetch the full history.
288
+ const shallow = skipped.some((s) => s.why === SHALLOW_HISTORY);
289
+ // Two commits that each link the repo fail closed (B-mar8x), whatever the commits need; as does HEAD naming another
290
+ // project than CI pins, when HEAD is a merge, which isn't checked as a commit of its own.
291
+ const link = h ? head.linkingCommit() : undefined;
292
+ const problems = [];
293
+ if (link && "problem" in link && link.problem !== SHALLOW_HISTORY)
294
+ problems.push(link.problem);
295
+ if (pinned) {
296
+ const tip = git(repo.toplevel, ["rev-parse", "HEAD"]);
297
+ if (!commits.some((c) => c.sha === tip))
298
+ problems.push(...otherProject(head, pinned).map((p) => `at HEAD, ${p}`));
299
+ }
300
+ if (pinned && linking === null) {
301
+ warnings.push(`no commit in HEAD's history links the repo to project ${pinned.project}, so no unsigned answer counts as one from before linking${cite("B-45xz8", "B-mar8x")}`);
302
+ }
303
+ const hosted = h
304
+ ? {
305
+ hosted: {
306
+ keys: h.pinned ? "pinned" : "record",
307
+ ...(h.project ? { project: h.project } : {}),
308
+ ...(link && "commit" in link ? { linked: link.commit } : {}),
309
+ skipped,
310
+ ...(shallow ? { shallow: true } : {}),
311
+ ...(problems.length ? { problems } : {}),
312
+ },
313
+ }
314
+ : {};
315
+ return { base, commits, ...(merge ? { merge } : {}), ...hosted, ...(warnings.length ? { warnings } : {}), ok: ok && !shallow && !problems.length };
316
+ });
317
+ }
318
+ finally {
319
+ records.dispose();
320
+ }
321
+ }
322
+ /** A commit as `verify` names it: seven characters of its ID. */
323
+ const short = (ref) => (/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(ref) ? ref.slice(0, 7) : ref);
324
+ /** What a failing commit says: why, and what to add; the local gate's advice on previewing and bypassing goes. */
325
+ function commitBody(r) {
326
+ if (r.uncovered.length !== 1)
327
+ return blockedBody(r, { steps: false });
328
+ // One decision uncovered, as most failing commits have: a sentence, and the two trailers that cover it.
329
+ const f = r.uncovered[0];
330
+ const rest = blockedBody({ ...r, uncovered: [] }, { steps: false });
331
+ return [
332
+ ...rest,
333
+ text(f.id, " ", dim(`(${f.detail})`), " isn't covered: add a trailer for a card whose touches include it, or, for a two-way door, an ack with a reason:"),
334
+ trailer(`${CARD_TRAILER}: <id>`),
335
+ trailer(`${ACK_TRAILER}: ${f.id} <why this is fine>`),
336
+ ].map((l) => (l.kind === "text" && l.indent === 0 ? { ...l, indent: 2 } : l));
337
+ }
338
+ /** What `verify` prints, for CI's log: a headline, each commit and the merge gate, and what to do about a failure. */
339
+ export function describeVerify(r) {
340
+ const out = [];
341
+ const n = r.commits.length;
342
+ const failing = r.commits.filter((c) => !c.ok);
343
+ const mergeFails = Boolean(r.merge && (r.merge.problems.length || r.merge.conflicts.length));
344
+ const hostedFails = Boolean(r.hosted?.shallow || r.hosted?.problems?.length);
345
+ const commits = `${failing.length && failing.length < n ? `${failing.length} of ` : ""}${n} commit${n === 1 ? "" : "s"} since ${short(r.base)}`;
346
+ const what = r.ok
347
+ ? `${commits}${r.merge ? ", and the merge gate" : ""}`
348
+ : [failing.length ? commits : "", mergeFails ? "the merge gate" : "", hostedFails ? "the hosted record" : ""].filter(Boolean).join(", and ") || commits;
349
+ out.push(r.ok ? head("done", strong("baesian verify:"), " ", ok("ok"), `: ${what}`) : failed(strong("baesian verify:"), " ", failure("failed"), `: ${what}`));
350
+ if (r.hosted) {
351
+ const where = [r.hosted.project ? `project ${r.hosted.project}` : "", r.hosted.linked ? `linked at ${r.hosted.linked.slice(0, 7)}` : ""].filter(Boolean).join(", ");
352
+ out.push(...fields([
353
+ { label: "hosted", value: `only answers the service signed count${cite("D138")}, checked with ${r.hosted.keys === "pinned" ? "the keys CI pins" : "the record's copy of the keys"}${where ? `; ${where}` : ""}` },
354
+ ]));
355
+ }
356
+ for (const c of r.commits) {
357
+ out.push(indent(2, text(c.ok ? ok("ok ") : failure("FAIL"), " ", id(c.sha.slice(0, 7)), " ", HANG, c.subject)));
358
+ const body = [...(c.report.ok ? [] : commitBody(c.report)), ...c.trust.map((t) => text(t))];
359
+ out.push(...indent(5, body));
360
+ }
361
+ if (r.merge) {
362
+ if (!mergeFails)
363
+ out.push(indent(2, text(ok("ok "), " merge gate: every cited card is resolved or deferred")));
364
+ else
365
+ out.push(indent(2, text(failure("FAIL"), " merge gate")));
366
+ for (const p of r.merge.problems)
367
+ out.push(indent(7, text(p.message)));
368
+ for (const c of r.merge.conflicts) {
369
+ out.push(indent(7, text(`conflict on ${c.topic}: ${c.base.id} on the base decided ${q(c.base.decision)}, ${c.branch.id} here decided ${q(c.branch.decision)}.`)));
370
+ }
371
+ if (r.merge.conflicts.length) {
372
+ out.push(indent(7, text("Settle it: run the command below locally and answer the conflict card it files, or file a card that ", cmd("supersedes"), " one of them.")));
373
+ }
374
+ for (const m of r.merge.notes)
375
+ out.push(indent(2, note(m)));
376
+ }
377
+ if (r.hosted?.shallow)
378
+ out.push(indent(2, text(failure("FAIL"), ` answers from before the repo was linked can't be checked in a shallow clone${cite("B-45xz8")}: fetch the full history (in CI, fetch-depth: 0)`)));
379
+ for (const p of r.hosted?.problems ?? [])
380
+ out.push(indent(2, text(failure("FAIL"), " ", p)));
381
+ // A file that doesn't count is ignored, not an error (D138): an answer given outside the website, say. Those that don't
382
+ // count because the link can't be told are said once, above.
383
+ const told = new Set([SHALLOW_HISTORY, ...(r.hosted?.problems ?? [])]);
384
+ for (const s of r.hosted?.skipped ?? [])
385
+ if (!told.has(s.why))
386
+ out.push(note(`${s.file} doesn't count: ${s.why}`));
387
+ for (const w of r.warnings ?? [])
388
+ out.push(warning(w));
389
+ if (r.merge?.conflicts.length)
390
+ out.push(next("baesian verify --merge --file-conflicts", "locally: files a conflict card to answer"));
391
+ const reword = failing.filter((c) => !c.report.ok);
392
+ if (reword.length) {
393
+ const shas = named(reword.map((c) => c.sha.slice(0, 7)));
394
+ out.push(next(`git rebase -i ${short(r.base)}`, `reword ${shas}'s message${reword.length === 1 ? "" : "s"} to add ${reword.length === 1 ? "its" : "their"} trailers`));
395
+ out.push(indent(2, text(dim("Commits from before the repo used Baesian fail too: verify from a later --base."))));
396
+ }
397
+ return out;
398
+ }
399
+ /**
400
+ * File a conflict card for each conflict that doesn't already have one (D35). Local only: CI can't
401
+ * write to the live layer. The card is blocking: the merge waits on it.
402
+ */
403
+ export function fileConflicts(store, conflicts) {
404
+ const filed = [];
405
+ for (const c of conflicts) {
406
+ const exists = store
407
+ .cards()
408
+ .some((k) => k.meta.conflict_of?.includes(c.base.id) && k.meta.conflict_of.includes(c.branch.id));
409
+ if (exists)
410
+ continue;
411
+ // Baesian writes this card itself, with no model (D65), so its crux is a fixed one (D94).
412
+ const draft = {
413
+ title: `Two answers on ${c.topic}`,
414
+ question: `Which answer on \`${c.topic}\` should the project keep?`,
415
+ doing: `merging ${store.repo.branch}, which answers \`${c.topic}\` differently from the base branch`,
416
+ topic: c.topic,
417
+ level: "blocking",
418
+ reverse: "moderate",
419
+ proposal: c.base.decision,
420
+ proposal_then: "it stays; this branch's work built on the other answer gets a rework brief",
421
+ alternative: c.branch.decision,
422
+ alternative_then: "it replaces the base branch's decision; work built on that one gets a rework brief",
423
+ crux: "Was the newer decision made with context the older one lacked?",
424
+ crux_proposal: "If it wasn't",
425
+ crux_alternative: "If it was",
426
+ proposal_breaks: [c.branch.id],
427
+ alternative_breaks: [c.base.id],
428
+ conflict_of: [c.base.id, c.branch.id],
429
+ caught_at: "merge",
430
+ };
431
+ const body = [
432
+ `Two decisions answer \`${c.topic}\` differently:`,
433
+ `- ${c.base.id}, already on the base branch: ${c.base.decision}`,
434
+ `- ${c.branch.id}, on this branch: ${c.branch.decision}`,
435
+ "",
436
+ "Whichever you don't keep becomes superseded, and the work built on it gets a rework brief.",
437
+ "",
438
+ ].join("\n");
439
+ filed.push(store.fileCard(draft, body, { agent: "baesian", force: true }).card);
440
+ }
441
+ return filed;
442
+ }
443
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1,70 @@
1
+ // Baesian's release, for the code that needs it apart from Node's filesystem: the format a file is written in (B-4p43b,
2
+ // `writtenFormat()`), the hook block `baesian init` writes (B-amxkp, `writtenBlock()`), and the version the CLI sends the
3
+ // relay and the relay checks (`RELEASE_HEADER`). The schema, the service and the client all import it as it is.
4
+ // `npm version` rewrites the line below from package.json (scripts/set-version.mjs); test/plugin.test.ts holds the two
5
+ // together, with the service's and the plugin's versions.
6
+ /** This release, as package.json names it. */
7
+ export const VERSION = "1.0.0";
8
+ /**
9
+ * The first release whose files, relay protocol and agents' commands every later release keeps working (B-h0vyy,
10
+ * B-4kj4d). The relay serves no Baesian older than it (`minimumRelease()`): before it, formats changed in place (D108).
11
+ */
12
+ export const FIRST_STABLE = "1.0.0";
13
+ /** The header that carries the CLI's release on every request it makes to the hosted service (B-4p43b). */
14
+ export const RELEASE_HEADER = "baesian-version";
15
+ /**
16
+ * The status the relay refuses a Baesian too old for it with (B-4p43b): 426, Upgrade Required, with the error, the
17
+ * oldest release it serves (`minimum`), by which the CLI knows it, and its own (`release`).
18
+ */
19
+ export const TOO_OLD = 426;
20
+ /** A release number, `major.minor.patch` with an optional pre-release, as semver writes it; build metadata is ignored. */
21
+ const RELEASE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
22
+ /** Whether a string is a release number (`major.minor.patch`, optionally with a pre-release). */
23
+ export const isRelease = (v) => RELEASE.test(v);
24
+ /**
25
+ * Two releases in semver's order: negative when `a` comes first, positive when `b` does, 0 when they're the same release.
26
+ * A pre-release comes before its release. Throws on a string that isn't a release number.
27
+ */
28
+ export function compareReleases(a, b) {
29
+ const pa = RELEASE.exec(a);
30
+ const pb = RELEASE.exec(b);
31
+ if (!pa)
32
+ throw new Error(`${JSON.stringify(a)} isn't a release number`);
33
+ if (!pb)
34
+ throw new Error(`${JSON.stringify(b)} isn't a release number`);
35
+ for (let i = 1; i <= 3; i++) {
36
+ const d = Number(pa[i]) - Number(pb[i]);
37
+ if (d)
38
+ return d;
39
+ }
40
+ const [xa, xb] = [pa[4], pb[4]];
41
+ if (xa === xb)
42
+ return 0;
43
+ if (xa === undefined)
44
+ return 1;
45
+ if (xb === undefined)
46
+ return -1;
47
+ const ia = xa.split(".");
48
+ const ib = xb.split(".");
49
+ for (let i = 0; i < Math.max(ia.length, ib.length); i++) {
50
+ const [s, t] = [ia[i], ib[i]];
51
+ if (s === undefined)
52
+ return -1;
53
+ if (t === undefined)
54
+ return 1;
55
+ if (s === t)
56
+ continue;
57
+ const [ns, nt] = [/^\d+$/.test(s), /^\d+$/.test(t)];
58
+ if (ns && nt)
59
+ return Number(s) - Number(t);
60
+ if (ns)
61
+ return -1;
62
+ if (nt)
63
+ return 1;
64
+ return s < t ? -1 : 1;
65
+ }
66
+ return 0;
67
+ }
68
+ /** The later of two releases. */
69
+ export const laterRelease = (a, b) => (compareReleases(a, b) >= 0 ? a : b);
70
+ //# sourceMappingURL=version.js.map