baesian 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +217 -2
  3. package/dist/agents.js +249 -0
  4. package/dist/api.js +13 -0
  5. package/dist/bin.js +14 -0
  6. package/dist/ci.js +82 -0
  7. package/dist/cite.js +66 -0
  8. package/dist/cli.js +831 -0
  9. package/dist/client/assets/alegreya-cyrillic-ext-wght-italic-DzWRXZb0.woff2 +0 -0
  10. package/dist/client/assets/alegreya-cyrillic-ext-wght-normal-CN0-BfWD.woff2 +0 -0
  11. package/dist/client/assets/alegreya-cyrillic-wght-italic-Dhrv2xfk.woff2 +0 -0
  12. package/dist/client/assets/alegreya-cyrillic-wght-normal-CU5lb38f.woff2 +0 -0
  13. package/dist/client/assets/alegreya-greek-ext-wght-italic-CoUI9EEu.woff2 +0 -0
  14. package/dist/client/assets/alegreya-greek-ext-wght-normal-C8PPRmwJ.woff2 +0 -0
  15. package/dist/client/assets/alegreya-greek-wght-italic-CVt9-Jr4.woff2 +0 -0
  16. package/dist/client/assets/alegreya-greek-wght-normal-BPx8yWcZ.woff2 +0 -0
  17. package/dist/client/assets/alegreya-latin-ext-wght-italic-BIuo_2sR.woff2 +0 -0
  18. package/dist/client/assets/alegreya-latin-ext-wght-normal-o8axeAGu.woff2 +0 -0
  19. package/dist/client/assets/alegreya-latin-wght-italic-Bxkcgmge.woff2 +0 -0
  20. package/dist/client/assets/alegreya-latin-wght-normal-BG9rTJo4.woff2 +0 -0
  21. package/dist/client/assets/alegreya-vietnamese-wght-italic-BAPuWgnQ.woff2 +0 -0
  22. package/dist/client/assets/alegreya-vietnamese-wght-normal-DOV9JYup.woff2 +0 -0
  23. package/dist/client/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
  24. package/dist/client/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
  25. package/dist/client/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
  26. package/dist/client/assets/index-9yf70Vqv.css +1 -0
  27. package/dist/client/assets/index-Dwg9e0YR.js +15 -0
  28. package/dist/client/index.html +15 -0
  29. package/dist/facts.js +278 -0
  30. package/dist/folder-storage.js +71 -0
  31. package/dist/frontmatter.js +38 -0
  32. package/dist/gate-paths.js +77 -0
  33. package/dist/gate.js +237 -0
  34. package/dist/git.js +100 -0
  35. package/dist/globs.js +120 -0
  36. package/dist/hookblock.js +146 -0
  37. package/dist/hooks.js +911 -0
  38. package/dist/ids.js +23 -0
  39. package/dist/login.js +301 -0
  40. package/dist/mcp.js +303 -0
  41. package/dist/merges.js +849 -0
  42. package/dist/modes.js +245 -0
  43. package/dist/notify.js +91 -0
  44. package/dist/ops.js +381 -0
  45. package/dist/passkey.js +86 -0
  46. package/dist/personal.js +163 -0
  47. package/dist/practice-answers.js +42 -0
  48. package/dist/proposals.js +197 -0
  49. package/dist/quote.js +28 -0
  50. package/dist/report.js +261 -0
  51. package/dist/routing.js +411 -0
  52. package/dist/schema.js +1178 -0
  53. package/dist/server.js +514 -0
  54. package/dist/similarity.js +45 -0
  55. package/dist/sshsig.js +159 -0
  56. package/dist/state.js +135 -0
  57. package/dist/storage.js +61 -0
  58. package/dist/store.js +2259 -0
  59. package/dist/style.js +408 -0
  60. package/dist/sync.js +797 -0
  61. package/dist/trailers.js +121 -0
  62. package/dist/trust.js +222 -0
  63. package/dist/usage-fields.js +161 -0
  64. package/dist/vault.js +849 -0
  65. package/dist/verify.js +443 -0
  66. package/dist/version.js +70 -0
  67. package/dist/wait.js +449 -0
  68. package/dist/wikilinks.js +25 -0
  69. package/package.json +71 -4
package/dist/vault.js ADDED
@@ -0,0 +1,849 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
3
+ import { parseDoc, renderDoc } from "./frontmatter.js";
4
+ import { blank, cmd, literal, note } from "./style.js";
5
+ import { asWritten, newestFormat, upgradeFile } from "./schema.js";
6
+ import { Store, normalise } from "./store.js";
7
+ import { linkText, parseWikilink } from "./wikilinks.js";
8
+ // M6, the vault adapter (docs/vault/Design/Vault Adapter.md, P17; D170–D175). A vault is Markdown in the repo, with
9
+ // Obsidian as its interface. The record stays the source of truth: this module renders one note per decision and per
10
+ // rule from it, extends a note when its decision changes, and resolves a deck's wikilinks. It reads nothing from the
11
+ // vault that could change what the gate lets through (D173).
12
+ /** Where decision and rule notes go, inside the vault (D171). */
13
+ export const DECISIONS = "Decisions";
14
+ export const RULES = "Rules";
15
+ /** The vault's hand-written log, which embeds the query (D43). */
16
+ export const DECISION_LOG = "Decision Log.md";
17
+ /** The notes agents are pointed to as guidance on when to file, at the vault's root, whichever exist (D173). */
18
+ export const ROUTING_NOTES = ["Working Agreement.md", "Code Conventions.md"];
19
+ /** A decision note's title is cut at this many characters, and a rule's first words at this many (D171). */
20
+ export const TITLE_MAX = 80;
21
+ export const RULE_WORDS_MAX = 60;
22
+ // ---------------------------------------------------------------- the vault's folder
23
+ /** Why a configured vault path can't be used, if it can't: it must be relative, and inside the repo. */
24
+ export function vaultPathProblem(toplevel, vault) {
25
+ if (isAbsolute(vault))
26
+ return `the vault must be a path relative to the repo root, not ${vault}`;
27
+ const dir = resolve(toplevel, vault);
28
+ if (dir !== toplevel && !dir.startsWith(toplevel + sep))
29
+ return `the vault must be inside the repo: ${vault} is outside it`;
30
+ return undefined;
31
+ }
32
+ /** A vault path as the config keeps it: relative, with forward slashes, and no trailing slash. */
33
+ export function cleanVaultPath(vault) {
34
+ return normalize(vault).split(sep).join("/").replace(/\/+$/, "") || ".";
35
+ }
36
+ /** The store's configured vault, if it names one that's inside the repo. */
37
+ export function vaultOf(store) {
38
+ const rel = store.config().vault;
39
+ if (!rel)
40
+ return undefined;
41
+ const problem = vaultPathProblem(store.repo.toplevel, rel);
42
+ if (problem)
43
+ return { rel, problem };
44
+ return { rel: cleanVaultPath(rel), dir: resolve(store.repo.toplevel, rel) };
45
+ }
46
+ // ---------------------------------------------------------------- names (D171)
47
+ /** The characters Obsidian can't use in a note's name or a link to it. */
48
+ const FORBIDDEN = /[*"\\/<>:|?#^[\]]/g;
49
+ /** A title made fit for a file name: those characters removed, spacing collapsed, and cut at a word boundary. */
50
+ export function cleanName(text, max) {
51
+ const s = text.replace(FORBIDDEN, "").replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
52
+ const chars = [...s];
53
+ let out = s;
54
+ if (chars.length > max) {
55
+ const head = chars.slice(0, max + 1).join("");
56
+ const space = head.lastIndexOf(" ");
57
+ out = space > 0 ? head.slice(0, space) : chars.slice(0, max).join("");
58
+ }
59
+ // A name ending in a dot or a space trips some file systems.
60
+ return out.replace(/[\s.]+$/, "");
61
+ }
62
+ /** `B-3akjw How an answer becomes a rule`: the card ID, and the first revision's title, which never changes. */
63
+ export function decisionNoteName(id, firstTitle) {
64
+ const title = cleanName(firstTitle, TITLE_MAX);
65
+ return title ? `${id} ${title}` : id;
66
+ }
67
+ /** `R-g4mn8 Never put secrets in URLs`: the rule ID, and the rule's first words. */
68
+ export function ruleNoteName(id, text) {
69
+ const words = cleanName(text, RULE_WORDS_MAX);
70
+ return words ? `${id} ${words}` : id;
71
+ }
72
+ /** A note's ID, from the start of its file name: a note renamed by hand is still found if it keeps it. */
73
+ const NOTE_ID = /^([BR]-[0-9a-hjkmnp-tv-z]{5})(?=[\s.]|$)/;
74
+ export const noteIdOf = (fileName) => NOTE_ID.exec(fileName)?.[1];
75
+ const day = (iso) => iso.slice(0, 10);
76
+ const link = (name, id) => `[[${name}|${id}]]`;
77
+ /** Text ending as a sentence: a full stop added unless it already ends in one. */
78
+ const sentence = (s) => (/[.!?…]["”’)]?$/.test(s.trim()) ? s.trim() : `${s.trim()}.`);
79
+ const withoutStop = (s) => s.trim().replace(/[.]+$/, "");
80
+ /** What follows from an option: "Then runs are recorded…", or, when the agent wrote it as a sentence, that sentence. */
81
+ const consequence = (s) => (/^\p{Lu}/u.test(s.trim()) ? sentence(s) : `Then ${sentence(s)}`);
82
+ const quote = (s) => `"${s.trim()}"`;
83
+ const RESOLVES = new Set(["accept", "alternative", "correction"]);
84
+ const REOPENED_BY = {
85
+ reject: "rejected it",
86
+ question: "asked a question",
87
+ need_context: "asked for more context",
88
+ see_it_run: "asked to see it run",
89
+ not_my_area: "passed it on, as outside their area",
90
+ defer: "deferred it",
91
+ };
92
+ const HOW = {
93
+ accept: "the agent's pick",
94
+ alternative: "the alternative",
95
+ correction: "the reviewer's own answer",
96
+ };
97
+ /** One read of a record, for rendering every note from it. */
98
+ class Reading {
99
+ record;
100
+ /** The cards that supersede a card, or name it in a conflict (D35). */
101
+ claims = new Map();
102
+ histories = new Map();
103
+ constructor(record) {
104
+ this.record = record;
105
+ for (const c of record.cards()) {
106
+ for (const target of [c.meta.supersedes, ...(c.meta.conflict_of ?? [])]) {
107
+ if (target)
108
+ (this.claims.get(target) ?? this.claims.set(target, []).get(target)).push(c);
109
+ }
110
+ }
111
+ }
112
+ first(id) {
113
+ return this.record.revisions(id)[0];
114
+ }
115
+ /** What a verdict decided, for one that resolves. */
116
+ answerOf(v) {
117
+ if (!RESOLVES.has(v.response))
118
+ return undefined;
119
+ const card = this.record.revisions(v.card).find((r) => r.meta.revision === v.revision);
120
+ if (!card)
121
+ return undefined;
122
+ return { verdict: v, card, answer: v.response === "accept" ? card.meta.proposal : v.choice };
123
+ }
124
+ /** A link to a card's decision note: its name is built from its first revision, so any copy of the card builds it. */
125
+ linkTo(id) {
126
+ const first = this.first(id);
127
+ return first ? link(decisionNoteName(id, first.meta.title), id) : id;
128
+ }
129
+ /** A link for the frontmatter, which is rendered afresh each time: only to a card that has a note. */
130
+ linkIfNoted(id) {
131
+ return this.history(id) ? this.linkTo(id) : id;
132
+ }
133
+ /** Whether a card gets a decision note: it is or was resolved, and isn't a rule draft (D110). */
134
+ noted(id) {
135
+ return this.history(id) !== undefined;
136
+ }
137
+ /**
138
+ * The card's history after its first answer, walked in time order from immutable files only, so the sections a
139
+ * note gains are the same on every branch that holds the same files, and a note written fresh equals one written
140
+ * earlier and extended since.
141
+ */
142
+ history(id) {
143
+ if (this.histories.has(id))
144
+ return this.histories.get(id);
145
+ this.histories.set(id, undefined); // a cycle of claims reads as no history, not a loop
146
+ const h = this.walk(id);
147
+ this.histories.set(id, h);
148
+ return h;
149
+ }
150
+ walk(id) {
151
+ const revisions = this.record.revisions(id);
152
+ const r1 = revisions[0];
153
+ if (!r1 || r1.meta.rule !== undefined)
154
+ return undefined;
155
+ const evs = this.record.verdicts(id).map((v) => ({ at: v.at, rank: 0, kind: "verdict", v }));
156
+ for (const rev of revisions)
157
+ if (rev.meta.reply !== undefined)
158
+ evs.push({ at: rev.meta.created, rank: 1, kind: "reply", rev });
159
+ for (const parent of r1.meta.depends_on) {
160
+ for (const v of this.first(parent) ? this.record.verdicts(parent) : []) {
161
+ if (v.at < r1.meta.created)
162
+ continue;
163
+ if (v.response === "reject" || v.response === "alternative" || v.response === "correction")
164
+ evs.push({ at: v.at, rank: 2, kind: "parent", parent, v });
165
+ }
166
+ }
167
+ for (const claimant of this.claims.get(id) ?? []) {
168
+ for (const v of this.record.verdicts(claimant.meta.id))
169
+ if (RESOLVES.has(v.response))
170
+ evs.push({ at: v.at, rank: 3, kind: "claim", claimant, v });
171
+ }
172
+ const w = this.record.withdrawal(id);
173
+ if (w)
174
+ evs.push({ at: w.at, rank: 4, kind: "withdrawn", by: w.by, reason: w.reason });
175
+ evs.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : a.rank - b.rank));
176
+ let phase = "none";
177
+ let first;
178
+ let latest;
179
+ let supersededBy;
180
+ const sections = [];
181
+ const add = (at, heading, lines) => sections.push({ at, heading: `${heading} on ${day(at)}`, text: lines.filter((l) => Boolean(l)).join("\n\n") });
182
+ const notInForce = "Until the card is answered again, this decision isn't in force.";
183
+ for (const e of evs) {
184
+ if (phase === "done")
185
+ break;
186
+ if (e.kind === "verdict") {
187
+ const r = this.answerOf(e.v);
188
+ if (r) {
189
+ if (phase === "none") {
190
+ first = latest = r;
191
+ phase = "active";
192
+ continue;
193
+ }
194
+ const was = latest.answer;
195
+ const how = HOW[e.v.response];
196
+ const comment = e.v.comment && `**Comment:** ${quote(e.v.comment)}`;
197
+ if (normalise(r.answer) !== normalise(was)) {
198
+ add(e.at, "Changed", [`**Now:** ${sentence(`${withoutStop(r.answer)}, ${how}`)} **Was:** ${sentence(was)} Answered by ${e.v.reviewer}.`, comment]);
199
+ }
200
+ else if (phase === "reopened") {
201
+ add(e.at, "Answered again", [`**Decided:** ${sentence(`${withoutStop(r.answer)}, ${how}, as before`)} Answered by ${e.v.reviewer}.`, comment]);
202
+ }
203
+ latest = r;
204
+ phase = "active";
205
+ }
206
+ else if (phase === "active") {
207
+ const what = REOPENED_BY[e.v.response] ?? e.v.response;
208
+ add(e.at, "Reopened", [e.v.comment ? `${e.v.reviewer} ${what}: ${quote(e.v.comment)}` : `${e.v.reviewer} ${what}.`, notInForce]);
209
+ phase = "reopened";
210
+ }
211
+ }
212
+ else if (phase === "none") {
213
+ if (e.kind === "withdrawn" || (e.kind === "parent" && e.v.response === "reject"))
214
+ return undefined; // withdrawn before any answer
215
+ }
216
+ else if (e.kind === "reply") {
217
+ if (phase !== "active")
218
+ continue;
219
+ add(e.at, "Reopened", [`${e.rev.meta.source_agent} replied to the answer, once: ${quote(e.rev.meta.reply)}`, notInForce]);
220
+ phase = "reopened";
221
+ }
222
+ else if (e.kind === "parent") {
223
+ if (e.v.response === "reject") {
224
+ add(e.at, "Withdrawn", [`It builds on ${e.parent}, which was rejected${e.v.comment ? `: ${quote(e.v.comment)}` : "."}`]);
225
+ phase = "done";
226
+ }
227
+ else if (phase === "active") {
228
+ add(e.at, "Reopened", [`It builds on ${this.linkTo(e.parent)}, which was decided otherwise: ${quote(e.v.choice ?? "")}.`, notInForce]);
229
+ phase = "reopened";
230
+ }
231
+ }
232
+ else if (e.kind === "claim") {
233
+ const won = this.answerOf(e.v);
234
+ const supersedes = e.claimant.meta.supersedes === id;
235
+ // A conflict card supersedes the decisions it names that it didn't choose (D35).
236
+ if (!supersedes && normalise(won.answer) === normalise(latest.answer))
237
+ continue;
238
+ add(e.at, "Superseded", [`By ${this.linkTo(e.claimant.meta.id)}, which decided: ${sentence(won.answer)}`]);
239
+ supersededBy = e.claimant.meta.id;
240
+ phase = "done";
241
+ }
242
+ else {
243
+ add(e.at, "Withdrawn", [`By ${e.by}: ${sentence(e.reason)}`]);
244
+ phase = "done";
245
+ }
246
+ }
247
+ if (!first || !latest)
248
+ return undefined;
249
+ return { first, latest, sections, ...(supersededBy ? { supersededBy } : {}) };
250
+ }
251
+ }
252
+ // ---------------------------------------------------------------- rendering
253
+ /** The note's status, from the store's (D172): `active`, `superseded`, `withdrawn`, or `reopened` for anything open again. */
254
+ function noteStatus(s) {
255
+ return s === "resolved" ? "active" : s === "superseded" || s === "withdrawn" ? s : "reopened";
256
+ }
257
+ /** The options a resolution didn't choose: one, or both for the reviewer's own answer. */
258
+ function notChosen(r) {
259
+ const m = r.card.meta;
260
+ const options = [
261
+ { name: m.proposal, then: m.proposal_then, pick: true },
262
+ { name: m.alternative, then: m.alternative_then, pick: false },
263
+ ];
264
+ if (r.verdict.response === "correction")
265
+ return options;
266
+ return options.filter((o) => normalise(o.name) !== normalise(r.answer));
267
+ }
268
+ /** Keys the adapter owns, in the order it writes them: the vault's own keys, then the decision's (P17 §2). */
269
+ const DECISION_KEYS = [
270
+ "type", "format", "status", "authority", "livedocs", "summary", "created", "updated", "tags", "aliases",
271
+ "card", "revision", "title", "question", "answer", "response", "not_chosen", "answered", "answered_by",
272
+ "topic", "deck", "level", "reverse", "touches", "filed", "filed_by", "filed_model", "branch",
273
+ "depends_on", "supersedes", "superseded_by", "conflict_of",
274
+ ];
275
+ const RULE_KEYS = [
276
+ "type", "format", "status", "authority", "livedocs", "summary", "created", "updated", "tags", "aliases",
277
+ "rule", "topic", "covers", "from_card", "accepted_by", "accepted", "retired", "retired_by", "retired_on",
278
+ ];
279
+ function decisionNote(read, id, h) {
280
+ const record = read.record;
281
+ const r1 = read.first(id);
282
+ const a = h.latest;
283
+ const m = a.card.meta;
284
+ const meta = {
285
+ type: "decision",
286
+ format: newestFormat("decision-note"),
287
+ status: noteStatus(record.status(id)),
288
+ authority: "log",
289
+ livedocs: "snapshot",
290
+ summary: `${withoutStop(m.title)}: ${withoutStop(a.answer)} (${id}).`,
291
+ created: day(h.first.verdict.at),
292
+ updated: day(h.sections.at(-1)?.at ?? h.first.verdict.at),
293
+ tags: ["baesian", "decision"],
294
+ aliases: [id],
295
+ card: id,
296
+ revision: m.revision,
297
+ title: m.title,
298
+ question: m.question,
299
+ answer: a.answer,
300
+ response: a.verdict.response,
301
+ not_chosen: notChosen(a).map((o) => o.name),
302
+ answered: a.verdict.at,
303
+ answered_by: a.verdict.reviewer,
304
+ topic: record.canonicalTopic(m.topic),
305
+ deck: m.deck,
306
+ level: m.level,
307
+ reverse: m.reverse,
308
+ touches: m.touches,
309
+ filed: r1.meta.created,
310
+ filed_by: r1.meta.source_agent,
311
+ filed_model: r1.meta.source_model,
312
+ branch: r1.meta.source_branch,
313
+ depends_on: m.depends_on.map((p) => read.linkIfNoted(p)),
314
+ supersedes: m.supersedes ? read.linkIfNoted(m.supersedes) : undefined,
315
+ superseded_by: record.status(id) === "superseded" && h.supersededBy ? read.linkIfNoted(h.supersededBy) : undefined,
316
+ conflict_of: m.conflict_of?.map((c) => read.linkIfNoted(c)),
317
+ };
318
+ return { kind: "decision-note", meta, owned: DECISION_KEYS, head: decisionHead(read, id, h.first), sections: h.sections };
319
+ }
320
+ /** Who filed a card: its agent, and the model it ran on when its harness named one (B-k0w7d): "claude-code (claude-opus-5-5)". */
321
+ const filedBy = (m) => (m.source_model ? `${m.source_agent} (${m.source_model})` : m.source_agent);
322
+ /** The body as a person reads it, in the client's order: the question, what was decided, then the rest (P17 §2). */
323
+ function decisionHead(read, id, r) {
324
+ const m = r.card.meta;
325
+ const v = r.verdict;
326
+ const r1 = read.first(id);
327
+ const how = HOW[v.response];
328
+ const chosen = v.response === "accept" ? { then: m.proposal_then } : v.response === "alternative" && normalise(v.choice) === normalise(m.alternative) ? { then: m.alternative_then } : {};
329
+ const lines = [`# ${m.title}`, m.question];
330
+ lines.push(`**Decided:** ${sentence(`${withoutStop(r.answer)}, ${how}`)}${chosen.then ? ` ${consequence(chosen.then)}` : ""}`);
331
+ for (const o of notChosen(r))
332
+ lines.push(`**Not chosen:** ${sentence(o.pick ? `${withoutStop(o.name)}, the agent's pick` : o.name)} ${consequence(o.then)}`);
333
+ lines.push(`**It turned on:** ${m.crux}${m.crux_preference ? " (a preference)" : ""} ${withoutStop(m.crux_proposal)}: ${sentence(m.proposal)} ${withoutStop(m.crux_alternative)}: ${sentence(m.alternative)}`);
334
+ if (m.crux_fact)
335
+ lines.push(`**The fact given:** ${sentence(m.crux_fact)}`);
336
+ if (v.comment)
337
+ lines.push(`**Comment:** ${quote(v.comment)}`);
338
+ // B-hswmx: the reviewer answered it in chat first, and the agent filed that answer as its pick.
339
+ if (m.answered_in_chat)
340
+ lines.push(`**Answered in chat first:** ${quote(m.answered_in_chat)}, which the agent filed as its pick.`);
341
+ const option = v.response === "accept" ? "proposal" : v.response === "alternative" ? "alternative" : undefined;
342
+ if (v.first_choice && option && v.first_choice !== option)
343
+ lines.push(`**First chose:** ${sentence(`${withoutStop(m[v.first_choice])}, then switched once the agent's pick was shown`)}`);
344
+ if (m.rationale)
345
+ lines.push(`**The agent's case:** ${sentence(m.rationale)}`);
346
+ if (m.wrong_if)
347
+ lines.push(`**Wrong if:** ${sentence(m.wrong_if)}${m.check ? ` **To check:** ${sentence(m.check)}` : ""}`);
348
+ if (m.reply)
349
+ lines.push(`**The agent's reply:** ${sentence(m.reply)}`);
350
+ const deck = read.record.deck(m.deck);
351
+ const context = (deck?.meta.context_notes ?? []).map((n) => (n.startsWith("[[") ? n : `[[${n}]]`));
352
+ lines.push([
353
+ `Answered by ${v.reviewer} on ${day(v.at)}, through Baesian (card ${id}, revision ${m.revision}).`,
354
+ `Filed by ${filedBy(r1.meta)} on ${r1.meta.source_branch}, in the deck ${deck ? `“${deck.meta.title}”` : m.deck}.`,
355
+ context.length ? `Context: ${context.join(", ")}.` : "",
356
+ ]
357
+ .filter(Boolean)
358
+ .join(" "), `The record is in \`.baesian/cards/${id}/\`. To change this decision, answer the card again or file one that supersedes it: editing this note changes nothing.`);
359
+ if (r.card.body.trim())
360
+ lines.push("## Background", r.card.body.trim());
361
+ return lines.join("\n\n");
362
+ }
363
+ /** A rule's first sentence, for its summary. */
364
+ function firstSentence(text) {
365
+ const flat = text.replace(/\s+/g, " ").trim();
366
+ const m = /^.*?[.!?](?=\s|$)/.exec(flat);
367
+ return m ? m[0] : flat;
368
+ }
369
+ function ruleNote(read, rule) {
370
+ const { meta: r, retired } = rule;
371
+ const text = rule.body.trim();
372
+ const meta = {
373
+ type: "rule",
374
+ format: newestFormat("rule-note"),
375
+ status: retired ? "retired" : "active",
376
+ authority: "specifies",
377
+ livedocs: "snapshot",
378
+ summary: firstSentence(text),
379
+ created: day(r.created),
380
+ updated: day(retired?.at ?? r.created),
381
+ tags: ["baesian", "rule"],
382
+ aliases: [r.id],
383
+ rule: r.id,
384
+ topic: r.topic,
385
+ covers: r.covers,
386
+ from_card: r.from_card ? read.linkIfNoted(r.from_card) : undefined,
387
+ accepted_by: r.accepted_by,
388
+ accepted: r.created,
389
+ retired: retired?.at,
390
+ retired_by: retired?.by,
391
+ retired_on: retired ? read.linkIfNoted(retired.card) : undefined,
392
+ };
393
+ // A rule reaches every branch's record, but the card it came from may not: the body names cards only by ID, so
394
+ // every branch writes the same bytes. The frontmatter, rendered afresh each time, links them.
395
+ const words = [...text.replace(/\s+/g, " ")].length > RULE_WORDS_MAX ? `${cleanName(text, RULE_WORDS_MAX)}…` : text.replace(/\s+/g, " ");
396
+ const covers = r.covers.length
397
+ ? `It lets a commit through the gate without a card when these are its only decisions: ${r.covers.map((c) => `\`${c}\``).join(", ")}.`
398
+ : `It covers no gate facts: it's shown beside the cards on its topic${r.topic ? `, ${r.topic}` : ""}.`;
399
+ const head = [
400
+ `# ${words}`,
401
+ text.split("\n").map((l) => `> ${l}`.trimEnd()).join("\n"),
402
+ [`Accepted by ${r.accepted_by} on ${day(r.created)}${r.from_card ? `, from the decision ${r.from_card}` : ""}.`, covers].join(" "),
403
+ `The rule is in \`.baesian/rules/${r.id}.md\`. Editing this note changes no rule: a rule is made by accepting a draft in the Baesian client, and retired by an answer there.`,
404
+ ].join("\n\n");
405
+ const sections = retired
406
+ ? [{ at: retired.at, heading: `Retired on ${day(retired.at)}`, text: `By ${retired.by}, with the answer on ${retired.card}, whose chosen option breaks it. The gate no longer applies it.` }]
407
+ : [];
408
+ return { kind: "rule-note", meta, owned: RULE_KEYS, head, sections };
409
+ }
410
+ /** A note written from scratch. */
411
+ function fresh(n) {
412
+ return renderDoc(asWritten(n.kind, n.meta), [n.head, ...n.sections.map(sectionText)].map((p) => p.trimEnd()).join("\n\n"));
413
+ }
414
+ const sectionText = (s) => `## ${s.heading}\n\n${s.text}`;
415
+ const GENERATED = /^## (?:Superseded|Changed|Reopened|Withdrawn|Answered again|Retired) on \d{4}-\d{2}-\d{2}$/;
416
+ /**
417
+ * An existing note brought up to date (D172): the owned keys rendered afresh, in order, then the keys a person added;
418
+ * and each missing section added, in its place among the ones already there, or at the end. Nothing in the body is
419
+ * rewritten, so text a person added stays where they put it. A note with no `format` was written before v1, and gains
420
+ * it now; one in a newer format than this Baesian writes is refused (D157), and left as it is.
421
+ */
422
+ export function extendNote(existing, n) {
423
+ const { meta: old, body } = parseDoc(existing);
424
+ upgradeFile(n.kind, old, "the note");
425
+ const owned = new Set(n.owned);
426
+ const union = (key) => [...new Set([...(n.meta[key] ?? []), ...(Array.isArray(old[key]) ? old[key].map(String) : [])])];
427
+ const meta = { ...n.meta, tags: union("tags"), aliases: union("aliases") };
428
+ for (const [k, v] of Object.entries(old))
429
+ if (!owned.has(k))
430
+ meta[k] = v;
431
+ const lines = body.split("\n");
432
+ const found = lines.flatMap((l, i) => (GENERATED.test(l.trimEnd()) ? [{ heading: l.trimEnd(), line: i }] : []));
433
+ let p = 0;
434
+ const at = n.sections.map((s) => {
435
+ const want = `## ${s.heading}`;
436
+ let q = p;
437
+ while (q < found.length && found[q].heading !== want)
438
+ q++;
439
+ if (q >= found.length)
440
+ return undefined;
441
+ p = q + 1;
442
+ return found[q].line;
443
+ });
444
+ const before = new Map();
445
+ const end = [];
446
+ n.sections.forEach((s, i) => {
447
+ if (at[i] !== undefined)
448
+ return;
449
+ const anchor = at.slice(i + 1).find((x) => x !== undefined);
450
+ if (anchor === undefined)
451
+ end.push(sectionText(s));
452
+ else
453
+ (before.get(anchor) ?? before.set(anchor, []).get(anchor)).push(sectionText(s));
454
+ });
455
+ const out = [];
456
+ lines.forEach((l, i) => {
457
+ for (const s of before.get(i) ?? []) {
458
+ if (out.length && out.at(-1).trim() !== "")
459
+ out.push("");
460
+ out.push(...s.split("\n"), "");
461
+ }
462
+ out.push(l);
463
+ });
464
+ let text = out.join("\n");
465
+ if (end.length)
466
+ text = `${text.trimEnd()}\n\n${end.join("\n\n")}\n`;
467
+ return renderDoc(asWritten(n.kind, meta), text);
468
+ }
469
+ /** Every note in a vault folder, by the ID its name starts with: a note moved into a subfolder is found too. */
470
+ function notesById(dir) {
471
+ const out = new Map();
472
+ const walk = (d) => {
473
+ let names;
474
+ try {
475
+ names = readdirSync(d).sort();
476
+ }
477
+ catch {
478
+ return;
479
+ }
480
+ for (const name of names) {
481
+ const path = join(d, name);
482
+ const st = lstatSync(path);
483
+ if (st.isDirectory() && !name.startsWith("."))
484
+ walk(path);
485
+ else if (st.isFile() && name.endsWith(".md")) {
486
+ const id = noteIdOf(name);
487
+ if (id && !out.has(id))
488
+ out.set(id, path);
489
+ }
490
+ }
491
+ };
492
+ walk(dir);
493
+ return out;
494
+ }
495
+ /** The IDs of the decisions and rules a record calls notes for. */
496
+ export function notedIds(record) {
497
+ return record.view(() => {
498
+ const read = new Reading(record);
499
+ return { decisions: record.cardIds().filter((id) => read.noted(id)), rules: record.rules().map((r) => r.meta.id) };
500
+ });
501
+ }
502
+ /**
503
+ * The notes a record calls for in a vault, each with what it should hold (D170–D172). Read-only. A note whose
504
+ * frontmatter can't be read is left alone, with a warning.
505
+ */
506
+ export function planNotes(record, toplevel, vault) {
507
+ const warnings = [];
508
+ const notes = [];
509
+ const rel = (abs) => relative(toplevel, abs).split(sep).join("/");
510
+ record.view(() => {
511
+ const read = new Reading(record);
512
+ const plan = (kind, id, folder, name, existing, n) => {
513
+ const path = existing.get(id) ?? join(vault.dir, folder, `${name}.md`);
514
+ const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
515
+ let next;
516
+ try {
517
+ next = current === undefined ? fresh(n) : extendNote(current, n);
518
+ }
519
+ catch (e) {
520
+ warnings.push(`left ${rel(path)} alone: ${e instanceof Error ? e.message : String(e)}`);
521
+ return;
522
+ }
523
+ notes.push({ kind, id, path: rel(path), ...(current !== undefined ? { current } : {}), next, update: (text) => extendNote(text, n) });
524
+ };
525
+ const decisions = notesById(join(vault.dir, DECISIONS));
526
+ for (const id of record.cardIds()) {
527
+ const h = read.history(id);
528
+ if (!h)
529
+ continue;
530
+ plan("decision", id, DECISIONS, decisionNoteName(id, read.first(id).meta.title), decisions, decisionNote(read, id, h));
531
+ }
532
+ const rules = notesById(join(vault.dir, RULES));
533
+ for (const rule of record.rules())
534
+ plan("rule", rule.meta.id, RULES, ruleNoteName(rule.meta.id, rule.body), rules, ruleNote(read, rule));
535
+ });
536
+ return { notes, warnings };
537
+ }
538
+ /**
539
+ * Bring the vault's notes up to date with the worktree's record (D170): a note for each decision and rule that has
540
+ * none, and the changes to those that have one (D172). Nothing happens without a vault in the config (D8). A vault
541
+ * that isn't there is a warning, never an error: the notes are a view.
542
+ */
543
+ export function writeNotes(store, opts = {}) {
544
+ const v = vaultOf(store);
545
+ if (!v)
546
+ return { notes: [], planned: [], written: [], skipped: [], warnings: [] };
547
+ const result = { vault: v.rel, notes: [], planned: [], written: [], skipped: [], warnings: [] };
548
+ if ("problem" in v) {
549
+ result.warnings.push(`${v.problem} (.baesian/config.json); no vault notes written`);
550
+ return { ...result, unusable: true };
551
+ }
552
+ if (!existsSync(v.dir)) {
553
+ result.warnings.push(`the vault ${v.rel}/ that .baesian/config.json names isn't there; no vault notes written`);
554
+ return { ...result, unusable: true };
555
+ }
556
+ if (!existsSync(store.record))
557
+ return result;
558
+ const record = store.readOnly ? store : Store.overRecord(store.repo, store.record);
559
+ const { notes, warnings } = planNotes(record, store.repo.toplevel, v);
560
+ result.warnings.push(...warnings);
561
+ result.planned = notes;
562
+ for (const n of notes) {
563
+ if (n.next !== n.current) {
564
+ if (opts.mayWrite && !opts.mayWrite(n)) {
565
+ result.skipped.push(n.path);
566
+ if (n.current !== undefined)
567
+ result.notes.push(n.path);
568
+ continue;
569
+ }
570
+ const abs = join(store.repo.toplevel, n.path);
571
+ mkdirSync(dirname(abs), { recursive: true });
572
+ writeFileSync(abs, n.next);
573
+ result.written.push(n.path);
574
+ }
575
+ result.notes.push(n.path);
576
+ }
577
+ return result;
578
+ }
579
+ /** What `baesian verify` warns about (P17 §11): a missing vault, and decisions or rules in the record with no note. */
580
+ export function vaultWarnings(record, files) {
581
+ const vault = record.config().vault;
582
+ if (!vault)
583
+ return [];
584
+ const rel = cleanVaultPath(vault);
585
+ if (!files.vaultExists)
586
+ return [`the vault ${rel}/ that .baesian/config.json names isn't in the commit`];
587
+ const present = new Set(files.paths.map((p) => noteIdOf(p.split("/").at(-1))).filter((x) => x !== undefined));
588
+ const { decisions, rules } = notedIds(record);
589
+ const missing = [...decisions, ...rules].filter((id) => !present.has(id));
590
+ if (!missing.length)
591
+ return [];
592
+ const shown = missing.slice(0, 8).join(", ") + (missing.length > 8 ? `, and ${missing.length - 8} more` : "");
593
+ return [`${rel}/ lacks ${missing.length} note${missing.length === 1 ? "" : "s"} for what the record holds (${shown}): run \`baesian sync\`, then commit.`];
594
+ }
595
+ // ---------------------------------------------------------------- `baesian init --vault` (P17 §5)
596
+ /** The Decision Log's section: one Bases query over the notes, with three views (P17 §5, unchanged). */
597
+ export const DECISION_LOG_SECTION = `## Decided through Baesian
598
+
599
+ One note per decision in \`Decisions/\`, and per rule in \`Rules/\`, written by Baesian from the record.
600
+
601
+ \`\`\`base
602
+ filters:
603
+ or:
604
+ - file.inFolder("Decisions")
605
+ - file.inFolder("Rules")
606
+ properties:
607
+ note.answer:
608
+ displayName: Decided
609
+ note.answered:
610
+ displayName: Answered
611
+ note.covers:
612
+ displayName: Gate facts
613
+ views:
614
+ - type: table
615
+ name: Decisions in force
616
+ filters:
617
+ and:
618
+ - 'note.type == "decision"'
619
+ - 'note.status == "active"'
620
+ order:
621
+ - file.name
622
+ - note.answer
623
+ - note.answered
624
+ - note.topic
625
+ sort:
626
+ - property: note.answered
627
+ direction: DESC
628
+ - type: table
629
+ name: Rules in force
630
+ filters:
631
+ and:
632
+ - 'note.type == "rule"'
633
+ - 'note.status == "active"'
634
+ order:
635
+ - file.name
636
+ - note.summary
637
+ - note.covers
638
+ - note.accepted
639
+ sort:
640
+ - property: note.accepted
641
+ direction: DESC
642
+ - type: table
643
+ name: Everything
644
+ order:
645
+ - file.name
646
+ - note.type
647
+ - note.status
648
+ - note.answer
649
+ - note.updated
650
+ sort:
651
+ - property: note.updated
652
+ direction: DESC
653
+ \`\`\`
654
+ `;
655
+ /** Whether a note already holds the query: the section's heading, or a base block over `Decisions`. */
656
+ function holdsQuery(text) {
657
+ return /^## Decided through Baesian\s*$/m.test(text) || /```base[\s\S]*?file\.inFolder\("Decisions"\)[\s\S]*?```/.test(text);
658
+ }
659
+ /**
660
+ * Insert the section into the vault's Decision Log, once, before its first second-level heading (or at the end). It's
661
+ * never rewritten after that. `no-log` means there's no Decision Log to put it in: print it to paste instead.
662
+ */
663
+ export function insertLogQuery(vaultDir) {
664
+ const path = join(vaultDir, DECISION_LOG);
665
+ if (!existsSync(path))
666
+ return "no-log";
667
+ const text = readFileSync(path, "utf8");
668
+ if (holdsQuery(text))
669
+ return "present";
670
+ const lines = text.split("\n");
671
+ let fenced = false;
672
+ let at = -1;
673
+ // Skip the frontmatter, and anything in a code block, looking for the first `## ` heading.
674
+ let i = lines[0]?.trim() === "---" ? lines.findIndex((l, j) => j > 0 && l.trim() === "---") + 1 : 0;
675
+ for (; i < lines.length; i++) {
676
+ const l = lines[i];
677
+ if (/^\s*(```|~~~)/.test(l))
678
+ fenced = !fenced;
679
+ else if (!fenced && /^## /.test(l)) {
680
+ at = i;
681
+ break;
682
+ }
683
+ }
684
+ const section = DECISION_LOG_SECTION.trimEnd().split("\n");
685
+ const next = at < 0 ? `${text.trimEnd()}\n\n${DECISION_LOG_SECTION}` : [...lines.slice(0, at), ...section, "", ...lines.slice(at)].join("\n");
686
+ writeFileSync(path, next);
687
+ return "inserted";
688
+ }
689
+ /**
690
+ * Turn Obsidian's Bases core plugin on (P17 §5), in the vault's `.obsidian/core-plugins.json`: set `"bases": true` in
691
+ * its object form, keeping the other keys and their order, or add `"bases"` to its older list form. A vault with no
692
+ * `.obsidian/`, or no `core-plugins.json`, is left alone: creating them would give Obsidian settings nobody chose.
693
+ */
694
+ export function enableBases(vaultDir) {
695
+ const obsidian = join(vaultDir, ".obsidian");
696
+ if (!existsSync(obsidian))
697
+ return "no-obsidian";
698
+ const path = join(obsidian, "core-plugins.json");
699
+ if (!existsSync(path))
700
+ return "no-core-plugins";
701
+ const text = readFileSync(path, "utf8");
702
+ let value;
703
+ try {
704
+ value = JSON.parse(text);
705
+ }
706
+ catch {
707
+ return "unreadable";
708
+ }
709
+ if (Array.isArray(value)) {
710
+ if (value.includes("bases"))
711
+ return "already";
712
+ value.push("bases");
713
+ }
714
+ else if (typeof value === "object" && value !== null) {
715
+ const plugins = value;
716
+ if (plugins.bases === true)
717
+ return "already";
718
+ plugins.bases = true;
719
+ }
720
+ else {
721
+ return "unreadable";
722
+ }
723
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}${text.endsWith("\n") ? "\n" : ""}`);
724
+ return "enabled";
725
+ }
726
+ /** The routing notes at a vault's root, whichever exist, relative to the repo root (D173). */
727
+ export function routingNotes(toplevel, vault) {
728
+ const rel = cleanVaultPath(vault);
729
+ return ROUTING_NOTES.filter((n) => existsSync(join(toplevel, rel, n))).map((n) => (rel === "." ? n : `${rel}/${n}`));
730
+ }
731
+ /** What the `AGENTS.md` block points agents to, with a vault configured (D173): its routing notes, and its rules. */
732
+ export function vaultGuidance(store) {
733
+ const v = vaultOf(store);
734
+ if (!v || "problem" in v)
735
+ return undefined;
736
+ return { rules: `${v.rel === "." ? "" : `${v.rel}/`}${RULES}/`, notes: routingNotes(store.repo.toplevel, v.rel) };
737
+ }
738
+ /**
739
+ * `baesian init --vault` (P17 §5, §11): record the vault, in a config that may already exist; turn Bases on; and insert
740
+ * the Decision Log's query, or say to paste it. Returns the lines to print. Writes no notes: the next commit does.
741
+ */
742
+ export function initVault(store, vault) {
743
+ const changed = store.setVault(vault);
744
+ const recorded = `${changed ? "recorded in" : "already in"} .baesian/config.json`;
745
+ const dir = resolve(store.repo.toplevel, vault);
746
+ if (!existsSync(dir)) {
747
+ return {
748
+ changed,
749
+ field: { label: "vault", value: `${vault}/`, why: recorded },
750
+ notes: [note(`there's no folder ${vault}/ yet. Create the vault there (`, cmd(`livedocs new-vault ${vault}`), "), then run this again.")],
751
+ };
752
+ }
753
+ const done = [recorded];
754
+ const notes = [];
755
+ const bases = enableBases(dir);
756
+ const where = `${vault}/.obsidian/core-plugins.json`;
757
+ if (bases === "enabled")
758
+ done.push(`; Bases turned on in ${where}`);
759
+ else if (bases === "no-obsidian")
760
+ notes.push(note(`${vault}/ has no .obsidian/, so Bases isn't turned on; open it in Obsidian, then run this again, or turn Bases on in Obsidian's settings.`));
761
+ else if (bases === "no-core-plugins" || bases === "unreadable")
762
+ notes.push(note(`couldn't ${bases === "unreadable" ? "read" : "find"} ${where}; turn Bases on in Obsidian's settings (Core plugins).`));
763
+ const log = insertLogQuery(dir);
764
+ if (log === "inserted")
765
+ done.push(`; the decision query added to ${vault}/${DECISION_LOG}`);
766
+ else if (log === "no-log")
767
+ notes.push(note(`${vault}/ has no ${DECISION_LOG}. To list the decisions in a note, paste this section into it:`), blank, ...DECISION_LOG_SECTION.trimEnd().split("\n").map(literal));
768
+ done.push("; notes for the decisions already made are written by the next commit, or now by ", cmd("baesian sync"));
769
+ return { changed, field: { label: "vault", value: `${vault}/`, why: done }, notes };
770
+ }
771
+ // ---------------------------------------------------------------- links from decks (P17 §8)
772
+ // A wikilink's parts, and what one shows, are in src/wikilinks.ts, which the relay's state module can import.
773
+ export { parseWikilink } from "./wikilinks.js";
774
+ /** The vault's Markdown notes, relative to it, with forward slashes and no `.md`. Hidden folders and symlinks are skipped. */
775
+ export function listNotes(vaultDir) {
776
+ const out = [];
777
+ const walk = (d, prefix) => {
778
+ let names;
779
+ try {
780
+ names = readdirSync(d).sort();
781
+ }
782
+ catch {
783
+ return;
784
+ }
785
+ for (const name of names) {
786
+ if (name.startsWith("."))
787
+ continue;
788
+ const path = join(d, name);
789
+ const st = lstatSync(path);
790
+ if (st.isDirectory())
791
+ walk(path, `${prefix}${name}/`);
792
+ else if (st.isFile() && name.endsWith(".md"))
793
+ out.push(`${prefix}${name.slice(0, -3)}`);
794
+ }
795
+ };
796
+ walk(vaultDir, "");
797
+ return out;
798
+ }
799
+ /**
800
+ * The note a wikilink names, as Obsidian resolves it: `[[Name]]` by file name anywhere in the vault, `[[Folder/Name]]`
801
+ * by path, ignoring case; if several match, the shortest path.
802
+ */
803
+ export function resolveWikilink(notes, target) {
804
+ const t = target.replace(/\.md$/i, "").replace(/^\/+/, "").toLowerCase();
805
+ if (!t)
806
+ return undefined;
807
+ const matches = notes.filter((n) => {
808
+ const l = n.toLowerCase();
809
+ return t.includes("/") ? l === t || l.endsWith(`/${t}`) : (l.split("/").at(-1) === t);
810
+ });
811
+ return matches.sort((a, b) => a.length - b.length || (a < b ? -1 : 1))[0];
812
+ }
813
+ /** A note's `summary:` line, if its frontmatter has one. */
814
+ function summaryOf(path) {
815
+ try {
816
+ const s = parseDoc(readFileSync(path, "utf8")).meta.summary;
817
+ return typeof s === "string" && s.trim() ? s.trim() : undefined;
818
+ }
819
+ catch {
820
+ return undefined;
821
+ }
822
+ }
823
+ /**
824
+ * A deck's context notes for the client (P17 §8): each link with what to show, and, with a vault, the note it
825
+ * resolves to and its summary line, or that it's missing. Without a vault, the links are plain text.
826
+ */
827
+ export function contextNotes(store, links, cache = {}) {
828
+ let v;
829
+ try {
830
+ v = links.length ? vaultOf(store) : undefined;
831
+ }
832
+ catch {
833
+ v = undefined; // an unreadable config: the links show as plain text
834
+ }
835
+ const vault = v && !("problem" in v) && existsSync(v.dir) ? v : undefined;
836
+ const all = vault ? (cache.notes ??= listNotes(vault.dir)) : [];
837
+ return links.map((raw) => {
838
+ const { target } = parseWikilink(raw);
839
+ const shown = linkText(raw);
840
+ if (!vault)
841
+ return { link: raw, text: shown };
842
+ const found = resolveWikilink(all, target);
843
+ if (!found)
844
+ return { link: raw, text: shown, missing: true };
845
+ const summary = summaryOf(join(vault.dir, `${found}.md`));
846
+ return { link: raw, text: shown, path: vault.rel === "." ? `${found}.md` : `${vault.rel}/${found}.md`, ...(summary ? { summary } : {}) };
847
+ });
848
+ }
849
+ //# sourceMappingURL=vault.js.map