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.
- package/LICENSE.md +105 -0
- package/README.md +217 -2
- package/dist/agents.js +249 -0
- package/dist/api.js +13 -0
- package/dist/bin.js +14 -0
- package/dist/ci.js +82 -0
- package/dist/cite.js +66 -0
- package/dist/cli.js +831 -0
- package/dist/client/assets/alegreya-cyrillic-ext-wght-italic-DzWRXZb0.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-ext-wght-normal-CN0-BfWD.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-wght-italic-Dhrv2xfk.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-wght-normal-CU5lb38f.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-ext-wght-italic-CoUI9EEu.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-ext-wght-normal-C8PPRmwJ.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-wght-italic-CVt9-Jr4.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-wght-normal-BPx8yWcZ.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-ext-wght-italic-BIuo_2sR.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-ext-wght-normal-o8axeAGu.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-wght-italic-Bxkcgmge.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-wght-normal-BG9rTJo4.woff2 +0 -0
- package/dist/client/assets/alegreya-vietnamese-wght-italic-BAPuWgnQ.woff2 +0 -0
- package/dist/client/assets/alegreya-vietnamese-wght-normal-DOV9JYup.woff2 +0 -0
- package/dist/client/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
- package/dist/client/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
- package/dist/client/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
- package/dist/client/assets/index-9yf70Vqv.css +1 -0
- package/dist/client/assets/index-Dwg9e0YR.js +15 -0
- package/dist/client/index.html +15 -0
- package/dist/facts.js +278 -0
- package/dist/folder-storage.js +71 -0
- package/dist/frontmatter.js +38 -0
- package/dist/gate-paths.js +77 -0
- package/dist/gate.js +237 -0
- package/dist/git.js +100 -0
- package/dist/globs.js +120 -0
- package/dist/hookblock.js +146 -0
- package/dist/hooks.js +911 -0
- package/dist/ids.js +23 -0
- package/dist/login.js +301 -0
- package/dist/mcp.js +303 -0
- package/dist/merges.js +849 -0
- package/dist/modes.js +245 -0
- package/dist/notify.js +91 -0
- package/dist/ops.js +381 -0
- package/dist/passkey.js +86 -0
- package/dist/personal.js +163 -0
- package/dist/practice-answers.js +42 -0
- package/dist/proposals.js +197 -0
- package/dist/quote.js +28 -0
- package/dist/report.js +261 -0
- package/dist/routing.js +411 -0
- package/dist/schema.js +1178 -0
- package/dist/server.js +514 -0
- package/dist/similarity.js +45 -0
- package/dist/sshsig.js +159 -0
- package/dist/state.js +135 -0
- package/dist/storage.js +61 -0
- package/dist/store.js +2259 -0
- package/dist/style.js +408 -0
- package/dist/sync.js +797 -0
- package/dist/trailers.js +121 -0
- package/dist/trust.js +222 -0
- package/dist/usage-fields.js +161 -0
- package/dist/vault.js +849 -0
- package/dist/verify.js +443 -0
- package/dist/version.js +70 -0
- package/dist/wait.js +449 -0
- package/dist/wikilinks.js +25 -0
- package/package.json +71 -4
package/dist/store.js
ADDED
|
@@ -0,0 +1,2259 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
import { FolderStorage } from "./folder-storage.js";
|
|
6
|
+
import { parseDoc, renderDoc } from "./frontmatter.js";
|
|
7
|
+
import { PATH_KEYS, PATH_KINDS, pathSetting } from "./gate-paths.js";
|
|
8
|
+
import { defaultBranch, git, openRepo, slug } from "./git.js";
|
|
9
|
+
import { newId, randomSuffix } from "./ids.js";
|
|
10
|
+
import { BAESIAN, GATE_VERSION, PRESETS, ROWS, TODAY, higherLevel, isOwnCard, levelInForce, loosening, rowOf, route, suggestPreset, } from "./routing.js";
|
|
11
|
+
import { Attachment, BeforeLinkingSnapshot, CardDraft, CardMeta, Config, Cursor, FACT_PATTERN, DeckMeta, DefaultBranch, Fallback, GateRecord, HARD_BY_DEFAULT, LedgerMeta, MergeEvent, NewerFormat, PREFERENCE_FILE, PasskeyProof, Preferences, Presence, RuleMeta, RuleRetirement, SessionModel, TopicAlias, Verdict, Waiting, Withdrawal, filingProblems, filingWarnings, secretsIn, describeDraftError, describeError, asWritten, newestFormat, upgradeFile, } from "./schema.js";
|
|
12
|
+
import { Told, cite } from "./cite.js";
|
|
13
|
+
import { proofProblem } from "./passkey.js";
|
|
14
|
+
import { byLine, cardAuthor } from "./quote.js";
|
|
15
|
+
import { SIMILAR_TITLE, jaccard, nearTopics, tokens } from "./similarity.js";
|
|
16
|
+
import { alreadyExists } from "./storage.js";
|
|
17
|
+
import { readHistory } from "./trailers.js";
|
|
18
|
+
import { checkSigned, pinnedTrust, readTrustFile } from "./trust.js";
|
|
19
|
+
/** The live layer, inside git's common directory: shared by every worktree of a clone, never committed (D34). */
|
|
20
|
+
export const LIVE_DIR = "baesian";
|
|
21
|
+
/** The record layer, committed on the branch (D34). */
|
|
22
|
+
export const RECORD_DIR = ".baesian";
|
|
23
|
+
/**
|
|
24
|
+
* Personal mode (B-4rwyz): the clone's own config, in the live layer, in place of the record's. Its being there is what
|
|
25
|
+
* makes a clone personal: nothing of Baesian's is committed, and the live layer plays the record's part.
|
|
26
|
+
*/
|
|
27
|
+
export const PERSONAL_CONFIG = "config.json";
|
|
28
|
+
/** Personal mode: what the clone held of what a person writes when it was linked (`BeforeLinkingSnapshot`), in the live layer. */
|
|
29
|
+
export const BEFORE_LINKING = "before-linking.json";
|
|
30
|
+
/** The repo's default branch as a machine sent it (B-jr8mk, `DefaultBranch`): only on the relay, whose store has no git. */
|
|
31
|
+
export const DEFAULT_BRANCH = "default-branch.json";
|
|
32
|
+
const KINDS = ["cards", "verdicts", "ledger", "decks", "rules", "topics", "attachments", "waiting", "merges"];
|
|
33
|
+
/** Statuses that still need the reviewer. A held card doesn't, unless it's drawn for audit (D150, D151). */
|
|
34
|
+
export const PENDING = new Set(["open", "returned", "rerouted", "deferred"]);
|
|
35
|
+
export { BAESIAN } from "./routing.js";
|
|
36
|
+
/** Responses that return a card to its agent. */
|
|
37
|
+
const RETURNS = new Set(["reject", "question", "need_context", "see_it_run"]);
|
|
38
|
+
/** A choice the reviewer answered in chat comes to the gate as one, and is shown, for them to confirm (B-hswmx). */
|
|
39
|
+
const chatOr = (m, otherwise) => (m.answered_in_chat !== undefined ? "chat" : otherwise);
|
|
40
|
+
/**
|
|
41
|
+
* A card revision's draw for audit (P16 section 3): a SHA-256 hash of its ID and revision, its first six bytes read as a
|
|
42
|
+
* number from 0 to 1. The store picks the ID, so the agent can't choose it; `baesian verify` recomputes it.
|
|
43
|
+
*/
|
|
44
|
+
export function drawOf(id, revision) {
|
|
45
|
+
return createHash("sha256").update(`${id} r${revision}`).digest().readUIntBE(0, 6) / 2 ** 48;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Where a harness session's model is kept in the live layer (B-k0w7d): `models/<session>.json`, by the session's ID as
|
|
49
|
+
* `slug()` makes it, which the plugin's hooks repeat.
|
|
50
|
+
*/
|
|
51
|
+
export const modelPath = (session) => `models/${slug(session)}.json`;
|
|
52
|
+
/** The settings a preference file holds, as the gate reads them. */
|
|
53
|
+
export function settingsOf(p) {
|
|
54
|
+
return {
|
|
55
|
+
mode: p.mode,
|
|
56
|
+
rows: Object.fromEntries(ROWS.map((r) => [r, p[`row_${r}`]])),
|
|
57
|
+
rate_agent_decides: p.rate_agent_decides,
|
|
58
|
+
rate_sample: p.rate_sample,
|
|
59
|
+
burn_in: p.burn_in,
|
|
60
|
+
paths: Object.fromEntries(PATH_KINDS.flatMap((k) => (p[PATH_KEYS[k]] ? [[k, p[PATH_KEYS[k]]]] : []))),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** An inbox item's name in the cursor's `delivered` list: the card it's listed under, and its verdict. */
|
|
64
|
+
const inboxKey = (i) => `${i.card} ${i.verdict.card} ${i.verdict.at}`;
|
|
65
|
+
/** A time as a file name starts with it (D107): `20261101T093012.345Z`. */
|
|
66
|
+
const compactAt = (at) => at.replace(/[-:]/g, "");
|
|
67
|
+
/** A rule, or its retirement (D119): `rules/<id>.md`, `rules/<id>-retired.json`. */
|
|
68
|
+
const RULE_FILE = /^R-[0-9a-z]{5}(\.md|-retired\.json)$/;
|
|
69
|
+
/** The card a verdict file is for, from its name: `<at>-<id>-r<revision>-<suffix>.json` (D107). */
|
|
70
|
+
const VERDICT_FILE = /^\d{8}T\d{6}\.\d{3}Z-(B-[0-9a-z]{5})-r\d+-[0-9a-z]+\.json$/;
|
|
71
|
+
export const verdictFileCard = (name) => VERDICT_FILE.exec(name)?.[1];
|
|
72
|
+
/** A relay's gate record, which the service signs (D158). */
|
|
73
|
+
const RELAY_GATE = /^gate-r\d+-relay\.json$/;
|
|
74
|
+
/** A file that goes with another: its signature (D160), or its passkey proof (D139). Copied with it, never read as it. */
|
|
75
|
+
const isCompanion = (name) => name.endsWith(".sig") || name.endsWith(".passkey.json");
|
|
76
|
+
/**
|
|
77
|
+
* Where a merge is recorded (D181): `merges/<commit>-<key>.json`, the key the first 12 hex digits of the SHA-256 of the
|
|
78
|
+
* merged branch's name and its target's, so the same merge found on two machines is the same file, a branch's name never
|
|
79
|
+
* needs escaping, and one branch fast-forwarded into two branches at one commit is two merges, not one lost (the bug sweep
|
|
80
|
+
* of 2026-11-05: two builders' worktrees brought up to one v1/next tip).
|
|
81
|
+
*/
|
|
82
|
+
export const mergeFile = (commit, branch, into) => `merges/${commit}-${createHash("sha256").update(`${branch}\0${into}`).digest("hex").slice(0, 12)}.json`;
|
|
83
|
+
/** Where a file's passkey proof would be: beside it, `.passkey.json` for `.json` (P16 section 7). */
|
|
84
|
+
export const proofPath = (rel) => rel.replace(/\.json$/, ".passkey.json");
|
|
85
|
+
/**
|
|
86
|
+
* Whether a file in the live layer is of a kind the service signs (D138, P16 section 7): what a person writes, as
|
|
87
|
+
* `Store.judge` judges it (verdicts, rules and their retirements, topic aliases, preference files), the relay's gate
|
|
88
|
+
* records, and passkey keys. The relay signs each as it's written; a machine may upload none of them (P15 section 3).
|
|
89
|
+
*/
|
|
90
|
+
export function signedFile(rel) {
|
|
91
|
+
const [dir, a, b, ...rest] = rel.split("/");
|
|
92
|
+
if (!a || rest.length)
|
|
93
|
+
return false;
|
|
94
|
+
if (b !== undefined)
|
|
95
|
+
return dir === "cards" && RELAY_GATE.test(b);
|
|
96
|
+
if (dir === "verdicts")
|
|
97
|
+
return verdictFileCard(a) !== undefined;
|
|
98
|
+
if (dir === "rules")
|
|
99
|
+
return RULE_FILE.test(a);
|
|
100
|
+
if (dir === "topics")
|
|
101
|
+
return a.startsWith("alias-") && a.endsWith(".json");
|
|
102
|
+
if (dir === "preferences")
|
|
103
|
+
return PREFERENCE_FILE.test(a);
|
|
104
|
+
if (dir === "trust")
|
|
105
|
+
return a.startsWith("passkey-") && a.endsWith(".json");
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const COUNTS = { ok: true };
|
|
109
|
+
const doesnt = (why) => ({ ok: false, why });
|
|
110
|
+
/** A waiting marker counts as gone after three missed beats, as presence does (D41, D90). */
|
|
111
|
+
export const WAITING_BEAT_MS = 30_000;
|
|
112
|
+
export const WAITING_STALE_MS = 90_000;
|
|
113
|
+
// The store's errors, by kind, so the MCP server can name each with a code (P18). The CLI prints their messages, and
|
|
114
|
+
// exits 1, as it always has; `PossibleDuplicate` exits 3.
|
|
115
|
+
/** The card fails its schema or a filing rule. */
|
|
116
|
+
export class ValidationError extends Told {
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* In a hosted repo, a draft that looks like it holds a secret, with no `secret_reason` (B-jfkv9): nothing is written. The
|
|
120
|
+
* message names each field and what it looks like, never the text, as filing's warning does (D136).
|
|
121
|
+
*/
|
|
122
|
+
export class LooksLikeSecret extends ValidationError {
|
|
123
|
+
secrets;
|
|
124
|
+
constructor(secrets) {
|
|
125
|
+
super([
|
|
126
|
+
`not filed: in a hosted repo a card goes to the service as it's filed, and this one looks like it holds a secret${cite("B-jfkv9")}`,
|
|
127
|
+
...[...secrets].map(([field, kind]) => ` ${field} looks like it holds ${kind}`),
|
|
128
|
+
"Take it out and file again. If it isn't a secret, file it anyway, with a reason that's kept on the card (secret_reason in the MCP tools):",
|
|
129
|
+
].join("\n"), [{ command: 'baesian file --secret-reason "<why>"', why: "only if it isn't a secret" }]);
|
|
130
|
+
this.secrets = secrets;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Raised by `fileCard` when the decision may already be open or decided (D36, D38). */
|
|
134
|
+
export class PossibleDuplicate extends Error {
|
|
135
|
+
matches;
|
|
136
|
+
title;
|
|
137
|
+
constructor(matches, title) {
|
|
138
|
+
super("this decision may already be on file");
|
|
139
|
+
this.matches = matches;
|
|
140
|
+
this.title = title;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Baesian isn't set up in the repository. */
|
|
144
|
+
export class NotSetUp extends Told {
|
|
145
|
+
constructor() {
|
|
146
|
+
super("Baesian is not set up in this repository", [{ command: "baesian init", why: "set it up" }]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** No card with that ID. */
|
|
150
|
+
export class NoCard extends Told {
|
|
151
|
+
id;
|
|
152
|
+
constructor(id) {
|
|
153
|
+
super(`no card ${id}`);
|
|
154
|
+
this.id = id;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** The card's state, or who is asking, doesn't allow it: revising a card that isn't returned, a second reply. */
|
|
158
|
+
export class NotAllowed extends Told {
|
|
159
|
+
}
|
|
160
|
+
/** The card names a deck that doesn't exist. */
|
|
161
|
+
export class NoDeck extends Told {
|
|
162
|
+
}
|
|
163
|
+
/** A preference change made from an older view: its `replaces` isn't the newest file (P16 section 2). The server says 409. */
|
|
164
|
+
export class StalePreferences extends Error {
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* What was asked needs git or the record, and this store runs over storage alone, as the relay's does (B-0ag7h): setting
|
|
168
|
+
* up, the record, the vault, a document's path in the repo. Nothing is written when it's raised.
|
|
169
|
+
*/
|
|
170
|
+
export class NeedsRepo extends Error {
|
|
171
|
+
constructor(what) {
|
|
172
|
+
super(`${what} needs the repository and its record, and this store has neither: it runs over the live layer's storage alone`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Why an unsigned answer from before a repo was linked can't be checked (B-45xz8): in a shallow clone, the commit that
|
|
177
|
+
* linked the repo may be beyond its history.
|
|
178
|
+
*/
|
|
179
|
+
export const SHALLOW_HISTORY = `it isn't signed, and whether it was in the record when the repo was linked can't be told in a shallow clone${cite("B-45xz8")}: fetch the full history (in CI, fetch-depth: 0)`;
|
|
180
|
+
/** Why the commit that linked the repo can't be told (B-mar8x): two or more commits link it, none descending from another. */
|
|
181
|
+
export function unclearLink(project, commits) {
|
|
182
|
+
return `${commits.length} commits in this history link the repo to project ${project}, none descending from another (${commits.map((c) => c.slice(0, 12)).join(", ")}), so which one linked it can't be told, and nothing unsigned counts as from before linking${cite("B-mar8x")}. One was made on a branch from before the link, or the link was committed again, by a cherry-pick or a rebase.`;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The record at the commit that linked the repo to `project` (B-45xz8): in `rev`'s history, the commit whose config names
|
|
186
|
+
* `project` and no ancestor of which names it. It's found by ancestry, never by commit dates, which anyone can set
|
|
187
|
+
* (B-mar8x). Two or more such commits, none descending from another, fail closed (`unclearLink()`). With none, the record
|
|
188
|
+
* at `rev` stands in for it when `standIn` says so (on your machine, before the link is committed); else there's none.
|
|
189
|
+
*/
|
|
190
|
+
export function recordAtLinking(top, rev, project, opts) {
|
|
191
|
+
const config = `${RECORD_DIR}/config.json`;
|
|
192
|
+
// The commits in the given commits' history that changed the config: a merge counts when it differs from its parents.
|
|
193
|
+
const changing = (revs) => git(top, ["rev-list", "--full-history", ...revs, "--", config]).split("\n").filter(Boolean);
|
|
194
|
+
let changed;
|
|
195
|
+
try {
|
|
196
|
+
changed = changing([rev]);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return undefined; // no commits yet
|
|
200
|
+
}
|
|
201
|
+
const names = (commit) => {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(git(top, ["show", `${commit}:${config}`])).project === project;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const naming = new Set(changed.filter(names));
|
|
210
|
+
// A commit whose config names the project either changed the config, and is in `naming`, or has the config of a parent,
|
|
211
|
+
// which then names it too. So a commit has an ancestor naming the project exactly when one in `naming` is among them.
|
|
212
|
+
const first = [...naming].filter((c) => {
|
|
213
|
+
const parents = git(top, ["rev-list", "--parents", "-n", "1", c]).split(" ").slice(1);
|
|
214
|
+
return !parents.length || !changing(parents).some((a) => naming.has(a));
|
|
215
|
+
});
|
|
216
|
+
// A shallow clone's oldest commits seem to add every file: the real link may be further back.
|
|
217
|
+
let shallow = [];
|
|
218
|
+
try {
|
|
219
|
+
shallow = readFileSync(git(top, ["rev-parse", "--path-format=absolute", "--git-path", "shallow"]), "utf8").split("\n");
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// not shallow
|
|
223
|
+
}
|
|
224
|
+
if (first.some((c) => shallow.includes(c)))
|
|
225
|
+
return { problem: SHALLOW_HISTORY };
|
|
226
|
+
if (first.length > 1)
|
|
227
|
+
return { problem: unclearLink(project, first.sort()) };
|
|
228
|
+
let commit = first[0];
|
|
229
|
+
if (commit === undefined) {
|
|
230
|
+
if (!opts.standIn)
|
|
231
|
+
return undefined;
|
|
232
|
+
try {
|
|
233
|
+
commit = git(top, ["rev-parse", "--verify", "-q", `${rev}^{commit}`]);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const blobs = new Map();
|
|
240
|
+
for (const entry of git(top, ["ls-tree", "-r", "-z", commit, "--", RECORD_DIR]).split("\0")) {
|
|
241
|
+
const m = /^\d+ blob ([0-9a-f]+)\t(.+)$/.exec(entry);
|
|
242
|
+
if (m && m[2].startsWith(`${RECORD_DIR}/`))
|
|
243
|
+
blobs.set(m[2].slice(RECORD_DIR.length + 1), m[1]);
|
|
244
|
+
}
|
|
245
|
+
let objectFormat = "sha1";
|
|
246
|
+
try {
|
|
247
|
+
objectFormat = git(top, ["rev-parse", "--show-object-format"]) || "sha1";
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// an older git: SHA-1
|
|
251
|
+
}
|
|
252
|
+
return { commit, blobs, objectFormat };
|
|
253
|
+
}
|
|
254
|
+
function now() {
|
|
255
|
+
return new Date().toISOString();
|
|
256
|
+
}
|
|
257
|
+
const encoder = new TextEncoder();
|
|
258
|
+
const encode = (text) => encoder.encode(text);
|
|
259
|
+
/** A file's bytes as a Buffer, without copying them: signatures and digests read them this way. */
|
|
260
|
+
const asBuffer = (bytes) => (Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
261
|
+
const decode = (bytes) => asBuffer(bytes).toString("utf8");
|
|
262
|
+
/** A file's git blob ID, as `git hash-object` gives it, in the repo's object format. */
|
|
263
|
+
const blobId = (bytes, objectFormat) => createHash(objectFormat === "sha256" ? "sha256" : "sha1").update(`blob ${bytes.byteLength}\0`).update(bytes).digest("hex");
|
|
264
|
+
/** With no git to search, no commit is found by its trailers, and none is said to be rewritten. */
|
|
265
|
+
const NO_HISTORY = { citing: new Map(), onBranch: () => true };
|
|
266
|
+
/** Compare decisions loosely: case and spacing don't make two answers different. */
|
|
267
|
+
export function normalise(text) {
|
|
268
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
269
|
+
}
|
|
270
|
+
function parse(schema, value, what) {
|
|
271
|
+
const result = schema.safeParse(value);
|
|
272
|
+
if (!result.success) {
|
|
273
|
+
// A draft an agent filed is summarised, with where to see its fields (B-eyrt5); a file Baesian reads is named.
|
|
274
|
+
if (what === "the card")
|
|
275
|
+
throw new ValidationError(describeDraftError(result.error, value), [TEMPLATE_STEP]);
|
|
276
|
+
throw new ValidationError(`${what} is not valid:\n${describeError(result.error)}`);
|
|
277
|
+
}
|
|
278
|
+
return result.data;
|
|
279
|
+
}
|
|
280
|
+
/** Where to see a card's fields, with a note on each. */
|
|
281
|
+
const TEMPLATE_STEP = { command: "baesian template", why: "the draft, with a note on each field" };
|
|
282
|
+
/**
|
|
283
|
+
* A file read from disk, in the newest format of its kind (D156): upgraded in memory, then checked by its schema. One in a
|
|
284
|
+
* newer format than this Baesian reads throws `NewerFormat` (D157); a view that reports problems reports it instead.
|
|
285
|
+
*/
|
|
286
|
+
function readAs(kind, schema, fields, what) {
|
|
287
|
+
return parse(schema, upgradeFile(kind, fields, what), what);
|
|
288
|
+
}
|
|
289
|
+
export class Store {
|
|
290
|
+
storage;
|
|
291
|
+
local;
|
|
292
|
+
options;
|
|
293
|
+
/** A read-only store over one directory, e.g. a commit's record in CI (M3b). */
|
|
294
|
+
readOnly;
|
|
295
|
+
constructor(
|
|
296
|
+
/** The live layer (B-0ag7h): a folder on your machine, the relay's table, or memory. */
|
|
297
|
+
storage, local, options) {
|
|
298
|
+
this.storage = storage;
|
|
299
|
+
this.local = local;
|
|
300
|
+
this.options = options;
|
|
301
|
+
this.readOnly = options.readOnly;
|
|
302
|
+
}
|
|
303
|
+
static open(cwd, trust) {
|
|
304
|
+
const repo = openRepo(cwd);
|
|
305
|
+
const live = join(repo.commonDir, LIVE_DIR);
|
|
306
|
+
return new Store(new FolderStorage(live), { repo, live, record: join(repo.toplevel, RECORD_DIR) }, { trust: trust ?? {}, readOnly: false });
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Read a record as if it were the live layer. This is how CI sees cards and verdicts: it has no
|
|
310
|
+
* live layer, only what was committed (D34). `commit` is the one whose record it is, whose history holds the commit
|
|
311
|
+
* that linked the repo (B-45xz8); by default, HEAD.
|
|
312
|
+
*/
|
|
313
|
+
static overRecord(repo, dir, trust, commit) {
|
|
314
|
+
return new Store(new FolderStorage(dir), { repo, live: dir, record: dir }, { trust: trust ?? {}, readOnly: true, ...(commit ? { commit } : {}) });
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* A store over the live layer's storage alone (B-0ag7h), as the relay runs it: no repo, no record, no git. It files,
|
|
318
|
+
* answers and checks as any store does; what needs git or the record throws `NeedsRepo`, and nothing touches a disk.
|
|
319
|
+
*/
|
|
320
|
+
static over(storage, options) {
|
|
321
|
+
return new Store(storage, undefined, { branch: options.branch, config: options.config, trust: { pinned: options.trust?.pinned ?? null }, readOnly: false, ...(options.beforeLinking ? { beforeLinking: options.beforeLinking } : {}), ...(options.heldEarlier ? { heldEarlier: options.heldEarlier } : {}) });
|
|
322
|
+
}
|
|
323
|
+
// ---------------------------------------------------------------- layers
|
|
324
|
+
/** The repo, the live layer's folder and the record, or `NeedsRepo` for a store over storage alone. */
|
|
325
|
+
need(what) {
|
|
326
|
+
if (!this.local)
|
|
327
|
+
throw new NeedsRepo(what);
|
|
328
|
+
return this.local;
|
|
329
|
+
}
|
|
330
|
+
/** The git repository. A store over storage alone has none (`NeedsRepo`). */
|
|
331
|
+
get repo() {
|
|
332
|
+
return this.need("the repository").repo;
|
|
333
|
+
}
|
|
334
|
+
/** The live layer's folder, on your machine; a store over storage alone has none (`NeedsRepo`). */
|
|
335
|
+
get live() {
|
|
336
|
+
return this.need("the live layer's folder").live;
|
|
337
|
+
}
|
|
338
|
+
/** The worktree's record, `.baesian/`, or the directory a read-only store reads. */
|
|
339
|
+
get record() {
|
|
340
|
+
return this.need("the record").record;
|
|
341
|
+
}
|
|
342
|
+
/** Whether this store has a repo and a record, as it does on your machine; the relay's has neither (B-0ag7h). */
|
|
343
|
+
get hasRepo() {
|
|
344
|
+
return this.local !== undefined;
|
|
345
|
+
}
|
|
346
|
+
/** The branch it files on: the checked-out one, or the one a store over storage alone was given. */
|
|
347
|
+
get branch() {
|
|
348
|
+
return this.local ? this.local.repo.branch : this.options.branch;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Set up means the shared live layer exists. A worktree's record is created on first write: a new
|
|
352
|
+
* worktree has no `.baesian/` until the branch it came from committed one. A store over storage alone is set up.
|
|
353
|
+
*/
|
|
354
|
+
isInitialised() {
|
|
355
|
+
return this.local ? existsSync(this.local.live) : true;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Whether this clone uses Baesian in personal mode (B-4rwyz): its config is the clone's own, in the live layer, and
|
|
359
|
+
* nothing of Baesian's is committed. The live layer plays the record's part; no record is written. A read-only view of a
|
|
360
|
+
* committed record, and a store over storage alone, never are.
|
|
361
|
+
*/
|
|
362
|
+
get personal() {
|
|
363
|
+
return this.local !== undefined && !this.readOnly && existsSync(join(this.local.live, PERSONAL_CONFIG));
|
|
364
|
+
}
|
|
365
|
+
/** Where the config is: the worktree's record, or in personal mode the clone's live layer. */
|
|
366
|
+
configPath() {
|
|
367
|
+
const local = this.need("the config");
|
|
368
|
+
return this.personal ? join(local.live, PERSONAL_CONFIG) : join(local.record, "config.json");
|
|
369
|
+
}
|
|
370
|
+
/** Write the config whole, in the newest format, where `configPath()` says. */
|
|
371
|
+
writeConfig(config) {
|
|
372
|
+
if (this.readOnly)
|
|
373
|
+
throw new Error("this store is a read-only view of a committed record");
|
|
374
|
+
const path = this.configPath();
|
|
375
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
376
|
+
writeFileSync(path, `${JSON.stringify(asWritten("config", parse(Config, { ...config, format: newestFormat("config") }, "config")), null, 2)}\n`);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Create both layers. Safe to run again. Returns the paths it created. In personal mode (B-4rwyz), the live layer only,
|
|
380
|
+
* with the config in it: nothing is written in the worktree.
|
|
381
|
+
*/
|
|
382
|
+
init(config = {}, opts = {}) {
|
|
383
|
+
const { live, record } = this.need("setting up (`baesian init`)");
|
|
384
|
+
const created = [];
|
|
385
|
+
for (const kind of KINDS) {
|
|
386
|
+
const dir = join(live, kind);
|
|
387
|
+
if (!existsSync(dir)) {
|
|
388
|
+
mkdirSync(dir, { recursive: true });
|
|
389
|
+
created.push(dir);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const configPath = opts.personal || this.personal ? join(live, PERSONAL_CONFIG) : join(record, "config.json");
|
|
393
|
+
if (!existsSync(configPath)) {
|
|
394
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
395
|
+
writeFileSync(configPath, `${JSON.stringify(asWritten("config", parse(Config, { ...config, format: newestFormat("config") }, "config")), null, 2)}\n`);
|
|
396
|
+
created.push(configPath);
|
|
397
|
+
}
|
|
398
|
+
return created;
|
|
399
|
+
}
|
|
400
|
+
/** The repo's config, from the record, or the clone's in personal mode; for a store over storage alone, the one it was given. */
|
|
401
|
+
config() {
|
|
402
|
+
if (!this.local)
|
|
403
|
+
return this.options.config ?? {};
|
|
404
|
+
const path = this.configPath();
|
|
405
|
+
return existsSync(path) ? readAs("config", Config, JSON.parse(readFileSync(path, "utf8")), "config.json") : {};
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Record the vault in this worktree's config (M6), keeping its other keys: `init` writes a config only once, so
|
|
409
|
+
* `init --vault` in a repo already set up comes here. Returns whether it changed anything.
|
|
410
|
+
*/
|
|
411
|
+
setVault(vault) {
|
|
412
|
+
this.need("recording the vault");
|
|
413
|
+
const { vault: was, ...rest } = this.config();
|
|
414
|
+
if (was === vault)
|
|
415
|
+
return false;
|
|
416
|
+
this.writeConfig({ ...rest, vault });
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Link the repo to a project on the hosted service (M9d, B-5gnea): the service, the project and the repo's first commit,
|
|
421
|
+
* in this worktree's config, keeping its other keys. A gate fact when committed (B-w4g6c). In personal mode (B-4rwyz),
|
|
422
|
+
* in the clone's config, and what the clone holds of what a person wrote is kept, as the record at the linking commit
|
|
423
|
+
* would have kept it (`snapshotBeforeLinking()`).
|
|
424
|
+
*/
|
|
425
|
+
setLink(link) {
|
|
426
|
+
this.need("linking the repo");
|
|
427
|
+
const { service: _s, project: _p, first_commit: _f, ...rest } = this.config();
|
|
428
|
+
if (this.personal)
|
|
429
|
+
this.snapshotBeforeLinking(link.project);
|
|
430
|
+
this.writeConfig({ ...rest, ...link });
|
|
431
|
+
this.linkCache = undefined;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Personal mode (B-4rwyz): keep the blob ID of each unsigned file a person wrote, as the clone holds them when it's
|
|
435
|
+
* linked to `project`, unless that's kept already: those are the answers from before linking (B-45xz8), which count as
|
|
436
|
+
* they are, and go up at first link (B-3zjjx). Linking again to the same project keeps the first snapshot, so nothing
|
|
437
|
+
* written since counts.
|
|
438
|
+
*/
|
|
439
|
+
snapshotBeforeLinking(project) {
|
|
440
|
+
if (this.snapshotFor(project))
|
|
441
|
+
return;
|
|
442
|
+
const top = this.repo.toplevel;
|
|
443
|
+
let objectFormat = "sha1";
|
|
444
|
+
try {
|
|
445
|
+
if (git(top, ["rev-parse", "--show-object-format"]) === "sha256")
|
|
446
|
+
objectFormat = "sha256";
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// an older git: SHA-1
|
|
450
|
+
}
|
|
451
|
+
const files = {};
|
|
452
|
+
for (const dir of ["verdicts", "rules", "topics", "preferences"]) {
|
|
453
|
+
for (const f of this.storage.list(dir).sort()) {
|
|
454
|
+
const rel = `${dir}/${f}`;
|
|
455
|
+
if (f.endsWith(".sig") || this.storage.read(`${rel}.sig`) !== undefined)
|
|
456
|
+
continue;
|
|
457
|
+
const bytes = this.storage.read(rel);
|
|
458
|
+
if (bytes)
|
|
459
|
+
files[rel] = blobId(bytes, objectFormat);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const snapshot = parse(BeforeLinkingSnapshot, { type: "before-linking", format: newestFormat("before-linking"), project, at: now(), object_format: objectFormat, files }, "the before-linking snapshot");
|
|
463
|
+
this.storage.replace(BEFORE_LINKING, encode(`${JSON.stringify(asWritten("before-linking", snapshot), null, 2)}\n`));
|
|
464
|
+
}
|
|
465
|
+
/** The snapshot personal mode took when the clone was linked to `project` (B-4rwyz), if it took one. */
|
|
466
|
+
snapshotFor(project) {
|
|
467
|
+
const bytes = this.storage.read(BEFORE_LINKING);
|
|
468
|
+
if (!bytes)
|
|
469
|
+
return undefined;
|
|
470
|
+
const s = readAs("before-linking", BeforeLinkingSnapshot, JSON.parse(decode(bytes)), BEFORE_LINKING);
|
|
471
|
+
return s.project === project ? s : undefined;
|
|
472
|
+
}
|
|
473
|
+
/** Throws `NotSetUp` unless the live layer exists. */
|
|
474
|
+
requireInit() {
|
|
475
|
+
if (!this.isInitialised())
|
|
476
|
+
throw new NotSetUp();
|
|
477
|
+
}
|
|
478
|
+
/** Write a file that must never change (D34), through the storage's `create`: it fails if the file exists. */
|
|
479
|
+
writeOnce(rel, text) {
|
|
480
|
+
if (this.readOnly)
|
|
481
|
+
throw new Error("this store is a read-only view of a committed record");
|
|
482
|
+
this.storage.create(rel, encode(text));
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Copy a live file into this worktree's record, unless it's already there (D37). A store with no record has nowhere
|
|
486
|
+
* to copy it: the record is your machine's, and it pulls what the relay holds (P15).
|
|
487
|
+
*/
|
|
488
|
+
toRecord(rel) {
|
|
489
|
+
// Personal mode (B-4rwyz) writes no record: the live layer plays its part.
|
|
490
|
+
if (!this.local || this.personal)
|
|
491
|
+
return false;
|
|
492
|
+
const target = join(this.local.record, ...rel.split("/"));
|
|
493
|
+
if (existsSync(target))
|
|
494
|
+
return false;
|
|
495
|
+
const bytes = this.storage.read(rel);
|
|
496
|
+
if (!bytes)
|
|
497
|
+
throw new Error(`${rel} isn't in the live layer, so it can't be copied into the record`);
|
|
498
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
499
|
+
writeFileSync(target, bytes);
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
/** A folder's names, sorted. In a view, each folder is listed once. */
|
|
503
|
+
list(dir) {
|
|
504
|
+
const cached = this.session?.lists.get(dir);
|
|
505
|
+
if (cached)
|
|
506
|
+
return cached;
|
|
507
|
+
const names = this.storage.list(dir).sort();
|
|
508
|
+
this.session?.lists.set(dir, names);
|
|
509
|
+
return names;
|
|
510
|
+
}
|
|
511
|
+
/** A card ID no card has yet (D51). */
|
|
512
|
+
freeCardId() {
|
|
513
|
+
return newId("B", (candidate) => this.storage.list(`cards/${candidate}`).length > 0);
|
|
514
|
+
}
|
|
515
|
+
/** One consistent read of the live layer, while `view` runs. */
|
|
516
|
+
session;
|
|
517
|
+
/**
|
|
518
|
+
* Run `fn` over one consistent view of the live layer: each file is read once. With `report`, a file that can't be
|
|
519
|
+
* read (half-written, or invalid) is reported and skipped instead of thrown; the server reads that way (D84).
|
|
520
|
+
* Without it, a bad file still throws, so the CLI stays loud: commands that read every card read through a view,
|
|
521
|
+
* since a card's status reads the others, and reading them afresh each time made a listing quadratic.
|
|
522
|
+
*/
|
|
523
|
+
view(fn, report) {
|
|
524
|
+
const outer = this.session;
|
|
525
|
+
if (outer && !report)
|
|
526
|
+
return fn(); // already inside one: share it
|
|
527
|
+
this.session = { files: new Map(), lists: new Map(), report };
|
|
528
|
+
try {
|
|
529
|
+
return fn();
|
|
530
|
+
}
|
|
531
|
+
finally {
|
|
532
|
+
this.session = outer;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Read and parse one live file, or undefined if there's none: a file that can't be parsed throws, unless a view is
|
|
537
|
+
* reporting problems instead.
|
|
538
|
+
*/
|
|
539
|
+
read(rel, parseText) {
|
|
540
|
+
if (this.session?.files.has(rel))
|
|
541
|
+
return this.session.files.get(rel);
|
|
542
|
+
let value;
|
|
543
|
+
try {
|
|
544
|
+
const bytes = this.storage.read(rel);
|
|
545
|
+
value = bytes === undefined ? undefined : parseText(decode(bytes));
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
if (!this.session?.report)
|
|
549
|
+
throw e;
|
|
550
|
+
this.session.report(rel, e instanceof Error ? e.message : String(e));
|
|
551
|
+
}
|
|
552
|
+
this.session?.files.set(rel, value);
|
|
553
|
+
return value;
|
|
554
|
+
}
|
|
555
|
+
/** A file's bytes as written, from the layer: a signature covers these, never the parsed fields (P15). */
|
|
556
|
+
bytes(rel) {
|
|
557
|
+
const bytes = this.storage.read(rel);
|
|
558
|
+
if (!bytes)
|
|
559
|
+
throw Object.assign(new Error(`${rel} isn't in the live layer`), { code: "ENOENT" });
|
|
560
|
+
return asBuffer(bytes);
|
|
561
|
+
}
|
|
562
|
+
/** A file's text, read afresh, or undefined if there's none. */
|
|
563
|
+
text(rel) {
|
|
564
|
+
const bytes = this.storage.read(rel);
|
|
565
|
+
return bytes && decode(bytes);
|
|
566
|
+
}
|
|
567
|
+
// ---------------------------------------------------------------- the hosted service's proof (M9a: D138, D139, D142, D158–D160, D163)
|
|
568
|
+
/** CI's pinned keys, read once: from the options, or from CI's settings. A pin that can't be read throws (`BadTrust`). */
|
|
569
|
+
pinnedRoot() {
|
|
570
|
+
if (this.pinCache)
|
|
571
|
+
return this.pinCache.value;
|
|
572
|
+
const value = this.options.trust.pinned !== undefined ? (this.options.trust.pinned ?? undefined) : pinnedTrust();
|
|
573
|
+
this.pinCache = { value };
|
|
574
|
+
return value;
|
|
575
|
+
}
|
|
576
|
+
pinCache;
|
|
577
|
+
/** The trust files in this layer, `trust/`: the record's copy of the keys (P16 section 7). */
|
|
578
|
+
trustFiles() {
|
|
579
|
+
const out = { service: [], revoked: [], passkeys: [] };
|
|
580
|
+
for (const f of this.list("trust").filter((n) => n.endsWith(".json"))) {
|
|
581
|
+
const rel = `trust/${f}`;
|
|
582
|
+
const t = this.read(rel, (text) => readTrustFile(JSON.parse(text), rel));
|
|
583
|
+
if (!t)
|
|
584
|
+
continue;
|
|
585
|
+
if (t.type === "service-key")
|
|
586
|
+
out.service.push({ file: rel, key: t });
|
|
587
|
+
else if (t.type === "service-key-revocation")
|
|
588
|
+
out.revoked.push({ file: rel, key: t });
|
|
589
|
+
else
|
|
590
|
+
out.passkeys.push({ file: rel, key: t });
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Whether this is a hosted repo, and the keys its answers are checked with (D138): CI's pinned keys whenever it pins
|
|
596
|
+
* any (D142, D159), or else the service's keys in this layer, when it has any. The layer's passkey keys count only with
|
|
597
|
+
* the service's signature; revocations count wherever they are, since they only take trust away. The project is the one
|
|
598
|
+
* CI pins beside the keys (B-mar8x), whatever the config says, or else the config's. Undefined outside a hosted repo,
|
|
599
|
+
* where nothing here changes anything.
|
|
600
|
+
*/
|
|
601
|
+
hosted() {
|
|
602
|
+
if (this.session?.hosted)
|
|
603
|
+
return this.session.hosted.value;
|
|
604
|
+
const pinned = this.pinnedRoot();
|
|
605
|
+
const files = this.trustFiles();
|
|
606
|
+
const revoked = [...(pinned?.revoked ?? []), ...files.revoked.map((r) => r.key)];
|
|
607
|
+
const project = pinned?.project ?? this.config().project;
|
|
608
|
+
let value;
|
|
609
|
+
if (pinned)
|
|
610
|
+
value = { pinned: true, root: { service: pinned.service, passkeys: pinned.passkeys, revoked }, project };
|
|
611
|
+
else if (files.service.length) {
|
|
612
|
+
const base = { service: files.service.map((s) => s.key), revoked };
|
|
613
|
+
const passkeys = files.passkeys.filter((p) => this.signedBy(p.file, p.key.enrolled, base).ok).map((p) => p.key);
|
|
614
|
+
value = { pinned: false, root: { ...base, passkeys }, project };
|
|
615
|
+
}
|
|
616
|
+
if (this.session)
|
|
617
|
+
this.session.hosted = { value };
|
|
618
|
+
return value;
|
|
619
|
+
}
|
|
620
|
+
/** Whether a file has its signature, by a key in `root` that was valid at `at` (D160). */
|
|
621
|
+
signedBy(rel, at, root) {
|
|
622
|
+
const sig = this.text(`${rel}.sig`);
|
|
623
|
+
if (sig === undefined)
|
|
624
|
+
return doesnt(`it has no signature, so the service didn't write it${cite("D138")}`);
|
|
625
|
+
const r = checkSigned(root, this.bytes(rel), sig, at, rel);
|
|
626
|
+
return "problem" in r ? doesnt(r.problem) : { ok: true, key: r.key };
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* The record at the commit that linked the repo (B-45xz8), found once: the one given (CI's, found from the tip it
|
|
630
|
+
* checks, B-mar8x), or in this store's commit's history (HEAD's on your machine), by ancestry (`recordAtLinking()`). With
|
|
631
|
+
* keys pinned it's the pinned project's, and no record stands in before there's one: a commit can't make up its own
|
|
632
|
+
* linking commit. A store with no repo, as the relay's, has none.
|
|
633
|
+
*/
|
|
634
|
+
linking() {
|
|
635
|
+
if (this.linkCache)
|
|
636
|
+
return this.linkCache.value;
|
|
637
|
+
let value;
|
|
638
|
+
const given = this.options.trust.linking;
|
|
639
|
+
if (given !== undefined)
|
|
640
|
+
value = given ?? undefined;
|
|
641
|
+
else if (this.local) {
|
|
642
|
+
const pinned = this.pinnedRoot();
|
|
643
|
+
const project = pinned?.project ?? this.config().project;
|
|
644
|
+
// Personal mode (B-4rwyz) commits no record: what the clone held when it was linked plays its part. A clone that
|
|
645
|
+
// switched to shared mode keeps that in its place until the link is committed.
|
|
646
|
+
const snapshot = project && !pinned && !this.readOnly ? this.snapshotFor(project) : undefined;
|
|
647
|
+
const atSnapshot = (s) => ({ commit: "", blobs: new Map(Object.entries(s.files)), objectFormat: s.object_format });
|
|
648
|
+
if (snapshot && this.personal)
|
|
649
|
+
value = atSnapshot(snapshot);
|
|
650
|
+
else if (project)
|
|
651
|
+
value = recordAtLinking(this.local.repo.toplevel, this.options.commit ?? "HEAD", project, { standIn: !pinned && !snapshot }) ?? (snapshot && atSnapshot(snapshot));
|
|
652
|
+
}
|
|
653
|
+
this.linkCache = { value };
|
|
654
|
+
return value;
|
|
655
|
+
}
|
|
656
|
+
linkCache;
|
|
657
|
+
/**
|
|
658
|
+
* The commit that linked the repo, whose record holds the answers from before linking (B-45xz8), or why it can't be
|
|
659
|
+
* told: in a shallow clone, or with two in the history (B-mar8x). Undefined when there's none.
|
|
660
|
+
*/
|
|
661
|
+
linkingCommit() {
|
|
662
|
+
const b = this.linking();
|
|
663
|
+
return b && ("problem" in b ? { problem: b.problem } : { commit: b.commit });
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* An unsigned file a person wrote counts as it is when it was in the record, with these bytes, at the commit that linked
|
|
667
|
+
* the repo (B-45xz8): answers from before linking were trusted then (D62). Anything added later needs the service's
|
|
668
|
+
* signature. Undefined when that isn't so, or the file is signed; a judgement that it can't be told, in a shallow clone.
|
|
669
|
+
*/
|
|
670
|
+
fromBeforeLinking(rel) {
|
|
671
|
+
if (this.storage.read(`${rel}.sig`) !== undefined)
|
|
672
|
+
return undefined;
|
|
673
|
+
// The relay's store, with no repo: what a machine uploaded as from before linking, while it still could (B-3zjjx).
|
|
674
|
+
if (!this.local)
|
|
675
|
+
return this.options.beforeLinking?.has(rel) ? COUNTS : undefined;
|
|
676
|
+
const b = this.linking();
|
|
677
|
+
if (!b)
|
|
678
|
+
return undefined;
|
|
679
|
+
if ("problem" in b)
|
|
680
|
+
return doesnt(b.problem);
|
|
681
|
+
const blob = b.blobs.get(rel);
|
|
682
|
+
if (!blob)
|
|
683
|
+
return undefined;
|
|
684
|
+
const bytes = this.storage.read(rel);
|
|
685
|
+
if (!bytes)
|
|
686
|
+
return undefined;
|
|
687
|
+
return blobId(bytes, b.objectFormat) === blob ? COUNTS : undefined;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* The files a person wrote that count here as from before linking (B-45xz8): unsigned, and in the record at the commit
|
|
691
|
+
* that linked the repo, with these bytes. At first link the CLI uploads them (B-3zjjx): rules, then their retirements,
|
|
692
|
+
* topic merges, answers and preference files, each folder in name order.
|
|
693
|
+
*/
|
|
694
|
+
answersFromBeforeLinking() {
|
|
695
|
+
const rules = this.list("rules").filter((f) => RULE_FILE.test(f));
|
|
696
|
+
return [
|
|
697
|
+
...rules.filter((f) => f.endsWith(".md")).map((f) => `rules/${f}`),
|
|
698
|
+
...rules.filter((f) => f.endsWith("-retired.json")).map((f) => `rules/${f}`),
|
|
699
|
+
...this.list("topics").filter((f) => f.startsWith("alias-") && f.endsWith(".json")).map((f) => `topics/${f}`),
|
|
700
|
+
...this.list("verdicts").filter((f) => verdictFileCard(f)).map((f) => `verdicts/${f}`),
|
|
701
|
+
...this.list("preferences").filter((f) => PREFERENCE_FILE.test(f)).map((f) => `preferences/${f}`),
|
|
702
|
+
].filter((rel) => this.fromBeforeLinking(rel)?.ok === true);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Whether a resolved card was decided before the repo was linked (B-3zjjx): the answer its status rests on counts as one
|
|
706
|
+
* from before linking, not by the service's signature. The website says so beside it.
|
|
707
|
+
*/
|
|
708
|
+
decidedBeforeLinking(id) {
|
|
709
|
+
const card = this.card(id);
|
|
710
|
+
if (!card || this.status(id) !== "resolved")
|
|
711
|
+
return false;
|
|
712
|
+
const last = this.latestVerdict(card);
|
|
713
|
+
if (!last)
|
|
714
|
+
return false;
|
|
715
|
+
const prefix = `${compactAt(last.at)}-${id}-r${last.revision}-`;
|
|
716
|
+
return this.verdictFilesOf(id).some((f) => f.startsWith(prefix) && this.fromBeforeLinking(`verdicts/${f}`)?.ok === true);
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Whether a file a person writes counts (D138): in a hosted repo, only with the service's signature, and for an answer
|
|
720
|
+
* on a hard card (D139) or a preference change that lets more cards past you (D163), with a passkey proof that checks
|
|
721
|
+
* against a trusted passkey key for the file's account. Outside a hosted repo, everything counts, as before. Files are
|
|
722
|
+
* written once, so a judgement holds for a view.
|
|
723
|
+
*/
|
|
724
|
+
judge(rel) {
|
|
725
|
+
const h = this.hosted();
|
|
726
|
+
if (!h)
|
|
727
|
+
return COUNTS;
|
|
728
|
+
const cached = this.session?.judged?.get(rel);
|
|
729
|
+
if (cached)
|
|
730
|
+
return cached;
|
|
731
|
+
let j;
|
|
732
|
+
try {
|
|
733
|
+
j = this.judgeAfresh(rel, h);
|
|
734
|
+
}
|
|
735
|
+
catch (e) {
|
|
736
|
+
if (e instanceof NewerFormat)
|
|
737
|
+
throw e;
|
|
738
|
+
j = doesnt(`it can't be read (${e instanceof Error ? e.message.split("\n")[0] : String(e)})`);
|
|
739
|
+
}
|
|
740
|
+
if (this.session)
|
|
741
|
+
(this.session.judged ??= new Map()).set(rel, j);
|
|
742
|
+
return j;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* A signature covers a file's bytes, not its name, so each kind counts only where its bytes say it belongs: a copy of a
|
|
746
|
+
* signed answer under another card's name, or a relay's record in another card's folder, would otherwise count there.
|
|
747
|
+
*/
|
|
748
|
+
judgeAfresh(rel, h) {
|
|
749
|
+
const [dir, a, b] = rel.split("/");
|
|
750
|
+
if (dir === "preferences")
|
|
751
|
+
return this.preferenceJudgements(h).get(a) ?? doesnt("it isn't a preference file");
|
|
752
|
+
// What a person wrote before the repo was linked counts as it is (B-45xz8); the relay's gate records are never from then.
|
|
753
|
+
if (dir !== "cards") {
|
|
754
|
+
const before = this.fromBeforeLinking(rel);
|
|
755
|
+
if (before)
|
|
756
|
+
return before;
|
|
757
|
+
}
|
|
758
|
+
let claim;
|
|
759
|
+
const elsewhere = (says) => doesnt(`its name or folder doesn't match what it says (${says}), so it was copied or renamed`);
|
|
760
|
+
if (dir === "verdicts" && a && verdictFileCard(a)) {
|
|
761
|
+
const v = this.verdictFile(a);
|
|
762
|
+
if (!v)
|
|
763
|
+
return doesnt("it can't be read");
|
|
764
|
+
if (!a.startsWith(`${compactAt(v.at)}-${v.card}-r${v.revision}-`))
|
|
765
|
+
return elsewhere(`${v.card} r${v.revision}, at ${v.at}`);
|
|
766
|
+
const hard = this.levelOf(v.card, v.revision) === "hard";
|
|
767
|
+
claim = {
|
|
768
|
+
at: v.at,
|
|
769
|
+
account: v.account,
|
|
770
|
+
project: v.project,
|
|
771
|
+
answers: { card: v.card, revision: v.revision, digest: v.card_digest },
|
|
772
|
+
proof: hard ? `it answers ${v.card}, a hard card, so it takes the reviewer's passkey${cite("D139")}` : undefined,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
else if (dir === "rules" && a?.endsWith("-retired.json")) {
|
|
776
|
+
const r = this.retirementFile(a);
|
|
777
|
+
if (!r)
|
|
778
|
+
return doesnt("it can't be read");
|
|
779
|
+
if (a !== `${r.rule}-retired.json`)
|
|
780
|
+
return elsewhere(`it retires ${r.rule}`);
|
|
781
|
+
claim = { at: r.at, account: r.account, project: r.project };
|
|
782
|
+
}
|
|
783
|
+
else if (dir === "rules" && a?.endsWith(".md")) {
|
|
784
|
+
const r = this.ruleFile(a)?.meta;
|
|
785
|
+
if (!r)
|
|
786
|
+
return doesnt("it can't be read");
|
|
787
|
+
if (a !== `${r.id}.md`)
|
|
788
|
+
return elsewhere(`it's ${r.id}`);
|
|
789
|
+
claim = { at: r.created, account: r.account, project: r.project };
|
|
790
|
+
}
|
|
791
|
+
else if (dir === "topics" && a?.endsWith(".json")) {
|
|
792
|
+
const t = this.read(rel, (x) => readAs("topic-alias", TopicAlias, JSON.parse(x), rel));
|
|
793
|
+
if (!t)
|
|
794
|
+
return doesnt("it can't be read");
|
|
795
|
+
claim = { at: t.at, account: t.account, project: t.project };
|
|
796
|
+
}
|
|
797
|
+
else if (dir === "cards" && b && RELAY_GATE.test(b)) {
|
|
798
|
+
const g = this.read(rel, (t) => readAs("gate", GateRecord, JSON.parse(t), rel));
|
|
799
|
+
if (!g)
|
|
800
|
+
return doesnt("it can't be read");
|
|
801
|
+
if (g.by !== "relay")
|
|
802
|
+
return doesnt("it's named for the relay, but says it ran elsewhere");
|
|
803
|
+
if (g.card !== a || b !== `gate-r${g.revision}-relay.json`)
|
|
804
|
+
return elsewhere(`it gated ${g.card} r${g.revision}`);
|
|
805
|
+
claim = { at: g.at, project: g.project };
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
return doesnt("it isn't a kind of file a person writes");
|
|
809
|
+
}
|
|
810
|
+
return this.signedClaim(rel, claim, h);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* The checks every signed file takes (P16 section 7): the signature; the project, since one key signs every project's
|
|
814
|
+
* files (B-tf9fc); for an answer, the card's text it answered (B-f571v); and the passkey proof when one is needed.
|
|
815
|
+
*/
|
|
816
|
+
signedClaim(rel, claim, h) {
|
|
817
|
+
const signed = this.signedBy(rel, claim.at, h.root);
|
|
818
|
+
if (!signed.ok)
|
|
819
|
+
return signed;
|
|
820
|
+
if (claim.project !== h.project) {
|
|
821
|
+
if (!claim.project)
|
|
822
|
+
return doesnt(`it names no project, so it could be another repo's${cite("B-tf9fc")}`);
|
|
823
|
+
return doesnt(h.project ? `it's for project ${claim.project}, not this repo's (${h.project}), so it was copied from another repo` : `it's for project ${claim.project}, and .baesian/config.json names no project`);
|
|
824
|
+
}
|
|
825
|
+
if (claim.answers) {
|
|
826
|
+
const { card, revision, digest } = claim.answers;
|
|
827
|
+
if (!digest)
|
|
828
|
+
return doesnt(`it doesn't name the digest of the card it answers, so what was answered can't be checked${cite("B-f571v")}`);
|
|
829
|
+
const cardRel = `cards/${card}/r${revision}.md`;
|
|
830
|
+
const answered = this.storage.read(cardRel);
|
|
831
|
+
if (!answered)
|
|
832
|
+
return doesnt(`${card} r${revision} isn't here, so what was answered can't be checked`);
|
|
833
|
+
if (createHash("sha256").update(answered).digest("hex") !== digest)
|
|
834
|
+
return doesnt(`${card} r${revision} has changed since it was answered: it isn't the text the answer names${cite("B-f571v")}`);
|
|
835
|
+
}
|
|
836
|
+
if (!claim.proof)
|
|
837
|
+
return COUNTS;
|
|
838
|
+
const proofRel = proofPath(rel);
|
|
839
|
+
const proofText = this.text(proofRel);
|
|
840
|
+
if (proofText === undefined)
|
|
841
|
+
return doesnt(`${claim.proof}, and has no passkey proof`);
|
|
842
|
+
let proof;
|
|
843
|
+
try {
|
|
844
|
+
proof = readAs("passkey-proof", PasskeyProof, JSON.parse(proofText), proofRel);
|
|
845
|
+
}
|
|
846
|
+
catch (e) {
|
|
847
|
+
if (e instanceof NewerFormat)
|
|
848
|
+
throw e;
|
|
849
|
+
return doesnt(`its passkey proof can't be read (${e instanceof Error ? e.message.split("\n")[0] : String(e)})`);
|
|
850
|
+
}
|
|
851
|
+
if (proof.file !== rel.slice(rel.lastIndexOf("/") + 1))
|
|
852
|
+
return doesnt(`its passkey proof is for ${proof.file}`);
|
|
853
|
+
if (!claim.account)
|
|
854
|
+
return doesnt(`${claim.proof}, but names no account to check its passkey against`);
|
|
855
|
+
const key = h.root.passkeys.find((k) => k.account === claim.account && k.credential === proof.credential);
|
|
856
|
+
if (!key)
|
|
857
|
+
return doesnt(`${claim.proof}, and its proof was made with a passkey that isn't trusted for account ${claim.account}${cite("D159")}`);
|
|
858
|
+
const why = proofProblem(proof, this.bytes(rel), key, signed.key);
|
|
859
|
+
return why ? doesnt(`its passkey proof doesn't check: ${why}`) : COUNTS;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The preference files' judgements, oldest first (D163): each is compared with the newest one before it that counts, or
|
|
863
|
+
* with "Today" in shadow, so a change that loosens takes the passkey whatever file it says it replaces.
|
|
864
|
+
*/
|
|
865
|
+
preferenceJudgements(h) {
|
|
866
|
+
if (this.session?.preferences)
|
|
867
|
+
return this.session.preferences;
|
|
868
|
+
const out = new Map();
|
|
869
|
+
let before = TODAY;
|
|
870
|
+
for (const name of this.list("preferences").filter((f) => PREFERENCE_FILE.test(f))) {
|
|
871
|
+
let file;
|
|
872
|
+
try {
|
|
873
|
+
file = this.preferenceRaw(name);
|
|
874
|
+
}
|
|
875
|
+
catch (e) {
|
|
876
|
+
if (e instanceof NewerFormat)
|
|
877
|
+
throw e;
|
|
878
|
+
out.set(name, doesnt(`it can't be read (${e instanceof Error ? e.message.split("\n")[0] : String(e)})`));
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (!file) {
|
|
882
|
+
out.set(name, doesnt("it can't be read"));
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
// Named for its own time, which its signature covers: an old change copied under a newer name can't come back in force.
|
|
886
|
+
if (!name.startsWith(`${compactAt(file.at)}-`)) {
|
|
887
|
+
out.set(name, doesnt(`its name doesn't match its time (${file.at}), so it was copied or renamed`));
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const loose = loosening(before, settingsOf(file));
|
|
891
|
+
const j = this.fromBeforeLinking(`preferences/${name}`) ?? this.signedClaim(`preferences/${name}`, { at: file.at, account: file.account, project: file.project, proof: loose.length ? `it lets more cards past you (${loose.join("; ")}), so it takes your passkey${cite("D163")}` : undefined }, h);
|
|
892
|
+
out.set(name, j);
|
|
893
|
+
if (j.ok)
|
|
894
|
+
before = settingsOf(file);
|
|
895
|
+
}
|
|
896
|
+
if (this.session)
|
|
897
|
+
this.session.preferences = out;
|
|
898
|
+
return out;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* The level in force on a card revision (P16 section 3): the card's own, set by the CLI's gate at filing, and in a hosted
|
|
902
|
+
* repo raised by the relay's signed gate record, if that shows it higher. The latest revision's, unless one is named.
|
|
903
|
+
*/
|
|
904
|
+
levelOf(id, revision) {
|
|
905
|
+
const revs = this.revisions(id);
|
|
906
|
+
const card = revision === undefined ? revs.at(-1) : revs.find((r) => r.meta.revision === revision);
|
|
907
|
+
if (!card)
|
|
908
|
+
return undefined;
|
|
909
|
+
if (!this.hosted())
|
|
910
|
+
return card.meta.level;
|
|
911
|
+
const relay = this.gateRecordBy(id, card.meta.revision, "relay");
|
|
912
|
+
return relay?.outcome === "shown" && relay.level ? higherLevel(card.meta.level, relay.level) : card.meta.level;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* In a hosted repo, the files a person writes that don't count (D138), and why: for `baesian verify`'s notes, since a
|
|
916
|
+
* file that doesn't count is ignored, not an error. Passkey keys the service didn't sign are among them.
|
|
917
|
+
*/
|
|
918
|
+
uncounted() {
|
|
919
|
+
const h = this.hosted();
|
|
920
|
+
if (!h)
|
|
921
|
+
return [];
|
|
922
|
+
const rels = [
|
|
923
|
+
...this.list("verdicts").filter((f) => verdictFileCard(f)).map((f) => `verdicts/${f}`),
|
|
924
|
+
...this.list("rules").filter((f) => RULE_FILE.test(f)).map((f) => `rules/${f}`),
|
|
925
|
+
...this.list("topics").filter((f) => f.startsWith("alias-") && f.endsWith(".json")).map((f) => `topics/${f}`),
|
|
926
|
+
...this.list("preferences").filter((f) => PREFERENCE_FILE.test(f)).map((f) => `preferences/${f}`),
|
|
927
|
+
...this.cardIds().flatMap((id) => this.list(`cards/${id}`).filter((f) => RELAY_GATE.test(f)).map((f) => `cards/${id}/${f}`)),
|
|
928
|
+
];
|
|
929
|
+
const out = rels.flatMap((file) => {
|
|
930
|
+
const j = this.judge(file);
|
|
931
|
+
return j.ok ? [] : [{ file, why: j.why }];
|
|
932
|
+
});
|
|
933
|
+
if (!h.pinned) {
|
|
934
|
+
for (const p of this.trustFiles().passkeys) {
|
|
935
|
+
const j = this.signedBy(p.file, p.key.enrolled, h.root);
|
|
936
|
+
if (!j.ok)
|
|
937
|
+
out.push({ file: p.file, why: j.why });
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return out;
|
|
941
|
+
}
|
|
942
|
+
// ---------------------------------------------------------------- cards
|
|
943
|
+
cardIds() {
|
|
944
|
+
return this.list("cards");
|
|
945
|
+
}
|
|
946
|
+
revisions(id) {
|
|
947
|
+
return this.list(`cards/${id}`)
|
|
948
|
+
.filter((f) => /^r\d+\.md$/.test(f))
|
|
949
|
+
.flatMap((f) => this.read(`cards/${id}/${f}`, (text) => {
|
|
950
|
+
const { meta, body } = parseDoc(text);
|
|
951
|
+
return { meta: readAs("card", CardMeta, meta, `cards/${id}/${f}`), body };
|
|
952
|
+
}) ?? [])
|
|
953
|
+
.sort((a, b) => a.meta.revision - b.meta.revision);
|
|
954
|
+
}
|
|
955
|
+
card(id) {
|
|
956
|
+
return this.revisions(id).at(-1);
|
|
957
|
+
}
|
|
958
|
+
cards() {
|
|
959
|
+
// In a view, the list is built once: a card's status reads every other card, so a listing asks for it per card.
|
|
960
|
+
if (this.session?.cards)
|
|
961
|
+
return this.session.cards;
|
|
962
|
+
const all = this.cardIds()
|
|
963
|
+
.map((id) => this.card(id))
|
|
964
|
+
.filter((c) => c !== undefined);
|
|
965
|
+
if (this.session)
|
|
966
|
+
this.session.cards = all;
|
|
967
|
+
return all;
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Validate and normalise a draft, without writing anything. The body is optional context (D92): the
|
|
971
|
+
* strongest alternative is a field now, and the filing rules check it (D12, D77).
|
|
972
|
+
*/
|
|
973
|
+
prepare(draft, body = "", agentDraft = false) {
|
|
974
|
+
const parsed = readAs("card-draft", CardDraft, draft, "the card");
|
|
975
|
+
const problems = [...filingProblems(parsed), ...this.unknownRefs(parsed)];
|
|
976
|
+
if (problems.length === 1)
|
|
977
|
+
throw new ValidationError(`not filed: ${problems[0]}`);
|
|
978
|
+
if (problems.length)
|
|
979
|
+
throw new ValidationError([`not filed: ${problems.length} things about the card need fixing`, ...problems.map((p) => ` ${p}`)].join("\n"));
|
|
980
|
+
let near = [];
|
|
981
|
+
let topic = parsed.topic;
|
|
982
|
+
if (topic) {
|
|
983
|
+
topic = this.canonicalTopic(topic);
|
|
984
|
+
const known = this.topics();
|
|
985
|
+
if (!known.has(topic))
|
|
986
|
+
near = nearTopics(topic, known);
|
|
987
|
+
}
|
|
988
|
+
// In a hosted repo a card goes to the service as it's filed, and its first version can't be taken back: a draft an agent
|
|
989
|
+
// files or revises that looks like it holds a secret is refused, before anything is written, unless it says why it's
|
|
990
|
+
// filed anyway (B-jfkv9). Elsewhere, and for the cards Baesian drafts itself, it's a warning (D136).
|
|
991
|
+
if (agentDraft && this.config().project !== undefined && !parsed.secret_reason) {
|
|
992
|
+
const secrets = secretsIn(parsed, body);
|
|
993
|
+
if (secrets.size)
|
|
994
|
+
throw new LooksLikeSecret(secrets);
|
|
995
|
+
}
|
|
996
|
+
// The draft's `level` is what the agent asks for; the gate sets the level the card is written with (D144, D145).
|
|
997
|
+
return { draft: { ...parsed, topic }, nearTopics: near, warnings: filingWarnings(parsed, body) };
|
|
998
|
+
}
|
|
999
|
+
// ---------------------------------------------------------------- the gate at filing (D144–D148)
|
|
1000
|
+
/**
|
|
1001
|
+
* Run the gate over a card revision about to be written (D145): the rules over its stated facts, its `reverse` and the
|
|
1002
|
+
* level it asks for, under the preferences in force. Returns its record and the level the card is written with. It
|
|
1003
|
+
* fails toward the reviewer: if it can't run, the card is shown at the level asked for, or provisional, and never a
|
|
1004
|
+
* card with a hard-by-default fact below hard (D144, D146).
|
|
1005
|
+
*/
|
|
1006
|
+
runGate(id, revision, draft, source) {
|
|
1007
|
+
const asked = draft.level;
|
|
1008
|
+
const force = this.inForce();
|
|
1009
|
+
const warnings = force.problem ? [`${force.problem}: the gate used "Today", in shadow, which shows every card`] : [];
|
|
1010
|
+
const base = {
|
|
1011
|
+
type: "gate",
|
|
1012
|
+
format: newestFormat("gate"),
|
|
1013
|
+
card: id,
|
|
1014
|
+
revision,
|
|
1015
|
+
by: "cli",
|
|
1016
|
+
gate: GATE_VERSION,
|
|
1017
|
+
mode: force.settings.mode,
|
|
1018
|
+
...(force.name && force.file ? { preferences: force.name } : {}),
|
|
1019
|
+
...(draft.touches.length ? { touches: draft.touches } : {}),
|
|
1020
|
+
reverse: draft.reverse,
|
|
1021
|
+
...(asked ? { asked } : {}),
|
|
1022
|
+
source,
|
|
1023
|
+
at: now(),
|
|
1024
|
+
};
|
|
1025
|
+
try {
|
|
1026
|
+
const decision = route({ touches: draft.touches, reverse: draft.reverse, asked, source, draw: drawOf(id, revision), heldBefore: (row) => this.heldBefore(row) }, force.settings);
|
|
1027
|
+
const record = parse(GateRecord, { ...base, ...decision }, "the gate record");
|
|
1028
|
+
return { record, level: levelInForce(decision, force.settings.mode, asked), warnings };
|
|
1029
|
+
}
|
|
1030
|
+
catch (e) {
|
|
1031
|
+
const floor = draft.touches.some((f) => HARD_BY_DEFAULT.has(f)) ? "hard" : "provisional";
|
|
1032
|
+
const level = asked === "hard" || floor === "hard" ? "hard" : (asked ?? "provisional");
|
|
1033
|
+
const { row, position } = rowOf(draft.touches, TODAY);
|
|
1034
|
+
const record = parse(GateRecord, { ...base, row, position, outcome: "shown", level, reason: "error" }, "the gate record");
|
|
1035
|
+
return { record, level, warnings: [...warnings, `the gate failed (${e instanceof Error ? e.message : String(e)}), so the card is shown`] };
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* How many held cards a row has had, enforcing, as one writer's gate records say, the CLI's or the relay's: the burn-in
|
|
1040
|
+
* counts them (B-fgc4y).
|
|
1041
|
+
*/
|
|
1042
|
+
heldBefore(row, by = "cli") {
|
|
1043
|
+
let n = this.options.heldEarlier?.(row, by) ?? 0;
|
|
1044
|
+
for (const id of this.cardIds()) {
|
|
1045
|
+
for (const g of this.gateRecords(id))
|
|
1046
|
+
if (g.by === by && g.mode === "enforce" && g.outcome === "held" && g.row === row)
|
|
1047
|
+
n++;
|
|
1048
|
+
}
|
|
1049
|
+
return n;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* What a card revision is to the gate, as filing it decided (P16 section 3), worked out from the files alone for the
|
|
1053
|
+
* relay, which receives revisions rather than files them: Baesian's own card; a revision that follows the reviewer's
|
|
1054
|
+
* own answer, or the agent's one reply; or else the agent's.
|
|
1055
|
+
*/
|
|
1056
|
+
gateSourceOf(card, previous) {
|
|
1057
|
+
if (!previous)
|
|
1058
|
+
return isOwnCard(card.meta) ? "baesian" : chatOr(card.meta, "agent");
|
|
1059
|
+
if (card.meta.reply !== undefined && previous.meta.reply === undefined)
|
|
1060
|
+
return "answer";
|
|
1061
|
+
const own = this.latestVerdict(previous);
|
|
1062
|
+
return this.parentReturns(previous).length === 0 && own !== undefined && RETURNS.has(own.response) ? "answer" : chatOr(card.meta, "agent");
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* The relay's gate record for a card revision it accepts (D158), as the service writes it (`test/service.ts`'s
|
|
1066
|
+
* `TestService.gate()`): the rules over the revision's facts, under the preferences in force, with the draw the service
|
|
1067
|
+
* took from its own signature over the card (`draw_proof`, kept on a held card), the burn-in counted on the relay's
|
|
1068
|
+
* records, and the project it was gated in (B-tf9fc). Written once, beside the CLI's, at
|
|
1069
|
+
* `cards/<id>/gate-r<n>-relay.json`; the relay's storage signs it. The store never signs: it's given the draw.
|
|
1070
|
+
*/
|
|
1071
|
+
relayGate(id, revision, drawn, project) {
|
|
1072
|
+
this.requireInit();
|
|
1073
|
+
const revs = this.revisions(id);
|
|
1074
|
+
const card = revs.find((r) => r.meta.revision === revision);
|
|
1075
|
+
if (!card)
|
|
1076
|
+
throw new NoCard(`${id} r${revision}`);
|
|
1077
|
+
const m = card.meta;
|
|
1078
|
+
const source = this.gateSourceOf(card, revs.find((r) => r.meta.revision === revision - 1));
|
|
1079
|
+
const force = this.inForce();
|
|
1080
|
+
const decision = route({ touches: m.touches, reverse: m.reverse, source, draw: drawn.draw, heldBefore: (row) => this.heldBefore(row, "relay") }, force.settings);
|
|
1081
|
+
// Built in the stand-in's order, and checked by the schema, but written as built: the bytes are what's signed.
|
|
1082
|
+
const record = {
|
|
1083
|
+
type: "gate",
|
|
1084
|
+
format: newestFormat("gate"),
|
|
1085
|
+
card: id,
|
|
1086
|
+
revision,
|
|
1087
|
+
by: "relay",
|
|
1088
|
+
gate: GATE_VERSION,
|
|
1089
|
+
mode: force.settings.mode,
|
|
1090
|
+
...(force.name ? { preferences: force.name } : {}),
|
|
1091
|
+
...(m.touches.length ? { touches: m.touches } : {}),
|
|
1092
|
+
reverse: m.reverse,
|
|
1093
|
+
source,
|
|
1094
|
+
...decision,
|
|
1095
|
+
...(decision.outcome === "held" ? { draw_proof: drawn.draw_proof } : {}),
|
|
1096
|
+
project,
|
|
1097
|
+
at: now(),
|
|
1098
|
+
};
|
|
1099
|
+
const parsed = parse(GateRecord, record, "the relay's gate record");
|
|
1100
|
+
this.writeOnce(`cards/${id}/gate-r${revision}-relay.json`, `${JSON.stringify(asWritten("gate", record), null, 2)}\n`);
|
|
1101
|
+
return parsed;
|
|
1102
|
+
}
|
|
1103
|
+
/** A card's gate records, every revision's and every writer's; in a hosted repo, the relay's only once signed (D158). */
|
|
1104
|
+
gateRecords(id) {
|
|
1105
|
+
return this.list(`cards/${id}`)
|
|
1106
|
+
.filter((f) => /^gate-r\d+-(cli|relay)\.json$/.test(f) && (!RELAY_GATE.test(f) || this.judge(`cards/${id}/${f}`).ok))
|
|
1107
|
+
.flatMap((f) => this.read(`cards/${id}/${f}`, (t) => readAs("gate", GateRecord, JSON.parse(t), `cards/${id}/${f}`)) ?? []);
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* The gate record that counts for a revision: the CLI's; in a hosted repo, only the relay's signed one (D158), so a card
|
|
1111
|
+
* the relay hasn't gated isn't held, and needs an answer before merge.
|
|
1112
|
+
*/
|
|
1113
|
+
gateRecord(id, revision) {
|
|
1114
|
+
return this.gateRecordBy(id, revision, this.hosted() ? "relay" : "cli");
|
|
1115
|
+
}
|
|
1116
|
+
/** One writer's gate record for a revision: the CLI's, or the relay's, once signed (D158). */
|
|
1117
|
+
gateRecordBy(id, revision, by) {
|
|
1118
|
+
const rev = revision ?? this.card(id)?.meta.revision;
|
|
1119
|
+
return this.gateRecords(id).find((g) => g.revision === rev && g.by === by);
|
|
1120
|
+
}
|
|
1121
|
+
/** The gate's facts that made a card hard above the level its agent asked for (D42, D144): for the message. */
|
|
1122
|
+
hardBecause(draft, level) {
|
|
1123
|
+
if (level !== "hard" || draft.level === "hard")
|
|
1124
|
+
return undefined;
|
|
1125
|
+
const facts = draft.touches.filter((f) => HARD_BY_DEFAULT.has(f));
|
|
1126
|
+
return facts.length ? facts : undefined;
|
|
1127
|
+
}
|
|
1128
|
+
/** Write a revision the gate has seen, and its record beside it, filed by `by`: its agent, and the model it ran on, if known. */
|
|
1129
|
+
writeGated(id, revision, draft, body, by, source) {
|
|
1130
|
+
const gate = this.view(() => this.runGate(id, revision, draft, source));
|
|
1131
|
+
const card = this.writeRevision(id, revision, { ...draft, level: gate.level }, body, by);
|
|
1132
|
+
const rel = `cards/${id}/gate-r${revision}-cli.json`;
|
|
1133
|
+
this.writeOnce(rel, `${JSON.stringify(asWritten("gate", gate.record), null, 2)}\n`);
|
|
1134
|
+
this.toRecord(rel);
|
|
1135
|
+
const hard = this.hardBecause(draft, gate.level);
|
|
1136
|
+
return { card, gate: gate.record, ...(hard ? { hardBecause: hard } : {}), warnings: gate.warnings };
|
|
1137
|
+
}
|
|
1138
|
+
/** Cards and rules a draft names that don't exist. */
|
|
1139
|
+
unknownRefs(d) {
|
|
1140
|
+
const rules = new Set(this.rules().map((r) => r.meta.id));
|
|
1141
|
+
const missing = (ids) => ids.filter((id) => (id.startsWith("R-") ? !rules.has(id) : !this.card(id)));
|
|
1142
|
+
const out = [];
|
|
1143
|
+
for (const [field, ids] of [["depends_on", d.depends_on], ["rules", d.rules], ["proposal_breaks", d.proposal_breaks], ["alternative_breaks", d.alternative_breaks]]) {
|
|
1144
|
+
const gone = missing([...ids]);
|
|
1145
|
+
if (gone.length)
|
|
1146
|
+
out.push(`${field} names ${gone.join(", ")}, which ${gone.length === 1 ? "doesn't" : "don't"} exist`);
|
|
1147
|
+
}
|
|
1148
|
+
return out;
|
|
1149
|
+
}
|
|
1150
|
+
/** Cards that may already answer `draft` (D36, D38): same topic, or a similar title. */
|
|
1151
|
+
matches(draft) {
|
|
1152
|
+
const topic = draft.topic ? this.canonicalTopic(draft.topic) : undefined;
|
|
1153
|
+
const titleTokens = tokens(draft.title);
|
|
1154
|
+
const out = [];
|
|
1155
|
+
for (const card of this.cards()) {
|
|
1156
|
+
const status = this.status(card.meta.id);
|
|
1157
|
+
if (status === "withdrawn" || status === "superseded")
|
|
1158
|
+
continue;
|
|
1159
|
+
const theirs = card.meta.topic ? this.canonicalTopic(card.meta.topic) : undefined;
|
|
1160
|
+
if (topic && theirs === topic)
|
|
1161
|
+
out.push({ card, status, by: "topic" });
|
|
1162
|
+
else if (jaccard(titleTokens, tokens(card.meta.title)) >= SIMILAR_TITLE)
|
|
1163
|
+
out.push({ card, status, by: "title" });
|
|
1164
|
+
}
|
|
1165
|
+
return out;
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* File a new card. Unless `force`, refuses with `PossibleDuplicate` when an open card or a resolved
|
|
1169
|
+
* decision may already cover it: attach to the open card, or follow the decision (D36, D38).
|
|
1170
|
+
*/
|
|
1171
|
+
fileCard(draft, body, opts) {
|
|
1172
|
+
this.requireInit();
|
|
1173
|
+
// The checks read every card: once, in a view, which ends before anything is written.
|
|
1174
|
+
const { prep, matches } = this.view(() => {
|
|
1175
|
+
const prep = this.prepare(draft, body, true);
|
|
1176
|
+
return { prep, matches: opts.force ? [] : this.matches(prep.draft) };
|
|
1177
|
+
});
|
|
1178
|
+
if (matches.length > 0)
|
|
1179
|
+
throw new PossibleDuplicate(matches, prep.draft.title);
|
|
1180
|
+
const id = this.freeCardId();
|
|
1181
|
+
// Baesian's own cards always reach you; the store files them under its name (conflict cards, D35).
|
|
1182
|
+
// A choice the reviewer answered in chat is shown too, for them to confirm (B-hswmx).
|
|
1183
|
+
const { warnings, ...gated } = this.writeGated(id, 1, prep.draft, body, opts, opts.agent === BAESIAN ? "baesian" : chatOr(prep.draft, "agent"));
|
|
1184
|
+
return { ...gated, nearTopics: prep.nearTopics, warnings: [...prep.warnings, ...warnings] };
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* File a card Baesian writes itself, with no model (D65): a convention card (D161) or a slider proposal (B-y6jn6), with
|
|
1188
|
+
* the fields only Baesian sets. Its deck is created when it's first used. The gate always shows it.
|
|
1189
|
+
*/
|
|
1190
|
+
fileOwn(draft, body, deck) {
|
|
1191
|
+
this.requireInit();
|
|
1192
|
+
if (!this.deck(deck.id)) {
|
|
1193
|
+
try {
|
|
1194
|
+
this.createDeck({ id: deck.id, title: deck.title }, deck.brief);
|
|
1195
|
+
}
|
|
1196
|
+
catch (e) {
|
|
1197
|
+
if (!alreadyExists(e))
|
|
1198
|
+
throw e;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
const { convention, pref_row, pref_from, pref_to, ...rest } = draft;
|
|
1202
|
+
const prep = this.view(() => this.prepare({ ...rest, deck: deck.id }, body));
|
|
1203
|
+
const id = this.freeCardId();
|
|
1204
|
+
const own = { ...(convention ? { convention } : {}), ...(pref_row ? { pref_row, pref_from, pref_to } : {}) };
|
|
1205
|
+
return this.writeGated(id, 1, { ...prep.draft, ...own }, body, { agent: BAESIAN }, "baesian").card;
|
|
1206
|
+
}
|
|
1207
|
+
/** File the next revision of a returned card: the agent's reply to a verdict. */
|
|
1208
|
+
reviseCard(id, draft, body, opts) {
|
|
1209
|
+
this.requireInit();
|
|
1210
|
+
const latest = this.card(id);
|
|
1211
|
+
if (!latest)
|
|
1212
|
+
throw new NoCard(id);
|
|
1213
|
+
const status = this.status(id);
|
|
1214
|
+
if (status !== "returned")
|
|
1215
|
+
throw new NotAllowed(`${id} is ${status}; only a returned card takes a new revision`);
|
|
1216
|
+
const prep = this.prepare(draft, body, true);
|
|
1217
|
+
// A revision that follows your answer is shown; one returned because a parent went the other way (D80) is a new
|
|
1218
|
+
// decision, so it's gated as one (P16 section 3).
|
|
1219
|
+
const own = this.latestVerdict(latest);
|
|
1220
|
+
const source = this.parentReturns(latest).length === 0 && own !== undefined && RETURNS.has(own.response) ? "answer" : chatOr(prep.draft, "agent");
|
|
1221
|
+
const { warnings, ...gated } = this.writeGated(id, latest.meta.revision + 1, prep.draft, body, opts, source);
|
|
1222
|
+
return { ...gated, nearTopics: prep.nearTopics, warnings: [...prep.warnings, ...warnings] };
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Whether the agent may still reply to this card (D89, D104): its latest answer went against the agent's pick
|
|
1226
|
+
* (the alternative, or the reviewer's own answer), and it hasn't had its one reply.
|
|
1227
|
+
*/
|
|
1228
|
+
canReply(id) {
|
|
1229
|
+
const card = this.card(id);
|
|
1230
|
+
// Nobody replies for Baesian: its conflict, convention and slider cards take none.
|
|
1231
|
+
if (!card || card.meta.source_agent === BAESIAN || this.revisions(id).some((r) => r.meta.reply !== undefined) || this.status(id) !== "resolved")
|
|
1232
|
+
return false;
|
|
1233
|
+
const last = this.latestVerdict(card);
|
|
1234
|
+
return last?.response === "alternative" || last?.response === "correction";
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* The agent's one reply to an answer that went against its pick (D89, D104): the card's next revision, the same
|
|
1238
|
+
* as the last but for the fact or check in `reply`. It reopens the card for the reviewer.
|
|
1239
|
+
*/
|
|
1240
|
+
replyToCard(id, reply, opts) {
|
|
1241
|
+
this.requireInit();
|
|
1242
|
+
const latest = this.card(id);
|
|
1243
|
+
if (!latest)
|
|
1244
|
+
throw new NoCard(id);
|
|
1245
|
+
if (this.revisions(id).some((r) => r.meta.reply !== undefined))
|
|
1246
|
+
throw new NotAllowed(`${id} has had its reply: an agent replies once to a card${cite("D89")}`);
|
|
1247
|
+
if (!this.canReply(id)) {
|
|
1248
|
+
throw new NotAllowed(`${id} is ${this.status(id)}; only a card answered against your pick (the alternative, or the reviewer's own answer) takes a reply`);
|
|
1249
|
+
}
|
|
1250
|
+
const text = reply.trim();
|
|
1251
|
+
if (!text)
|
|
1252
|
+
throw new ValidationError("the reply is empty: give the fact or the check, in a line");
|
|
1253
|
+
const { type: _t, format: _f, id: _i, revision: _r, source_agent: _a, source_model: _m, source_branch: _b, created: _c, ...draft } = latest.meta;
|
|
1254
|
+
// A reply follows an answer, so the gate shows it, at least at the level the card had (P16 section 3).
|
|
1255
|
+
return this.writeGated(id, latest.meta.revision + 1, { ...draft, reply: text }, latest.body, opts, "answer").card;
|
|
1256
|
+
}
|
|
1257
|
+
/** The cards that draft a rule from decision `id` (D110), whatever became of them, short of being withdrawn. */
|
|
1258
|
+
ruleDrafts(id) {
|
|
1259
|
+
return this.cards().filter((c) => c.meta.rule !== undefined && c.meta.depends_on.includes(id) && this.status(c.meta.id) !== "withdrawn");
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Whether the reviewer asked for a rule from this decision (D16's toggle) and none has been drafted yet: the
|
|
1263
|
+
* latest verdict on its latest revision carries `make_rule` (D109), or accepts a convention card, whose yes is a
|
|
1264
|
+
* request for a rule (D161).
|
|
1265
|
+
*/
|
|
1266
|
+
ruleAsked(id) {
|
|
1267
|
+
const card = this.card(id);
|
|
1268
|
+
if (!card || !this.asksForRule(card))
|
|
1269
|
+
return false;
|
|
1270
|
+
return this.ruleDrafts(id).length === 0;
|
|
1271
|
+
}
|
|
1272
|
+
asksForRule(card) {
|
|
1273
|
+
const last = this.latestVerdict(card);
|
|
1274
|
+
return Boolean(last?.make_rule) || (card.meta.convention !== undefined && last?.response === "accept");
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Draft the rule the reviewer asked for (D109, D110): a card built from the decision, which asks them to make
|
|
1278
|
+
* `rule` a rule covering `covers`. Accepting it saves the rule. It builds on the decision, in the same topic and
|
|
1279
|
+
* deck, so it isn't a duplicate of it.
|
|
1280
|
+
*/
|
|
1281
|
+
draftRule(id, rule, covers, opts) {
|
|
1282
|
+
this.requireInit();
|
|
1283
|
+
const from = this.card(id);
|
|
1284
|
+
if (!from)
|
|
1285
|
+
throw new NoCard(id);
|
|
1286
|
+
if (this.ruleDrafts(id).length)
|
|
1287
|
+
throw new NotAllowed(`a rule from ${id} is already drafted: it's one card per decision`);
|
|
1288
|
+
if (!this.asksForRule(from))
|
|
1289
|
+
throw new NotAllowed(`the reviewer didn't ask for a rule from ${id}: only an answer marked "make this a rule" takes one${cite("D109")}`);
|
|
1290
|
+
const text = rule.trim();
|
|
1291
|
+
if (!text)
|
|
1292
|
+
throw new ValidationError("the rule is empty: give it in a line");
|
|
1293
|
+
const bad = covers.filter((p) => !FACT_PATTERN.test(p));
|
|
1294
|
+
if (bad.length)
|
|
1295
|
+
throw new ValidationError(`not a gate pattern: ${bad.join(", ")}; patterns look like dep:pytest-* or schema:migrations/*`);
|
|
1296
|
+
const lets = covers.length ? `the gate lets through, without a card, commits whose only decisions it covers (${covers.join(", ")})` : "it shows beside every card on this topic";
|
|
1297
|
+
const prep = this.prepare({
|
|
1298
|
+
title: `A rule from “${from.meta.title}”`,
|
|
1299
|
+
question: `Make this a rule: “${text}”?`,
|
|
1300
|
+
doing: `drafting the rule you asked for on “${from.meta.title}”`,
|
|
1301
|
+
topic: from.meta.topic,
|
|
1302
|
+
deck: from.meta.deck,
|
|
1303
|
+
touches: [],
|
|
1304
|
+
reverse: "moderate",
|
|
1305
|
+
proposal: "Make it a rule",
|
|
1306
|
+
proposal_then: `it's saved, and ${lets}`,
|
|
1307
|
+
alternative: "Keep it to this decision",
|
|
1308
|
+
alternative_then: "no rule is saved; the next decision like this one comes to you as a card",
|
|
1309
|
+
crux: "Should every decision like this one go the same way from now on?",
|
|
1310
|
+
crux_proposal: "If so",
|
|
1311
|
+
crux_alternative: "If it depends",
|
|
1312
|
+
depends_on: [id],
|
|
1313
|
+
});
|
|
1314
|
+
const newCard = this.freeCardId();
|
|
1315
|
+
// A rule draft is Baesian's own card, filed by an agent: the gate always shows it.
|
|
1316
|
+
return this.writeGated(newCard, 1, { ...prep.draft, rule: text, rule_covers: covers }, "", opts, "baesian").card;
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Save the rule a draft asked for, once the reviewer accepts it, or answers with their own wording (D110). Rules
|
|
1320
|
+
* are immutable, so it's saved once per decision.
|
|
1321
|
+
*/
|
|
1322
|
+
saveRule(card, v) {
|
|
1323
|
+
if (card.meta.rule === undefined || (v.response !== "accept" && v.response !== "correction"))
|
|
1324
|
+
return;
|
|
1325
|
+
const from = card.meta.depends_on[0];
|
|
1326
|
+
if (from && this.rules().some((r) => r.meta.from_card === from))
|
|
1327
|
+
return;
|
|
1328
|
+
const ruleId = newId("R", (candidate) => this.storage.read(`rules/${candidate}.md`) !== undefined);
|
|
1329
|
+
const meta = parse(RuleMeta, { type: "rule", format: newestFormat("rule"), id: ruleId, topic: this.canonicalTopic(card.meta.topic), covers: card.meta.rule_covers, ...(from ? { from_card: from } : {}), accepted_by: v.reviewer, ...(v.account ? { account: v.account } : {}), ...(v.project ? { project: v.project } : {}), created: v.at }, "the rule");
|
|
1330
|
+
const rel = `rules/${ruleId}.md`;
|
|
1331
|
+
this.writeOnce(rel, renderDoc(asWritten("rule", meta), `${v.response === "correction" ? v.choice : card.meta.rule}\n`));
|
|
1332
|
+
this.toRecord(rel);
|
|
1333
|
+
}
|
|
1334
|
+
writeRevision(id, revision, draft, body, by) {
|
|
1335
|
+
const deck = draft.deck ?? this.ensureBranchDeck();
|
|
1336
|
+
if (!this.deck(deck))
|
|
1337
|
+
throw new NoDeck(`no deck "${deck}": create it with \`baesian deck ${deck} --title "<what it decides>"\``);
|
|
1338
|
+
const meta = parse(CardMeta, {
|
|
1339
|
+
...draft,
|
|
1340
|
+
type: "card",
|
|
1341
|
+
format: newestFormat("card"),
|
|
1342
|
+
id,
|
|
1343
|
+
revision,
|
|
1344
|
+
deck,
|
|
1345
|
+
source_agent: by.agent,
|
|
1346
|
+
...(by.model ? { source_model: by.model } : {}),
|
|
1347
|
+
source_branch: this.branch,
|
|
1348
|
+
created: now(),
|
|
1349
|
+
}, "the card");
|
|
1350
|
+
const rel = `cards/${id}/r${revision}.md`;
|
|
1351
|
+
this.writeOnce(rel, renderDoc(asWritten("card", meta), body));
|
|
1352
|
+
this.toRecord(rel);
|
|
1353
|
+
this.toRecord(`decks/${deck}.md`);
|
|
1354
|
+
return { meta, body };
|
|
1355
|
+
}
|
|
1356
|
+
/** The agent withdraws its own card. */
|
|
1357
|
+
withdraw(id, reason, by) {
|
|
1358
|
+
this.requireInit();
|
|
1359
|
+
const card = this.card(id);
|
|
1360
|
+
if (!card)
|
|
1361
|
+
throw new NoCard(id);
|
|
1362
|
+
// Only the agent that filed it, or one attached to it, withdraws a card. Names are what agents say they are, so
|
|
1363
|
+
// this guards against mistakes, not against an agent set on it.
|
|
1364
|
+
const mine = card.meta.source_agent === by || this.attachments().some((a) => a.card === id && a.agent === by);
|
|
1365
|
+
if (!mine)
|
|
1366
|
+
throw new NotAllowed(`${id} was filed by ${byLine(cardAuthor(card.meta))}: only the agent that filed it, or one attached to it, may withdraw it`);
|
|
1367
|
+
if (this.storage.read(`cards/${id}/withdrawn.json`) !== undefined)
|
|
1368
|
+
throw new NotAllowed(`${id} is already withdrawn`);
|
|
1369
|
+
const w = parse(Withdrawal, { type: "withdrawal", format: newestFormat("withdrawal"), card: id, reason, by, at: now() }, "the withdrawal");
|
|
1370
|
+
const rel = `cards/${id}/withdrawn.json`;
|
|
1371
|
+
this.writeOnce(rel, `${JSON.stringify(asWritten("withdrawal", w), null, 2)}\n`);
|
|
1372
|
+
this.toRecord(rel);
|
|
1373
|
+
return w;
|
|
1374
|
+
}
|
|
1375
|
+
/** Wait on an existing card instead of filing a duplicate (D36). Copies the card into this worktree's record. */
|
|
1376
|
+
attach(id, agent) {
|
|
1377
|
+
this.requireInit();
|
|
1378
|
+
const card = this.card(id);
|
|
1379
|
+
if (!card)
|
|
1380
|
+
throw new NoCard(id);
|
|
1381
|
+
const a = parse(Attachment, { type: "attachment", format: newestFormat("attachment"), card: id, agent, branch: this.branch, at: now() }, "the attachment");
|
|
1382
|
+
this.writeOnce(`attachments/${id}-${randomSuffix()}.json`, `${JSON.stringify(asWritten("attachment", a), null, 2)}\n`);
|
|
1383
|
+
for (const r of this.revisions(id))
|
|
1384
|
+
this.toRecord(`cards/${id}/r${r.meta.revision}.md`);
|
|
1385
|
+
this.toRecord(`decks/${card.meta.deck}.md`);
|
|
1386
|
+
return a;
|
|
1387
|
+
}
|
|
1388
|
+
/** The card's withdrawal by its agent, if it has one. */
|
|
1389
|
+
withdrawal(id) {
|
|
1390
|
+
return this.read(`cards/${id}/withdrawn.json`, (t) => readAs("withdrawal", Withdrawal, JSON.parse(t), `cards/${id}/withdrawn.json`));
|
|
1391
|
+
}
|
|
1392
|
+
attachments(id) {
|
|
1393
|
+
return this.list("attachments")
|
|
1394
|
+
.filter((f) => !id || f.startsWith(`${id}-`))
|
|
1395
|
+
.flatMap((f) => this.read(`attachments/${f}`, (t) => readAs("attachment", Attachment, JSON.parse(t), `attachments/${f}`)) ?? []);
|
|
1396
|
+
}
|
|
1397
|
+
/** How many agents are waiting on a card: its filer plus every attachment (D36). */
|
|
1398
|
+
waiting(id) {
|
|
1399
|
+
return 1 + this.attachments(id).length;
|
|
1400
|
+
}
|
|
1401
|
+
/** Record that the agent proceeded on a blocking card without a verdict (D46). */
|
|
1402
|
+
recordFallback(id, reason, agent) {
|
|
1403
|
+
this.requireInit();
|
|
1404
|
+
const card = this.card(id);
|
|
1405
|
+
if (!card)
|
|
1406
|
+
throw new NoCard(id);
|
|
1407
|
+
const f = parse(Fallback, { type: "fallback", format: newestFormat("fallback"), card: id, revision: card.meta.revision, reason, agent, at: now() }, "the fallback");
|
|
1408
|
+
const rel = `cards/${id}/fallback-${randomSuffix()}.json`;
|
|
1409
|
+
this.writeOnce(rel, `${JSON.stringify(asWritten("fallback", f), null, 2)}\n`);
|
|
1410
|
+
this.toRecord(rel);
|
|
1411
|
+
return f;
|
|
1412
|
+
}
|
|
1413
|
+
fallbacks(id) {
|
|
1414
|
+
return this.list(`cards/${id}`)
|
|
1415
|
+
.filter((f) => f.startsWith("fallback-"))
|
|
1416
|
+
.flatMap((f) => this.read(`cards/${id}/${f}`, (t) => readAs("fallback", Fallback, JSON.parse(t), `cards/${id}/${f}`)) ?? []);
|
|
1417
|
+
}
|
|
1418
|
+
// ---------------------------------------------------------------- ledger and rules
|
|
1419
|
+
/** Record that a commit builds on a card (D15). Written by the post-commit hook. */
|
|
1420
|
+
writeLedger(id, entry, body) {
|
|
1421
|
+
this.requireInit();
|
|
1422
|
+
if (!this.card(id))
|
|
1423
|
+
throw new NoCard(id);
|
|
1424
|
+
const meta = parse(LedgerMeta, { type: "ledger", format: newestFormat("ledger"), card: id, branch: entry.branch ?? this.branch, commit: entry.commit, at: now() }, "the ledger entry");
|
|
1425
|
+
this.writeOnce(`ledger/${id}-${randomSuffix()}.md`, renderDoc(asWritten("ledger", meta), body));
|
|
1426
|
+
return id;
|
|
1427
|
+
}
|
|
1428
|
+
ledger(id) {
|
|
1429
|
+
return this.list("ledger")
|
|
1430
|
+
.filter((f) => f.startsWith(`${id}-`))
|
|
1431
|
+
.flatMap((f) => this.ledgerFile(f) ?? [])
|
|
1432
|
+
.sort((a, b) => a.meta.at.localeCompare(b.meta.at));
|
|
1433
|
+
}
|
|
1434
|
+
/** Every ledger entry, any card's, oldest first (D181): the commits that cite cards, for the branches panel and merges. */
|
|
1435
|
+
ledgerEntries() {
|
|
1436
|
+
return this.list("ledger")
|
|
1437
|
+
.filter((f) => f.endsWith(".md"))
|
|
1438
|
+
.flatMap((f) => this.ledgerFile(f) ?? [])
|
|
1439
|
+
.sort((a, b) => a.meta.at.localeCompare(b.meta.at));
|
|
1440
|
+
}
|
|
1441
|
+
ledgerFile(f) {
|
|
1442
|
+
return this.read(`ledger/${f}`, (text) => {
|
|
1443
|
+
const { meta, body } = parseDoc(text);
|
|
1444
|
+
return { meta: readAs("ledger", LedgerMeta, meta, `ledger/${f}`), body };
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
// ---------------------------------------------------------------- merges (D181)
|
|
1448
|
+
/**
|
|
1449
|
+
* Record that a branch merged into another (D181), as the hooks found it: `merges/<commit>-<key>.json`, named by the
|
|
1450
|
+
* commit, the merged branch and its target (`mergeFile()`), so the same merge found twice, here or on another machine,
|
|
1451
|
+
* is one file.
|
|
1452
|
+
* Returns it, or undefined when that merge is recorded already. Live only: it goes to the relay, never the record. A
|
|
1453
|
+
* later fast-forward of the same branch lands at another commit, so it's a file of its own (B-w41p7): nothing is ever
|
|
1454
|
+
* rewritten, and the relay takes it as it takes any new file.
|
|
1455
|
+
*/
|
|
1456
|
+
writeMerge(m) {
|
|
1457
|
+
this.requireInit();
|
|
1458
|
+
if (m.branch === m.into)
|
|
1459
|
+
throw new ValidationError(`a merge is of one branch into another, not ${m.branch} into itself`);
|
|
1460
|
+
const event = parse(MergeEvent, { type: "merge", format: newestFormat("merge"), ...m, cards: [...new Set(m.cards ?? [])].sort(), commits: [...new Set(m.commits ?? [])] }, "the merge");
|
|
1461
|
+
const rel = mergeFile(event.commit, event.branch, event.into);
|
|
1462
|
+
if (this.storage.read(rel) !== undefined)
|
|
1463
|
+
return undefined;
|
|
1464
|
+
try {
|
|
1465
|
+
this.writeOnce(rel, `${JSON.stringify(asWritten("merge", event), null, 2)}\n`);
|
|
1466
|
+
}
|
|
1467
|
+
catch (e) {
|
|
1468
|
+
if (alreadyExists(e))
|
|
1469
|
+
return undefined;
|
|
1470
|
+
throw e;
|
|
1471
|
+
}
|
|
1472
|
+
return event;
|
|
1473
|
+
}
|
|
1474
|
+
/** Every merge recorded, oldest first. */
|
|
1475
|
+
merges() {
|
|
1476
|
+
return this.list("merges")
|
|
1477
|
+
.filter((f) => f.endsWith(".json"))
|
|
1478
|
+
.flatMap((f) => this.read(`merges/${f}`, (t) => readAs("merge", MergeEvent, JSON.parse(t), `merges/${f}`)) ?? [])
|
|
1479
|
+
.sort((a, b) => a.at.localeCompare(b.at));
|
|
1480
|
+
}
|
|
1481
|
+
// ---------------------------------------------------------------- the default branch (B-jr8mk)
|
|
1482
|
+
/**
|
|
1483
|
+
* The repo's default branch, the trunk the branches panel draws on top: on your machine, read from git each time
|
|
1484
|
+
* (`defaultBranch()` in src/git.ts: the remote's HEAD, else `init.defaultBranch` where that branch exists, else the
|
|
1485
|
+
* branch the first commit was made on); on the relay, whose store has no git, what a machine last sent
|
|
1486
|
+
* (`DEFAULT_BRANCH`). Undefined when neither says.
|
|
1487
|
+
*/
|
|
1488
|
+
defaultBranch() {
|
|
1489
|
+
if (this.local)
|
|
1490
|
+
return defaultBranch(this.local.repo.toplevel)?.branch;
|
|
1491
|
+
return this.read(DEFAULT_BRANCH, (t) => readAs("default-branch", DefaultBranch, JSON.parse(t), DEFAULT_BRANCH))?.branch;
|
|
1492
|
+
}
|
|
1493
|
+
/** Every rule, retired or not: a retired one carries its retirement (D119). In a hosted repo, only those signed (D138). */
|
|
1494
|
+
rules() {
|
|
1495
|
+
const files = this.list("rules");
|
|
1496
|
+
return files
|
|
1497
|
+
.filter((f) => f.endsWith(".md") && this.judge(`rules/${f}`).ok)
|
|
1498
|
+
.flatMap((f) => {
|
|
1499
|
+
const rule = this.ruleFile(f);
|
|
1500
|
+
if (!rule)
|
|
1501
|
+
return [];
|
|
1502
|
+
const gone = `${rule.meta.id}-retired.json`;
|
|
1503
|
+
const retired = files.includes(gone) && this.judge(`rules/${gone}`).ok ? this.retirementFile(gone) : undefined;
|
|
1504
|
+
return [{ ...rule, ...(retired ? { retired } : {}) }];
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
/** One rule file, read whether or not it counts. A view caches it by path, so it's read the same way everywhere. */
|
|
1508
|
+
ruleFile(name) {
|
|
1509
|
+
return this.read(`rules/${name}`, (text) => {
|
|
1510
|
+
const { meta, body } = parseDoc(text);
|
|
1511
|
+
return { meta: readAs("rule", RuleMeta, meta, `rules/${name}`), body };
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
retirementFile(name) {
|
|
1515
|
+
return this.read(`rules/${name}`, (t) => readAs("rule-retirement", RuleRetirement, JSON.parse(t), `rules/${name}`));
|
|
1516
|
+
}
|
|
1517
|
+
// ---------------------------------------------------------------- presence
|
|
1518
|
+
/** The client's last heartbeat, if it has ever run (D41). */
|
|
1519
|
+
presence() {
|
|
1520
|
+
try {
|
|
1521
|
+
const text = this.text("presence.json");
|
|
1522
|
+
return text === undefined ? undefined : readAs("presence", Presence, JSON.parse(text), "presence.json");
|
|
1523
|
+
}
|
|
1524
|
+
catch {
|
|
1525
|
+
return undefined; // a half-written or foreign file, or one in a newer format (D157), means "unknown", never a crash
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
/** The client's heartbeat, a mutable file replaced whole (`Storage.replace`), so readers never see half a file. */
|
|
1529
|
+
touchPresence(client, at = now()) {
|
|
1530
|
+
this.requireInit();
|
|
1531
|
+
const p = parse(Presence, { type: "presence", format: newestFormat("presence"), client, at }, "presence");
|
|
1532
|
+
this.storage.replace("presence.json", encode(`${JSON.stringify(asWritten("presence", p))}\n`));
|
|
1533
|
+
return p;
|
|
1534
|
+
}
|
|
1535
|
+
// ---------------------------------------------------------------- decks
|
|
1536
|
+
deck(id) {
|
|
1537
|
+
return this.read(`decks/${id}.md`, (text) => {
|
|
1538
|
+
const { meta, body } = parseDoc(text);
|
|
1539
|
+
return { meta: readAs("deck", DeckMeta, meta, `decks/${id}.md`), body };
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
decks() {
|
|
1543
|
+
return this.list("decks")
|
|
1544
|
+
.filter((f) => f.endsWith(".md"))
|
|
1545
|
+
.map((f) => this.deck(f.slice(0, -3)))
|
|
1546
|
+
.filter((d) => d !== undefined);
|
|
1547
|
+
}
|
|
1548
|
+
createDeck(input, body) {
|
|
1549
|
+
this.requireInit();
|
|
1550
|
+
// Only a document in the repo is named: a path outside it would show the machine's folders in the record (D105).
|
|
1551
|
+
const from = input.from ? relative(this.need("naming the document a deck came from").repo.toplevel, input.from) : undefined;
|
|
1552
|
+
const inRepo = from !== undefined && from !== "" && from !== ".." && !from.startsWith(`..${sep}`) && !isAbsolute(from);
|
|
1553
|
+
const meta = parse(DeckMeta, {
|
|
1554
|
+
type: "deck",
|
|
1555
|
+
format: newestFormat("deck"),
|
|
1556
|
+
id: input.id,
|
|
1557
|
+
title: input.title,
|
|
1558
|
+
branch: this.branch,
|
|
1559
|
+
...(inRepo ? { from } : {}),
|
|
1560
|
+
context_notes: input.context_notes ?? [],
|
|
1561
|
+
created: now(),
|
|
1562
|
+
}, "the deck");
|
|
1563
|
+
try {
|
|
1564
|
+
this.writeOnce(`decks/${meta.id}.md`, renderDoc(asWritten("deck", meta), body));
|
|
1565
|
+
}
|
|
1566
|
+
catch (e) {
|
|
1567
|
+
if (!alreadyExists(e))
|
|
1568
|
+
throw e;
|
|
1569
|
+
throw Object.assign(new Error(`there's already a deck "${meta.id}"`), { code: "EEXIST" });
|
|
1570
|
+
}
|
|
1571
|
+
return { meta, body };
|
|
1572
|
+
}
|
|
1573
|
+
/** The branch's default deck (D29), created on first use. */
|
|
1574
|
+
branchDeckId() {
|
|
1575
|
+
return `branch-${slug(this.branch)}`;
|
|
1576
|
+
}
|
|
1577
|
+
ensureBranchDeck() {
|
|
1578
|
+
const id = this.branchDeckId();
|
|
1579
|
+
if (!this.deck(id)) {
|
|
1580
|
+
try {
|
|
1581
|
+
this.createDeck({ id, title: `Decisions on ${this.branch}` }, `Decisions filed while working on the branch \`${this.branch}\`.\n`);
|
|
1582
|
+
}
|
|
1583
|
+
catch (e) {
|
|
1584
|
+
// Another agent on this branch created it at the same moment: the exclusive create lost the race,
|
|
1585
|
+
// and the deck it wanted exists. Found by the server's concurrency test.
|
|
1586
|
+
if (!alreadyExists(e))
|
|
1587
|
+
throw e;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return id;
|
|
1591
|
+
}
|
|
1592
|
+
// ---------------------------------------------------------------- verdicts
|
|
1593
|
+
/** A card's verdicts, oldest first. In a hosted repo, only those that count: signed, and on a hard card proved (D138, D139). */
|
|
1594
|
+
verdicts(id) {
|
|
1595
|
+
return this.verdictFilesOf(id)
|
|
1596
|
+
.flatMap((f) => {
|
|
1597
|
+
const v = this.verdictFile(f);
|
|
1598
|
+
return v && this.judge(`verdicts/${f}`).ok ? [v] : [];
|
|
1599
|
+
})
|
|
1600
|
+
.sort((a, b) => a.at.localeCompare(b.at));
|
|
1601
|
+
}
|
|
1602
|
+
/** One verdict file, by name, read whether or not it counts. */
|
|
1603
|
+
verdictFile(name) {
|
|
1604
|
+
return this.read(`verdicts/${name}`, (t) => readAs("verdict", Verdict, JSON.parse(t), `verdicts/${name}`));
|
|
1605
|
+
}
|
|
1606
|
+
/** A card's verdict files, oldest first by name. In a view, every card's are sorted out once. */
|
|
1607
|
+
verdictFilesOf(id) {
|
|
1608
|
+
if (!this.session)
|
|
1609
|
+
return this.list("verdicts").filter((f) => verdictFileCard(f) === id);
|
|
1610
|
+
if (!this.session.verdictFiles) {
|
|
1611
|
+
const byCard = new Map();
|
|
1612
|
+
for (const f of this.list("verdicts")) {
|
|
1613
|
+
const card = verdictFileCard(f);
|
|
1614
|
+
if (card)
|
|
1615
|
+
(byCard.get(card) ?? byCard.set(card, []).get(card)).push(f);
|
|
1616
|
+
}
|
|
1617
|
+
this.session.verdictFiles = byCard;
|
|
1618
|
+
}
|
|
1619
|
+
return this.session.verdictFiles.get(id) ?? [];
|
|
1620
|
+
}
|
|
1621
|
+
/** The cards that supersede `id`, or name it in `conflict_of` (D35). In a view, found once for every card. */
|
|
1622
|
+
claimants(id) {
|
|
1623
|
+
const build = () => {
|
|
1624
|
+
const by = new Map();
|
|
1625
|
+
for (const c of this.cards()) {
|
|
1626
|
+
for (const target of [c.meta.supersedes, ...(c.meta.conflict_of ?? [])]) {
|
|
1627
|
+
if (target)
|
|
1628
|
+
(by.get(target) ?? by.set(target, []).get(target)).push(c);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
return by;
|
|
1632
|
+
};
|
|
1633
|
+
const by = this.session ? (this.session.claims ??= build()) : build();
|
|
1634
|
+
return by.get(id) ?? [];
|
|
1635
|
+
}
|
|
1636
|
+
/** Record a verdict. Only the client calls this: agents never resolve cards (D32). */
|
|
1637
|
+
addVerdict(input) {
|
|
1638
|
+
this.requireInit();
|
|
1639
|
+
const card = this.card(input.card);
|
|
1640
|
+
if (!card)
|
|
1641
|
+
throw new NoCard(input.card);
|
|
1642
|
+
if (input.revision !== card.meta.revision) {
|
|
1643
|
+
throw new Error(`${input.card} is at revision ${card.meta.revision}; a verdict on r${input.revision} is stale`);
|
|
1644
|
+
}
|
|
1645
|
+
// A card's verdicts are ordered by `at`, so keep it strictly increasing per card: two verdicts in
|
|
1646
|
+
// the same millisecond would otherwise sort arbitrarily, and "the latest verdict" decides the status.
|
|
1647
|
+
let at = input.at ?? now();
|
|
1648
|
+
const previous = this.verdicts(input.card).at(-1)?.at;
|
|
1649
|
+
if (previous && at <= previous)
|
|
1650
|
+
at = new Date(Date.parse(previous) + 1).toISOString();
|
|
1651
|
+
// A card answered in chat (B-hswmx): its pick was seen before the answer, so every verdict on it says so, and none
|
|
1652
|
+
// carries a blind choice. Only such a card's verdicts are marked seen.
|
|
1653
|
+
const seen = card.meta.answered_in_chat !== undefined;
|
|
1654
|
+
if (seen && input.first_choice !== undefined) {
|
|
1655
|
+
throw new ValidationError(`${card.meta.id} was answered in chat, so its pick was seen first: a verdict on it has no blind choice (first_choice)${cite("B-hswmx")}`);
|
|
1656
|
+
}
|
|
1657
|
+
if (!seen && input.seen !== undefined)
|
|
1658
|
+
throw new ValidationError(`${card.meta.id} wasn't answered in chat: only a verdict on a card answered in chat is marked seen${cite("B-hswmx")}`);
|
|
1659
|
+
const v = parse(Verdict, { ...input, ...(seen ? { seen: true } : {}), type: "verdict", format: newestFormat("verdict"), at }, "the verdict");
|
|
1660
|
+
for (const id of v.retire_rules ?? []) {
|
|
1661
|
+
const problem = this.retirementProblem(card, v, id);
|
|
1662
|
+
if (problem)
|
|
1663
|
+
throw new ValidationError(problem);
|
|
1664
|
+
}
|
|
1665
|
+
// "You should have asked me" is for a card the gate held back, or in shadow would have (D151, B-87tkc).
|
|
1666
|
+
if (v.should_have_asked !== undefined && this.gateRecord(card.meta.id, card.meta.revision)?.outcome !== "held") {
|
|
1667
|
+
throw new ValidationError(`${card.meta.id} wasn't held back: "you should have asked me" is only for a card the gate held, or in shadow would have`);
|
|
1668
|
+
}
|
|
1669
|
+
const name = `${compactAt(v.at)}-${v.card}-r${v.revision}-${randomSuffix()}.json`;
|
|
1670
|
+
this.writeOnce(`verdicts/${name}`, `${JSON.stringify(asWritten("verdict", v), null, 2)}\n`);
|
|
1671
|
+
this.saveRule(card, v);
|
|
1672
|
+
this.retireRules(card, v);
|
|
1673
|
+
this.moveSlider(card, v);
|
|
1674
|
+
return v;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Accepting a slider proposal writes a new preference file, moving its row (B-y6jn6), with `from_card` naming it. If
|
|
1678
|
+
* the row has moved since the card was filed, the proposal is stale and nothing is written: a person moved it.
|
|
1679
|
+
*/
|
|
1680
|
+
moveSlider(card, v) {
|
|
1681
|
+
const { pref_row: row, pref_from: from, pref_to: to } = card.meta;
|
|
1682
|
+
if (!row || !from || !to || v.response !== "accept")
|
|
1683
|
+
return;
|
|
1684
|
+
const force = this.inForce();
|
|
1685
|
+
if (force.settings.rows[row] !== from)
|
|
1686
|
+
return;
|
|
1687
|
+
// At the answer's own time, so an answer written again at that time (the relay's, after the passkey) writes the same bytes.
|
|
1688
|
+
this.writePreferences({ replaces: force.name, [`row_${row}`]: to }, { by: v.reviewer, ...(v.account ? { account: v.account } : {}), ...(v.project ? { project: v.project } : {}), client: v.client, from_card: card.meta.id, at: v.at });
|
|
1689
|
+
}
|
|
1690
|
+
// ---------------------------------------------------------------- preferences (D147)
|
|
1691
|
+
/** The preference files, oldest first by name, which is by time (D107). In a hosted repo, only those that count (D138, D163). */
|
|
1692
|
+
preferenceNames() {
|
|
1693
|
+
return this.list("preferences").filter((f) => PREFERENCE_FILE.test(f) && this.judge(`preferences/${f}`).ok);
|
|
1694
|
+
}
|
|
1695
|
+
/** One preference file, by name; in a hosted repo, undefined unless it counts. */
|
|
1696
|
+
preferenceFile(name) {
|
|
1697
|
+
const file = this.preferenceRaw(name);
|
|
1698
|
+
return file && this.judge(`preferences/${name}`).ok ? file : undefined;
|
|
1699
|
+
}
|
|
1700
|
+
/** One preference file, read whether or not it counts. */
|
|
1701
|
+
preferenceRaw(name) {
|
|
1702
|
+
if (!PREFERENCE_FILE.test(name))
|
|
1703
|
+
return undefined;
|
|
1704
|
+
return this.read(`preferences/${name}`, (t) => readAs("preferences", Preferences, JSON.parse(t), `preferences/${name}`));
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* The preferences the gate applies now (P14, D148): the newest file, or with none, "Today" in shadow, which routes as
|
|
1708
|
+
* Baesian did before the gate. A newest file that can't be read fails toward the reviewer: "Today" in shadow, saying why.
|
|
1709
|
+
*/
|
|
1710
|
+
inForce() {
|
|
1711
|
+
const name = this.preferenceNames().at(-1);
|
|
1712
|
+
if (!name)
|
|
1713
|
+
return { settings: TODAY };
|
|
1714
|
+
try {
|
|
1715
|
+
const file = this.preferenceFile(name);
|
|
1716
|
+
if (!file)
|
|
1717
|
+
return { name, settings: TODAY, problem: `the preference file preferences/${name} can't be read` };
|
|
1718
|
+
return { name, file, settings: settingsOf(file) };
|
|
1719
|
+
}
|
|
1720
|
+
catch (e) {
|
|
1721
|
+
return { name, settings: TODAY, problem: `the preference file preferences/${name} can't be read (${e instanceof Error ? e.message.split("\n")[0] : String(e)})` };
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Write a change to the preferences (D147): for the client only, through the local server, and for an accepted slider
|
|
1726
|
+
* proposal. The change is made over the file in force (or "Today" in shadow), from a preset if it names one, or the
|
|
1727
|
+
* one the setup survey's answers suggest (B-5na1k). What the commit gate looks at (B-qkek1) is kept from the file in
|
|
1728
|
+
* force unless the change sends a list (`pathSetting()`). `replaces` must name the newest file, or be absent when
|
|
1729
|
+
* there's none: a change made from an older view is refused (`StalePreferences`), so two tabs can't undo each other.
|
|
1730
|
+
*/
|
|
1731
|
+
writePreferences(change, who) {
|
|
1732
|
+
this.requireInit();
|
|
1733
|
+
const force = this.inForce();
|
|
1734
|
+
if ((change.replaces ?? undefined) !== force.name) {
|
|
1735
|
+
throw new StalePreferences(force.name ? `the preferences have changed since: preferences/${force.name} is in force now` : "there's no preference file yet: a first one replaces none");
|
|
1736
|
+
}
|
|
1737
|
+
const { replaces: _r, preset: presetId, gate_schema, gate_infra, gate_api, gate_ignore, ...fields } = change;
|
|
1738
|
+
const list = (v) => (Array.isArray(v) ? v.filter((r) => ROWS.includes(r)) : []);
|
|
1739
|
+
const survey = fields.survey_see || fields.survey_fine ? suggestPreset({ see: list(fields.survey_see), fine: list(fields.survey_fine) }) : undefined;
|
|
1740
|
+
const preset = presetId !== undefined ? PRESETS.find((p) => p.id === presetId) : survey;
|
|
1741
|
+
if (presetId !== undefined && !preset)
|
|
1742
|
+
throw new ValidationError(`no preset "${presetId}": the presets are ${PRESETS.map((p) => p.id).join(", ")}`);
|
|
1743
|
+
const base = force.file ? settingsOf(force.file) : TODAY;
|
|
1744
|
+
const rows = preset ? preset.rows : base.rows;
|
|
1745
|
+
// What the commit gate looks at (B-qkek1): kept from the file in force unless the change sets it; a list that's the
|
|
1746
|
+
// built-in one is left out, so it means the built-in list.
|
|
1747
|
+
const given = { schema: gate_schema, infra: gate_infra, api: gate_api, ignore: gate_ignore };
|
|
1748
|
+
const paths = Object.fromEntries(PATH_KINDS.flatMap((k) => {
|
|
1749
|
+
const v = given[k];
|
|
1750
|
+
if (v === undefined)
|
|
1751
|
+
return base.paths[k] ? [[PATH_KEYS[k], base.paths[k]]] : [];
|
|
1752
|
+
if (!Array.isArray(v) || v.some((g) => typeof g !== "string"))
|
|
1753
|
+
throw new ValidationError(`${PATH_KEYS[k]} is a list of globs`);
|
|
1754
|
+
const kept = pathSetting(k, v);
|
|
1755
|
+
return kept ? [[PATH_KEYS[k], kept]] : [];
|
|
1756
|
+
}));
|
|
1757
|
+
let at = who.at ?? now();
|
|
1758
|
+
const previous = force.file?.at;
|
|
1759
|
+
if (previous && at <= previous)
|
|
1760
|
+
at = new Date(Date.parse(previous) + 1).toISOString();
|
|
1761
|
+
const file = parse(Preferences, {
|
|
1762
|
+
type: "preferences",
|
|
1763
|
+
format: newestFormat("preferences"),
|
|
1764
|
+
mode: base.mode,
|
|
1765
|
+
...Object.fromEntries(ROWS.map((r) => [`row_${r}`, rows[r]])),
|
|
1766
|
+
rate_agent_decides: base.rate_agent_decides,
|
|
1767
|
+
rate_sample: base.rate_sample,
|
|
1768
|
+
burn_in: base.burn_in,
|
|
1769
|
+
...paths,
|
|
1770
|
+
...(preset ? { profile: preset.id } : force.file?.profile ? { profile: force.file.profile } : {}),
|
|
1771
|
+
...Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && !(Array.isArray(v) && v.length === 0))),
|
|
1772
|
+
...(force.name ? { replaces: force.name } : {}),
|
|
1773
|
+
...(who.from_card ? { from_card: who.from_card } : {}),
|
|
1774
|
+
by: who.by,
|
|
1775
|
+
...(who.account ? { account: who.account } : {}),
|
|
1776
|
+
...(who.project ? { project: who.project } : {}),
|
|
1777
|
+
client: who.client,
|
|
1778
|
+
at,
|
|
1779
|
+
}, "the preferences");
|
|
1780
|
+
const name = `${compactAt(at)}-${randomSuffix()}.json`;
|
|
1781
|
+
const rel = `preferences/${name}`;
|
|
1782
|
+
this.writeOnce(rel, `${JSON.stringify(asWritten("preferences", file), null, 2)}\n`);
|
|
1783
|
+
this.toRecord(rel);
|
|
1784
|
+
return { name, file };
|
|
1785
|
+
}
|
|
1786
|
+
/** An answer retires only a rule that the option it chose breaks, and a rule is retired once (D119). */
|
|
1787
|
+
retirementProblem(card, v, id) {
|
|
1788
|
+
const option = v.response === "accept" ? "proposal" : v.response === "alternative" ? "alternative" : undefined;
|
|
1789
|
+
if (!option)
|
|
1790
|
+
return `only choosing one of a card's options can retire a rule, not a ${v.response}`;
|
|
1791
|
+
if (!card.meta[`${option}_breaks`].includes(id))
|
|
1792
|
+
return `${id} isn't a rule that ${card.meta.id}'s ${option} breaks`;
|
|
1793
|
+
const rule = this.rules().find((r) => r.meta.id === id);
|
|
1794
|
+
if (!rule)
|
|
1795
|
+
return `no rule ${id}`;
|
|
1796
|
+
if (rule.retired)
|
|
1797
|
+
return `${id} is already retired`;
|
|
1798
|
+
return undefined;
|
|
1799
|
+
}
|
|
1800
|
+
retireRules(card, v) {
|
|
1801
|
+
for (const id of v.retire_rules ?? []) {
|
|
1802
|
+
const r = parse(RuleRetirement, { type: "rule-retirement", format: newestFormat("rule-retirement"), rule: id, card: card.meta.id, by: v.reviewer, ...(v.account ? { account: v.account } : {}), ...(v.project ? { project: v.project } : {}), at: v.at }, "the retirement");
|
|
1803
|
+
const rel = `rules/${id}-retired.json`;
|
|
1804
|
+
this.writeOnce(rel, `${JSON.stringify(asWritten("rule-retirement", r), null, 2)}\n`);
|
|
1805
|
+
this.toRecord(rel);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
// ---------------------------------------------------------------- status
|
|
1809
|
+
/** A card's status, computed from its files; nothing is ever edited in place (D34). */
|
|
1810
|
+
status(id, seen = new Set()) {
|
|
1811
|
+
const card = this.card(id);
|
|
1812
|
+
if (!card)
|
|
1813
|
+
throw new NoCard(id);
|
|
1814
|
+
// Listed, not read: in a view, the card's folder is listed once.
|
|
1815
|
+
if (this.list(`cards/${id}`).includes("withdrawn.json"))
|
|
1816
|
+
return "withdrawn";
|
|
1817
|
+
seen.add(id);
|
|
1818
|
+
// Rejecting a parent withdraws the cards that depend on it (D14). `>=`, not `>`: timestamps have
|
|
1819
|
+
// millisecond resolution, and a rejection in the same millisecond as the filing still counts.
|
|
1820
|
+
for (const parent of card.meta.depends_on) {
|
|
1821
|
+
const rejectedAfter = this.verdicts(parent).some((v) => v.response === "reject" && v.at >= card.meta.created);
|
|
1822
|
+
if (rejectedAfter)
|
|
1823
|
+
return "withdrawn";
|
|
1824
|
+
}
|
|
1825
|
+
for (const other of this.claimants(id)) {
|
|
1826
|
+
if (seen.has(other.meta.id))
|
|
1827
|
+
continue;
|
|
1828
|
+
const supersedes = other.meta.supersedes === id;
|
|
1829
|
+
// A resolved conflict card supersedes the decisions it names that it didn't choose (D35).
|
|
1830
|
+
const lostConflict = other.meta.conflict_of?.includes(id) ?? false;
|
|
1831
|
+
if (!supersedes && !lostConflict)
|
|
1832
|
+
continue;
|
|
1833
|
+
if (this.status(other.meta.id, seen) !== "resolved")
|
|
1834
|
+
continue;
|
|
1835
|
+
if (supersedes)
|
|
1836
|
+
return "superseded";
|
|
1837
|
+
if (normalise(this.decision(other.meta.id) ?? "") !== normalise(this.answer(card) ?? "")) {
|
|
1838
|
+
return "superseded";
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
// Choosing the alternative returns the cards built on the proposal (D80).
|
|
1842
|
+
if (this.parentReturns(card).length)
|
|
1843
|
+
return "returned";
|
|
1844
|
+
const last = this.latestVerdict(card);
|
|
1845
|
+
// Held back by the gate, enforcing, and not answered yet (D150): any answer on the revision ends it.
|
|
1846
|
+
if (!last)
|
|
1847
|
+
return this.isHeld(card) ? "held" : "open";
|
|
1848
|
+
switch (last.response) {
|
|
1849
|
+
case "accept":
|
|
1850
|
+
case "alternative":
|
|
1851
|
+
case "correction":
|
|
1852
|
+
return "resolved";
|
|
1853
|
+
case "reject":
|
|
1854
|
+
case "question":
|
|
1855
|
+
case "need_context":
|
|
1856
|
+
case "see_it_run":
|
|
1857
|
+
return "returned";
|
|
1858
|
+
case "not_my_area":
|
|
1859
|
+
return "rerouted";
|
|
1860
|
+
case "defer":
|
|
1861
|
+
return "deferred";
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
/** The gate held this revision back, enforcing: a shadow record never holds a card (D148). */
|
|
1865
|
+
isHeld(card) {
|
|
1866
|
+
const g = this.gateRecord(card.meta.id, card.meta.revision);
|
|
1867
|
+
return g?.mode === "enforce" && g.outcome === "held";
|
|
1868
|
+
}
|
|
1869
|
+
/** The latest verdict on a card's latest revision. */
|
|
1870
|
+
latestVerdict(card) {
|
|
1871
|
+
return this.verdicts(card.meta.id)
|
|
1872
|
+
.filter((v) => v.revision === card.meta.revision)
|
|
1873
|
+
.at(-1);
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* The cards `card` builds on whose latest verdict chose something other than their proposal (D80), after
|
|
1877
|
+
* this card's latest revision and its own latest verdict. A verdict given after the parent's, or a revision
|
|
1878
|
+
* filed after it, means the agent or the reviewer has already taken the new answer into account.
|
|
1879
|
+
*/
|
|
1880
|
+
parentReturns(card) {
|
|
1881
|
+
const own = this.latestVerdict(card)?.at ?? "";
|
|
1882
|
+
const since = own > card.meta.created ? own : card.meta.created;
|
|
1883
|
+
const out = [];
|
|
1884
|
+
for (const parentId of card.meta.depends_on) {
|
|
1885
|
+
const parent = this.card(parentId);
|
|
1886
|
+
const pv = parent && this.latestVerdict(parent);
|
|
1887
|
+
if (!parent || !pv || (pv.response !== "alternative" && pv.response !== "correction") || pv.at < since)
|
|
1888
|
+
continue;
|
|
1889
|
+
out.push({
|
|
1890
|
+
kind: "parent-decided-otherwise",
|
|
1891
|
+
card: card.meta.id,
|
|
1892
|
+
parent: parentId,
|
|
1893
|
+
parentTitle: parent.meta.title,
|
|
1894
|
+
parentAgent: parent.meta.source_agent,
|
|
1895
|
+
parentBranch: parent.meta.source_branch,
|
|
1896
|
+
assumed: parent.meta.proposal,
|
|
1897
|
+
decided: pv.choice,
|
|
1898
|
+
reviewer: pv.reviewer,
|
|
1899
|
+
at: pv.at,
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
return out;
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* The answer on `card` changed after an earlier verdict resolved it (D81). A reply (D104) is the same question
|
|
1906
|
+
* asked again, so the answer it replied to counts as earlier.
|
|
1907
|
+
*/
|
|
1908
|
+
answerChange(card) {
|
|
1909
|
+
const asked = (r) => r === card.meta.revision || (card.meta.reply !== undefined && r === card.meta.revision - 1);
|
|
1910
|
+
const vs = this.verdicts(card.meta.id).filter((v) => asked(v.revision));
|
|
1911
|
+
const last = vs.at(-1);
|
|
1912
|
+
const answerOf = (v) => (v.response === "accept" ? card.meta.proposal : v.response === "alternative" || v.response === "correction" ? v.choice : undefined);
|
|
1913
|
+
const earlier = vs.slice(0, -1).reverse().find((v) => answerOf(v) !== undefined);
|
|
1914
|
+
const g = last && this.gateRecord(card.meta.id, last.revision);
|
|
1915
|
+
if (last && !earlier && g?.mode === "enforce" && g.outcome === "held") {
|
|
1916
|
+
// Held back, so the agent went ahead with its pick (D150): an answer that overturns it, or a rejection, means
|
|
1917
|
+
// rework (D151). A question or a deferral doesn't: the card's status says what to do.
|
|
1918
|
+
const now = answerOf(last);
|
|
1919
|
+
if (now === undefined ? last.response !== "reject" : normalise(now) === normalise(card.meta.proposal))
|
|
1920
|
+
return undefined;
|
|
1921
|
+
return { kind: "held-answered", card: card.meta.id, assumed: card.meta.proposal, agent: card.meta.source_agent, branch: card.meta.source_branch, now, reviewer: last.reviewer, response: last.response, commits: this.builtOn(card.meta.id, last.at), at: last.at };
|
|
1922
|
+
}
|
|
1923
|
+
if (!last || !earlier)
|
|
1924
|
+
return undefined;
|
|
1925
|
+
const was = answerOf(earlier), now = answerOf(last);
|
|
1926
|
+
if (now !== undefined && normalise(now) === normalise(was))
|
|
1927
|
+
return undefined;
|
|
1928
|
+
return { kind: "answer-changed", card: card.meta.id, was, wasReviewer: earlier.reviewer, now, reviewer: last.reviewer, response: last.response, commits: this.builtOn(card.meta.id, last.at), at: last.at };
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* The commits built on a card before `before` (B-9v5xj). First those on a branch whose trailers cite it, found by
|
|
1932
|
+
* searching the history, so a rebase, or a squash that keeps the trailer, doesn't lose them; then the ledger's
|
|
1933
|
+
* entries whose commit is on no branch now, marked rewritten. A commit in both is named by the branch the ledger
|
|
1934
|
+
* says it was made on, unless that was a detached HEAD (as during a rebase).
|
|
1935
|
+
*/
|
|
1936
|
+
builtOn(id, before) {
|
|
1937
|
+
const history = this.history();
|
|
1938
|
+
const ledger = this.ledger(id).filter((e) => e.meta.at < before);
|
|
1939
|
+
const madeOn = new Map(ledger.flatMap((e) => (e.meta.commit && !e.meta.branch.startsWith("detached-") ? [[e.meta.commit, e.meta.branch]] : [])));
|
|
1940
|
+
// When the work was done: a rebase keeps the author's date, and a commit made after the change built on the new answer.
|
|
1941
|
+
const found = (history.citing.get(id) ?? []).filter((c) => c.authored < Date.parse(before));
|
|
1942
|
+
const listed = new Set(found.map((c) => c.commit));
|
|
1943
|
+
const out = found.map((c) => ({ commit: c.commit, branch: madeOn.get(c.commit) ?? c.branch }));
|
|
1944
|
+
for (const e of ledger) {
|
|
1945
|
+
const commit = e.meta.commit;
|
|
1946
|
+
if (!commit) {
|
|
1947
|
+
out.push({ branch: e.meta.branch });
|
|
1948
|
+
}
|
|
1949
|
+
else if (!listed.has(commit)) {
|
|
1950
|
+
listed.add(commit);
|
|
1951
|
+
out.push({ commit, branch: e.meta.branch, ...(history.onBranch(commit) ? {} : { rewritten: true }) });
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return out;
|
|
1955
|
+
}
|
|
1956
|
+
/** The history's `Baesian-Card` trailers (B-9v5xj): searched once per view, and cached per process while the branches stay put. */
|
|
1957
|
+
history() {
|
|
1958
|
+
if (this.session?.history)
|
|
1959
|
+
return this.session.history;
|
|
1960
|
+
const history = this.local ? readHistory(this.local.repo.toplevel) : NO_HISTORY;
|
|
1961
|
+
if (this.session)
|
|
1962
|
+
this.session.history = history;
|
|
1963
|
+
return history;
|
|
1964
|
+
}
|
|
1965
|
+
/** What an agent should be told about a card now, beyond its verdict (D80, D81). */
|
|
1966
|
+
briefs(id) {
|
|
1967
|
+
const card = this.card(id);
|
|
1968
|
+
if (!card)
|
|
1969
|
+
throw new NoCard(id);
|
|
1970
|
+
const change = this.answerChange(card);
|
|
1971
|
+
return [...this.parentReturns(card), ...(change ? [change] : [])];
|
|
1972
|
+
}
|
|
1973
|
+
/** What was decided, for a resolved card. */
|
|
1974
|
+
decision(id) {
|
|
1975
|
+
const card = this.card(id);
|
|
1976
|
+
if (!card || this.status(id) !== "resolved")
|
|
1977
|
+
return undefined;
|
|
1978
|
+
return this.answer(card);
|
|
1979
|
+
}
|
|
1980
|
+
/** What the latest verdict on the latest revision chose, without checking supersession (so `status` can use it). */
|
|
1981
|
+
answer(card) {
|
|
1982
|
+
const last = this.latestVerdict(card);
|
|
1983
|
+
if (!last)
|
|
1984
|
+
return undefined;
|
|
1985
|
+
if (last.response === "accept")
|
|
1986
|
+
return card.meta.proposal;
|
|
1987
|
+
if (last.response === "alternative" || last.response === "correction")
|
|
1988
|
+
return last.choice;
|
|
1989
|
+
return undefined;
|
|
1990
|
+
}
|
|
1991
|
+
// ---------------------------------------------------------------- topics
|
|
1992
|
+
/** Topic merges (D44); in a hosted repo, only those signed (D138). */
|
|
1993
|
+
aliases() {
|
|
1994
|
+
return this.list("topics")
|
|
1995
|
+
.filter((f) => f.startsWith("alias-") && f.endsWith(".json") && this.judge(`topics/${f}`).ok)
|
|
1996
|
+
.flatMap((f) => this.read(`topics/${f}`, (t) => readAs("topic-alias", TopicAlias, JSON.parse(t), `topics/${f}`)) ?? []);
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* Merge two topics that mean the same thing (D44, D111): an alias from `from`'s canonical key to `to`'s. Aliases
|
|
2000
|
+
* are immutable, so a merge can't be undone. Each always points at a canonical key, so no cycle can form. On the
|
|
2001
|
+
* relay, `who` names the account and the project it was made in (P16 section 7, B-tf9fc).
|
|
2002
|
+
*/
|
|
2003
|
+
mergeTopics(from, to, by, who = {}) {
|
|
2004
|
+
this.requireInit();
|
|
2005
|
+
const source = this.canonicalTopic(from), target = this.canonicalTopic(to);
|
|
2006
|
+
if (source === target)
|
|
2007
|
+
throw new ValidationError(`"${from}" and "${to}" are already the same topic`);
|
|
2008
|
+
if (!this.topics().has(target))
|
|
2009
|
+
throw new ValidationError(`no card uses the topic "${to}": merge into a topic that's in use`);
|
|
2010
|
+
const alias = parse(TopicAlias, { type: "topic-alias", format: newestFormat("topic-alias"), from: source, to: target, by, ...(who.account ? { account: who.account } : {}), ...(who.project ? { project: who.project } : {}), at: now() }, "the alias");
|
|
2011
|
+
const rel = `topics/alias-${randomSuffix()}.json`;
|
|
2012
|
+
this.writeOnce(rel, `${JSON.stringify(asWritten("topic-alias", alias), null, 2)}\n`);
|
|
2013
|
+
this.toRecord(rel);
|
|
2014
|
+
return alias;
|
|
2015
|
+
}
|
|
2016
|
+
/** Follow topic aliases to the canonical key (D44). */
|
|
2017
|
+
canonicalTopic(topic) {
|
|
2018
|
+
const map = new Map(this.aliases().map((a) => [a.from, a.to]));
|
|
2019
|
+
let current = topic;
|
|
2020
|
+
for (let hops = 0; map.has(current) && hops < 20; hops++)
|
|
2021
|
+
current = map.get(current);
|
|
2022
|
+
return current;
|
|
2023
|
+
}
|
|
2024
|
+
topics() {
|
|
2025
|
+
return new Set(this.cards()
|
|
2026
|
+
.map((c) => c.meta.topic)
|
|
2027
|
+
.filter((t) => t !== undefined)
|
|
2028
|
+
.map((t) => this.canonicalTopic(t)));
|
|
2029
|
+
}
|
|
2030
|
+
// ---------------------------------------------------------------- inbox
|
|
2031
|
+
/** Cards this branch filed or attached to. */
|
|
2032
|
+
branchCards(branch = this.branch) {
|
|
2033
|
+
const attached = new Set(this.attachments().filter((a) => a.branch === branch).map((a) => a.card));
|
|
2034
|
+
return this.cards().filter((c) => c.meta.source_branch === branch || attached.has(c.meta.id));
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Verdicts on this branch's cards newer than its inbox cursor, less those `baesian wait` has already delivered.
|
|
2038
|
+
* The cursor is local state, not a record.
|
|
2039
|
+
*
|
|
2040
|
+
* With a `session` (a harness's session id, which the plugin's hooks pass), the session keeps a cursor of its own, so
|
|
2041
|
+
* every session on the branch hears each verdict once (B-ghhym). A new session starts from the branch's cursor, the
|
|
2042
|
+
* newest any session has read, so it isn't handed old verdicts. What `wait` delivered stays delivered for all.
|
|
2043
|
+
*/
|
|
2044
|
+
inbox(opts) {
|
|
2045
|
+
this.requireInit();
|
|
2046
|
+
const branch = this.readCursor();
|
|
2047
|
+
const saved = opts.session ? this.readCursor(opts.session) : branch;
|
|
2048
|
+
const own = saved ?? { at: branch.at, delivered: [] };
|
|
2049
|
+
const all = this.inboxSince(own.at);
|
|
2050
|
+
const delivered = new Set([...own.delivered, ...branch.delivered]);
|
|
2051
|
+
const latest = all.at(-1)?.verdict.at;
|
|
2052
|
+
if (opts.advance && !opts.session && latest) {
|
|
2053
|
+
// Once everything newer than the cursor has been seen, here or through `wait`, the cursor moves past all of it.
|
|
2054
|
+
this.writeCursor({ at: latest });
|
|
2055
|
+
}
|
|
2056
|
+
else if (opts.advance && opts.session) {
|
|
2057
|
+
// A session's first read keeps its place even with nothing new, so another session reading first can't move it.
|
|
2058
|
+
if (latest || !saved)
|
|
2059
|
+
this.writeCursor({ at: latest ?? own.at }, opts.session);
|
|
2060
|
+
// The branch's cursor keeps up with the newest any session has read; what `wait` delivered stays listed, for
|
|
2061
|
+
// sessions still behind it.
|
|
2062
|
+
if (latest && latest > branch.at)
|
|
2063
|
+
this.writeCursor({ at: latest, ...(branch.delivered.length ? { delivered: branch.delivered } : {}) });
|
|
2064
|
+
}
|
|
2065
|
+
return all.filter((i) => !delivered.has(inboxKey(i)));
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Mark as seen what `baesian wait` just told the agent about `id`, its verdict and its briefs, so that `inbox`, and
|
|
2069
|
+
* the harness hooks that run it (D116), don't deliver them again. The cursor stays where it is, so verdicts on the
|
|
2070
|
+
* branch's other cards that the agent hasn't seen stay in the inbox; the delivered ones are listed beside it.
|
|
2071
|
+
*/
|
|
2072
|
+
markDelivered(id, shown) {
|
|
2073
|
+
this.requireInit();
|
|
2074
|
+
const cursor = this.readCursor();
|
|
2075
|
+
const ats = new Set([shown.verdict?.at, ...shown.briefs.map((b) => b.at)]);
|
|
2076
|
+
const keys = this.inboxSince(cursor.at)
|
|
2077
|
+
.filter((i) => i.card === id && ats.has(i.verdict.at))
|
|
2078
|
+
.map(inboxKey);
|
|
2079
|
+
if (keys.every((k) => cursor.delivered.includes(k)))
|
|
2080
|
+
return;
|
|
2081
|
+
this.writeCursor({ at: cursor.at, delivered: [...new Set([...cursor.delivered, ...keys])] });
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* The model a harness session runs on, as the plugin's hooks last heard it (B-k0w7d): `models/<session>.json`, the
|
|
2085
|
+
* first of `sessions` that has one, or undefined. A file that can't be read names no model, and never fails a filing.
|
|
2086
|
+
*/
|
|
2087
|
+
sessionModel(sessions) {
|
|
2088
|
+
for (const session of sessions) {
|
|
2089
|
+
const rel = modelPath(session);
|
|
2090
|
+
try {
|
|
2091
|
+
const text = this.text(rel);
|
|
2092
|
+
if (text !== undefined)
|
|
2093
|
+
return readAs("session-model", SessionModel, JSON.parse(text), rel).model;
|
|
2094
|
+
}
|
|
2095
|
+
catch {
|
|
2096
|
+
// unreadable: no model from this session's file
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
return undefined;
|
|
2100
|
+
}
|
|
2101
|
+
/** Note a session's model, as the plugin's hooks do (B-k0w7d): replaced whole, in the newest format. */
|
|
2102
|
+
noteSessionModel(session, model) {
|
|
2103
|
+
const file = parse(SessionModel, { type: "session-model", format: newestFormat("session-model"), model, at: now() }, "the session's model");
|
|
2104
|
+
this.storage.replace(modelPath(session), encode(`${JSON.stringify(asWritten("session-model", file))}\n`));
|
|
2105
|
+
}
|
|
2106
|
+
/** Whether a harness's session has read this branch's inbox before: its own cursor is written at its first read. */
|
|
2107
|
+
sessionKnown(session) {
|
|
2108
|
+
return this.storage.read(this.cursorPath(session)) !== undefined;
|
|
2109
|
+
}
|
|
2110
|
+
/** The branch's cursor, or a session's own: `cursors/<branch>.json`, `cursors/<branch>/<session>.json`. */
|
|
2111
|
+
cursorPath(session) {
|
|
2112
|
+
const branch = slug(this.branch);
|
|
2113
|
+
return session ? `cursors/${branch}/${slug(session)}.json` : `cursors/${branch}.json`;
|
|
2114
|
+
}
|
|
2115
|
+
readCursor(session) {
|
|
2116
|
+
const rel = this.cursorPath(session);
|
|
2117
|
+
const text = this.text(rel);
|
|
2118
|
+
if (text === undefined)
|
|
2119
|
+
return session ? undefined : { at: "", delivered: [] };
|
|
2120
|
+
const c = readAs("cursor", Cursor, JSON.parse(text), rel);
|
|
2121
|
+
return { at: c.at, delivered: c.delivered ?? [] };
|
|
2122
|
+
}
|
|
2123
|
+
/** A cursor is mutable: it's replaced whole (`Storage.replace`), in the newest format (D156). */
|
|
2124
|
+
writeCursor(c, session) {
|
|
2125
|
+
const rel = this.cursorPath(session);
|
|
2126
|
+
const cursor = parse(Cursor, { type: "cursor", format: newestFormat("cursor"), at: c.at, ...(c.delivered?.length ? { delivered: c.delivered } : {}) }, "the cursor");
|
|
2127
|
+
this.storage.replace(rel, encode(`${JSON.stringify(asWritten("cursor", cursor))}\n`));
|
|
2128
|
+
}
|
|
2129
|
+
/** Every inbox item newer than `since`, delivered or not, oldest first. */
|
|
2130
|
+
inboxSince(since) {
|
|
2131
|
+
const items = [];
|
|
2132
|
+
// Baesian's own proposals are the reviewer's business, not an agent's: a slider proposal never reaches an inbox, and
|
|
2133
|
+
// a convention card reaches every branch's only with a yes, which asks any agent for a rule (P16 section 6).
|
|
2134
|
+
const mine = this.branchCards().filter((c) => c.meta.pref_row === undefined && c.meta.convention === undefined);
|
|
2135
|
+
for (const c of this.cards().filter((k) => k.meta.convention !== undefined)) {
|
|
2136
|
+
for (const v of this.verdicts(c.meta.id)) {
|
|
2137
|
+
if (v.at > since && (v.make_rule || v.response === "accept"))
|
|
2138
|
+
items.push({ card: c.meta.id, verdict: v });
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
for (const c of mine) {
|
|
2142
|
+
const change = this.answerChange(c);
|
|
2143
|
+
for (const v of this.verdicts(c.meta.id)) {
|
|
2144
|
+
if (v.at > since)
|
|
2145
|
+
items.push({ card: c.meta.id, verdict: v, ...(change && change.at === v.at ? { brief: change } : {}) });
|
|
2146
|
+
}
|
|
2147
|
+
// A dependent returned by its parent's verdict (D80): the verdict is the parent's.
|
|
2148
|
+
for (const b of this.parentReturns(c)) {
|
|
2149
|
+
const pv = b.kind === "parent-decided-otherwise" ? this.verdicts(b.parent).find((v) => v.at === b.at) : undefined;
|
|
2150
|
+
if (pv && b.at > since)
|
|
2151
|
+
items.push({ card: c.meta.id, verdict: pv, brief: b });
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
items.sort((a, b) => a.verdict.at.localeCompare(b.verdict.at));
|
|
2155
|
+
return items;
|
|
2156
|
+
}
|
|
2157
|
+
// ---------------------------------------------------------------- waiting (D90)
|
|
2158
|
+
/**
|
|
2159
|
+
* Mark an agent as waiting on a card (D90). `baesian wait` refreshes the marker every `WAITING_BEAT_MS`, and stops
|
|
2160
|
+
* it when the wait ends. A wait that runs in short slices (D101) picks up its own fresh marker again, so `since`
|
|
2161
|
+
* is when it first started waiting. An agent killed mid-wait leaves its marker behind, to go stale.
|
|
2162
|
+
*/
|
|
2163
|
+
startWaiting(id, agent, at = now()) {
|
|
2164
|
+
this.requireInit();
|
|
2165
|
+
const card = this.card(id);
|
|
2166
|
+
if (!card)
|
|
2167
|
+
throw new NoCard(id);
|
|
2168
|
+
const atMs = Date.parse(at);
|
|
2169
|
+
const mine = this.list("waiting")
|
|
2170
|
+
.filter((f) => f.startsWith(`${id}-`) && f.endsWith(".json"))
|
|
2171
|
+
.flatMap((f) => {
|
|
2172
|
+
try {
|
|
2173
|
+
const text = this.text(`waiting/${f}`);
|
|
2174
|
+
return text === undefined ? [] : [{ f, w: readAs("waiting", Waiting, JSON.parse(text), `waiting/${f}`) }];
|
|
2175
|
+
}
|
|
2176
|
+
catch {
|
|
2177
|
+
return [];
|
|
2178
|
+
}
|
|
2179
|
+
})
|
|
2180
|
+
.find(({ w }) => w.agent === agent && w.revision === card.meta.revision && atMs - Date.parse(w.at) <= WAITING_STALE_MS);
|
|
2181
|
+
const rel = `waiting/${mine ? mine.f : `${id}-${randomSuffix()}.json`}`;
|
|
2182
|
+
const since = mine ? mine.w.since : at;
|
|
2183
|
+
const write = (beat) => {
|
|
2184
|
+
const w = parse(Waiting, { type: "waiting", format: newestFormat("waiting"), card: id, revision: card.meta.revision, agent, since, at: beat }, "the waiting marker");
|
|
2185
|
+
this.storage.replace(rel, encode(`${JSON.stringify(asWritten("waiting", w))}\n`));
|
|
2186
|
+
};
|
|
2187
|
+
write(at);
|
|
2188
|
+
return { since, path: rel, refresh: (beat = now()) => write(beat), stop: () => this.storage.remove(rel) };
|
|
2189
|
+
}
|
|
2190
|
+
/** The agents waiting on a card, and whether each has gone quiet: stopped waiting without returning (D90). */
|
|
2191
|
+
waitingOn(id, nowMs = Date.now()) {
|
|
2192
|
+
return this.list("waiting")
|
|
2193
|
+
.filter((f) => f.startsWith(`${id}-`) && f.endsWith(".json"))
|
|
2194
|
+
.flatMap((f) => this.read(`waiting/${f}`, (t) => readAs("waiting", Waiting, JSON.parse(t), `waiting/${f}`)) ?? [])
|
|
2195
|
+
.map((marker) => ({ marker, stale: nowMs - Date.parse(marker.at) > WAITING_STALE_MS }));
|
|
2196
|
+
}
|
|
2197
|
+
// ---------------------------------------------------------------- record
|
|
2198
|
+
/**
|
|
2199
|
+
* Copy verdicts and ledger entries for the cards this worktree's record holds (D37), and every rule, retirement,
|
|
2200
|
+
* topic alias and preference file, which apply to the whole repo. Run by `baesian sync`, and by the pre-commit hook. Returns the
|
|
2201
|
+
* files it added. `also` names cards the record doesn't hold yet, to copy in with their decks: a clone switching from
|
|
2202
|
+
* personal mode to shared writes its branch's record so (B-4rwyz). Personal mode writes no record, so nothing here.
|
|
2203
|
+
*/
|
|
2204
|
+
syncRecord(also = []) {
|
|
2205
|
+
this.requireInit();
|
|
2206
|
+
const recordCards = join(this.need("syncing the record").record, "cards");
|
|
2207
|
+
if (this.personal)
|
|
2208
|
+
return [];
|
|
2209
|
+
const held = [...new Set([...(existsSync(recordCards) ? readdirSync(recordCards) : []), ...also])];
|
|
2210
|
+
const added = [];
|
|
2211
|
+
const copy = (rel) => {
|
|
2212
|
+
if (this.toRecord(rel))
|
|
2213
|
+
added.push(rel);
|
|
2214
|
+
};
|
|
2215
|
+
for (const id of also) {
|
|
2216
|
+
const deck = this.card(id)?.meta.deck;
|
|
2217
|
+
if (deck && this.storage.read(`decks/${deck}.md`) !== undefined)
|
|
2218
|
+
copy(`decks/${deck}.md`);
|
|
2219
|
+
}
|
|
2220
|
+
// A file a person writes goes with its signature and passkey proof, if it has them; in a hosted repo, only once it
|
|
2221
|
+
// counts, so an answer written on the machine never reaches the record (D138, P15).
|
|
2222
|
+
const hosted = this.hosted() !== undefined;
|
|
2223
|
+
const signed = (rel) => {
|
|
2224
|
+
if (hosted && !this.judge(rel).ok)
|
|
2225
|
+
return;
|
|
2226
|
+
for (const extra of [`${rel}.sig`, proofPath(rel)])
|
|
2227
|
+
if (extra !== rel && this.storage.read(extra) !== undefined)
|
|
2228
|
+
copy(extra);
|
|
2229
|
+
copy(rel);
|
|
2230
|
+
};
|
|
2231
|
+
// The service's keys, revocations and passkey keys: every branch's record keeps the copy that checks on your machine
|
|
2232
|
+
// use, and that CI compares with its pinned keys (D142, D159).
|
|
2233
|
+
for (const f of this.list("trust"))
|
|
2234
|
+
copy(`trust/${f}`);
|
|
2235
|
+
// The client saves a rule into the record of the worktree it runs in. The local gate reads every rule from the
|
|
2236
|
+
// live layer, but CI reads each commit's own record, so every branch's record must hold them too.
|
|
2237
|
+
// Preference files too: CI rechecks a gate record against the file it names, in the commit's own record (P16).
|
|
2238
|
+
for (const kind of ["rules", "topics", "preferences"]) {
|
|
2239
|
+
for (const f of this.list(kind))
|
|
2240
|
+
if (!isCompanion(f))
|
|
2241
|
+
signed(`${kind}/${f}`);
|
|
2242
|
+
}
|
|
2243
|
+
for (const id of held) {
|
|
2244
|
+
// Everything in the card's directory: revisions, gate records, a withdrawal, fallbacks; the relay's gate record as signed.
|
|
2245
|
+
for (const f of this.list(`cards/${id}`)) {
|
|
2246
|
+
if (RELAY_GATE.test(f))
|
|
2247
|
+
signed(`cards/${id}/${f}`);
|
|
2248
|
+
else if (!isCompanion(f))
|
|
2249
|
+
copy(`cards/${id}/${f}`);
|
|
2250
|
+
}
|
|
2251
|
+
for (const f of this.list("verdicts").filter((name) => verdictFileCard(name) === id))
|
|
2252
|
+
signed(`verdicts/${f}`);
|
|
2253
|
+
for (const f of this.list("ledger").filter((name) => name.startsWith(`${id}-`)))
|
|
2254
|
+
copy(`ledger/${f}`);
|
|
2255
|
+
}
|
|
2256
|
+
return added;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
//# sourceMappingURL=store.js.map
|