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/schema.js ADDED
@@ -0,0 +1,1178 @@
1
+ import { z } from "zod";
2
+ import { cite } from "./cite.js";
3
+ import { ALL_ROWS, POSITIONS, ROWS, ROW_FLOOR, rank } from "./routing.js";
4
+ import { FIRST_STABLE, VERSION, compareReleases, laterRelease } from "./version.js";
5
+ /**
6
+ * Every kind of file Baesian writes, or reads from a person, and each format after its first (D156). Each kind is numbered
7
+ * on its own: a card format 2 leaves verdicts at 1. A file is upgraded in memory as it's read, and never rewritten: files
8
+ * are written once (D34), and CI checks each commit against its own record (`checkCommit()`), so an old commit's files
9
+ * must stay readable as they are.
10
+ *
11
+ * A file with no `format` was written before v1, and reads as format 1. That changes nothing, except that the inbox
12
+ * cursor gains the `type` it lacked. A format 2 adds one entry to its kind's `formats`: the release that first reads it,
13
+ * the upgrade to it and the way back; and its schema changes to match. That entry is the whole of its rollout (B-4p43b):
14
+ * every writer writes through `asWritten()`, which writes the format before until a release after `since`, and the relay
15
+ * serves no Baesian too old to read what's written (`minimumRelease()`).
16
+ */
17
+ export const FILE_KINDS = {
18
+ card: { name: "card", formats: [] },
19
+ "card-draft": { name: "card draft", formats: [] },
20
+ verdict: { name: "verdict", formats: [] },
21
+ withdrawal: { name: "withdrawal", formats: [] },
22
+ fallback: { name: "fallback", formats: [] },
23
+ ledger: { name: "ledger entry", formats: [] },
24
+ deck: { name: "deck", formats: [] },
25
+ rule: { name: "rule", formats: [] },
26
+ "rule-retirement": { name: "rule retirement", formats: [] },
27
+ "topic-alias": { name: "topic alias", formats: [] },
28
+ attachment: { name: "attachment", formats: [] },
29
+ // D181: a branch merged into another, as the CLI found it.
30
+ merge: { name: "merge", formats: [] },
31
+ // B-jr8mk: the repo's default branch, as the CLI read it, which it sends the relay for the website.
32
+ "default-branch": { name: "default branch", formats: [] },
33
+ cursor: { name: "inbox cursor", formats: [], beforeV1: (f) => ({ type: "cursor", ...f }) },
34
+ // B-k0w7d: a harness session's model, as the plugin's hooks last heard it, for the cards the session files.
35
+ "session-model": { name: "session model", formats: [] },
36
+ presence: { name: "presence", formats: [] },
37
+ waiting: { name: "waiting marker", formats: [] },
38
+ server: { name: "server file", formats: [] },
39
+ port: { name: "port file", formats: [] },
40
+ config: { name: "config", formats: [] },
41
+ "user-config": { name: "personal config", formats: [] },
42
+ "decision-note": { name: "decision note", formats: [] },
43
+ "rule-note": { name: "rule note", formats: [] },
44
+ preferences: { name: "preference file", formats: [] },
45
+ gate: { name: "gate record", formats: [] },
46
+ // M9a, the hosted service's proof (P16 section 7). The relay writes these, never the CLI on its own.
47
+ /** A `.sig` beside a signed file: OpenSSH's own format (D160), with no `format` field; its number is SSHSIG's version. */
48
+ signature: { name: "signature", formats: [] },
49
+ "passkey-proof": { name: "passkey proof", formats: [] },
50
+ "service-key": { name: "service key", formats: [] },
51
+ "service-key-revocation": { name: "service key revocation", formats: [] },
52
+ "passkey-key": { name: "passkey key", formats: [] },
53
+ /** The repo's project, a line of CI's pin beside the keys (B-mar8x); never a file in `trust/`. */
54
+ project: { name: "pinned project", formats: [] },
55
+ // M9d, the CLI's side of the hosted service (P16 section 8): what it keeps of the relay, and the machine's grant.
56
+ relay: { name: "relay state", formats: [] },
57
+ grant: { name: "machine grant", formats: [] },
58
+ // B-4rwyz, personal mode: what a clone that commits nothing held when it was linked, in the record's place.
59
+ "before-linking": { name: "before-linking snapshot", formats: [] },
60
+ // B-rehdw: an answer on a practice card, a mark in the blind test, or the test's start. Never in the record or the log.
61
+ practice: { name: "practice answer", formats: [] },
62
+ };
63
+ /** The newest format of a kind of file this Baesian reads (D156), which its schema checks; writers write `writtenFormat()`. */
64
+ export function newestFormat(kind) {
65
+ return FILE_KINDS[kind].formats.length + 1;
66
+ }
67
+ /**
68
+ * The format a release writes a kind of file in (B-4p43b): the newest one a release before it already reads. A release
69
+ * that adds a format reads it, and writes the one before; the releases after it write the new one. So a teammate, a
70
+ * machine or CI one release behind is never sent a file it refuses (D157).
71
+ */
72
+ export function writtenFormat(kind, release = VERSION) {
73
+ const formats = FILE_KINDS[kind].formats;
74
+ let n = 1;
75
+ while (n <= formats.length && compareReleases(formats[n - 1].since, release) < 0)
76
+ n++;
77
+ return n;
78
+ }
79
+ /**
80
+ * A file's fields as a release writes them (B-4p43b): made and checked in the newest format, as the schema reads it, then
81
+ * taken back, in memory, to `writtenFormat()` through the kind's `down` functions. Unchanged when that's the newest. Every
82
+ * writer of a Baesian file writes through it, so a kind's rollout is its `formats` entry and nothing else.
83
+ */
84
+ export function asWritten(kind, fields, release = VERSION) {
85
+ const formats = FILE_KINDS[kind].formats;
86
+ let f = fields;
87
+ for (let n = newestFormat(kind); n > writtenFormat(kind, release); n--)
88
+ f = { ...formats[n - 2].down(f), format: n - 1 };
89
+ return f;
90
+ }
91
+ /**
92
+ * The oldest release the hosted service serves when it runs `release` (B-4p43b): the oldest that reads every format
93
+ * `release` writes, and never one before `FIRST_STABLE`, since formats changed in place until then (D108). A release always
94
+ * serves itself, so a build of the next release, before its version is set, serves its own CLI. The relay tells an older
95
+ * CLI to upgrade before it pulls anything.
96
+ */
97
+ export function minimumRelease(release = VERSION) {
98
+ let oldest = FIRST_STABLE;
99
+ for (const k of Object.keys(FILE_KINDS)) {
100
+ const written = writtenFormat(k, release);
101
+ if (written > 1)
102
+ oldest = laterRelease(oldest, FILE_KINDS[k].formats[written - 2].since);
103
+ }
104
+ return compareReleases(oldest, release) > 0 ? release : oldest;
105
+ }
106
+ /** A schema's `format`: only the newest, since a file is upgraded before its schema reads it. */
107
+ const formatOf = (kind) => z.literal(newestFormat(kind));
108
+ /**
109
+ * A file in a newer format than this Baesian reads (D157). It's refused as a bad file is: thrown, or, in a view that
110
+ * reports problems (the server's), reported and skipped.
111
+ */
112
+ export class NewerFormat extends Error {
113
+ file;
114
+ kind;
115
+ format;
116
+ constructor(file, kind, format) {
117
+ super(`${file} has ${FILE_KINDS[kind].name} format ${format}; this Baesian reads up to ${newestFormat(kind)}: upgrade Baesian`);
118
+ this.file = file;
119
+ this.kind = kind;
120
+ this.format = format;
121
+ }
122
+ }
123
+ /**
124
+ * A file's fields in the newest format of its kind, for its schema to check (D156): a file with no `format` reads as
125
+ * format 1, and an older one is upgraded, in memory. A newer one throws `NewerFormat` (D157), naming `file`. A `format`
126
+ * that isn't a whole number from 1 is left for the schema to reject.
127
+ */
128
+ export function upgradeFile(kind, fields, file) {
129
+ if (typeof fields !== "object" || fields === null || Array.isArray(fields))
130
+ return fields;
131
+ const k = FILE_KINDS[kind];
132
+ let f = fields;
133
+ if (f.format === undefined)
134
+ f = { ...(k.beforeV1 ? k.beforeV1(f) : f), format: 1 };
135
+ const from = f.format;
136
+ if (typeof from !== "number" || !Number.isInteger(from) || from < 1)
137
+ return f;
138
+ const newest = newestFormat(kind);
139
+ if (from > newest)
140
+ throw new NewerFormat(file, kind, from);
141
+ for (let n = from; n < newest; n++)
142
+ f = { ...k.formats[n - 1].up(f), format: n + 1 };
143
+ return f;
144
+ }
145
+ // ---------------------------------------------------------------- the formats
146
+ /** Crockford base32, lowercase, with no i, l, o or u (D51). Here rather than in ids.ts, so the schema imports nothing from Node. */
147
+ const SUFFIX = "[0-9a-hjkmnp-tv-z]";
148
+ export const CARD_ID = new RegExp(`^B-${SUFFIX}{5}$`);
149
+ export const RULE_ID = new RegExp(`^R-${SUFFIX}{5}$`);
150
+ export const CardId = z.string().regex(CARD_ID, "a card ID looks like B-7f3kq");
151
+ export const RuleId = z.string().regex(RULE_ID, "a rule ID looks like R-7f3kq");
152
+ export const Timestamp = z.iso.datetime();
153
+ /** `storage/run-metadata`: lowercase segments joined by slashes (D38). */
154
+ export const Topic = z
155
+ .string()
156
+ .regex(/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/, "a topic looks like storage/run-metadata");
157
+ /** A deck ID is a slug: `storage-layer`, or `branch-feat-run-store` for a branch's default deck. */
158
+ export const DeckId = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "a deck ID is a lowercase slug");
159
+ /** provisional: proceed on the proposal; blocking: wait while the reviewer is there; hard: never proceed (D42). */
160
+ export const Level = z.enum(["provisional", "blocking", "hard"]);
161
+ /** The D11 impact checklist, as facts the agent states. */
162
+ export const Fact = z.enum([
163
+ "schema", // changes a schema or on-disk data format
164
+ "public-api", // changes a public API, URL scheme or module boundary
165
+ "dependency", // adds a runtime dependency
166
+ "user-facing", // is user-facing
167
+ "publishes", // publishes something outside the project
168
+ "security", // touches security
169
+ "privacy", // touches personal data or privacy (split from security: card B-z3agk)
170
+ "money", // spends money
171
+ "data-loss", // deletes or overwrites stored data
172
+ "infra", // changes CI, infrastructure or deployment (D53)
173
+ ]);
174
+ /** Facts that make a card hard unless the agent says otherwise (D42). */
175
+ export const HARD_BY_DEFAULT = new Set(["publishes", "security", "privacy", "money", "data-loss"]);
176
+ export const CaughtAt = z.enum(["filing", "merge", "late"]);
177
+ /** Which of a card's two options something refers to. */
178
+ export const OptionKind = z.enum(["proposal", "alternative"]);
179
+ /** How costly a decision is to undo (D95). It decides the card's stakes line and feeds rarity. */
180
+ export const Reverse = z.enum(["cheap", "moderate", "one-way"]);
181
+ /** A decision or a rule, for the decisions an option would replace or break. */
182
+ export const DecisionRef = z.union([CardId, RuleId]);
183
+ const Text = z.string().min(1);
184
+ /**
185
+ * The stable ID of an account on the hosted service (P16 section 7): Baesian's own, never a Globus identity, which can
186
+ * change (D153). Set by the relay on everything a person writes, beside the handle in `reviewer`, `by` or `accepted_by`.
187
+ */
188
+ export const AccountId = z.string().max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "an account ID is letters, digits, dots, dashes and underscores");
189
+ /**
190
+ * A project's random ID on the hosted service (P16 section 8), in the committed config and, set by the relay, on every
191
+ * file the service signs (B-tf9fc): one key signs every project's files, so a file counts only in its own project.
192
+ */
193
+ export const ProjectId = z.string().max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "a project ID is letters, digits, dots, dashes and underscores");
194
+ /** A SHA-256 digest, in lowercase hex. */
195
+ const Sha256 = z.string().regex(/^[0-9a-f]{64}$/, "a SHA-256 digest in lowercase hex");
196
+ /** A git commit's full ID, in lowercase hex: SHA-1's 40 digits, or SHA-256's 64. */
197
+ export const CommitId = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/, "a commit's full ID, in lowercase hex");
198
+ /** Bytes as base64url, with no padding, as WebAuthn gives them. */
199
+ const Base64Url = z.string().min(1).regex(/^[A-Za-z0-9_-]+$/, "base64url, with no padding");
200
+ /** A gate fact ID, or a pattern of them, as a rule covers them: `dep:httpx`, `dep:pytest-*`, `schema:migrations/*`. */
201
+ export const FACT_PATTERN = /^(dep|schema|infra|api):\S+$/;
202
+ /** Frontmatter of one card revision: `cards/<id>/r<revision>.md`. */
203
+ export const CardMeta = z.strictObject({
204
+ type: z.literal("card"),
205
+ format: formatOf("card"),
206
+ id: CardId,
207
+ revision: z.number().int().min(1),
208
+ /** A neutral short name, not a question: "Where the access token lives". */
209
+ title: Text,
210
+ /** The decision, as a neutral question. */
211
+ question: Text,
212
+ /** What the agent was doing when the decision came up, in its own voice (D73). */
213
+ doing: Text,
214
+ topic: Topic,
215
+ deck: DeckId,
216
+ level: Level,
217
+ touches: z.array(Fact).default([]),
218
+ reverse: Reverse,
219
+ // The two options (D93): the agent's pick and the strongest alternative, each with what it would do.
220
+ proposal: Text,
221
+ proposal_then: Text,
222
+ alternative: Text,
223
+ alternative_then: Text,
224
+ /** Other options the agent considered, by name. Not shown on the card. */
225
+ also_considered: z.array(Text).default([]),
226
+ // The crux (D74): the one question the choice turns on, and the answer that leads to each option.
227
+ crux: Text,
228
+ crux_proposal: Text,
229
+ crux_alternative: Text,
230
+ /** The crux is about what the reviewer values, not a fact. */
231
+ crux_preference: z.boolean().optional(),
232
+ crux_fact: Text.optional(),
233
+ // The agent's case (D77): the rationale is optional; "wrong if" and its check are required when a facet asks.
234
+ rationale: Text.optional(),
235
+ /**
236
+ * The agent's best case for its alternative, as it would write its rationale if the alternative were its pick (B-sy0kz).
237
+ * Never shown on the card itself: it's the case a practice card gives its planted pick, when the card comes back with
238
+ * its options swapped (B-kb5yt), and only a card that carries one can.
239
+ */
240
+ alternative_rationale: Text.optional(),
241
+ wrong_if: Text.optional(),
242
+ check: Text.optional(),
243
+ // The trial facets (D75).
244
+ premise: Text.optional(),
245
+ premise_option: OptionKind.optional(),
246
+ premise_check: Text.optional(),
247
+ acted: Text.optional(),
248
+ acted_option: OptionKind.optional(),
249
+ serves: Text.optional(),
250
+ /** Rules the card bears on (D79). */
251
+ rules: z.array(RuleId).default([]),
252
+ /** The decisions or rules each option would replace or break, as the agent declares them. */
253
+ proposal_breaks: z.array(DecisionRef).default([]),
254
+ alternative_breaks: z.array(DecisionRef).default([]),
255
+ modules_touched: z.number().int().min(0).optional(),
256
+ depends_on: z.array(CardId).default([]),
257
+ supersedes: CardId.optional(),
258
+ conflict_of: z.array(CardId).optional(),
259
+ /**
260
+ * The reviewer already answered this choice in conversation (B-hswmx): what they said, quoted and short. It marks the
261
+ * card seen: its pick is their answer, the client shows it with the pick and confirms it with one tap, and each verdict
262
+ * on it says it was seen (`Verdict.seen`). It joins the record and can become a rule like any card; calibration leaves
263
+ * it out, since a choice discussed in chat isn't made blind. Only a person resolves it, in the client (D32).
264
+ */
265
+ answered_in_chat: Text.optional(),
266
+ /**
267
+ * The agent's one reply to an answer that went against its pick (D89, D104): a fact the reviewer lacked, or a
268
+ * check that would settle it. The revision is otherwise the one before it. Only `baesian reply` sets it.
269
+ */
270
+ reply: Text.optional(),
271
+ /**
272
+ * A drafted rule (D16, D109, D110): its wording, and the gate facts it covers, as patterns (`dep:pytest-*`). A card
273
+ * that carries one asks you to make it a rule, and accepting it saves `rules/<id>.md`. Only `baesian rule` sets
274
+ * them, on a card it files from the decision you asked a rule of.
275
+ */
276
+ rule: Text.optional(),
277
+ rule_covers: z.array(z.string().regex(FACT_PATTERN, "a gate pattern looks like dep:pytest-* or schema:migrations/*")).default([]),
278
+ /**
279
+ * Why a card that looks like it holds a secret was filed all the same (B-jfkv9). In a hosted repo, where a card goes to
280
+ * the service when it's filed, filing refuses such a card unless the agent gives this; elsewhere it only warns (D136).
281
+ */
282
+ secret_reason: Text.optional(),
283
+ /**
284
+ * A convention card (D161): Baesian found a pattern in your answers, by the same choice on one area's decisions or by
285
+ * a comment in rule words, and asks whether they should become one rule. Only Baesian sets it.
286
+ */
287
+ convention: z.enum(["same-choice", "rule-words"]).optional(),
288
+ /**
289
+ * A slider proposal (B-y6jn6): Baesian proposes moving a row of the repo's preferences one stop, from the record.
290
+ * Accepting it writes a new preference file. Only Baesian sets them, all three together.
291
+ */
292
+ pref_row: z.enum(ROWS).optional(),
293
+ pref_from: z.enum(POSITIONS).optional(),
294
+ pref_to: z.enum(POSITIONS).optional(),
295
+ caught_at: CaughtAt.optional(),
296
+ confidence: z.number().min(0).max(1).optional(),
297
+ domain: z.array(z.string().min(1)).default([]),
298
+ evidence: z.array(z.string().min(1)).default([]),
299
+ source_agent: z.string().min(1),
300
+ /**
301
+ * The model the agent ran on when it filed this revision, as its harness named it to the plugin's hooks (B-k0w7d):
302
+ * Baesian fills it in from the session's model file (`SessionModel`), never the agent. Absent when the card was filed
303
+ * without the plugin, outside a harness session, or before the harness named a model.
304
+ */
305
+ source_model: z.string().min(1).max(200).optional(),
306
+ source_branch: z.string().min(1),
307
+ created: Timestamp,
308
+ });
309
+ /**
310
+ * What an agent writes: a card without the fields Baesian fills in. Its optional `format` is the draft's own, never
311
+ * the revision's: Baesian writes every revision in the newest card format.
312
+ */
313
+ export const CardDraft = CardMeta.omit({
314
+ type: true,
315
+ format: true,
316
+ id: true,
317
+ revision: true,
318
+ source_agent: true,
319
+ source_model: true,
320
+ source_branch: true,
321
+ created: true,
322
+ reply: true,
323
+ rule: true,
324
+ rule_covers: true,
325
+ convention: true,
326
+ pref_row: true,
327
+ pref_from: true,
328
+ pref_to: true,
329
+ }).extend({
330
+ type: z.literal("card").optional(),
331
+ format: formatOf("card-draft").optional(),
332
+ deck: DeckId.optional(),
333
+ level: Level.optional(),
334
+ });
335
+ /** Why a well-formed draft still can't be filed (D74, D77): the rules that span fields. */
336
+ export function filingProblems(d) {
337
+ const out = [];
338
+ if (same(d.proposal, d.alternative))
339
+ out.push("the proposal and the alternative are the same option: a card needs two real options");
340
+ if (same(d.crux_proposal, d.crux_alternative)) {
341
+ out.push(`crux_proposal and crux_alternative give the same answer, so the crux doesn't decide between the options${cite("D74")}`);
342
+ }
343
+ const asks = [
344
+ ...(d.reverse === "one-way" ? ["it's one-way"] : []),
345
+ ...(d.touches.includes("security") ? ["it touches security"] : []),
346
+ ...(d.touches.includes("privacy") ? ["it touches privacy"] : []),
347
+ ...(d.premise ? ["it rests on an unchecked premise"] : []),
348
+ ];
349
+ if (asks.length && !(d.wrong_if && d.check)) {
350
+ out.push(`wrong_if and check are required because ${asks.join(" and ")}${cite("D77")}: say what would show the pick is wrong, and where to look`);
351
+ }
352
+ const partial = (name, fields) => {
353
+ const set = fields.filter((f) => f !== undefined).length;
354
+ if (set > 0 && set < fields.length)
355
+ out.push(`the ${name} facet needs all of its fields${cite("D75")}`);
356
+ };
357
+ partial("premise (premise, premise_option, premise_check)", [d.premise, d.premise_option, d.premise_check]);
358
+ partial("acted (acted, acted_option)", [d.acted, d.acted_option]);
359
+ // A card answered in chat has the reviewer's answer as its pick (B-hswmx): an answer that is the alternative's name is the wrong way round.
360
+ if (d.answered_in_chat !== undefined && same(d.answered_in_chat, d.alternative)) {
361
+ out.push(`answered_in_chat is the alternative, “${d.alternative}”: a card answered in chat has the reviewer's answer as its pick${cite("B-hswmx")}, so make their answer the proposal`);
362
+ }
363
+ return out;
364
+ }
365
+ /** Whether `text` names `name`, as whole words, ignoring case and spacing. */
366
+ function mentions(text, name) {
367
+ const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
368
+ const t = norm(text), n = norm(name);
369
+ if (!n)
370
+ return false;
371
+ for (let i = t.indexOf(n); i >= 0; i = t.indexOf(n, i + 1)) {
372
+ const before = t[i - 1], after = t[i + n.length];
373
+ if (!(before && /[\p{L}\p{N}]/u.test(before)) && !(after && /[\p{L}\p{N}]/u.test(after)))
374
+ return true;
375
+ }
376
+ return false;
377
+ }
378
+ /**
379
+ * A card answered in chat whose quote names the alternative and not the pick (B-hswmx): its pick should be the answer the
380
+ * reviewer gave. "Not SQLite" names the option it turns down, so a program can't tell; this only asks.
381
+ */
382
+ function chatNamesAlternative(d) {
383
+ const said = d.answered_in_chat;
384
+ if (said === undefined || !mentions(said, d.alternative) || mentions(said, d.proposal))
385
+ return [];
386
+ return [`answered_in_chat names the alternative, “${d.alternative}”, and not your pick: a card answered in chat has the reviewer's answer as its pick, so check that the proposal is what they chose`];
387
+ }
388
+ /**
389
+ * Text that looks like a secret (B-vqn69): the record is committed with the code, so a card shouldn't carry one. Each
390
+ * says what it looks like, for the warning, which never quotes the text.
391
+ */
392
+ const SECRET_PATTERNS = [
393
+ { looksLike: "a private key", pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/ },
394
+ { looksLike: "an AWS access key ID", pattern: /\b(?:AKIA|ASIA|ABIA|ACCA|A3T[A-Z0-9])[A-Z0-9]{16}\b/ },
395
+ { looksLike: "a GitHub token", pattern: /\b(?:gh[opsur]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/ },
396
+ { looksLike: "a Slack token", pattern: /\b(?:xox[abposr]-[A-Za-z0-9-]{10,}|xapp-\d-[A-Za-z0-9-]{10,})/ },
397
+ { looksLike: "a JWT", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/ },
398
+ { looksLike: "a token in a URL", pattern: /[?&][\w-]*(?:token|key|secret)=[^\s&#"'<>]{16,}/i },
399
+ // `api_key = …`, `"client_secret": "…"`: a long value with letters and digits, so `process.env.API_KEY` and prose don't count.
400
+ {
401
+ looksLike: "a key, token or password",
402
+ pattern: /\b[\w-]*(?:api[_-]?key|access[_-]?key|secret|token|passw(?:or)?d)[\w-]*["']?\s*[:=]\s*["']?(?=[A-Za-z0-9_\-+/=]*[0-9])(?=[A-Za-z0-9_\-+/=]*[A-Za-z])[A-Za-z0-9_\-+/=]{16,}/i,
403
+ },
404
+ ];
405
+ /** What a text looks like it holds, if it looks like a secret. */
406
+ function looksLikeSecret(text) {
407
+ return SECRET_PATTERNS.find((s) => s.pattern.test(text))?.looksLike;
408
+ }
409
+ /**
410
+ * The fields of a draft, and its body, that look like they hold a secret (B-vqn69), each with what it looks like: the
411
+ * field's name and the kind, never the text. `secret_reason`, the reason for filing one anyway (B-jfkv9), isn't read.
412
+ */
413
+ export function secretsIn(d, body = "") {
414
+ const texts = [
415
+ ...Object.entries(d)
416
+ .filter(([name]) => name !== "secret_reason")
417
+ .flatMap(([name, v]) => typeof v === "string" ? [[name, v]] : Array.isArray(v) ? v.filter((x) => typeof x === "string").map((x) => [name, x]) : []),
418
+ ["the body", body],
419
+ ];
420
+ const secrets = new Map();
421
+ for (const [name, text] of texts) {
422
+ const kind = secrets.has(name) ? undefined : looksLikeSecret(text);
423
+ if (kind)
424
+ secrets.set(name, kind);
425
+ }
426
+ return secrets;
427
+ }
428
+ /**
429
+ * An outcome that starts with a capital (B-b0dzp): the card shows it after "then", so it reads "then The relay…". A
430
+ * program can't tell a name from an ordinary first word, so this only asks; a first word in capitals (CI, API) passes.
431
+ */
432
+ function thenCapitals(d) {
433
+ const capital = (v) => v !== undefined && /^[A-Z]/.test(v) && !/^[A-Z0-9]{2,}\b/.test(v);
434
+ return ["proposal_then", "alternative_then"]
435
+ .filter((name) => capital(d[name]))
436
+ .map((name) => `${name} starts with a capital, and the card shows it after "then": start it lowercase, unless its first word is a name`);
437
+ }
438
+ /**
439
+ * Advice on a draft that files anyway: no length limit has been measured yet, so long fields only warn; and a field,
440
+ * or the body, that looks like it holds a secret (B-vqn69), named without quoting it. In a hosted repo the store refuses
441
+ * such a draft instead, unless it gives `secret_reason` (B-jfkv9).
442
+ */
443
+ export function filingWarnings(d, body = "") {
444
+ const long = [
445
+ ["title", d.title, 60], ["proposal", d.proposal, 60], ["alternative", d.alternative, 60],
446
+ ["question", d.question, 160], ["crux", d.crux, 160], ["doing", d.doing, 160],
447
+ ["proposal_then", d.proposal_then, 240], ["alternative_then", d.alternative_then, 240],
448
+ ["answered_in_chat", d.answered_in_chat, 240],
449
+ ];
450
+ return [
451
+ ...long.filter(([, v, max]) => v !== undefined && v.length > max).map(([name, v, max]) => `${name} is ${v.length} characters; the card reads best under ${max}`),
452
+ ...thenCapitals(d),
453
+ ...chatNamesAlternative(d),
454
+ ...[...secretsIn(d, body)].map(([name, kind]) => `${name} looks like it holds a secret (${kind}): the record is committed with the code, so check it before the next commit`),
455
+ ];
456
+ }
457
+ const same = (a, b) => a.toLowerCase().replace(/\s+/g, " ").trim() === b.toLowerCase().replace(/\s+/g, " ").trim();
458
+ export const Response = z.enum([
459
+ "accept", // resolves
460
+ "alternative", // resolves with one of the card's alternatives
461
+ "correction", // resolves with the reviewer's own answer
462
+ "reject", // returns: the agent re-proposes or withdraws
463
+ "question", // returns: "have you considered X?"
464
+ "need_context", // returns: re-file with what was missing
465
+ "see_it_run", // returns: come back with a demo, a screenshot or a running preview (D85)
466
+ "not_my_area", // reroutes
467
+ "defer", // deferred, with a reason (D31)
468
+ ]);
469
+ /**
470
+ * A verdict on one card revision: `verdicts/<at>-<id>-r<revision>-<suffix>.json`, with `at` first and compact, so a
471
+ * listing shows verdicts in the order they were given (D107). Only the client writes these (D32).
472
+ */
473
+ export const Verdict = z
474
+ .strictObject({
475
+ type: z.literal("verdict"),
476
+ format: formatOf("verdict"),
477
+ card: CardId,
478
+ revision: z.number().int().min(1),
479
+ response: Response,
480
+ choice: z.string().min(1).optional(),
481
+ comment: z.string().min(1).optional(),
482
+ /** The option chosen before the agent's pick was shown (D86, D88). */
483
+ first_choice: OptionKind.optional(),
484
+ /**
485
+ * The card was answered in chat (B-hswmx): the reviewer had seen the agent's pick, their own answer, so there was no
486
+ * blind choice, and calibration leaves it out. The store sets it on every verdict on a revision with `answered_in_chat`.
487
+ */
488
+ seen: z.literal(true).optional(),
489
+ make_rule: z.boolean().optional(),
490
+ /**
491
+ * "You should have asked me" (D151, B-87tkc): on a card the gate held back, or in shadow would have, the reviewer
492
+ * would have wanted to see it, whatever they chose. It counts as a miss on the card's row.
493
+ */
494
+ should_have_asked: z.boolean().optional(),
495
+ /** Rules the chosen option breaks that the reviewer retired with this answer (D119). */
496
+ retire_rules: z.array(RuleId).optional(),
497
+ reviewer: z.string().min(1),
498
+ /** The account that wrote it, on the hosted service (P16 section 7): set by the relay, beside the handle. */
499
+ account: AccountId.optional(),
500
+ /** The project it was given in, on the hosted service: set by the relay (B-tf9fc). */
501
+ project: ProjectId.optional(),
502
+ /**
503
+ * The SHA-256 digest of the card revision's file as the relay received it (B-f571v): set by the relay, so the
504
+ * service's signature, and a hard card's passkey proof, cover what was answered, not only its ID.
505
+ */
506
+ card_digest: Sha256.optional(),
507
+ /** Which client wrote the verdict (D62). */
508
+ client: z.string().min(1),
509
+ at: Timestamp,
510
+ })
511
+ .superRefine((v, ctx) => {
512
+ if ((v.response === "alternative" || v.response === "correction") && !v.choice) {
513
+ ctx.addIssue({ code: "custom", path: ["choice"], message: `a ${v.response} verdict needs a choice` });
514
+ }
515
+ if ((v.response === "question" || v.response === "defer") && !v.comment) {
516
+ ctx.addIssue({ code: "custom", path: ["comment"], message: `a ${v.response} verdict needs a comment` });
517
+ }
518
+ if (v.seen && v.first_choice)
519
+ ctx.addIssue({ code: "custom", path: ["first_choice"], message: "a verdict on a card answered in chat has no blind choice" });
520
+ });
521
+ /** In the blind test (B-xx49m, B-bb7ga), whether you think a card you answered was rebuilt from a past one. */
522
+ export const PracticeMark = z.enum(["no", "maybe", "sure"]);
523
+ /** Who wrote a practice entry, with which client, and when: set by the server, never sent. */
524
+ const practiceWho = {
525
+ type: z.literal("practice"),
526
+ format: formatOf("practice"),
527
+ reviewer: z.string().min(1),
528
+ /** The account that wrote it, on the hosted service: set by the relay, beside the handle. */
529
+ account: AccountId.optional(),
530
+ client: z.string().min(1),
531
+ at: Timestamp,
532
+ };
533
+ /**
534
+ * Practice (B-kb5yt, B-rehdw): one entry per file, `practice/<at>-<suffix>.json` in the live layer of a clone that isn't
535
+ * linked, or one row of the relay's practice table (`PracticeTable`), apart from its log. Never a verdict, never in the
536
+ * record or the log a machine reads, and never read by anything an agent runs. Never what you typed: only the kind of
537
+ * answer. Three kinds:
538
+ * - `test-start`: you started the blind test (B-xx49m). Once.
539
+ * - `answer`: your answer on a practice card: the card it was rebuilt from and its revision, when that card's final answer
540
+ * was given (`answered`, for B-j583c's check), your blind choice against the planted pick (`blind`), and your final
541
+ * answer (`final`): the planted pick, the other option, a written answer or "can't judge"; `test` when it came in the
542
+ * blind test.
543
+ * - `mark`: in the blind test, your guess about a card you answered, made on a card of its own after the answer (B-9prve):
544
+ * the card (for a practice card, the one it was rebuilt from), and `practice` when it was a practice card.
545
+ */
546
+ export const PracticeEntry = z.discriminatedUnion("kind", [
547
+ z.strictObject({ ...practiceWho, kind: z.literal("test-start") }),
548
+ z.strictObject({
549
+ ...practiceWho,
550
+ kind: z.literal("answer"),
551
+ card: CardId,
552
+ revision: z.number().int().min(1),
553
+ answered: Timestamp,
554
+ blind: z.enum(["planted", "other"]).optional(),
555
+ final: z.enum(["planted", "other", "write", "pass"]),
556
+ test: z.literal(true).optional(),
557
+ }),
558
+ z.strictObject({ ...practiceWho, kind: z.literal("mark"), card: CardId, revision: z.number().int().min(1), mark: PracticeMark, practice: z.literal(true).optional() }),
559
+ ]);
560
+ /** The agent withdrawing its own card: `cards/<id>/withdrawn.json`. */
561
+ export const Withdrawal = z.strictObject({
562
+ type: z.literal("withdrawal"),
563
+ format: formatOf("withdrawal"),
564
+ card: CardId,
565
+ reason: z.string().min(1),
566
+ by: z.string().min(1),
567
+ at: Timestamp,
568
+ });
569
+ /** Frontmatter of a ledger entry: `ledger/<id>-<suffix>.md`. The body names symbols in backticks (D15). */
570
+ export const LedgerMeta = z.strictObject({
571
+ type: z.literal("ledger"),
572
+ format: formatOf("ledger"),
573
+ card: CardId,
574
+ branch: z.string().min(1),
575
+ commit: z.string().optional(),
576
+ at: Timestamp,
577
+ });
578
+ /**
579
+ * A ledger entry's body (D120): one line naming the commit's short ID and the card, never its subject or message, which
580
+ * stay on the machine (D181). Only an entry whose body is exactly this goes up to the relay; those written before D120
581
+ * listed the commit's files, and stay on the machine.
582
+ */
583
+ export const ledgerBody = (commit, card) => `Commit \`${commit.slice(0, 12)}\` builds on ${card}.\n`;
584
+ /** Whether a ledger entry's body, as `parseDoc()` reads it (after the blank line under the frontmatter), is `ledgerBody()`. */
585
+ export const isLedgerBody = (body, commit, card) => body.replace(/^\n+/, "") === ledgerBody(commit, card);
586
+ /**
587
+ * A branch merged into another (D181): `merges/<commit>-<key>.json` in the live layer, where `key` is the first 12 hex
588
+ * digits of the SHA-256 of `branch` and `into` (`mergeFile()`), so two machines that find the same merge write the same
589
+ * path, and one branch fast-forwarded into two branches at one commit is two files. Found
590
+ * by the CLI's hooks (src/merges.ts), never in the record; it goes up to the relay and comes down to the project's other
591
+ * machines. `commit` is where it landed on `into`: the merge commit, the squash commit, or for a fast-forward the commit
592
+ * the branch moved to; `tip`, the commit that landed from the merged side (B-w41p7): a merge commit's other parent, the
593
+ * commit a fast-forward moved to, the tip a squash squashed; `at` is when, as git says it (a fast-forward found as it
594
+ * happens: then); `cards`, the cards whose `source_branch` is `branch` it brought, or the cards its commits cite, where
595
+ * the record isn't committed; `commits`, the commits made on `branch` that cite cards which it brought, oldest first,
596
+ * checked against git's ancestry as it's written: reachable from `tip`, and not from `into` before. A later fast-forward
597
+ * of the same branch is a merge of its own, at its own commit. Commit subjects and messages are never in it.
598
+ */
599
+ export const MergeEvent = z.strictObject({
600
+ type: z.literal("merge"),
601
+ format: formatOf("merge"),
602
+ branch: z.string().min(1),
603
+ into: z.string().min(1),
604
+ commit: CommitId,
605
+ /** Absent only from a merge recorded before B-w41p7 (2026-11-05): before v1 a format changes in place (D108). */
606
+ tip: CommitId.optional(),
607
+ at: Timestamp,
608
+ cards: z.array(CardId).default([]),
609
+ commits: z.array(CommitId).default([]),
610
+ });
611
+ /**
612
+ * A branch's name as git allows one (`git check-ref-format --branch`): no spaces, control characters or `~^:?*[\`, no
613
+ * `..` or `@{`, no part starting with a dot or ending in `.lock`, not starting with a dash or a slash, nor ending in a
614
+ * slash or a dot, and not `@`. Only a name, so nothing else can ride in it.
615
+ */
616
+ export const BranchName = z
617
+ .string()
618
+ .min(1)
619
+ .max(255)
620
+ .refine((b) => b !== "@" && !/[\x00-\x20\x7f~^:?*[\\]/.test(b) && !b.includes("..") && !b.includes("@{") && !b.includes("//") && !/^[-/]|[/.]$/.test(b) && !b.split("/").some((p) => p.startsWith(".") || p.endsWith(".lock")), "a branch's name, as git allows one");
621
+ /**
622
+ * The repo's default branch (B-jr8mk), as the command line read it (`defaultBranch()`): `default-branch.json`, which a
623
+ * machine sends the relay, so the website's branches panel knows which branch is the trunk. Only its name. Mutable: the
624
+ * relay keeps the latest a machine sent, never logs it, and keeps it while the project exists. Never in the record, nor
625
+ * in a machine's own live layer, which reads git instead.
626
+ */
627
+ export const DefaultBranch = z.strictObject({
628
+ type: z.literal("default-branch"),
629
+ format: formatOf("default-branch"),
630
+ branch: BranchName,
631
+ });
632
+ /** Frontmatter of a deck: `decks/<id>.md`. The body is the briefing. Membership is computed from cards. */
633
+ export const DeckMeta = z.strictObject({
634
+ type: z.literal("deck"),
635
+ format: formatOf("deck"),
636
+ id: DeckId,
637
+ title: z.string().min(1),
638
+ branch: z.string().optional(),
639
+ from: z.string().optional(),
640
+ context_notes: z.array(z.string()).default([]),
641
+ created: Timestamp,
642
+ });
643
+ /** Frontmatter of a rule: `rules/<id>.md`. The body is the rule. */
644
+ export const RuleMeta = z.strictObject({
645
+ type: z.literal("rule"),
646
+ format: formatOf("rule"),
647
+ id: RuleId,
648
+ topic: Topic.optional(),
649
+ /** Gate fact IDs this rule answers, as globs (`dep:pytest-*`): the gate acks them automatically (D24). */
650
+ covers: z.array(z.string().min(1)).default([]),
651
+ from_card: CardId.optional(),
652
+ accepted_by: z.string().min(1),
653
+ /** The account that wrote it, on the hosted service (P16 section 7): set by the relay, beside the handle. */
654
+ account: AccountId.optional(),
655
+ /** The project it was saved in, on the hosted service: set by the relay (B-tf9fc). */
656
+ project: ProjectId.optional(),
657
+ created: Timestamp,
658
+ });
659
+ /**
660
+ * A rule retired by an answer that chose an option breaking it (D119): `rules/<id>-retired.json`, beside the rule,
661
+ * which stays on record. The gate and the client skip a retired rule.
662
+ */
663
+ export const RuleRetirement = z.strictObject({
664
+ type: z.literal("rule-retirement"),
665
+ format: formatOf("rule-retirement"),
666
+ rule: RuleId,
667
+ /** The card it was retired on. */
668
+ card: CardId,
669
+ by: z.string().min(1),
670
+ /** The account that wrote it, on the hosted service (P16 section 7): set by the relay, beside the handle. */
671
+ account: AccountId.optional(),
672
+ /** The project it was made in, on the hosted service: set by the relay (B-tf9fc). */
673
+ project: ProjectId.optional(),
674
+ at: Timestamp,
675
+ });
676
+ /** A topic-key merge made in the client: `topics/alias-<suffix>.json` (D44). */
677
+ export const TopicAlias = z.strictObject({
678
+ type: z.literal("topic-alias"),
679
+ format: formatOf("topic-alias"),
680
+ from: Topic,
681
+ to: Topic,
682
+ by: z.string().min(1),
683
+ /** The account that wrote it, on the hosted service (P16 section 7): set by the relay, beside the handle. */
684
+ account: AccountId.optional(),
685
+ /** The project it was made in, on the hosted service: set by the relay (B-tf9fc). */
686
+ project: ProjectId.optional(),
687
+ at: Timestamp,
688
+ });
689
+ /**
690
+ * An agent waiting on an existing card instead of filing a duplicate (D36): `attachments/<id>-<suffix>.json`. No
691
+ * folder path: the branch already names the worktree, since git checks a branch out in only one (D121).
692
+ */
693
+ export const Attachment = z.strictObject({
694
+ type: z.literal("attachment"),
695
+ format: formatOf("attachment"),
696
+ card: CardId,
697
+ agent: z.string().min(1),
698
+ branch: z.string().min(1),
699
+ at: Timestamp,
700
+ });
701
+ /** A blocking card the agent proceeded on without a verdict (D46): `cards/<id>/fallback-<suffix>.json`. */
702
+ export const Fallback = z.strictObject({
703
+ type: z.literal("fallback"),
704
+ format: formatOf("fallback"),
705
+ card: CardId,
706
+ revision: z.number().int().min(1),
707
+ reason: z.enum(["timeout", "away"]),
708
+ agent: z.string().min(1),
709
+ at: Timestamp,
710
+ });
711
+ /** The client's heartbeat, `presence.json` in the live layer (D41). Mutable; never in the record. */
712
+ export const Presence = z.strictObject({
713
+ type: z.literal("presence"),
714
+ format: formatOf("presence"),
715
+ client: z.string().min(1),
716
+ at: Timestamp,
717
+ });
718
+ /**
719
+ * An agent waiting on a card, `waiting/<id>-<suffix>.json` in the live layer (D90). Mutable: `baesian wait`
720
+ * rewrites it while it waits and removes it when it returns. Once it's stale, the agent stopped waiting.
721
+ */
722
+ export const Waiting = z.strictObject({
723
+ type: z.literal("waiting"),
724
+ format: formatOf("waiting"),
725
+ card: CardId,
726
+ revision: z.number().int().min(1),
727
+ agent: z.string().min(1),
728
+ since: Timestamp,
729
+ at: Timestamp,
730
+ });
731
+ /**
732
+ * The running server, `server.json` in the live layer (D97). Mutable and readable only by the user; written
733
+ * when `baesian serve` starts and removed when it stops. The token is new each time.
734
+ */
735
+ export const ServerInfo = z.strictObject({
736
+ type: z.literal("server"),
737
+ format: formatOf("server"),
738
+ pid: z.number().int().positive(),
739
+ port: z.number().int().min(1).max(65535),
740
+ token: z.string().min(32),
741
+ started: Timestamp,
742
+ });
743
+ /**
744
+ * The port the last server listened on, `port.json` in the live layer (D103). It outlives the server, so the next
745
+ * one starts on the same address when it can, and the browser keeps what it stores for that address: the client's
746
+ * own data, and permission to notify. Not a secret: the token is still new each time (D97).
747
+ */
748
+ export const LastPort = z.strictObject({
749
+ type: z.literal("port"),
750
+ format: formatOf("port"),
751
+ port: z.number().int().min(1).max(65535),
752
+ });
753
+ /**
754
+ * An inbox cursor, `cursors/<branch>.json` in the live layer: the branch's place in its inbox, the time of the newest
755
+ * verdict it has read (empty before the first), and `delivered`, what `baesian wait` has delivered since. A session
756
+ * keeps its own place in `cursors/<branch>/<session>.json` (B-ghhym), with no `delivered`. Mutable; never in the
757
+ * record. Written before v1, a cursor had neither `type` nor `format`.
758
+ */
759
+ export const Cursor = z.strictObject({
760
+ type: z.literal("cursor"),
761
+ format: formatOf("cursor"),
762
+ at: z.union([Timestamp, z.literal("")]),
763
+ delivered: z.array(z.string().min(1)).optional(),
764
+ });
765
+ /**
766
+ * A harness session's model (B-k0w7d), `models/<session>.json` in the live layer: what the plugin's hooks last heard
767
+ * from the harness, when a session starts (Claude Code's `SessionStart` may leave it out, after /clear for one) and when
768
+ * it switches (Claude Code's `PostModelSwitch`; Codex names it to every hook). The plugin writes it, as
769
+ * `plugins/baesian/hooks/baesian.mjs`'s `noteModel()`, and `baesian file` reads it by the session the harness gives its
770
+ * commands, for the card's `source_model`. Mutable, replaced whole; never in the record.
771
+ */
772
+ export const SessionModel = z.strictObject({
773
+ type: z.literal("session-model"),
774
+ format: formatOf("session-model"),
775
+ model: z.string().min(1).max(200),
776
+ at: Timestamp,
777
+ });
778
+ // ---------------------------------------------------------------- routing (M8: D144–D151, P16 sections 2 and 3)
779
+ export const Position = z.enum(POSITIONS);
780
+ export const Row = z.enum(ROWS);
781
+ export const Mode = z.enum(["shadow", "enforce"]);
782
+ /** A path glob for the commit gate (`globToRegExp()`), with no space at either end. */
783
+ const Glob = z.string().regex(/^\S(?:.*\S)?$/, "a glob, with no space at either end");
784
+ /** A preference file's name: `<at>-<suffix>.json`, timestamp first like a verdict's (D107), so the newest sorts last. */
785
+ export const PREFERENCE_FILE = new RegExp(`^\\d{8}T\\d{6}\\.\\d{3}Z-${SUFFIX}{5}\\.json$`);
786
+ const PreferenceName = z.string().regex(PREFERENCE_FILE, "a preference file is named <at>-<suffix>.json");
787
+ /**
788
+ * One change to a repo's preferences: `preferences/<at>-<suffix>.json`, in the live layer and the record (D147). Written
789
+ * once, by the client only (or by accepting a slider proposal in it), never by an agent and never in `Config`. The newest
790
+ * is in force; the rest are the history. Every row is required, so a file means the same whatever a later Baesian's
791
+ * presets are (P16). Spending, destroying data and publishing have no key: they're fixed at "never without me" (D146).
792
+ */
793
+ export const Preferences = z
794
+ .strictObject({
795
+ type: z.literal("preferences"),
796
+ format: formatOf("preferences"),
797
+ /** shadow records what the gate would hold back, and holds nothing (D148). */
798
+ mode: Mode,
799
+ row_public_api: Position,
800
+ row_schema: Position,
801
+ row_dependency: Position,
802
+ row_infra: Position,
803
+ row_user_facing: Position,
804
+ row_security: Position,
805
+ row_privacy: Position,
806
+ row_other: Position,
807
+ /** The chance a held card is drawn for audit, at each of the two low positions (B-fgc4y). */
808
+ rate_agent_decides: z.number().min(0).max(1),
809
+ rate_sample: z.number().min(0).max(1),
810
+ /** How many of each row's first held cards are all drawn (B-fgc4y). */
811
+ burn_in: z.number().int().min(0),
812
+ /**
813
+ * What the commit gate looks at (B-qkek1), here rather than in the committed config so that only a person sets it,
814
+ * and a change that makes the gate see less takes the passkey (D163). Each is a list of globs; left out, it's the
815
+ * built-in list (`DEFAULT_SCHEMA`, `DEFAULT_INFRA`; no public modules; no paths ignored beyond `DEFAULT_IGNORE`). The
816
+ * schema and infra lists replace the built-in ones, and an empty one is kept: the gate looks for none.
817
+ */
818
+ gate_schema: z.array(Glob).optional(),
819
+ gate_infra: z.array(Glob).optional(),
820
+ /** The public modules: the `api:` detector runs only when there are some (D59). */
821
+ gate_api: z.array(Glob).min(1).optional(),
822
+ /** Paths the gate never looks at, added to `DEFAULT_IGNORE`. */
823
+ gate_ignore: z.array(Glob).min(1).optional(),
824
+ /** The preset it started from (B-wwxa3). */
825
+ profile: z.string().regex(/^[a-z0-9][a-z0-9-]*$/).optional(),
826
+ /** The file this change was made from; absent only on the first. A change from an older view is refused (P16). */
827
+ replaces: PreferenceName.optional(),
828
+ /** The slider proposal it came from (B-y6jn6). */
829
+ from_card: CardId.optional(),
830
+ /** The setup survey's answers (B-5na1k): the rows whose example you wanted to see, and those you'd be fine not seeing. */
831
+ survey_see: z.array(Row).optional(),
832
+ survey_fine: z.array(Row).optional(),
833
+ reason: z.string().min(1).optional(),
834
+ by: z.string().min(1),
835
+ /** The account that wrote it, on the hosted service (P16 section 7): set by the relay, beside the handle. */
836
+ account: AccountId.optional(),
837
+ /** The project it was made in, on the hosted service: set by the relay (B-tf9fc). */
838
+ project: ProjectId.optional(),
839
+ /** Which client wrote it (D62). */
840
+ client: z.string().min(1),
841
+ at: Timestamp,
842
+ })
843
+ .superRefine((p, ctx) => {
844
+ for (const [row, floor] of Object.entries(ROW_FLOOR)) {
845
+ const key = `row_${row}`;
846
+ if (floor && rank(p[key]) < rank(floor)) {
847
+ ctx.addIssue({ code: "custom", path: [key], message: `${row} may go no lower than ${floor} (D146)` });
848
+ }
849
+ }
850
+ });
851
+ /** The gate's outcome for one card revision (P16 section 3). */
852
+ export const GateOutcome = z.enum(["shown", "held"]);
853
+ /**
854
+ * What the gate did with one card revision, and why: `cards/<id>/gate-r<n>-<by>.json`, beside the card, in both layers,
855
+ * written once at filing (D145). Stored rather than computed, since the settings it applied and its draw are fixed when
856
+ * it runs. The CLI's (`by: cli`) is the one that counts locally; a hosted repo's relay will write its own, signed (D158).
857
+ * A classifier's model, score and threshold are reserved for later: a v1 reader refuses a record that sets them, as the
858
+ * schema is strict.
859
+ */
860
+ export const GateRecord = z
861
+ .strictObject({
862
+ type: z.literal("gate"),
863
+ format: formatOf("gate"),
864
+ card: CardId,
865
+ revision: z.number().int().min(1),
866
+ by: z.enum(["cli", "relay"]),
867
+ /** The rules' version: rules-1. */
868
+ gate: z.string().min(1),
869
+ /** Copied from the preferences; shadow when there are none. */
870
+ mode: Mode,
871
+ /** The preference file it applied; absent when there was none, so "Today" applied, in shadow. */
872
+ preferences: PreferenceName.optional(),
873
+ /** What it read from the card. */
874
+ touches: z.array(Fact).default([]),
875
+ reverse: Reverse,
876
+ /** The level the draft asked for, if any. It only raises a shown card's level (D144, B-chq61). */
877
+ asked: Level.optional(),
878
+ /**
879
+ * An agent's card; one Baesian filed itself; a revision that follows an answer, or a reply; or a card the reviewer
880
+ * answered in chat (B-hswmx). All but the first are always shown.
881
+ */
882
+ source: z.enum(["agent", "baesian", "answer", "chat"]),
883
+ /** The strictest row it fell in, and that row's position. */
884
+ row: z.enum(ALL_ROWS),
885
+ position: Position,
886
+ /** In shadow, what it would have done. */
887
+ outcome: GateOutcome,
888
+ /** The level it was shown at, when shown. */
889
+ level: Level.optional(),
890
+ reason: z.enum(["row", "floor", "one-way", "agent", "baesian", "answer", "chat", "error"]),
891
+ /** When held: the card's draw (a hash of its ID and revision, read as a number from 0 to 1), the chance it had, the row's earlier held cards the gate counted, and whether it was drawn, and by the burn-in (B-fgc4y). */
892
+ draw: z.number().min(0).max(1).optional(),
893
+ p: z.number().min(0).max(1).optional(),
894
+ held_before: z.number().int().min(0).optional(),
895
+ burn_in: z.boolean().optional(),
896
+ drawn: z.boolean().optional(),
897
+ /** The relay's signature its draw came from (D158): on the relay's record only, from M9. */
898
+ draw_proof: z.string().min(1).optional(),
899
+ /** The project it gated the card in: on the relay's record only (B-tf9fc). */
900
+ project: ProjectId.optional(),
901
+ at: Timestamp,
902
+ })
903
+ .superRefine((g, ctx) => {
904
+ if (g.outcome === "shown" && !g.level)
905
+ ctx.addIssue({ code: "custom", path: ["level"], message: "a shown card has a level" });
906
+ if (g.outcome === "held" && (g.drawn === undefined || g.p === undefined || g.draw === undefined || g.held_before === undefined)) {
907
+ ctx.addIssue({ code: "custom", path: ["drawn"], message: "a held card has its draw: draw, p, held_before and drawn" });
908
+ }
909
+ });
910
+ // ---------------------------------------------------------------- the hosted service's proof (M9a: D138, D139, D142, D159, D160)
911
+ /** A website's origin: its scheme and host, with no path. */
912
+ export const Origin = z.string().regex(/^https?:\/\/[^\s/?#]+$/, "an origin: https://host, with no path");
913
+ /** A service key's ID, chosen by the service: `trust/service-<key>.json`. */
914
+ const KeyId = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "a key ID is a lowercase slug");
915
+ /**
916
+ * One of the service's signing keys (D142): `trust/service-<key>.json`, in the live layer and every branch's record, and
917
+ * pinned in CI's own settings (`BAESIAN_TRUST`), where the copy must match. Written once, by the CLI from the relay. A
918
+ * person-written file counts only with a `.sig` this key made while it was valid (D138, D160).
919
+ */
920
+ export const ServiceKey = z.strictObject({
921
+ type: z.literal("service-key"),
922
+ format: formatOf("service-key"),
923
+ key: KeyId,
924
+ /** The Ed25519 public key in OpenSSH's form, as an allowed-signers line takes it: `ssh-ed25519 AAAA…`, with no comment. */
925
+ public_key: z.string().regex(/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$/, "an Ed25519 key in OpenSSH's form: ssh-ed25519 AAAA…"),
926
+ /** The website's origin and WebAuthn relying party, which a passkey proof under this key's signature must name (D139). */
927
+ origin: Origin,
928
+ rp_id: z.string().regex(/^[a-z0-9.-]+$/, "a relying party ID is a domain"),
929
+ /** It signs only files whose own time is from `valid_after`, and before `valid_before` (git's allowed signers, B5 F14). */
930
+ valid_after: Timestamp,
931
+ valid_before: Timestamp.optional(),
932
+ });
933
+ /** A service key withdrawn: `trust/service-<key>-revoked.json`. A file signed at or after `at` doesn't count. */
934
+ export const ServiceKeyRevocation = z.strictObject({
935
+ type: z.literal("service-key-revocation"),
936
+ format: formatOf("service-key-revocation"),
937
+ key: KeyId,
938
+ reason: z.string().min(1),
939
+ at: Timestamp,
940
+ });
941
+ /** The COSE algorithms a passkey proof is checked with: ES256, EdDSA and RS256. */
942
+ export const PASSKEY_ALGS = [-7, -8, -257];
943
+ /**
944
+ * A reviewer's passkey (D139, D159): `trust/passkey-<suffix>.json`, with a `.sig` beside it, written when it's enrolled on
945
+ * the website. Pinned in CI by hand, as the service's key is; the record's copy serves checks on your machine.
946
+ */
947
+ export const PasskeyKey = z.strictObject({
948
+ type: z.literal("passkey-key"),
949
+ format: formatOf("passkey-key"),
950
+ account: AccountId,
951
+ /** The account's handle, for people reading the file. */
952
+ handle: z.string().min(1),
953
+ credential: Base64Url,
954
+ /** The public key, SubjectPublicKeyInfo in base64url, and its COSE algorithm. */
955
+ public_key: Base64Url,
956
+ alg: z.literal(PASSKEY_ALGS),
957
+ enrolled: Timestamp,
958
+ });
959
+ /**
960
+ * The repo's project, pinned in CI beside the keys (B-mar8x): a line of `BAESIAN_TRUST`, never a file in `trust/`. With
961
+ * keys pinned, `baesian verify` checks every commit against this project, whatever a commit's config says, and finds the
962
+ * commit that linked the repo to it once, from the tip it checks. `baesian ci` prints it.
963
+ */
964
+ export const PinnedProject = z.strictObject({
965
+ type: z.literal("project"),
966
+ format: formatOf("project"),
967
+ project: ProjectId,
968
+ });
969
+ /**
970
+ * A passkey's proof that a person made a file (D139, D163): `<file without .json>.passkey.json` beside a verdict on a
971
+ * hard card, or a preference change that lets more cards past. WebAuthn's assertion as the browser returned it, whose
972
+ * challenge is the SHA-256 digest of the file's bytes (P16 section 7). Written once, by the relay.
973
+ *
974
+ * Or, for answers confirmed together by one tap (B-a6xpc), the batch form: `batch` lists the SHA-256 digest of every file
975
+ * the tap confirmed, this one's among them, and the challenge is the digest of that list (`batchChallenge()`). Every
976
+ * proof of the batch holds the same list and the same assertion: that's the signature they share.
977
+ */
978
+ export const PasskeyProof = z.strictObject({
979
+ type: z.literal("passkey-proof"),
980
+ format: formatOf("passkey-proof"),
981
+ /** The file it proves, by name, in the same folder. */
982
+ file: z.string().min(1),
983
+ /** In the batch form (B-a6xpc): the digests of the files one tap confirmed, in the order the relay wrote them. */
984
+ batch: z.array(Sha256).min(1).optional(),
985
+ credential: Base64Url,
986
+ authenticator_data: Base64Url,
987
+ client_data: Base64Url,
988
+ signature: Base64Url,
989
+ });
990
+ /**
991
+ * `.baesian/config.json`: committed, shared by everyone working on the repo. Its `format` is optional: people write it.
992
+ * In personal mode (B-4rwyz) the same config is the clone's own, `.git/baesian/config.json`, and nothing is committed.
993
+ */
994
+ export const Config = z
995
+ .strictObject({
996
+ format: formatOf("config").optional(),
997
+ vault: z.string().optional(),
998
+ /** The hosted service's address (P16 section 8), set with `project` when the repo is linked. */
999
+ service: Origin.optional(),
1000
+ /** The project's random ID on the service. No secret, since this file is committed. */
1001
+ project: ProjectId.optional(),
1002
+ /**
1003
+ * The repo's first commit, written when it's linked (B-5gnea): a checkout whose history doesn't start there is a copy
1004
+ * of the repo, not the repo, and doesn't sync with the project. Set only with `service`.
1005
+ */
1006
+ first_commit: CommitId.optional(),
1007
+ /**
1008
+ * The paths whose edits are user-facing in this repo, as globs (B-bf29j): the plugin's hook reminds an agent about
1009
+ * to edit one that a decision there goes to the person as a card (B-qrdhh). Only a reminder, never a gate fact, so
1010
+ * it lives here, where agents set it, with no built-in list: a repo that declares none gets no reminder.
1011
+ */
1012
+ user_facing: z.array(Glob).min(1).optional(),
1013
+ /**
1014
+ * No longer read (B-qkek1): what the gate looks at is in the preference files now, which only the client writes, since
1015
+ * agents edit this file. Still accepted, so a config that has them reads, and `baesian check` and `baesian verify`
1016
+ * warn that they're ignored (`staleGateKeys()`).
1017
+ */
1018
+ gate_schema: z.array(z.string().min(1)).optional(),
1019
+ gate_infra: z.array(z.string().min(1)).optional(),
1020
+ gate_ignore: z.array(z.string().min(1)).optional(),
1021
+ gate_api: z.array(z.string().min(1)).optional(),
1022
+ })
1023
+ .superRefine((c, ctx) => {
1024
+ if ((c.service === undefined) !== (c.project === undefined)) {
1025
+ ctx.addIssue({ code: "custom", path: [c.service === undefined ? "service" : "project"], message: "service and project are set together" });
1026
+ }
1027
+ if (c.first_commit !== undefined && c.service === undefined) {
1028
+ ctx.addIssue({ code: "custom", path: ["first_commit"], message: "first_commit is set only in a linked repo, with service and project" });
1029
+ }
1030
+ });
1031
+ /**
1032
+ * `~/.config/baesian/config.json`: personal, never committed. Notification settings live here, not in
1033
+ * the repo: a committed `notify` command would run on every clone, and push tokens would leak. Its `format` is
1034
+ * optional: Baesian never writes it.
1035
+ */
1036
+ export const UserConfig = z.strictObject({
1037
+ format: formatOf("user-config").optional(),
1038
+ /** A shell command run when a blocking or hard card is filed; it receives the card as JSON on stdin (D40). */
1039
+ notify: z.string().optional(),
1040
+ /** OS notifications for blocking and hard cards. Default true. */
1041
+ os_notify: z.boolean().optional(),
1042
+ });
1043
+ // ---------------------------------------------------------------- the relay's protocol (M9b: D127, D137, P16 section 8)
1044
+ const Base64 = z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/, "base64");
1045
+ /**
1046
+ * One file in a relay's append-only log (P16 section 8), as `GET /v1/projects/{project}/log` sends it and the CLI will pull
1047
+ * it (M9d). It's part of the relay's protocol, versioned by `/v1/` in its address, not a file on disk, so it has no
1048
+ * `type` or `format` of its own: `format` is the file's. A `.sig` rides in its file's entry, as `sig`, and is never an
1049
+ * entry itself. `enc` is reserved for encryption (D137): absent in v1, and an entry that sets it is refused
1050
+ * (`readRelayEntry()`), so an encrypted entry from a later version fails loudly rather than being read as text.
1051
+ */
1052
+ export const RelayEntry = z.strictObject({
1053
+ /** The project's sequence number for it: from 1, strictly increasing, never reused. */
1054
+ seq: z.number().int().min(1),
1055
+ /** Its path in the live layer. */
1056
+ path: z.string().min(1),
1057
+ /** The file's own format number; 1 for a file that names none (D156). */
1058
+ format: z.number().int().min(1),
1059
+ /** Its length in bytes, and the SHA-256 digest of its bytes, which a signature covers. */
1060
+ size: z.number().int().min(0),
1061
+ digest: Sha256,
1062
+ /** Its bytes, in base64. Dropped once it's delivered and its card resolved (D137); the rest of the entry is kept. */
1063
+ body: Base64.optional(),
1064
+ /** Its `.sig`'s bytes, in base64, for the kinds the service signs (D138, D160). */
1065
+ sig: Base64.optional(),
1066
+ /** The signed-in machine that uploaded it, if a machine did (D162: only the relay knows). */
1067
+ machine: z.string().min(1).optional(),
1068
+ /**
1069
+ * A file a person wrote before the repo was linked, which a machine uploaded at first link (B-3zjjx): unsigned, kept
1070
+ * apart, and counted by each machine only where it was in the record at the linking commit (B-45xz8).
1071
+ */
1072
+ before_linking: z.literal(true).optional(),
1073
+ /** When the relay accepted it, by the relay's clock. */
1074
+ at: Timestamp,
1075
+ });
1076
+ /** An entry from a relay, checked (P16 section 8). One that sets `enc` is from a later version that encrypts, and is refused by name (D137). */
1077
+ export function readRelayEntry(fields) {
1078
+ if (typeof fields === "object" && fields !== null && "enc" in fields) {
1079
+ throw new Error(`the relay's entry ${JSON.stringify(fields.path ?? "")} is encrypted (enc), which this Baesian can't read: upgrade Baesian`);
1080
+ }
1081
+ return RelayEntry.parse(fields);
1082
+ }
1083
+ /**
1084
+ * What the CLI keeps of a relay (M9d, P16 section 8): `relay.json` in the live layer, mutable, replaced whole whenever it
1085
+ * syncs. Never in the record, and never sent. For one service and project: a repo linked to another starts afresh.
1086
+ */
1087
+ export const RelayState = z.strictObject({
1088
+ type: z.literal("relay"),
1089
+ format: formatOf("relay"),
1090
+ service: Origin,
1091
+ project: ProjectId,
1092
+ /** The last sequence number pulled from the relay's log. */
1093
+ pulled: z.number().int().min(0),
1094
+ /** The live layer's files the relay has: those it acknowledged an upload of, and those pulled from it. */
1095
+ uploaded: z.array(z.string().min(1)),
1096
+ /** The repo's default branch as this machine last sent it (B-jr8mk): sent again only when what it reads changes. */
1097
+ default_branch: BranchName.optional(),
1098
+ /** The last sync. */
1099
+ at: Timestamp,
1100
+ });
1101
+ /**
1102
+ * What a clone in personal mode (B-4rwyz) held of what a person writes when it was linked: `before-linking.json` in the
1103
+ * live layer, written once per project by `baesian login`. Nothing is committed in personal mode, so there's no record at
1104
+ * the linking commit to say which answers from before linking count (B-45xz8): this plays its part. Never committed, and
1105
+ * never sent: the relay takes the answers themselves, at first link (B-3zjjx).
1106
+ */
1107
+ export const BeforeLinkingSnapshot = z.strictObject({
1108
+ type: z.literal("before-linking"),
1109
+ format: formatOf("before-linking"),
1110
+ project: ProjectId,
1111
+ /** When it was taken: when the clone was linked. */
1112
+ at: Timestamp,
1113
+ /** The repo's object format, which says how each blob ID was taken. */
1114
+ object_format: z.enum(["sha1", "sha256"]),
1115
+ /** Each unsigned file a person wrote, by its path in the live layer, with its git blob ID. */
1116
+ files: z.record(z.string().min(1), z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/, "a blob ID in lowercase hex")),
1117
+ });
1118
+ /**
1119
+ * A machine's grant for one project (B-84e0d, B-gq8j1, M9d): `grants/<project>.json` beside the personal config,
1120
+ * readable only by the user (mode 0600). Written by `baesian login`, replaced whole at each refresh, before the new
1121
+ * tokens are used, since a refresh token is used once (RFC 9700); removed by `baesian logout`. Never in a repo.
1122
+ */
1123
+ export const MachineGrant = z.strictObject({
1124
+ type: z.literal("grant"),
1125
+ format: formatOf("grant"),
1126
+ service: Origin,
1127
+ project: ProjectId,
1128
+ /** The name the website's Machines page shows. */
1129
+ machine: z.string().min(1),
1130
+ access_token: z.string().min(16),
1131
+ access_expires: Timestamp,
1132
+ refresh_token: z.string().min(16),
1133
+ });
1134
+ /** Format a Zod error for a person reading a terminal. */
1135
+ export function describeError(error) {
1136
+ return z.prettifyError(error);
1137
+ }
1138
+ /**
1139
+ * Why a draft isn't a card, in a few lines rather than the schema library's message for each field (B-eyrt5): the
1140
+ * headline, then the fields that are missing, those left empty, and those whose value isn't one it takes.
1141
+ */
1142
+ export function describeDraftError(error, draft) {
1143
+ const missing = [];
1144
+ const empty = [];
1145
+ const invalid = [];
1146
+ const unknown = [];
1147
+ for (const issue of error.issues) {
1148
+ const field = issue.path.map(String).join(".") || "the card";
1149
+ if (issue.code === "invalid_type" && /received undefined$/.test(issue.message))
1150
+ missing.push(field);
1151
+ else if (issue.code === "too_small" && issue.origin === "string")
1152
+ empty.push(field);
1153
+ else if (issue.code === "invalid_value")
1154
+ invalid.push(`${field} must be ${listOf(issue.values.map(String), "or")}`);
1155
+ else if (issue.code === "unrecognized_keys")
1156
+ unknown.push(...issue.keys);
1157
+ else
1158
+ invalid.push(`${field}: ${issue.message}`);
1159
+ }
1160
+ const fields = new Set(error.issues.map((i) => i.path.map(String).join(".")));
1161
+ // Unfilled: every field is missing, or empty, or one of a list left as the template has it, "".
1162
+ const valueAt = (path) => path.reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), draft);
1163
+ const unfilled = !unknown.length && error.issues.every((i) => i.code !== "invalid_value" || [undefined, ""].includes(valueAt(i.path))) && invalid.length === error.issues.filter((i) => i.code === "invalid_value").length;
1164
+ const n = fields.size;
1165
+ const head = `not filed: ${n === 1 ? "1 of the card's fields needs" : `${n} of the card's fields need`} ${unfilled ? "filling in" : "fixing"}`;
1166
+ return [
1167
+ head,
1168
+ ...(missing.length ? [` missing: ${missing.join(", ")}`] : []),
1169
+ ...(empty.length ? [` empty: ${empty.join(", ")}`] : []),
1170
+ ...invalid.map((i) => ` invalid: ${i}`),
1171
+ ...(unknown.length ? [` unknown: ${unknown.join(", ")}, which a card doesn't have`] : []),
1172
+ ].join("\n");
1173
+ }
1174
+ /** "a, b or c". */
1175
+ function listOf(items, last) {
1176
+ return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} ${last} ${items.at(-1)}`;
1177
+ }
1178
+ //# sourceMappingURL=schema.js.map