create-evda 0.1.4 → 0.1.6

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 (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +93 -8
  3. package/bin/evda.mjs +437 -5
  4. package/create-evda.mjs +68 -12
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +351 -19
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/folders.mjs +22 -8
  17. package/lib/gather-apply.mjs +635 -0
  18. package/lib/gather.mjs +808 -0
  19. package/lib/grants.mjs +7 -1
  20. package/lib/guard.mjs +132 -0
  21. package/lib/hooks.mjs +124 -0
  22. package/lib/keep-hook.mjs +185 -0
  23. package/lib/kinds.mjs +23 -6
  24. package/lib/kinds.schema.json +2 -1
  25. package/lib/manifest.mjs +14 -1
  26. package/lib/mcp-auth.mjs +370 -0
  27. package/lib/mcp-http.mjs +336 -0
  28. package/lib/mcp.mjs +467 -35
  29. package/lib/migrate-people.mjs +231 -0
  30. package/lib/organize.mjs +52 -12
  31. package/lib/parity.mjs +111 -0
  32. package/lib/presets.mjs +379 -0
  33. package/lib/prompt-hook.mjs +88 -0
  34. package/lib/publish.mjs +264 -0
  35. package/lib/readers.mjs +319 -0
  36. package/lib/records.mjs +52 -0
  37. package/lib/references.mjs +5 -51
  38. package/lib/render.mjs +158 -0
  39. package/lib/roles.mjs +102 -1
  40. package/lib/secret-shape.mjs +58 -0
  41. package/lib/setup.mjs +146 -2
  42. package/lib/skills.mjs +266 -0
  43. package/lib/subrecord.mjs +121 -0
  44. package/lib/sync-rules.mjs +87 -0
  45. package/lib/tools.mjs +120 -29
  46. package/lib/update-check.mjs +146 -0
  47. package/lib/work.mjs +180 -0
  48. package/lib/yaml.mjs +117 -42
  49. package/package.json +25 -15
  50. package/presets/journal/preset.json +19 -0
  51. package/presets/projects-and-areas/preset.json +34 -0
  52. package/skills/evda/SKILL.md +76 -0
  53. package/templates/AGENTS.md +129 -15
  54. package/templates/README.md +15 -1
  55. package/templates/circles-README.md +3 -3
  56. package/templates/evda-digest.md +32 -5
  57. package/LICENSE +0 -21
@@ -0,0 +1,622 @@
1
+ // File Changes: what the person layer hands the substrate's scan and sync (Spec 08, sections 1 to 3).
2
+ //
3
+ // The substrate walks a folder it does not own and calls a `mint` callback for every new or edited
4
+ // file (`atlas-local/src/vault-scan.ts`, `scanAndApply`), and its own comment says why the callback
5
+ // is ours: it does not know what a person's markdown means. This module is that callback for plain
6
+ // files, plus the three things the sync path needs from the person layer and must not read itself:
7
+ //
8
+ // circleIndex which circle each path belongs to, by the circle's opaque id
9
+ // grantView who holds a circle (routing) and who may write into it (receipt)
10
+ // circleCrypto a payload sealed to its circle, and opened only with the circle's key
11
+ // releasesOf the circles a marked file is released into, beyond its home (HQ-1351)
12
+ // addressing a file's address on the wire, and where it lands on a device that mounts the
13
+ // circle from another holder (HQ-835)
14
+ //
15
+ // A file Change:
16
+ //
17
+ // kind: file.set | file.remove
18
+ // target: <the file's address: its path in the HOME holder's record>
19
+ // eco: <opaque circle id>
20
+ // payload: sealed to the circle's current epoch, opening to { path, sha256, baseSha256, text }
21
+ //
22
+ // A released file (Spec 07 section 3) is one Change per circle it reaches: one sealed to its home
23
+ // circle, and one more sealed to each released circle, all with the same address and bytes. A
24
+ // device that holds a circle from another holder (its manifest carries `mount`) writes the file at
25
+ // `<mount>/<address>` and, when it edits the file there, mints the Change back at the address.
26
+ //
27
+ // This package has no runtime dependencies, so nothing here imports the substrate. Whoever composes
28
+ // a device passes the pieces in: the Change log, the host's crypto, and a routing function.
29
+
30
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { readCircles, frontmatterOf, matchesWhere, heldBy } from "./circles.mjs";
33
+ import { readGrants } from "./grants.mjs";
34
+ import { adoptDeclaration, declarationOf, freezeCircle } from "./declarations.mjs";
35
+ import { admitDevice, devicesOf, readDevices, recordOf, retireDevice } from "./devices.mjs";
36
+ import { resolveRole } from "./roles.mjs";
37
+ import { boundaries, insideAny } from "./records.mjs";
38
+ import { EvdaError } from "./errors.mjs";
39
+
40
+ export const FILE_SET = "file.set";
41
+ export const FILE_REMOVE = "file.remove";
42
+ export const SEALED_FORMAT = "evda-circle/1";
43
+ export const PUBLIC_FORMAT = "evda-public/1";
44
+
45
+ // Every circle in the record, with the prefixes it covers and its opaque id. Throws on a malformed
46
+ // circle: syncing a record whose boundary declaration is broken is worse than not syncing it.
47
+ //
48
+ // One failure is not a broken declaration here: a member with nothing on disk. A device that holds
49
+ // no grant on a circle was never sent its files, so on that device the member is absent by design.
50
+ // The member is still indexed, so the device knows which circle the path belongs to.
51
+ export function circleIndex(evda) {
52
+ const circles = readCircles(evda) ?? [];
53
+ const broken = circles.find((c) => c.problems.some((p) => p.level === "fail" && p.code !== "MISSING_MEMBER"));
54
+ if (broken) {
55
+ const first = broken.problems.find((p) => p.level === "fail" && p.code !== "MISSING_MEMBER");
56
+ throw new EvdaError(`circle "${broken.name}" is malformed: ${first.message}`, { code: "BAD_CIRCLE" });
57
+ }
58
+ const index = circles.map((c) => ({
59
+ name: c.name,
60
+ id: c.id,
61
+ public: c.manifest.public === true,
62
+ // A directory member covers everything under it, so its prefix ends in "/"; a file member covers
63
+ // exactly one path. An absent member could be either, so it covers both readings.
64
+ members: [
65
+ ...Object.values(c.members)
66
+ .filter((m) => m.kind === "path")
67
+ .map((m) => ({ rel: m.rel.replace(/\/+$/, ""), isDir: m.isDir })),
68
+ ...c.problems
69
+ .filter((p) => p.code === "MISSING_MEMBER")
70
+ .map((p) => ({ rel: p.member.rel.replace(/\/+$/, ""), isDir: null })),
71
+ ],
72
+ // The rule members: files gathered by their frontmatter, released out of their home.
73
+ rules: Object.values(c.members).filter((m) => m.kind === "rule").map((m) => m.where),
74
+ // Where a file living here may be released to: "never" or circle names.
75
+ release: c.release,
76
+ // Set when this circle is another holder's, held here: { holder, rel }.
77
+ mount: c.mount,
78
+ // Set when a held circle's grant was revoked: { since, by }. Read-only from then on.
79
+ frozen: c.frozen,
80
+ }));
81
+ // Where this record's circles stop (HQ-1371): every sub-record root when the record declares
82
+ // "subrecords": "bounded", none otherwise. A path inside one is in no circle of this record.
83
+ index.boundaries = boundaries(evda);
84
+ return index;
85
+ }
86
+
87
+ // The circles a file is released into beyond its home, by its frontmatter mark and its home's
88
+ // release list (Spec 07 section 3, HQ-1351): the same test `released()` in circles.mjs runs for
89
+ // doctor, for one file. A file in no circle, in a public circle, in a mounted circle, or in a home
90
+ // that releases nothing is released nowhere. `text` is the file's contents; a file that is not
91
+ // markdown carries no mark.
92
+ export function releasesOf(index, rel, text) {
93
+ const home = circleForPath(index, rel);
94
+ if (!home || home.public || home.mount || home.release === "never" || !Array.isArray(home.release)) return [];
95
+ const front = typeof text === "string" && /\.md$/i.test(rel) ? frontmatterOf(text) : null;
96
+ if (!front) return [];
97
+ // A sealed or just-noted entry stays in its home circle whatever its mark says (HQ-1414), as
98
+ // released() refuses it for doctor and publish: a mark never carries either one to another holder.
99
+ if (heldBy(front, "sync")) return [];
100
+ return index.filter((c) => c !== home && !c.public && home.release.includes(c.name) && c.rules.some((where) => matchesWhere(front, where)));
101
+ }
102
+
103
+ // The conflict file's original, when `rel` is named as one: `<name> (conflict, <device>, <date>)<ext>`
104
+ // (atlas-local `conflictPath`). Null otherwise.
105
+ export function conflictOriginal(rel) {
106
+ const m = /^(.*) \(conflict, [^,/()]+, \d{4}-\d{2}-\d{2}\)(\.[^/.]*)?$/.exec(rel);
107
+ return m ? `${m[1]}${m[2] ?? ""}` : null;
108
+ }
109
+
110
+ // Every circle a local file reaches: its home, then each release (HQ-1351). A conflict file also goes
111
+ // wherever its original goes (HQ-835): the disagreement came from a circle the original reaches, and
112
+ // every holder of that circle is owed both texts, whatever the incoming text's own mark says.
113
+ // `readText(rel)` reads a local file, or returns null; absent, only the file's own mark counts.
114
+ export function circlesFor(index, rel, text, readText = null) {
115
+ const home = circleForPath(index, rel);
116
+ if (!home) return [];
117
+ const out = [home, ...releasesOf(index, rel, text)];
118
+ const original = conflictOriginal(rel);
119
+ if (original && readText) {
120
+ const originalText = readText(original);
121
+ if (typeof originalText === "string") {
122
+ for (const c of releasesOf(index, original, originalText)) if (!out.includes(c)) out.push(c);
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ // The circles a file's address has reached on this device and not yet been removed from: for each
129
+ // circle it was logged in, whether its latest Change there is a file.set. Mounted circles are
130
+ // another holder's namespace and are left out, since an address there is theirs.
131
+ async function reachedBy(log, index, address) {
132
+ if (typeof log.forTargetIn !== "function") return [];
133
+ const latest = new Map();
134
+ for (const { eco, change } of await log.forTargetIn(address)) latest.set(eco, change);
135
+ return [...latest.entries()]
136
+ .filter(([, change]) => change.kind === FILE_SET)
137
+ .map(([eco]) => circleById(index, eco))
138
+ .filter((c) => c && !c.mount);
139
+ }
140
+
141
+ // Where a file is on the wire and on this device (HQ-835). The wire carries a file at its ADDRESS,
142
+ // its path in the home holder's record. On the home holder's devices that is the path itself. On a
143
+ // device holding the circle from another holder, the circle is mounted, and the file lands at
144
+ // `<mount>/<address>`; an edit made there is minted back at the address.
145
+ export function addressing(index) {
146
+ const mountOf = (eco) => circleById(index, eco)?.mount ?? null;
147
+ return {
148
+ // The local path for a Change's target, given the circle it arrived in.
149
+ local(eco, address) {
150
+ const mount = mountOf(eco);
151
+ return mount ? `${mount.rel}/${address}` : address;
152
+ },
153
+ // A local path's address, given the circle it is minted into.
154
+ address(circle, rel) {
155
+ if (!circle.mount) return rel;
156
+ const prefix = `${circle.mount.rel}/`;
157
+ if (!rel.startsWith(prefix)) throw new EvdaError(`${rel} is in mounted circle "${circle.name}" but not under its mount ${prefix}.`, { code: "BAD_MOUNT" });
158
+ return rel.slice(prefix.length);
159
+ },
160
+ // The opaque id of the circle a local path is homed in on THIS record, or null. A path under a
161
+ // mount is another holder's file, homed in their record, so it has no home here.
162
+ homeOf(rel) {
163
+ const c = circleForPath(index, rel);
164
+ return c && !c.mount ? c.id : null;
165
+ },
166
+ };
167
+ }
168
+
169
+ // A member at the record root ("" after normalising) is a directory member covering every path.
170
+ const covers = (member, rel) =>
171
+ member.rel === "" ? member.isDir !== false
172
+ : member.isDir === null ? rel === member.rel || rel.startsWith(`${member.rel}/`)
173
+ : member.isDir ? rel.startsWith(`${member.rel}/`)
174
+ : rel === member.rel;
175
+
176
+ // The member a path belongs to, by LONGEST matching path (HQ-1364): a circle declared at the record
177
+ // root ("") claims everything, and a circle whose member is a longer prefix carves its subtree out of
178
+ // it, so `02_Areas/Health & Energy/Therapy/x.md` is therapy, not health, not core. Two circles with
179
+ // members of the SAME length covering one path is still refused: one access answer per entry.
180
+ // Returns { circle, member } or null.
181
+ export function memberForPath(index, rel) {
182
+ if (index.boundaries && insideAny(index.boundaries, rel)) return null;
183
+ let best = null;
184
+ let tie = null;
185
+ for (const c of index) {
186
+ for (const m of c.members) {
187
+ if (!covers(m, rel)) continue;
188
+ if (best === null || m.rel.length > best.member.rel.length) { best = { circle: c, member: m }; tie = null; }
189
+ else if (m.rel.length === best.member.rel.length && c !== best.circle) tie = c;
190
+ }
191
+ }
192
+ if (tie) {
193
+ const names = [...new Set([best.circle.name, tie.name])];
194
+ throw new EvdaError(`${rel} is covered by ${names.length} circles (${names.join(", ")}) at the same depth; an entry has one access answer.`, { code: "TWO_CIRCLES" });
195
+ }
196
+ return best;
197
+ }
198
+
199
+ // The one circle a path belongs to, or null.
200
+ export function circleForPath(index, rel) {
201
+ return memberForPath(index, rel)?.circle ?? null;
202
+ }
203
+
204
+ export const circleById = (index, id) => index.find((c) => c.id === id) ?? null;
205
+
206
+ // What a scan walks for the circles a device holds, one entry per path member: `prefix` is where the
207
+ // listing starts (`dir/` for a directory member, "" for the record root, the path itself for a file
208
+ // member) and `match(path)` says whether a listed path is this member's, which is true only when the
209
+ // longest-prefix resolution lands on this very member. So a file member matches exactly one path
210
+ // (`CLAUDE.md`, never `CLAUDE.md.bak`), and a root member matches everything no longer member of any
211
+ // circle claims. A device scans only what it holds, so a circle it is not granted is never read,
212
+ // hashed or minted on it.
213
+ export function scanPrefixes(index, holds) {
214
+ const isMember = (m, path) => {
215
+ let hit;
216
+ try { hit = memberForPath(index, path); } catch { return false; }
217
+ return hit !== null && hit.member.rel === m.rel && hit.circle.id === m.circle.id;
218
+ };
219
+ return index.filter(holds).flatMap((c) =>
220
+ c.members
221
+ .filter((m) => m.isDir !== null)
222
+ .map((m) => ({ circle: c, prefix: m.rel === "" ? "" : m.isDir ? `${m.rel}/` : m.rel, match: (path) => isMember({ rel: m.rel, circle: c }, path) })),
223
+ );
224
+ }
225
+
226
+ // Who holds which circle, read from the grant files. The substrate's `CircleGrants`.
227
+ //
228
+ // A grant names a circle by its name, on the owner's devices; the wire names it by opaque id. This
229
+ // is where the two meet, and nothing past it sees a name.
230
+ //
231
+ // A grant names a HOLDER by record id; the substrate routes to, and hears from, DEVICES by atlas id
232
+ // (HQ-835). The device lists under the grants role (`lib/devices.mjs`) are where the two meet: a
233
+ // record id resolves to its active devices for routing, and a device resolves to the record it
234
+ // speaks for before any grant is looked up. A record with no list is one device of the same id.
235
+ //
236
+ // The view re-reads the record when a declaration, a revocation or a device notice from another
237
+ // holder lands on it (`adoptDeclaration`, `freezeCircle`, `admitDevice`, `retireDevice`), because
238
+ // those write the very files it is built from, and what follows in the same poll is judged against
239
+ // the new view. `reload()` does the same on request.
240
+ export function grantView(evda) {
241
+ const load = () => ({
242
+ index: circleIndex(evda),
243
+ grants: (readGrants(evda) ?? []).filter((g) => g.problems.every((p) => p.level !== "fail")),
244
+ devices: readDevices(evda) ?? { lists: new Map(), problems: [] },
245
+ });
246
+ let state = load();
247
+ const reload = () => {
248
+ state = load();
249
+ };
250
+ const named = (eco) => circleById(state.index, eco);
251
+ const onCircle = (name) => state.grants.filter((g) => g.circle === name);
252
+ // The holder a device speaks for; null for a retired device.
253
+ const holderOf = (atlasId) => recordOf(state.devices, atlasId);
254
+ return {
255
+ get index() {
256
+ return state.index;
257
+ },
258
+ reload,
259
+ // A public circle is held by the record's own devices and by no other holder, who reaches the
260
+ // public set through its export. When the record lists its own devices (grants/devices/<own
261
+ // id>.yaml), a public Change routes to them alone; a record that lists none routes it to every
262
+ // pinned peer, as before devices were listed.
263
+ async readers(eco) {
264
+ const c = named(eco);
265
+ if (!c) return [];
266
+ if (c.public) {
267
+ const own = evda.manifest?.id;
268
+ return typeof own === "string" && state.devices.lists.has(own) ? devicesOf(state.devices, own) : "all";
269
+ }
270
+ return [...new Set(onCircle(c.name).flatMap((g) => devicesOf(state.devices, g.principalId)))];
271
+ },
272
+ async mayAuthor(atlasId, eco) {
273
+ const c = named(eco);
274
+ if (!c) return false;
275
+ const holder = holderOf(atlasId);
276
+ if (holder === null) return false;
277
+ const writes = (g) => g.principalId === holder && (g.role === "owner" || g.role === "editor");
278
+ // A public circle takes no grant, so writing into one takes an OWNER grant on some circle of
279
+ // the record: a device of the record's own holder. Never a viewer, and never another holder,
280
+ // who holds an editor grant at most and must not be able to publish in this record's name.
281
+ return c.public ? state.grants.some((g) => g.principalId === holder && g.role === "owner") : onCircle(c.name).some(writes);
282
+ },
283
+ // A frozen circle is held in the sense of "kept", not "worked on": it is not scanned or minted into.
284
+ holds(atlasId) {
285
+ const holder = holderOf(atlasId);
286
+ return (c) => !c.frozen && (c.public || (holder !== null && onCircle(c.name).some((g) => g.principalId === holder)));
287
+ },
288
+ // The private circles, by opaque id: the ones with a key to deliver.
289
+ async circles() {
290
+ return state.index.filter((c) => !c.public).map((c) => c.id);
291
+ },
292
+ // Only an owner hands out a circle's key (Spec 08 §2). Checked by the sender before delivering
293
+ // and by the receiver before importing, so a viewer device can neither give a key nor be the
294
+ // source of one.
295
+ async mayShareKey(atlasId, eco) {
296
+ const c = named(eco);
297
+ if (!c || c.public) return false;
298
+ const holder = holderOf(atlasId);
299
+ return holder !== null && onCircle(c.name).some((g) => g.principalId === holder && g.role === "owner");
300
+ },
301
+ // The declaration of a circle this record homes, for the wire (HQ-835); null for a public
302
+ // circle, a circle held from someone else, or one that is not here.
303
+ async declaration(eco) {
304
+ const c = named(eco);
305
+ if (!c || c.public || c.mount) return null;
306
+ return declarationOf(evda, c.name);
307
+ },
308
+ // Hold a circle another holder declared, then read the record again. A refusal names why. The
309
+ // sender is a device; the declaration names its owner by record id.
310
+ async adoptDeclaration(from, declaration) {
311
+ const holder = holderOf(from);
312
+ if (holder === null) return `${from} is a retired device here and declares nothing`;
313
+ const r = adoptDeclaration(evda, holder, declaration);
314
+ if (!r.ok) return r.why;
315
+ reload();
316
+ return null;
317
+ },
318
+ // Freeze a held circle whose grant was removed, then read the record again.
319
+ async freezeCircle(from, eco, at) {
320
+ const holder = holderOf(from);
321
+ if (holder === null) return `${from} is a retired device here and revokes nothing`;
322
+ const r = freezeCircle(evda, holder, eco, at);
323
+ if (!r.ok) return r.why;
324
+ reload();
325
+ return null;
326
+ },
327
+ // The record a device speaks for, or null.
328
+ async recordOf(atlasId) {
329
+ return holderOf(atlasId);
330
+ },
331
+ // Whether a device speaks for another holder than this record, or for none: the fold's `foreign`,
332
+ // so an edit from another holder into a file this record homes is re-minted to every holder.
333
+ isForeign(atlasId) {
334
+ return holderOf(atlasId) !== evda.manifest?.id;
335
+ },
336
+ // The active devices of a record id.
337
+ async devicesOf(record) {
338
+ return devicesOf(state.devices, record);
339
+ },
340
+ // This record's own id: a device-list notice naming it comes from a sibling device.
341
+ async ownRecord() {
342
+ return typeof evda.manifest?.id === "string" && evda.manifest.id !== "" ? evda.manifest.id : null;
343
+ },
344
+ // The active devices of a record id, with the identity key listed for each. No list: none, since
345
+ // no key is known for a device nobody listed.
346
+ async deviceList(record) {
347
+ const list = state.devices.lists.get(record);
348
+ if (!list) return [];
349
+ return list.devices.filter((d) => typeof d.retiredAt !== "string" || d.retiredAt === "").map((d) => ({ atlasId: d.atlasId, identityPublicKey: d.identityPublicKey ?? "" }));
350
+ },
351
+ // A holder's device introduced a new one: list it, then read the record again.
352
+ async admitDevice(record, device, introducer, at) {
353
+ const r = admitDevice(evda, { record, device, introducer, at });
354
+ if (!r.ok) return r.why;
355
+ reload();
356
+ return null;
357
+ },
358
+ // A holder's device retired one of its devices: mark it, then read the record again.
359
+ async retireDevice(record, atlasId, by, at) {
360
+ const r = retireDevice(evda, { record, atlasId, by, at });
361
+ if (!r.ok) return r.why;
362
+ reload();
363
+ return null;
364
+ },
365
+ };
366
+ }
367
+
368
+ // Paths under an append-only role, where two devices' concurrent appends union instead of
369
+ // conflicting: the daily notes and the ledger (Spec 08, section 3).
370
+ export function unionPaths(evda) {
371
+ const roots = ["record/daily", "ledger"]
372
+ .map((role) => resolveRole(evda, role))
373
+ .filter((r) => r.mapped)
374
+ .map((r) => `${r.rel.replace(/\/+$/, "")}/`);
375
+ return (rel) => roots.some((root) => rel.startsWith(root));
376
+ }
377
+
378
+ // Seal a payload to its circle, and open one. `crypto` is the host's HostCrypto.
379
+ //
380
+ // A public circle needs no key, so its payload travels in the clear inside the device envelope. A
381
+ // private circle's payload is sealed to the circle's current epoch; a device with no key for the
382
+ // circle cannot open it, which is what makes a Change routed to the wrong device fail closed.
383
+ export function circleCrypto(crypto) {
384
+ return {
385
+ async seal(circle, payload) {
386
+ if (circle.public) return { format: PUBLIC_FORMAT, clear: payload };
387
+ const key = await crypto.circleKey(circle.id);
388
+ if (!key) throw new EvdaError(`this device holds no key for circle "${circle.name}", so it cannot write into it.`, { code: "NO_CIRCLE_KEY" });
389
+ const bytes = new TextEncoder().encode(JSON.stringify(payload));
390
+ return { format: SEALED_FORMAT, epoch: key.epoch, sealed: Buffer.from(await crypto.seal(key.publicKey, bytes)).toString("base64") };
391
+ },
392
+ async open(circle, sealed) {
393
+ if (sealed?.format === PUBLIC_FORMAT) {
394
+ // A private circle's payload in the clear is refused, not read: it means a seal was skipped.
395
+ if (!circle.public) throw new Error(`circle payload arrived unsealed for a private circle`);
396
+ return sealed.clear;
397
+ }
398
+ if (sealed?.format !== SEALED_FORMAT || typeof sealed.sealed !== "string") throw new Error("not a circle payload");
399
+ const opened = await crypto.openCircle(circle.id, new Uint8Array(Buffer.from(sealed.sealed, "base64")));
400
+ return JSON.parse(new TextDecoder().decode(opened));
401
+ },
402
+ };
403
+ }
404
+
405
+ // The mint callbacks for `scanAndApply`.
406
+ //
407
+ // log the substrate's ChangeLog (`append`)
408
+ // index circleIndex(evda)
409
+ // seal (circle, payload) -> sealed payload, from circleCrypto
410
+ // notifyFor (eco, changesetId) -> [{ targetAtlas, pushBatchId }], the routing
411
+ // previousSha (path) -> the sha this device last recorded for the path, or null: the edit's base
412
+ // changesetId () -> a fresh changeset id
413
+ // unionPath optional (path) -> whether the path is under an append-only role. Written into the
414
+ // payload as `union: true`, so a device holding the circle from another holder, which
415
+ // has no mapping for the home record, still unions where the home does.
416
+ // onMinted optional ({ change, circle, payload }) -> after the Change is appended, once per
417
+ // circle the file reaches
418
+ // blobs optional { seal(bytes) -> { hash, contentKey }, link(changeId, hash) }: a file above
419
+ // BLOB_THRESHOLD bytes, or one that is not UTF-8, rides the blob lane (Spec 08 §1). Its
420
+ // bytes are sealed once under a fresh content key and kept by the device; the payload
421
+ // carries { hash, contentKey } in place of the text. Absent, such a file is minted as text.
422
+ //
423
+ // Throwing is how a callback says "not recorded": the scan leaves the path for the next run.
424
+ export const BLOB_THRESHOLD = 256 * 1024;
425
+
426
+ const utf8OrNull = (bytes) => {
427
+ try {
428
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
429
+ } catch {
430
+ return null;
431
+ }
432
+ };
433
+
434
+ export function fileMinter({ log, index, seal, notifyFor, previousSha, changesetId, originUser = null, onMinted, blobs, unionPath = null, readText = null }) {
435
+ const at = addressing(index);
436
+ const circleOf = (path) => {
437
+ const c = circleForPath(index, path);
438
+ if (!c) throw new EvdaError(`${path} is in no circle, so it has no one to sync to.`, { code: "NO_CIRCLE" });
439
+ return c;
440
+ };
441
+ // One Change per circle the file reaches: its home first, then each release. Each is sealed to
442
+ // that circle's key and routed to that circle's readers, with its own changeset id, so a device
443
+ // holding two of the circles is owed two rows rather than one row twice.
444
+ const append = async (kind, path, payload, circles, blobHash = null) => {
445
+ let first = null;
446
+ for (const circle of circles) {
447
+ const id = await changesetId();
448
+ const change = await log.append({
449
+ changesetId: id,
450
+ eco: circle.id,
451
+ originUser,
452
+ kind,
453
+ target: payload.path,
454
+ payload: await seal(circle, payload),
455
+ notify: await notifyFor(circle.id, id),
456
+ });
457
+ if (blobHash !== null) await blobs.link(change.id, blobHash);
458
+ await onMinted?.({ change, circle, payload });
459
+ first ??= change;
460
+ }
461
+ return first;
462
+ };
463
+ return {
464
+ async mint(item, bytes) {
465
+ const home = circleOf(item.path);
466
+ const address = at.address(home, item.path);
467
+ const text = utf8OrNull(bytes);
468
+ const circles = home.mount ? [home] : circlesFor(index, item.path, text, readText);
469
+ const union = unionPath?.(item.path) === true ? { union: true } : {};
470
+ // A circle the file reached before and reaches no longer (its mark was taken off, or its home
471
+ // changed) is sent a removal, so devices holding only that circle lose it (Spec 08 section 2,
472
+ // an entry moving between circles). Minted after the new version, as a move is.
473
+ const withdrawn = home.mount ? [] : (await reachedBy(log, index, address)).filter((c) => !circles.includes(c));
474
+ const withdraw = async () => {
475
+ if (withdrawn.length > 0) await append(FILE_REMOVE, item.path, { path: address, sha256: null, baseSha256: await previousSha(item.path) }, withdrawn);
476
+ };
477
+ if (blobs && (text === null || bytes.length > BLOB_THRESHOLD)) {
478
+ const sealed = await blobs.seal(bytes);
479
+ const blob = { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") };
480
+ await append(FILE_SET, item.path, { path: address, sha256: item.sha256, baseSha256: await previousSha(item.path), blob }, circles, sealed.hash);
481
+ await withdraw();
482
+ return;
483
+ }
484
+ // ignoreBOM keeps a byte-order mark in the text, so the receiver's hash of the text is the
485
+ // scanner's hash of the bytes. Without a blob lane, a file that is not UTF-8 is decoded
486
+ // lossily, fails that check on receipt, and is refused there.
487
+ const payload = {
488
+ path: address,
489
+ sha256: item.sha256,
490
+ baseSha256: await previousSha(item.path),
491
+ text: text ?? new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes),
492
+ ...union,
493
+ };
494
+ await append(FILE_SET, item.path, payload, circles);
495
+ await withdraw();
496
+ },
497
+ // A removal reaches every circle the file reached: the mark went with the bytes, so the circles
498
+ // are read off this device's log, each one whose latest Change for the address is a file.set,
499
+ // with the home first (HQ-835). On a device holding the circle from another holder, the home
500
+ // (the mounted circle) only.
501
+ async mintRemoval(item) {
502
+ const home = circleOf(item.path);
503
+ const address = at.address(home, item.path);
504
+ const circles = [home, ...(home.mount ? [] : (await reachedBy(log, index, address)).filter((c) => c !== home))];
505
+ await append(FILE_REMOVE, item.path, { path: address, sha256: null, baseSha256: await previousSha(item.path) }, circles);
506
+ },
507
+ };
508
+ }
509
+
510
+ // The catch-up for a circle this device homes (HQ-835): what `deliverCatchUp` in atlas-local sends,
511
+ // once, to a device that joined the circle or whose copy thawed. The core decides when and to whom;
512
+ // this says what the circle holds.
513
+ //
514
+ // evda the record
515
+ // view () -> the current grant view (its `index`)
516
+ // log the substrate's ChangeLog: `append`, `forTargetIn`, `forEco`
517
+ // seal (circle, payload) -> sealed payload, from circleCrypto, at the circle's CURRENT epoch
518
+ // open (change, eco) -> payload, or throws: reads the earlier versions for `ancestors`
519
+ // changesetId () -> a fresh changeset id
520
+ // unionPath optional, as for fileMinter
521
+ // blobs optional, as for fileMinter
522
+ // exclude optional (rel) -> true for a path the sync rules leave out (sync-rules `excluder`),
523
+ // so a catch-up sends no more than the scan would
524
+ //
525
+ // `snapshot(circleId)` mints, into the log and routed to nobody, one file.set per file the circle
526
+ // reaches now (its home members and every file released into it, conflict files included), and one
527
+ // file.remove per address the circle reached before and reaches no longer. Each payload carries
528
+ // `ancestors`: every sha this device logged for the address before the current one, from any circle
529
+ // it can open. A receiver holding any of them fast-forwards; a receiver holding a version nobody
530
+ // here has seen keeps it and gets the current text beside it, by the ordinary merge table.
531
+ export function catchUp({ evda, view, log, seal, open, changesetId, originUser = null, unionPath = null, blobs = null, exclude = null }) {
532
+ const readText = (rel) => {
533
+ const abs = join(evda.dir, rel);
534
+ return existsSync(abs) && statSync(abs).isFile() ? readFileSync(abs, "utf8") : null;
535
+ };
536
+ const walk = (prefix) => {
537
+ const out = [];
538
+ const start = join(evda.dir, prefix);
539
+ if (!existsSync(start)) return out;
540
+ if (statSync(start).isFile()) return [prefix];
541
+ const stack = [prefix.replace(/\/+$/, "")];
542
+ while (stack.length > 0) {
543
+ const dir = stack.pop();
544
+ for (const name of readdirSync(join(evda.dir, dir)).sort()) {
545
+ if (name.startsWith(".")) continue;
546
+ const rel = dir === "" ? name : `${dir}/${name}`;
547
+ const st = statSync(join(evda.dir, rel));
548
+ if (st.isDirectory()) stack.push(rel);
549
+ else if (st.isFile()) out.push(rel);
550
+ }
551
+ }
552
+ return out;
553
+ };
554
+ const shaOf = async (bytes) => Buffer.from(await crypto.subtle.digest("SHA-256", bytes)).toString("hex");
555
+
556
+ // Every sha logged for an address before `current`, oldest first, from Changes this device can open.
557
+ const ancestorsOf = async (address, current) => {
558
+ const out = [];
559
+ for (const { eco, change } of await log.forTargetIn(address)) {
560
+ if (change.kind !== FILE_SET) continue;
561
+ try {
562
+ const p = await open(change, eco);
563
+ if (typeof p?.sha256 === "string" && p.sha256 !== current && !out.includes(p.sha256)) out.push(p.sha256);
564
+ } catch {
565
+ // A version this device cannot open is not one it can vouch for.
566
+ }
567
+ }
568
+ return out;
569
+ };
570
+
571
+ return {
572
+ async snapshot(circleId) {
573
+ const index = view().index;
574
+ const target = circleById(index, circleId);
575
+ if (!target) throw new EvdaError(`no circle ${circleId} in this record.`, { code: "NO_CIRCLE" });
576
+ if (target.mount) throw new EvdaError(`circle "${target.name}" is held from ${target.mount.holder}; only its home catches a device up.`, { code: "NOT_OWNER" });
577
+ const at = addressing(index);
578
+ const present = new Map();
579
+ for (const { prefix, match } of scanPrefixes(index, (c) => !c.mount && !c.frozen)) {
580
+ for (const rel of walk(prefix)) {
581
+ if (!match(rel) || present.has(rel) || exclude?.(rel)) continue;
582
+ const bytes = readFileSync(join(evda.dir, rel));
583
+ const text = utf8OrNull(bytes);
584
+ if (!circlesFor(index, rel, text, readText).includes(target)) continue;
585
+ present.set(rel, { bytes, text });
586
+ }
587
+ }
588
+ const minted = [];
589
+ const append = async (kind, payload, blobHash = null) => {
590
+ const id = await changesetId();
591
+ const change = await log.append({ changesetId: id, eco: target.id, originUser, kind, target: payload.path, payload: await seal(target, payload), notify: [] });
592
+ if (blobHash !== null) await blobs.link(change.id, blobHash);
593
+ minted.push(change);
594
+ };
595
+ const addresses = new Set();
596
+ for (const [rel, { bytes, text }] of [...present.entries()].sort(([a], [b]) => a.localeCompare(b))) {
597
+ const address = at.address(circleForPath(index, rel), rel);
598
+ addresses.add(address);
599
+ const sha256 = await shaOf(bytes);
600
+ const ancestors = await ancestorsOf(address, sha256);
601
+ const base = { path: address, sha256, baseSha256: ancestors.at(-1) ?? null, ancestors };
602
+ if (blobs && (text === null || bytes.length > BLOB_THRESHOLD)) {
603
+ const sealed = await blobs.seal(bytes);
604
+ await append(FILE_SET, { ...base, blob: { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") } }, sealed.hash);
605
+ continue;
606
+ }
607
+ await append(FILE_SET, { ...base, text: text ?? new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes), ...(unionPath?.(rel) === true ? { union: true } : {}) });
608
+ }
609
+ // Addresses the circle held before and does not now: removed, or no longer released into it.
610
+ const gone = new Set();
611
+ for (const change of await log.forEco(target.id)) {
612
+ if (change.kind === FILE_SET && !addresses.has(change.target)) gone.add(change.target);
613
+ }
614
+ for (const address of [...gone].sort()) {
615
+ const ancestors = await ancestorsOf(address, null);
616
+ if (ancestors.length === 0) continue;
617
+ await append(FILE_REMOVE, { path: address, sha256: null, baseSha256: ancestors.at(-1), ancestors });
618
+ }
619
+ return minted;
620
+ },
621
+ };
622
+ }
package/lib/folders.mjs CHANGED
@@ -112,24 +112,38 @@ export function validateFolderManifest(m, rel) {
112
112
  return out;
113
113
  }
114
114
 
115
- // Every folder manifest under the record's record roles, deepest last. Each item is
116
- // { rel, abs, dir, ...validateFolderManifest }. Throws only on a manifest that is not JSON.
115
+ // Every folder manifest in the record. Each item is { rel, abs, dir, ...validateFolderManifest }.
116
+ // Throws only on a manifest that is not JSON.
117
+ //
118
+ // The record roles are walked first, in role order and deepest last, then the rest of the record
119
+ // root, because a folder a preset or the owner made outside every role still says what it holds and
120
+ // is still read. Until HQ-1216 only the roles were walked, and a malformed manifest one level outside
121
+ // them printed OK. The walk skips dot-directories and node_modules and does not follow a symlink
122
+ // below a role or the root, so a link cannot pull another tree, or the same tree twice, into the count.
123
+ //
124
+ // FALSIFIED 2026-09-17: a fresh scaffold with Outside/folder.evda.json holding {"kind":"person"}
125
+ // (no type). The roles-only walk (main at 2788455) printed "CHECKED 3 folder manifests" and
126
+ // "OK 9 check(s), 0 warning(s)", exit 0. This walk prints "FAIL folder Outside
127
+ // Outside/folder.evda.json: has no "type". ..." and "CHECKED 4 folder manifests", exit 1.
117
128
  export function folderManifests(evda) {
118
129
  const out = [];
119
- const roots = resolveAll(evda).filter((r) => r.known && r.mapped && r.layer === "record" && r.exists && r.isDir);
130
+ const seen = new Set();
120
131
  const walk = (dir) => {
132
+ if (seen.has(dir)) return;
133
+ seen.add(dir);
121
134
  const m = readFolderManifest(dir);
122
135
  if (m !== null) {
123
136
  const abs = join(dir, FOLDER_MANIFEST);
124
137
  out.push({ rel: relative(evda.dir, abs), abs, dir, ...validateFolderManifest(m, relative(evda.dir, abs)) });
125
138
  }
126
- for (const name of readdirSync(dir).sort()) {
127
- if (name.startsWith(".")) continue;
128
- const p = join(dir, name);
129
- if (statSync(p).isDirectory()) walk(p);
139
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
140
+ for (const e of entries) {
141
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
142
+ if (e.isDirectory()) walk(join(dir, e.name));
130
143
  }
131
144
  };
132
- for (const r of roots) walk(r.abs);
145
+ for (const r of resolveAll(evda).filter((x) => x.known && x.mapped && x.layer === "record" && x.exists && x.isDir)) walk(r.abs);
146
+ walk(evda.dir);
133
147
  return out;
134
148
  }
135
149