visualproof 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/extractor/Extract.lean +1180 -0
  9. package/dist/extractor/Usage.lean +900 -0
  10. package/dist/mcp/visualproof-mcp.mjs +476 -0
  11. package/dist/server/access.js +95 -0
  12. package/dist/server/agent.js +3235 -0
  13. package/dist/server/agentdocs.js +35 -0
  14. package/dist/server/agentlibrary.js +202 -0
  15. package/dist/server/agentnotes.js +701 -0
  16. package/dist/server/agentviews.js +703 -0
  17. package/dist/server/askfollow.js +286 -0
  18. package/dist/server/boardspots.js +159 -0
  19. package/dist/server/dirs.js +175 -0
  20. package/dist/server/doctor.js +186 -0
  21. package/dist/server/events.js +198 -0
  22. package/dist/server/export.js +420 -0
  23. package/dist/server/extract.js +204 -0
  24. package/dist/server/fetchurl.js +321 -0
  25. package/dist/server/figures.js +158 -0
  26. package/dist/server/history.js +741 -0
  27. package/dist/server/historyapi.js +304 -0
  28. package/dist/server/hover.js +595 -0
  29. package/dist/server/identity.js +114 -0
  30. package/dist/server/invites.js +241 -0
  31. package/dist/server/layoutthread.js +51 -0
  32. package/dist/server/library.js +600 -0
  33. package/dist/server/libraryroute.js +211 -0
  34. package/dist/server/live.js +1101 -0
  35. package/dist/server/lostfound.js +199 -0
  36. package/dist/server/main.js +1394 -0
  37. package/dist/server/mcptools.js +497 -0
  38. package/dist/server/pastdoc.js +73 -0
  39. package/dist/server/pipeline.js +156 -0
  40. package/dist/server/poppler.js +251 -0
  41. package/dist/server/presence.js +252 -0
  42. package/dist/server/project.js +222 -0
  43. package/dist/server/prompt.js +53 -0
  44. package/dist/server/readonly.js +152 -0
  45. package/dist/server/readonlygate.js +123 -0
  46. package/dist/server/renames.js +268 -0
  47. package/dist/server/review.js +334 -0
  48. package/dist/server/reviewapi.js +188 -0
  49. package/dist/server/snapshot.js +49 -0
  50. package/dist/server/sourcestore.js +473 -0
  51. package/dist/server/status.js +360 -0
  52. package/dist/server/talk.js +476 -0
  53. package/dist/server/tokens.js +221 -0
  54. package/dist/server/usage.js +501 -0
  55. package/dist/server/versions.js +82 -0
  56. package/dist/server/view.js +157 -0
  57. package/dist/server/views.js +1167 -0
  58. package/dist/shared/askfollow.js +46 -0
  59. package/dist/shared/board.js +332 -0
  60. package/dist/shared/build.js +2 -0
  61. package/dist/shared/collab.js +148 -0
  62. package/dist/shared/comment.js +285 -0
  63. package/dist/shared/context.js +157 -0
  64. package/dist/shared/doc/authors.js +98 -0
  65. package/dist/shared/doc/build.js +358 -0
  66. package/dist/shared/doc/latex.js +455 -0
  67. package/dist/shared/doc/markdown.js +159 -0
  68. package/dist/shared/doc/model.js +37 -0
  69. package/dist/shared/doc/order.js +107 -0
  70. package/dist/shared/doc/proof.js +272 -0
  71. package/dist/shared/doc/sections.js +93 -0
  72. package/dist/shared/doc/settings.js +75 -0
  73. package/dist/shared/edgecodec.js +102 -0
  74. package/dist/shared/editor.js +64 -0
  75. package/dist/shared/excerpt.js +87 -0
  76. package/dist/shared/graph.js +16 -0
  77. package/dist/shared/items.js +92 -0
  78. package/dist/shared/keys.js +203 -0
  79. package/dist/shared/layout.js +926 -0
  80. package/dist/shared/lexer.js +130 -0
  81. package/dist/shared/library.js +711 -0
  82. package/dist/shared/lostfound.js +150 -0
  83. package/dist/shared/moves.js +243 -0
  84. package/dist/shared/notecard.js +295 -0
  85. package/dist/shared/noteplace.js +505 -0
  86. package/dist/shared/palette.js +46 -0
  87. package/dist/shared/pastgraph.js +95 -0
  88. package/dist/shared/places.js +80 -0
  89. package/dist/shared/projectnotes.js +244 -0
  90. package/dist/shared/query.js +810 -0
  91. package/dist/shared/review.js +642 -0
  92. package/dist/shared/reviewqueue.js +333 -0
  93. package/dist/shared/ring.js +366 -0
  94. package/dist/shared/scope.js +255 -0
  95. package/dist/shared/scopeedit.js +81 -0
  96. package/dist/shared/search.js +191 -0
  97. package/dist/shared/separate.js +178 -0
  98. package/dist/shared/sharesnap.js +280 -0
  99. package/dist/shared/sticky.js +501 -0
  100. package/dist/shared/timeline.js +73 -0
  101. package/dist/shared/titles.js +298 -0
  102. package/dist/shared/viewgraph.js +139 -0
  103. package/dist/shared/views.js +859 -0
  104. package/dist/web/bundle/app.js +48733 -0
  105. package/dist/web/bundle/edges.js +409 -0
  106. package/dist/web/bundle/layout.js +1010 -0
  107. package/dist/web/bundle/pdfworker.js +48322 -0
  108. package/dist/web/index.html +239 -0
  109. package/dist/web/src/motion.js +430 -0
  110. package/dist/web/src/past.js +224 -0
  111. package/dist/web/src/scene.js +787 -0
  112. package/dist/web/style.css +2312 -0
  113. package/docs/agent-api/README.md +91 -0
  114. package/docs/agent-api/advice.md +108 -0
  115. package/docs/agent-api/export.md +120 -0
  116. package/docs/agent-api/following.md +140 -0
  117. package/docs/agent-api/history.md +142 -0
  118. package/docs/agent-api/library.md +65 -0
  119. package/docs/agent-api/mcp.md +104 -0
  120. package/docs/agent-api/notes.md +93 -0
  121. package/docs/agent-api/reading.md +166 -0
  122. package/docs/agent-api/requests.md +94 -0
  123. package/docs/agent-api/talking.md +100 -0
  124. package/docs/agent-api/tcb.md +135 -0
  125. package/docs/agent-api/views.md +124 -0
  126. package/docs/agent-api/words.md +56 -0
  127. package/package.json +39 -4
  128. package/test/toolchains.json +5 -0
@@ -0,0 +1,642 @@
1
+ // The review (ReviewSpec.md): reviewers' opinions on declarations, each bound to one version of
2
+ // the declaration by its content hash, and the targets whose TCBs are being reviewed. What is
3
+ // stored, how the review is read back from it, and what the page reads off it. No consensus is
4
+ // decided here: a status is a summary of what reviewers said.
5
+ //
6
+ // What is stored (decided 2026-10-08): one append-only log per writer, each line one event — an
7
+ // opinion, a target marked or taken off, a prefix trusted or no longer trusted, a key declaration
8
+ // of a target marked or unmarked (ExportSpec decision 1) — carrying who wrote it and when. The
9
+ // review is what replaying every log gives (`replay`): one writer's events in the order of their
10
+ // file, different writers' by time. Each writer appends to a file of their own, so two never write
11
+ // the same one. `ReviewData` is that replay, the state the server, the page and the agents' API
12
+ // read; server/review.ts keeps the files.
13
+ //
14
+ // An agent's opinion is advice (AgentSpec decision 2): it is kept in the same list, marked
15
+ // `agent`, and nothing that summarises the review reads it — `current`, and so every status, mark
16
+ // and progress, is people's opinions only; `advice` is the agents'. Whatever else an agent does at
17
+ // level full (AgentSpec decision 9) — a target, a prefix trusted, a key declaration — counts as a
18
+ // person's would, and is marked `agent` too, so that the page always says it was an agent's. Every
19
+ // line of an agent's log is marked, and no line of a person's.
20
+ //
21
+ // Someone invited by a personal link (CollabSpec decision 9, 2026-10-09) is the link, not a name:
22
+ // their events go to one log named by the link's id, every line of it carrying the id (`person`)
23
+ // beside the name it was written under, and a rename appends a `name` line. The replay keeps, per
24
+ // id, the name its latest line gives (`people`), and shows everything that id wrote under it, so a
25
+ // rename never makes a new writer, and the log needs nothing but itself to say who is who.
26
+ //
27
+ // An explanation of a declaration was an event too, until the owner removed explanations on
28
+ // 2026-10-08 (ExportSpec decision 2): what a declaration is belongs in its docstring, and why a
29
+ // reviewer thinks it right in their opinion's comment. So was a checkpoint of a target's review,
30
+ // until the owner removed checkpoints on 2026-10-09 (ReviewSpec decision 4): an opinion goes stale
31
+ // by itself when what it judged changes, what newly enters a TCB shows as not yet reviewed, the
32
+ // timeline records each target's TCB changes, and the exported document records a target as it
33
+ // stands at a build. A log may still hold such a line: the replay skips it (`readEvent`) without
34
+ // calling it a problem, so that its writer writes on.
35
+ //
36
+ // Since 2026-10-09 a move (HistorySpec.md) carries what was said of one declaration to another, and
37
+ // says where one is another renamed: shared/moves.ts replays it. Since 2026-10-11 the library's
38
+ // files and the links into them are events too (AttachmentSpec.md): shared/library.ts replays them.
39
+ //
40
+ // Declarations are keyed by name, and a name can be anything Lean allows: `toString`,
41
+ // `constructor`, even `__proto__`. Every object keyed by names here has no prototype (`byName`),
42
+ // is read with Object.hasOwn, and is never spread or assigned into a plain object.
43
+ import { applyLibrary, invalidLibrary, isLibraryOp, libraryEventOf, libraryFromJson, libraryNoChange, readLibraryEvent, renamePerson } from "./library.js";
44
+ import { applyMove, moveIsIdle, readMove } from "./moves.js";
45
+ export const DEFAULT_TRUSTED = ["Batteries", "Init", "Lean", "Mathlib", "Std"];
46
+ /** The trusted entry that stands for everything outside the project, whatever its module (no Lean
47
+ * name starts with "@", so it is no prefix of one). */
48
+ export const OUTSIDE = "@external";
49
+ /** A trusted entry as the page and the timeline show it. */
50
+ export function prefixLabel(p) {
51
+ return p === OUTSIDE ? "the outside world" : p;
52
+ }
53
+ /** the longest text an opinion records, in UTF-16 units: far beyond any declaration a reviewer
54
+ * reads, and small enough that a log stays a file people read */
55
+ export const MAX_TEXT = 65536;
56
+ export const STANDARD_AXIOMS = ["Classical.choice", "Quot.sound", "propext"];
57
+ /** An object keyed by names: without a prototype, so that no name means something already. */
58
+ export function byName() {
59
+ return Object.create(null);
60
+ }
61
+ export function emptyReview() {
62
+ return { version: 1, trusted: [...DEFAULT_TRUSTED], targets: byName(), opinions: byName(), keys: byName(), people: byName() };
63
+ }
64
+ /** A personal link's id (server/invites.ts): who someone invited by one is, in the review's logs. */
65
+ export function validPersonId(s) {
66
+ return typeof s === "string" && /^[0-9a-f]{10}$/.test(s);
67
+ }
68
+ /** A prefix that can be trusted: a dotted Lean name, as a module or a namespace is written, and
69
+ * short enough to read in a list. */
70
+ export function validPrefix(s) {
71
+ if (s === OUTSIDE)
72
+ return true;
73
+ const part = "(?:[\\p{L}_][\\p{L}\\p{N}_'!?₀-₉ₐ-ₜ]*|«[^»\\n\\r]*»|[0-9]+)";
74
+ return typeof s === "string" && s.length <= 200 && new RegExp(`^${part}(?:\\.${part})*$`, "u").test(s);
75
+ }
76
+ /** A request is checked before anything is written: sizes are bounded, fields have the right
77
+ * kinds. Returns the reason it is refused, or null. */
78
+ export function invalid(r) {
79
+ if (!r || typeof r !== "object")
80
+ return "not an object";
81
+ const o = r;
82
+ const str = (v, max, optional = false) => v === undefined ? optional : typeof v === "string" && v.length > 0 && v.length <= max;
83
+ // trust names a prefix, a move two declarations; everything else one declaration
84
+ if (o.op === "trust") {
85
+ if (!validPrefix(o.prefix))
86
+ return "prefix";
87
+ }
88
+ else if (isLibraryOp(o.op)) {
89
+ // a file is named by its hash, a link by its id (shared/library.ts)
90
+ }
91
+ else if (o.op === "move") {
92
+ if (!str(o.from, 2000) || !str(o.to, 2000) || o.from === o.to)
93
+ return "a move is from one declaration to another";
94
+ }
95
+ else if (!str(o.name, 2000))
96
+ return "name";
97
+ if (!str(o.by, 120) || !o.by.trim())
98
+ return "reviewer";
99
+ // the server says whether the request came from an agent, from who asks, never the page; what
100
+ // an agent may record is its level's to say, checked where the request comes in
101
+ if (o.agent !== undefined && typeof o.agent !== "boolean")
102
+ return "agent";
103
+ if (o.person !== undefined && (!validPersonId(o.person) || o.agent))
104
+ return "person";
105
+ switch (o.op) {
106
+ case "opinion":
107
+ if (o.verdict !== "approve" && o.verdict !== "comment")
108
+ return "verdict";
109
+ if (!str(o.comment, 10000, true))
110
+ return "comment";
111
+ if (o.verdict === "comment" && !o.comment?.trim())
112
+ return "a comment needs text";
113
+ if (!str(o.hash, 64, true))
114
+ return "hash";
115
+ if (!str(o.text, MAX_TEXT, true))
116
+ return "text";
117
+ if (!str(o.statement, MAX_TEXT, true))
118
+ return "statement";
119
+ if (o.text !== undefined && o.statement !== undefined)
120
+ return "a text or a statement, not both";
121
+ return null;
122
+ case "target":
123
+ return typeof o.on === "boolean" ? null : "on";
124
+ case "trust":
125
+ return typeof o.on === "boolean" ? null : "on";
126
+ case "key":
127
+ if (!str(o.target, 2000))
128
+ return "target";
129
+ if (o.target === o.name)
130
+ return "a target is always key for itself";
131
+ return typeof o.on === "boolean" ? null : "on";
132
+ case "move":
133
+ if (o.rename !== undefined && typeof o.rename !== "boolean")
134
+ return "rename";
135
+ if (!str(o.auto, 200, true))
136
+ return "auto";
137
+ if (o.snap !== undefined && !(Number.isInteger(o.snap) && o.snap > 0))
138
+ return "snap";
139
+ return typeof o.on === "boolean" ? null : "on";
140
+ default:
141
+ return isLibraryOp(o.op) ? invalidLibrary(o) : "op";
142
+ }
143
+ }
144
+ /** The event that records a (checked) request, as it is appended to its writer's log;
145
+ * `toolchain` and `now` come from the server. `d` is the review it is recorded on: a change of
146
+ * mind on the same version and the same text does not repeat the text, so that a log grows by
147
+ * the opinion alone. */
148
+ export function eventOf(d, r, toolchain, now) {
149
+ const by = r.by.trim();
150
+ // an agent's event says so, whatever it is; so does one by someone invited by a personal link
151
+ const agent = r.agent ? { agent: true } : {};
152
+ const person = r.person ? { person: r.person } : {};
153
+ switch (r.op) {
154
+ case "opinion": {
155
+ const e = { op: "opinion", name: r.name, by, ...person, verdict: r.verdict, at: now, toolchain };
156
+ if (r.comment?.trim())
157
+ e.comment = r.comment.trim();
158
+ if (r.agent)
159
+ e.agent = true;
160
+ // the text matters only to an opinion that can go stale, one bound to a version
161
+ if (r.hash) {
162
+ e.hash = r.hash;
163
+ const was = (Object.hasOwn(d.opinions, r.name) ? d.opinions[r.name] : []).findLast((p) => author(p) === author(e));
164
+ const same = was?.hash === r.hash ? judgedOn(d, r.name, was) : null;
165
+ if (r.text !== undefined && !(same?.of === "source" && same.text === r.text))
166
+ e.text = r.text;
167
+ else if (r.text === undefined && r.statement !== undefined && !(same?.of === "statement" && same.text === r.statement))
168
+ e.statement = r.statement;
169
+ }
170
+ return e;
171
+ }
172
+ case "target":
173
+ return { op: "target", at: now, by, ...person, name: r.name, on: r.on, ...agent };
174
+ case "trust":
175
+ return { op: "trust", at: now, by, ...person, prefix: r.prefix, on: r.on, ...agent };
176
+ case "key":
177
+ return { op: "key", at: now, by, ...person, target: r.target, name: r.name, on: r.on, ...agent };
178
+ case "move":
179
+ return {
180
+ op: "move",
181
+ at: now,
182
+ by,
183
+ ...person,
184
+ from: r.from,
185
+ to: r.to,
186
+ on: r.on,
187
+ ...(r.rename ? { rename: true } : {}),
188
+ ...(r.auto ? { auto: r.auto } : {}),
189
+ ...(r.snap ? { snap: r.snap } : {}),
190
+ ...agent,
191
+ };
192
+ default:
193
+ return libraryEventOf(r, by, now, !!r.agent, r.person ?? null);
194
+ }
195
+ }
196
+ /** The line that says someone invited by a personal link goes by `name` from `now` on, appended to
197
+ * their log when they rename themselves (server/review.ts, `named`). */
198
+ export function nameEvent(person, name, now) {
199
+ return { op: "name", at: now, by: name.trim(), person };
200
+ }
201
+ /** One step of the replay: the review after `e`. Every page and the server take the same steps
202
+ * in the same order, so they hold the same review. */
203
+ export function applyEvent(d, e) {
204
+ // the name someone invited by a personal link goes by is the one their latest line gives: what
205
+ // they wrote before shows under it from now on
206
+ if (e.person)
207
+ named(d, e.person, e.by);
208
+ // who did it, where an agent did: the page marks it as an agent's
209
+ const agent = e.agent ? { agent: true } : {};
210
+ const person = e.person ? { person: e.person } : {};
211
+ switch (e.op) {
212
+ case "name":
213
+ return;
214
+ case "opinion": {
215
+ const { op: _op, name, ...o } = e;
216
+ (Object.hasOwn(d.opinions, name) ? d.opinions[name] : (d.opinions[name] = [])).push(o);
217
+ return;
218
+ }
219
+ case "target":
220
+ if (!e.on)
221
+ delete d.targets[e.name];
222
+ else if (!Object.hasOwn(d.targets, e.name))
223
+ d.targets[e.name] = { by: e.by, at: e.at, ...agent, ...person };
224
+ return;
225
+ case "trust": {
226
+ const rest = d.trusted.filter((p) => p !== e.prefix);
227
+ d.trusted = [...new Set(e.on ? [...rest, e.prefix] : rest)].sort();
228
+ return;
229
+ }
230
+ case "key": {
231
+ // the first mark stands (who marked it, and when) until it is taken off
232
+ const ks = Object.hasOwn(d.keys, e.target) ? d.keys[e.target] : null;
233
+ if (e.on) {
234
+ const m = ks ?? (d.keys[e.target] = byName());
235
+ if (!Object.hasOwn(m, e.name))
236
+ m[e.name] = { by: e.by, at: e.at, ...agent, ...person };
237
+ }
238
+ else if (ks) {
239
+ delete ks[e.name];
240
+ if (!Object.keys(ks).length)
241
+ delete d.keys[e.target];
242
+ }
243
+ return;
244
+ }
245
+ case "move":
246
+ applyMove(d, e);
247
+ return;
248
+ default:
249
+ applyLibrary(d, e);
250
+ }
251
+ }
252
+ /** Someone invited by a personal link goes by `name` now: what their id wrote shows under it, and
253
+ * the name they went by before is kept among those they had. */
254
+ function named(d, id, name) {
255
+ const p = Object.hasOwn(d.people, id) ? d.people[id] : null;
256
+ if (p?.name === name)
257
+ return;
258
+ if (!p) {
259
+ d.people[id] = { name, was: [] };
260
+ return;
261
+ }
262
+ p.was = [...p.was.filter((n) => n !== p.name && n !== name), p.name];
263
+ p.name = name;
264
+ for (const ops of Object.values(d.opinions))
265
+ for (const o of ops)
266
+ if (o.person === id)
267
+ o.by = name;
268
+ for (const t of Object.values(d.targets))
269
+ if (t.person === id)
270
+ t.by = name;
271
+ for (const marks of Object.values(d.keys))
272
+ for (const m of Object.values(marks))
273
+ if (m.person === id)
274
+ m.by = name;
275
+ renamePerson(d, id, name);
276
+ }
277
+ /** The opinion on `name` given at `at` by whoever is, or was, called `by`: what a quote of an
278
+ * opinion (shared/comment.ts, `opinionRef`) names, which stays right once its writer, invited by a
279
+ * personal link, renames themselves. */
280
+ export function findOpinion(d, name, by, at) {
281
+ const was = (o) => !!o.person && Object.hasOwn(d.people, o.person) && d.people[o.person].was.includes(by);
282
+ return (Object.hasOwn(d.opinions, name) ? d.opinions[name] : []).find((o) => o.at === at && (o.by === by || was(o)));
283
+ }
284
+ /** Record a (checked) request on `d` at once: the event, applied. */
285
+ export function record(d, r, toolchain, now) {
286
+ const e = eventOf(d, r, toolchain, now);
287
+ applyEvent(d, e);
288
+ return e;
289
+ }
290
+ /** Whether recording `r` would change nothing: a target marked that is one, a prefix trusted that
291
+ * is, a key declaration marked that is. Such a request is answered, and nothing is appended. */
292
+ export function noChange(d, r) {
293
+ if (r.op === "target")
294
+ return Object.hasOwn(d.targets, r.name) === r.on;
295
+ if (r.op === "trust")
296
+ return d.trusted.includes(r.prefix) === r.on;
297
+ if (r.op === "key")
298
+ return keyMarked(d, r.target, r.name) === r.on;
299
+ if (r.op === "move")
300
+ return moveIsIdle(d, { ...r, rename: r.rename || undefined });
301
+ if (isLibraryOp(r.op))
302
+ return libraryNoChange(d, r);
303
+ return false;
304
+ }
305
+ /** Events the review no longer records: an explanation (`explain`), removed by the owner on
306
+ * 2026-10-08, and a checkpoint of a target's review (`checkpoint`), removed on 2026-10-09. A line
307
+ * of one in a log is skipped, whatever it holds. */
308
+ const RETIRED_OPS = ["explain", "checkpoint"];
309
+ /** A line of a log, read: the event it holds, or why it is not one; null for an event of a kind
310
+ * the review no longer records (`RETIRED_OPS`), which is skipped — neither applied nor a problem,
311
+ * so that an older log is read and its writer writes on. Only known fields are kept, in objects
312
+ * of their own. Every line in an agent's log (`agentLog`) is marked as an agent's — its
313
+ * opinions advice, its other events what it did at level full, which was checked when it was
314
+ * written; a log read back from disk is replayed as written — and no line in a person's is.
315
+ * Likewise every line in the log of someone invited by a personal link (`personLog`, the link's
316
+ * id, from the file's name) carries that id, and no line elsewhere carries one; their `name` lines
317
+ * are read there only. */
318
+ export function readEvent(raw, agentLog, personLog = null) {
319
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
320
+ return "not an event";
321
+ const o = raw;
322
+ if (RETIRED_OPS.includes(o.op))
323
+ return null;
324
+ const str = (v, max) => typeof v === "string" && v.length > 0 && v.length <= max;
325
+ if (!str(o.at, 40) || !Number.isFinite(Date.parse(o.at)))
326
+ return "no time (at)";
327
+ if (!str(o.by, 120) || !o.by.trim())
328
+ return "no writer (by)";
329
+ const at = o.at, by = o.by;
330
+ if (agentLog ? o.agent !== true : o.agent !== undefined)
331
+ return agentLog ? "a line of an agent's log not marked as an agent's" : "an agent's line in a person's log";
332
+ if (personLog ? o.person !== personLog : o.person !== undefined)
333
+ return personLog ? "a line of an invited person's log without their id (person)" : "an invited person's line in another's log";
334
+ const agent = agentLog ? { agent: true } : {};
335
+ const person = personLog ? { person: personLog } : {};
336
+ switch (o.op) {
337
+ case "name":
338
+ return personLog ? { op: "name", at, by, person: personLog } : "a name line outside an invited person's log";
339
+ case "opinion": {
340
+ if (!str(o.name, 2000))
341
+ return "name";
342
+ if (o.verdict !== "approve" && o.verdict !== "comment")
343
+ return "verdict";
344
+ const e = { op: "opinion", name: o.name, by, ...person, verdict: o.verdict, at };
345
+ for (const [k, max] of [["comment", 10000], ["hash", 64], ["toolchain", 200], ["text", MAX_TEXT], ["statement", MAX_TEXT]]) {
346
+ if (o[k] === undefined)
347
+ continue;
348
+ if (!str(o[k], max))
349
+ return k;
350
+ e[k] = o[k];
351
+ }
352
+ if (e.text !== undefined && e.statement !== undefined)
353
+ return "a text and a statement";
354
+ if (agentLog)
355
+ e.agent = true;
356
+ return e;
357
+ }
358
+ case "target":
359
+ if (!str(o.name, 2000))
360
+ return "name";
361
+ return typeof o.on === "boolean" ? { op: "target", at, by, ...person, name: o.name, on: o.on, ...agent } : "on";
362
+ case "trust":
363
+ if (!validPrefix(o.prefix))
364
+ return "prefix";
365
+ return typeof o.on === "boolean" ? { op: "trust", at, by, ...person, prefix: o.prefix, on: o.on, ...agent } : "on";
366
+ case "key":
367
+ if (!str(o.name, 2000))
368
+ return "name";
369
+ if (!str(o.target, 2000))
370
+ return "target";
371
+ return typeof o.on === "boolean" ? { op: "key", at, by, ...person, target: o.target, name: o.name, on: o.on, ...agent } : "on";
372
+ case "move": {
373
+ const m = readMove(o, at, by, agentLog);
374
+ return typeof m === "string" ? m : { ...m, ...person };
375
+ }
376
+ default:
377
+ return isLibraryOp(o.op) ? readLibraryEvent(o, at, by, agentLog, personLog) : "op";
378
+ }
379
+ }
380
+ /** Every log merged into one sequence: one writer's events in the order of their file, different
381
+ * writers' by time; at the same time, by file name (the same on every replica, so every server
382
+ * replays the same). A writer's clock going backwards keeps their order and delays nobody else's
383
+ * past their own. */
384
+ export function merged(logs) {
385
+ const ls = [...logs].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
386
+ const time = ls.map((l) => l.events.map((e) => Date.parse(e.at)));
387
+ const next = ls.map(() => 0);
388
+ const out = [];
389
+ for (;;) {
390
+ let best = -1;
391
+ for (let k = 0; k < ls.length; k++)
392
+ if (next[k] < ls[k].events.length && (best < 0 || time[k][next[k]] < time[best][next[best]]))
393
+ best = k;
394
+ if (best < 0)
395
+ return out;
396
+ out.push({ file: ls[best].file, e: ls[best].events[next[best]++] });
397
+ }
398
+ }
399
+ /** The review every log gives. */
400
+ export function replay(logs) {
401
+ const d = emptyReview();
402
+ for (const { e } of merged(logs))
403
+ applyEvent(d, e);
404
+ return d;
405
+ }
406
+ /** The review as JSON carried it (the server's answer, a snapshot): the same review, with its
407
+ * objects keyed by names made again without a prototype. */
408
+ export function reviewFromJson(raw) {
409
+ const d = emptyReview();
410
+ const r = (raw && typeof raw === "object" ? raw : {});
411
+ if (Array.isArray(r.trusted))
412
+ d.trusted = [...new Set(r.trusted.filter((t) => typeof t === "string"))].sort();
413
+ const own = (o) => (o && typeof o === "object" && !Array.isArray(o) ? Object.entries(o) : []);
414
+ const marks = (x) => ({ ...(x.agent === true ? { agent: true } : {}), ...(validPersonId(x.person) ? { person: x.person } : {}) });
415
+ const was = (x) => (Array.isArray(x.was) && x.was.every((w) => typeof w === "string") ? { was: x.was } : {});
416
+ for (const [name, t] of own(r.targets)) {
417
+ const x = t;
418
+ d.targets[name] = { by: String(x.by ?? ""), at: String(x.at ?? ""), ...marks(x), ...was(x) };
419
+ }
420
+ for (const [name, ops] of own(r.opinions))
421
+ if (Array.isArray(ops))
422
+ d.opinions[name] = ops.filter((o) => !!o && typeof o === "object");
423
+ for (const [target, ks] of own(r.keys)) {
424
+ const m = byName();
425
+ for (const [name, k] of own(ks)) {
426
+ const x = (k && typeof k === "object" ? k : {});
427
+ const wasOf = Array.isArray(x.wasOf) && x.wasOf.every((w) => typeof w === "string") ? { wasOf: x.wasOf } : {};
428
+ m[name] = { by: String(x.by ?? ""), at: String(x.at ?? ""), ...marks(x), ...was(x), ...wasOf };
429
+ }
430
+ if (Object.keys(m).length)
431
+ d.keys[target] = m;
432
+ }
433
+ for (const [id, p] of own(r.people)) {
434
+ const x = (p && typeof p === "object" ? p : {});
435
+ if (validPersonId(id) && typeof x.name === "string")
436
+ d.people[id] = { name: x.name, was: Array.isArray(x.was) ? x.was.filter((n) => typeof n === "string") : [] };
437
+ }
438
+ const renamed = own(r.renamed).filter(([, v]) => typeof v === "string");
439
+ if (renamed.length) {
440
+ d.renamed = byName();
441
+ for (const [k, v] of renamed)
442
+ d.renamed[k] = v;
443
+ }
444
+ // the library's files, links and history (shared/library.ts)
445
+ Object.assign(d, libraryFromJson(r));
446
+ return d;
447
+ }
448
+ // ---------------------------------------------------------------------------------------------
449
+ // The logs' names
450
+ /** FNV-1a over a text's UTF-8 bytes, as 8 hex digits: short, and the same in the page and the
451
+ * server. */
452
+ export function shortHash(s) {
453
+ let h = 0x811c9dc5;
454
+ for (const b of new TextEncoder().encode(s))
455
+ h = Math.imul(h ^ b, 0x01000193) >>> 0;
456
+ return h.toString(16).padStart(8, "0");
457
+ }
458
+ /** the longest readable part of a log's name, in UTF-16 units: the whole name stays under 60 */
459
+ const SLUG = 32;
460
+ /** The file a writer's events are appended to, in the site's `log/`: a readable part of
461
+ * their name and a short hash of their exact name — `Alice-3f2a9c1b.jsonl`, an agent's
462
+ * `Claude-7c01aa52.agent.jsonl`. The site's folder may be copied, backed up or synced anywhere,
463
+ * Windows included, so the name is one every copy accepts: only letters and digits
464
+ * that read the same in every Unicode normal form (accents dropped), joined by hyphens — no
465
+ * `< > : " / \ | ? *`, no control character, no space or dot at the end, no `~` (an NTFS short
466
+ * name's shape), never a device name (the hash follows the readable part) — under 60 characters;
467
+ * and two names that differ only in case get two hashes, so their files differ by more than case
468
+ * (macOS and Windows would take them for one). The hash is of the exact name the events carry.
469
+ * Someone invited by a personal link writes to a log named by the link's id alone, whatever name
470
+ * they go by — `3f2a9c1b07.person.jsonl` — so that a rename never starts a new file. */
471
+ export function logFileName(name, agent, person = null) {
472
+ if (person)
473
+ return `${person}.person.jsonl`;
474
+ const plain = name.normalize("NFD").replace(/\p{M}+/gu, "").normalize("NFC");
475
+ let slug = "";
476
+ for (const c of plain) {
477
+ const keep = /^[\p{L}\p{N}]$/u.test(c) && c.normalize("NFD") === c && c.normalize("NFKC") === c;
478
+ const add = keep ? c : slug.endsWith("-") ? "" : "-";
479
+ if (slug.length + add.length > SLUG)
480
+ break;
481
+ slug += add;
482
+ }
483
+ slug = slug.replace(/^-+|-+$/g, "") || "writer";
484
+ return `${slug}-${shortHash(name)}${agent ? ".agent" : ""}.jsonl`;
485
+ }
486
+ /** The personal link's id a log is named by (`logFileName`), or null for any other log. */
487
+ export function personOfLog(file) {
488
+ return /^([0-9a-f]{10})\.person\.jsonl$/.exec(file)?.[1] ?? null;
489
+ }
490
+ /** The problems in a few words, for the page and /api/status; null when there are none. */
491
+ export function problemsText(ps) {
492
+ if (!ps.length)
493
+ return null;
494
+ const files = [...new Set(ps.map((p) => p.file))];
495
+ const each = ps.slice(0, 3).map((p) => `log/${p.file}, line ${p.line}: ${p.why}`).join("; ");
496
+ const who = [...new Set(ps.map((p) => p.writer ?? `the writer of log/${p.file}`))].join(", ");
497
+ return (`${ps.length === 1 ? "A line" : `${ps.length} lines`} of the review cannot be read — ${each}${ps.length > 3 ? `; and ${ps.length - 3} more` : ""}. ` +
498
+ `The rest is read; nothing is rewritten, and nothing more is written to ${files.length === 1 ? "that file" : "those files"} (${who}) until ${files.length === 1 ? "it is" : "they are"} fixed by hand.`);
499
+ }
500
+ /** Who gave an opinion: a person and an agent of the same name are two writers, and neither's
501
+ * replaces the other's; someone invited by a personal link is their link, whatever name they went
502
+ * by, and a guest who took a name they once had is someone else. */
503
+ function author(o) {
504
+ return o.person ? `link ${o.person}` : (o.agent ? "agent " : "person ") + o.by;
505
+ }
506
+ /** Each author's latest opinion on a declaration, people's or agents': a new one replaces their
507
+ * earlier one. */
508
+ function latest(d, name, agents) {
509
+ const out = new Map();
510
+ // own keys only: a declaration may be called `constructor` or `toString`
511
+ for (const o of Object.hasOwn(d.opinions, name) ? d.opinions[name] : [])
512
+ if (!!o.agent === agents)
513
+ out.set(author(o), o);
514
+ return [...out.values()];
515
+ }
516
+ /** Each reviewer's latest opinion on a declaration: a new one replaces their earlier one. People
517
+ * only: an agent's opinion is advice, and nothing read off this counts it. */
518
+ export function current(d, name) {
519
+ return latest(d, name, false);
520
+ }
521
+ /** Each agent's latest advice on a declaration (AgentSpec decision 2), shown apart and never
522
+ * counted. */
523
+ export function advice(d, name) {
524
+ return latest(d, name, true);
525
+ }
526
+ /** Which verdict a summary shows when the opinions on a declaration's present version differ, the
527
+ * first that any of them gives: a comment ahead of an approval, so that what a reviewer said is
528
+ * not hidden by someone else's approval (the owner's default, 2026-10-09). Every summary reads
529
+ * this order — the status (`statusOf`), what agents advise (`adviceOf`), and so every count,
530
+ * colour and query built on them — and changing it here changes them all. */
531
+ export const VERDICT_ORDER = ["comment", "approve"];
532
+ /** The status a verdict gives a declaration when it is the one shown. */
533
+ const STATUS_OF = { approve: "approved", comment: "commented" };
534
+ /** The verdict shown of those given, in `VERDICT_ORDER`; null when none is given. */
535
+ function summary(given) {
536
+ return VERDICT_ORDER.find((v) => given.some((o) => o.verdict === v)) ?? null;
537
+ }
538
+ /** What agents advise on the present version of a declaration, summed up as a status is
539
+ * (`VERDICT_ORDER`); null when no agent has advised on it. */
540
+ export function adviceOf(d, name, hash) {
541
+ return summary(advice(d, name).filter((o) => fresh(o, hash)));
542
+ }
543
+ /** What an opinion was given on, as recorded: with the opinion itself, or — where it replaced the
544
+ * same reviewer's opinion on the same version and the same text, which `record` does not repeat —
545
+ * with the one it replaced. Null for an opinion older than the record of it. */
546
+ export function judgedOn(d, name, o) {
547
+ const own = (p) => p.text !== undefined ? { of: "source", text: p.text } : p.statement !== undefined ? { of: "statement", text: p.statement } : null;
548
+ // each author's previous opinion, and what it was given on, in the order given
549
+ const last = new Map();
550
+ for (const p of Object.hasOwn(d.opinions, name) ? d.opinions[name] : []) {
551
+ const prev = last.get(author(p));
552
+ const judged = own(p) ?? (p.hash && prev?.hash === p.hash ? prev.judged : null);
553
+ if (p === o)
554
+ return judged;
555
+ last.set(author(p), { hash: p.hash, judged });
556
+ }
557
+ return own(o);
558
+ }
559
+ /** Whether an opinion is about the version there is now. */
560
+ export function fresh(o, hash) {
561
+ return !o.hash || !hash || o.hash === hash;
562
+ }
563
+ /** A summary of what reviewers said about the present version (ReviewSpec, "What the page shows"):
564
+ * the verdict shown of those on it (`VERDICT_ORDER`), else stale where every opinion is on an
565
+ * earlier version, else none. */
566
+ export function statusOf(d, name, hash) {
567
+ const now = current(d, name);
568
+ if (!now.length)
569
+ return "none";
570
+ const v = summary(now.filter((o) => fresh(o, hash)));
571
+ return v ? STATUS_OF[v] : "stale";
572
+ }
573
+ /** Whether a dotted name is `prefix` or lies under it, a whole component at a time: "Foo" covers
574
+ * "Foo" and "Foo.Bar", not "FooBar". */
575
+ export function under(s, prefix) {
576
+ return s === prefix || s.startsWith(prefix + ".");
577
+ }
578
+ /** Whether a trusted prefix covers a declaration. The world outside the project is trusted by
579
+ * module, as Mathlib is: its names (Nat.add, Finset.sum) say nothing of where they come from; or
580
+ * as a whole (OUTSIDE). A project's own declaration is trusted by its module or its name, so that
581
+ * a vendored library (a folder of files) and a layer reviewed on its own (a namespace) can both be
582
+ * named. */
583
+ export function covers(prefix, name, module, external) {
584
+ if (prefix === OUTSIDE)
585
+ return external;
586
+ return under(module, prefix) || (!external && under(name, prefix));
587
+ }
588
+ /** The trusted prefixes that cover a declaration, in the list's (sorted) order: a prefix comes
589
+ * before the longer ones under it. */
590
+ export function trustedBy(d, name, module, external) {
591
+ return d.trusted.filter((p) => covers(p, name, module, external));
592
+ }
593
+ /** Whether a declaration is trusted without review: a trusted prefix covers it, or it is a
594
+ * standard axiom. */
595
+ export function trusted(d, name, module, external) {
596
+ return STANDARD_AXIOMS.includes(name) || trustedBy(d, name, module, external).length > 0;
597
+ }
598
+ /** What the canvas and the lists mark a declaration with: what reviewers said about it, and when
599
+ * nobody has, whether a trusted prefix covers it — for the project's own declarations only:
600
+ * the outside world trusted by default is the rule there, not news. */
601
+ export function markOf(d, name, module, external, hash) {
602
+ const s = statusOf(d, name, hash);
603
+ if (s !== "none" || external)
604
+ return s;
605
+ return trustedBy(d, name, module, false).length ? "trusted" : "none";
606
+ }
607
+ /** How far the review of a set (a target's TCB) has come. */
608
+ export function progressOf(d, members) {
609
+ const p = { total: 0, approved: 0, commented: 0, stale: 0 };
610
+ for (const m of members) {
611
+ if (trusted(d, m.name, m.module, m.ext))
612
+ continue;
613
+ p.total++;
614
+ const s = statusOf(d, m.name, m.hash);
615
+ if (s === "approved")
616
+ p.approved++;
617
+ else if (s === "commented")
618
+ p.commented++;
619
+ else if (s === "stale")
620
+ p.stale++;
621
+ }
622
+ return p;
623
+ }
624
+ // ---------------------------------------------------------------------------------------------
625
+ // Key declarations (ExportSpec decision 1)
626
+ /** Whether `name` is marked as a key declaration of `target` (own keys only: a target may be
627
+ * called `constructor`). The target itself is key without a mark: see keyMarks. */
628
+ export function keyMarked(d, target, name) {
629
+ return Object.hasOwn(d.keys, target) && Object.hasOwn(d.keys[target], name);
630
+ }
631
+ /** The declarations marked as key for `target`, by name, sorted; the target itself, always
632
+ * key, is not among them. Whether each is still in the target's review is the graph's to say
633
+ * (shared/keys.ts, keySet). */
634
+ export function keyMarks(d, target) {
635
+ return Object.hasOwn(d.keys, target) ? Object.keys(d.keys[target]).sort() : [];
636
+ }
637
+ /** The targets `name` is marked key for, sorted. */
638
+ export function keyFor(d, name) {
639
+ return Object.keys(d.keys)
640
+ .filter((t) => Object.hasOwn(d.keys[t], name))
641
+ .sort();
642
+ }