create-evda 0.1.5 → 0.1.7

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 (60) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +74 -7
  3. package/bin/evda.mjs +427 -9
  4. package/create-evda.mjs +64 -16
  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 +346 -13
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/folders.mjs +1 -1
  17. package/lib/gather-apply.mjs +640 -0
  18. package/lib/gather.mjs +858 -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 +456 -36
  29. package/lib/messages.mjs +205 -0
  30. package/lib/migrate-people.mjs +231 -0
  31. package/lib/organize.mjs +176 -32
  32. package/lib/parity.mjs +184 -0
  33. package/lib/presets.mjs +58 -7
  34. package/lib/prompt-hook.mjs +88 -0
  35. package/lib/publish.mjs +264 -0
  36. package/lib/readers.mjs +319 -0
  37. package/lib/records.mjs +52 -0
  38. package/lib/references.mjs +5 -51
  39. package/lib/render.mjs +158 -0
  40. package/lib/roles.mjs +106 -1
  41. package/lib/routines.mjs +142 -18
  42. package/lib/secret-shape.mjs +58 -0
  43. package/lib/setup.mjs +124 -4
  44. package/lib/skills.mjs +266 -0
  45. package/lib/subrecord.mjs +121 -0
  46. package/lib/sync-rules.mjs +93 -0
  47. package/lib/tools.mjs +133 -27
  48. package/lib/update-check.mjs +146 -0
  49. package/lib/work.mjs +180 -0
  50. package/lib/yaml.mjs +117 -42
  51. package/package.json +25 -15
  52. package/presets/journal/preset.json +9 -1
  53. package/presets/projects-and-areas/preset.json +16 -9
  54. package/skills/evda/SKILL.md +67 -8
  55. package/templates/AGENTS.md +122 -14
  56. package/templates/README.md +15 -1
  57. package/templates/circles-README.md +3 -3
  58. package/templates/evda-digest.md +31 -6
  59. package/LICENSE +0 -21
  60. package/presets/student/preset.json +0 -13
package/lib/connect.mjs CHANGED
@@ -33,6 +33,52 @@ export function serverCommand(recordDir, { execPath = process.execPath, bin = jo
33
33
  return { command: stableNode(execPath, env), args: [real, "mcp", dir] };
34
34
  }
35
35
 
36
+ // The command Claude Code runs for the read guard (lib/guard.mjs), by the same rule as the server:
37
+ // absolute paths when the package is installed somewhere stable, the npx form from an npx cache.
38
+ export function guardCommand({ execPath = process.execPath, hook = join(PACKAGE_ROOT, "hooks", "evda-read-guard.mjs"), env = process.env } = {}) {
39
+ let real = hook;
40
+ try { real = realpathSync(hook); } catch { /* not on disk yet, as in a test */ }
41
+ const q = (s) => (/^[A-Za-z0-9_./:@=+-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
42
+ if (/[\\/]_npx[\\/]/.test(real)) return "npx -y -p create-evda@latest evda guard";
43
+ return `${q(stableNode(execPath, env))} ${q(real)}`;
44
+ }
45
+
46
+ // Install the read guard into one record's .claude/settings.json, for Claude Code sessions opened
47
+ // in that folder. Merges into a settings file that is already there: an existing PreToolUse entry
48
+ // with the same command is left alone, and nothing else in the file is touched.
49
+ export function installGuard(recordDir, { command = guardCommand() } = {}) {
50
+ const path = join(resolve(recordDir), ".claude", "settings.json");
51
+ let settings = {};
52
+ if (existsSync(path)) {
53
+ try { settings = JSON.parse(readFileSync(path, "utf8")); } catch { return { path, action: "left alone (not valid JSON):" }; }
54
+ }
55
+ const pre = (settings.hooks ??= {}).PreToolUse ??= [];
56
+ if (pre.some((m) => (m.hooks ?? []).some((h) => h.command === command))) return { path, action: "already installed:" };
57
+ pre.push({ matcher: "Read|Grep|Bash|NotebookRead", hooks: [{ type: "command", command }] });
58
+ mkdirSync(dirname(path), { recursive: true });
59
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
60
+ return { path, action: "installed" };
61
+ }
62
+
63
+ // Whether one record's .claude/settings.json runs the read guard, for doctor (HQ-1377):
64
+ // { registered, target, file, problem }. `target` is the hook file the command names, `file` whether
65
+ // it is on disk; the npx form names no file and is present whenever it is registered. Never throws.
66
+ export const GUARD_MARK = /evda-read-guard\.mjs|\bevda guard\b/;
67
+ // EVDA_HOOKS_FALSIFY=blind reports no guard at all, for the test that shows doctor's line is read
68
+ // from the settings file.
69
+ export function guardStatus(recordDir) {
70
+ if ((process.env.EVDA_HOOKS_FALSIFY ?? "").split(",").map((x) => x.trim()).includes("blind")) return { registered: false, target: null, file: false, problem: null };
71
+ const path = join(resolve(recordDir), ".claude", "settings.json");
72
+ if (!existsSync(path)) return { registered: false, target: null, file: false, problem: null };
73
+ let settings;
74
+ try { settings = JSON.parse(readFileSync(path, "utf8")); } catch (e) { return { registered: false, target: null, file: false, problem: `.claude/settings.json is not valid JSON (${e.message}), so no hook in it runs.` }; }
75
+ const commands = (settings?.hooks?.PreToolUse ?? []).flatMap((m) => (m?.hooks ?? []).map((h) => h?.command)).filter((c) => typeof c === "string" && GUARD_MARK.test(c));
76
+ if (!commands.length) return { registered: false, target: null, file: false, problem: null };
77
+ const m = /'([^']*evda-read-guard\.mjs)'|(\S*evda-read-guard\.mjs)/.exec(commands[0]);
78
+ const target = m ? (m[1] ?? m[2]) : null;
79
+ return { registered: true, target, file: target === null || existsSync(target), problem: null };
80
+ }
81
+
36
82
  // A `node` on PATH that is this same binary, when there is one: Homebrew's /opt/homebrew/bin/node
37
83
  // survives an upgrade, and the versioned Cellar path process.execPath reports does not.
38
84
  function stableNode(execPath, env) {
@@ -0,0 +1,93 @@
1
+ // The day's file, and the one way words are appended to it (HQ-1385): evda_write over stdio and
2
+ // over HTTP, the Claude Code prompt hook, and the chat-export backstop in `evda gather` all build
3
+ // their bytes here, so a section looks the same whoever wrote it.
4
+ //
5
+ // ONE SECTION PER CONVERSATION. The first write in a conversation opens `## HH:MM` (the owner's
6
+ // local time) with its one line of context; every later write in the same conversation appends
7
+ // under it, as a new paragraph, with no heading. A conversation is whatever the caller passes as
8
+ // `session`: one MCP session (a stdio process, or one Mcp-Session-Id over HTTP), or one Claude Code
9
+ // session id in the prompt hook. When the file changed since this conversation last wrote to it
10
+ // (another conversation wrote a section, or someone edited it by hand), the next write opens a new
11
+ // heading, so words never land under a heading that is not theirs.
12
+ //
13
+ // FILING. evda.json `filing` is the owner's answer to the one setup question: "everything" (file
14
+ // every message they send, without being asked) or "asked" (only when they ask). Absent means they
15
+ // have not been asked yet; the MCP server asks at first connect. The tool descriptions, the server's
16
+ // instructions, the nudge, the Stop hook and the prompt hook all read it through filingOf.
17
+ //
18
+ // node: built-ins only and no imports of the package's own: `evda hook install` copies this file
19
+ // byte for byte beside the Claude Code hooks, which import it. `evda doctor` says when a copy is
20
+ // older than this file.
21
+ //
22
+ // EVDA_CHAT_FALSIFY=heading-per-write opens a new heading on every write, for the test that shows
23
+ // one conversation is one section.
24
+
25
+ import { appendFileSync, existsSync, readFileSync, statSync } from "node:fs";
26
+ import { dirname, join, resolve } from "node:path";
27
+
28
+ export const FILING_MODES = ["everything", "asked"];
29
+ export const CONTEXT_LABEL = "Context, not their words:";
30
+
31
+ const falsified = (mode) => (process.env.EVDA_CHAT_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
32
+
33
+ // The owner's filing answer: "everything", "asked", or null when they have not been asked (or the
34
+ // value is not one of the two, which doctor fails).
35
+ export function filingOf(manifest) {
36
+ const v = (manifest?.manifest ?? manifest)?.filing;
37
+ return FILING_MODES.includes(v) ? v : null;
38
+ }
39
+
40
+ // The role the day's record lives under: `record/daily` where a record maps it (every scaffold
41
+ // does), else the `journal` role (HQ-1371). One rule, read by evda_write and by every hook.
42
+ export function dayRole(mapping) {
43
+ if (typeof mapping?.["record/daily"] === "string") return "record/daily";
44
+ if (typeof mapping?.journal === "string") return "journal";
45
+ return null;
46
+ }
47
+
48
+ // The record root at or above `from`, or null.
49
+ export function findRecord(from) {
50
+ let d = resolve(from);
51
+ for (;;) {
52
+ if (existsSync(join(d, "evda.json"))) return d;
53
+ const up = dirname(d);
54
+ if (up === d) return null;
55
+ d = up;
56
+ }
57
+ }
58
+
59
+ const pad = (n) => String(n).padStart(2, "0");
60
+ export const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
61
+ export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
62
+
63
+ export const contextLine = (context) => `*${CONTEXT_LABEL} ${String(context).trim().replace(/\*/g, "")}*`;
64
+
65
+ // The bytes one write appends to a day file whose current text is `current` (null when it does not
66
+ // exist yet). `continuing` is true when this conversation's section is the last thing in the file.
67
+ // Returns { text, opened }.
68
+ export function sectionBytes({ current, date, time, words, context = null, continuing = false, contextWritten = false }) {
69
+ const body = String(words).replace(/\n+$/, "");
70
+ const lead = current !== null && current !== "" && !current.endsWith("\n") ? "\n" : "";
71
+ if (continuing && current !== null && !falsified("heading-per-write")) {
72
+ const ctx = context && !contextWritten ? `${contextLine(context)}\n\n` : "";
73
+ return { text: `${lead}\n${ctx}${body}\n`, opened: false };
74
+ }
75
+ const head = current === null ? `# ${date}\n` : "";
76
+ const ctx = context ? `${contextLine(context)}\n\n` : "";
77
+ return { text: `${lead}${head}\n## ${time}\n\n${ctx}${body}\n`, opened: true };
78
+ }
79
+
80
+ // Append `words` to the day file at `abs`. `session` is the conversation's mutable state ({} to
81
+ // start one, null for a write that belongs to no conversation, which always opens a heading); it
82
+ // remembers which file this conversation's section is in and how long that file was after its last
83
+ // write. Returns { opened, bytes }.
84
+ export function appendToDay(abs, { now = new Date(), words, context = null, session = null }) {
85
+ const exists = existsSync(abs);
86
+ const current = exists ? readFileSync(abs, "utf8") : null;
87
+ const s = session?.section;
88
+ const continuing = Boolean(s && exists && s.abs === abs && statSync(abs).size === s.size);
89
+ const w = sectionBytes({ current, date: localDate(now), time: localTime(now), words, context, continuing, contextWritten: Boolean(s?.context) });
90
+ appendFileSync(abs, w.text);
91
+ if (session) session.section = { abs, size: statSync(abs).size, context: Boolean(context) || (continuing && Boolean(s?.context)) };
92
+ return { opened: w.opened, bytes: Buffer.byteLength(w.text) };
93
+ }
@@ -0,0 +1,235 @@
1
+ // Circle declarations: how a circle's declaration and its grants reach another holder (HQ-835).
2
+ //
3
+ // The first two-holder slice wrote the other holder's copy of a circle by hand: its manifest with
4
+ // the owner's opaque id and a `mount`, and one grant file per reader, copied from the owner's record.
5
+ // This module is what replaces the copying. The owner's device reads the declaration off its own
6
+ // record (`declarationOf`), the substrate carries it to each grantee device signed by the owner
7
+ // (`atlas-local/src/rendezvous.ts`, `deliverCircleDeclarations`), and the grantee device holds it
8
+ // (`adoptDeclaration`): a manifest under its circles role, mounted at `mounts/<owner record id>`,
9
+ // and a grant file per reader under its grants role, all in the shapes `circles.mjs` and
10
+ // `grants.mjs` already read. Nothing is copied by a person.
11
+ //
12
+ // What travels is the owner's manifest minus what only means something on the owner's disk: the
13
+ // opaque id and namespace, the title as the owner wrote it, `public: false`, `release`, and the
14
+ // grant list. Not `members` (paths in the owner's tree) and not the directory name (it stays on the
15
+ // owner's devices; the id is what travels). The receiver names its copy from the owner's record id
16
+ // and the title, and mounts it at one path per owner.
17
+ //
18
+ // Revocation reaches the receiver as a "grant removed" notice, and the copy is FROZEN, never
19
+ // deleted: the manifest gains `frozen: { since: <date>, by: revocation }`, the files under the
20
+ // mount stay as they are, and a frozen circle is neither scanned for edits nor minted into. Spec 00
21
+ // #37: revocation cannot reach a copy already opened, and each side keeps what it holds.
22
+ //
23
+ // Where a held circle lands is the `mounts` role (HQ-835). Mapped, each owner gets one directory
24
+ // under it and each of their circles one subfolder, `<mounts>/<owner record id>/<slug of title>`,
25
+ // so a receiver holds any number of circles from one owner side by side. The subfolder is chosen
26
+ // once, when the circle is first held, and kept on every re-declaration, so a retitled circle does
27
+ // not move. Unmapped, a record holds one circle per owner at `mounts/<owner record id>`, the layout
28
+ // the first two-holder slice used, and a second circle from the same owner is refused with the
29
+ // reason and the remedy (map the role).
30
+
31
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { circleManifest, circlesDir, ecoManifestPath, ecoNamespace, isRelease, readCircles } from "./circles.mjs";
34
+ import { grant, grantsDir, MEMBER_ROLES, readGrants } from "./grants.mjs";
35
+ import { resolveRole } from "./roles.mjs";
36
+ import { HOLDER_KINDS } from "./manifest.mjs";
37
+ import { parseYaml, toYaml } from "./yaml.mjs";
38
+ import { EvdaError } from "./errors.mjs";
39
+
40
+ // Where another holder's circles land in this record when no `mounts` role is mapped: one
41
+ // directory per owner, holding one circle's files at their addresses.
42
+ export const MOUNT_ROOT = "mounts";
43
+ export const mountPathFor = (holder) => `${MOUNT_ROOT}/${holder}`;
44
+
45
+ // The mount root this record declares, or null when the `mounts` role is not mapped. Normalised,
46
+ // no trailing slash; "" is refused, since a mount root at the record root would put another
47
+ // holder's files beside this record's own.
48
+ export function mountRoot(evda) {
49
+ const r = resolveRole(evda, "mounts");
50
+ if (!r.mapped) return null;
51
+ const rel = String(r.rel).replace(/\/+$/, "").replace(/^\.\/+/, "");
52
+ if (rel === "" || rel === ".") throw new EvdaError('the "mounts" role cannot be the record root; another holder\'s files would sit beside this record\'s own.', { code: "BAD_MAPPING", exit: 2 });
53
+ return rel;
54
+ }
55
+
56
+ // Where a circle from `holder` titled `title` would be mounted here, given the mounts already
57
+ // taken. Under a mapped root: `<root>/<holder>/<slug(title)>`, with the circle id's first eight
58
+ // characters appended when two of the owner's circles share a title. Unmapped: the legacy path.
59
+ export function mountFor(evda, holder, title, id, taken = new Set()) {
60
+ const root = mountRoot(evda);
61
+ if (root === null) return mountPathFor(holder);
62
+ const base = `${root}/${holder}/${slug(title)}`;
63
+ return taken.has(base) ? `${base}-${String(id).slice(0, 8)}` : base;
64
+ }
65
+
66
+ // Whether one mount path is the other or lies inside it.
67
+ const nests = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
68
+ // The `by` a revocation writes into `frozen`.
69
+ export const FROZEN_BY_REVOCATION = "revocation";
70
+
71
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
72
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
73
+ const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "circle";
74
+
75
+ // The directory name a held circle gets here: the owner's record id without its `.atlas`, then the
76
+ // title, both slugged. `ava.atlas` + "Shared with Ben" -> `ava-shared-with-ben`. A name, not an
77
+ // identity: the id on the manifest is what the wire matches on.
78
+ export const mountedName = (holder, title) => `${slug(holder.replace(/\.atlas$/, ""))}-${slug(title)}`;
79
+
80
+ // The declaration of a circle this record homes, for the wire. Throws for a circle that is not
81
+ // here, is malformed, is public (it travels as its export), or is held here from someone else.
82
+ export function declarationOf(evda, circleName) {
83
+ const circles = readCircles(evda) ?? [];
84
+ const c = circles.find((x) => x.name === circleName);
85
+ if (!c) throw new EvdaError(`no circle named "${circleName}" in this record.`, { code: "NO_CIRCLE", exit: 2 });
86
+ const broken = c.problems.find((p) => p.level === "fail" && p.code !== "MISSING_MEMBER");
87
+ if (broken) throw new EvdaError(`circle "${circleName}" is malformed: ${broken.message}`, { code: "BAD_CIRCLE", exit: 2 });
88
+ if (c.manifest.public === true) throw new EvdaError(`circle "${circleName}" is public; a public circle travels as its export, not as a declaration.`, { code: "PUBLIC_CIRCLE", exit: 2 });
89
+ if (c.mount) throw new EvdaError(`circle "${circleName}" is held here from ${c.mount.holder}; only its owner declares it.`, { code: "NOT_OWNER", exit: 2 });
90
+ const grants = (readGrants(evda) ?? []).filter((g) => g.circle === circleName && g.problems.every((p) => p.level !== "fail"));
91
+ return {
92
+ id: c.id,
93
+ namespace: ecoNamespace(c.id),
94
+ title: c.manifest.title,
95
+ public: false,
96
+ release: c.release,
97
+ grants: grants
98
+ .map((g) => ({
99
+ principalId: g.principalId,
100
+ role: g.role,
101
+ holder: g.holder.name ? { kind: g.holder.kind, name: g.holder.name } : { kind: g.holder.kind },
102
+ grantedAt: String(g.manifest.grantedAt),
103
+ grantedBy: String(g.manifest.grantedBy),
104
+ }))
105
+ .sort((a, b) => a.principalId.localeCompare(b.principalId)),
106
+ };
107
+ }
108
+
109
+ // Why a declaration cannot be held, by its shape alone. null when it can.
110
+ export function checkDeclaration(d) {
111
+ if (!isObject(d)) return "declaration is not a mapping";
112
+ if (!nonEmpty(d.id) || d.id.includes("/") || d.id.includes("..")) return "declaration has no usable id";
113
+ if (d.namespace !== ecoNamespace(d.id)) return `declaration namespace ${JSON.stringify(d.namespace)} does not name circle ${d.id}`;
114
+ if (!nonEmpty(d.title)) return "declaration has no title";
115
+ if (d.public !== false) return "a public circle travels as its export, not as a declaration";
116
+ if (!isRelease(d.release)) return `declaration release must be "never" or a list of circle names, got ${JSON.stringify(d.release)}`;
117
+ if (!Array.isArray(d.grants) || d.grants.length === 0) return "declaration carries no grants";
118
+ for (const g of d.grants) {
119
+ if (!isObject(g) || !nonEmpty(g.principalId)) return "a grant in the declaration names no principal";
120
+ if (!MEMBER_ROLES.includes(g.role)) return `the grant to ${g.principalId} has role ${JSON.stringify(g.role)}`;
121
+ if (!isObject(g.holder) || !HOLDER_KINDS.includes(g.holder.kind)) return `the grant to ${g.principalId} names no holder kind (${HOLDER_KINDS.join(", ")})`;
122
+ if (!nonEmpty(g.grantedAt) || !nonEmpty(g.grantedBy)) return `the grant to ${g.principalId} says not when or by whom`;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ const maintainersOf = (grants) => grants.map((g) => ({ name: g.holder.name ?? g.principalId, role: "holder" }));
128
+
129
+ // Hold a circle declared by `from`, its owner. Writes the manifest (mounted at `mounts/<from>`) and
130
+ // one grant file per reader, and removes grant files on the circle that the declaration no longer
131
+ // carries. Idempotent: the same declaration twice writes the same bytes and mints nothing. A
132
+ // re-declaration of a frozen circle (a grant given again) thaws it.
133
+ //
134
+ // Returns { ok: true, name, mount, created } or { ok: false, why }. Refuses, with the reason:
135
+ // - a declaration whose grant list does not name `from` as owner (only an owner declares);
136
+ // - a declaration that grants nothing to this record;
137
+ // - a circle already homed here (a holder does not receive its own circle), or already held here
138
+ // from someone else under the same id;
139
+ // - a different circle already mounted from `from` (one mount per owner), or a circle already
140
+ // here under the name this one would take.
141
+ export function adoptDeclaration(evda, from, d) {
142
+ const bad = checkDeclaration(d);
143
+ if (bad) return { ok: false, why: `circle declaration is malformed: ${bad}` };
144
+ if (!nonEmpty(from)) return { ok: false, why: "a declaration comes from a record id" };
145
+ if (!d.grants.some((g) => g.principalId === from && g.role === "owner")) return { ok: false, why: `${from} holds no owner grant in the declaration of circle ${d.id}; only its owner declares a circle` };
146
+ const me = evda.manifest?.id;
147
+ if (!nonEmpty(me)) return { ok: false, why: "this record has no id, so no declaration can name it" };
148
+ if (!d.grants.some((g) => g.principalId === me)) return { ok: false, why: `the declaration of circle ${d.id} grants nothing to ${me}` };
149
+ const cr = circlesDir(evda);
150
+ if (!cr.mapped) return { ok: false, why: 'no "circles" role in the mapping, so there is nowhere to hold a circle' };
151
+ const gr = grantsDir(evda);
152
+ if (!gr.mapped) return { ok: false, why: 'no "grants" role in the mapping, so there is nowhere to write its grants' };
153
+
154
+ const circles = readCircles(evda) ?? [];
155
+ let root;
156
+ try {
157
+ root = mountRoot(evda);
158
+ } catch (e) {
159
+ return { ok: false, why: e.message };
160
+ }
161
+ let name = mountedName(from, d.title);
162
+ let mount;
163
+ const existing = circles.find((c) => c.id === d.id);
164
+ if (existing) {
165
+ if (!existing.mount) return { ok: false, why: `circle ${d.id} is homed in this record as "${existing.name}"; a holder does not receive its own circle` };
166
+ if (existing.mount.holder !== from) return { ok: false, why: `circle ${d.id} is already held here from ${existing.mount.holder}, not from ${from}` };
167
+ name = existing.name;
168
+ // Chosen when the circle was first held and kept, so a retitled circle does not move.
169
+ mount = existing.mount.rel;
170
+ } else {
171
+ if (from === me) return { ok: false, why: `${from} is this record; a holder does not mount its own circles` };
172
+ const mounts = circles.filter((c) => c.mount).map((c) => ({ rel: c.mount.rel, c }));
173
+ mount = mountFor(evda, from, d.title, d.id, new Set(mounts.map((m) => m.rel)));
174
+ const clash = mounts.find((m) => nests(m.rel, mount));
175
+ if (clash) {
176
+ return root === null
177
+ ? { ok: false, why: `${clash.rel} already holds circle ${clash.c.id} ("${clash.c.name}") from ${clash.c.mount.holder}; without a "mounts" role a record holds one circle per owner. Map "mounts" in evda.json to hold several, one subfolder each` }
178
+ : { ok: false, why: `${mount} would sit inside or around ${clash.rel}, where circle ${clash.c.id} ("${clash.c.name}") is held; one subfolder per circle, never nested` };
179
+ }
180
+ if (circles.some((c) => c.name === name)) name = `${name}-${d.id.slice(0, 8)}`;
181
+ const named = circles.find((c) => c.name === name);
182
+ if (named) return { ok: false, why: `a circle named "${name}" is already here with id ${named.id ?? "(none)"}` };
183
+ }
184
+
185
+ mkdirSync(join(evda.dir, mount), { recursive: true });
186
+ const fresh = circleManifest({
187
+ name,
188
+ id: d.id,
189
+ title: d.title,
190
+ description: `Held from ${from}, by grant.`,
191
+ isPublic: false,
192
+ holders: maintainersOf(d.grants),
193
+ release: d.release === "never" ? null : d.release,
194
+ mount: { holder: from, path: mount },
195
+ members: { files: { path: mount } },
196
+ });
197
+ // A copy already here keeps what its holder added locally (a policy, a description of their own);
198
+ // what the owner declares is rewritten over it, and `frozen` is lifted by a re-declaration.
199
+ const manifest = existing ? { ...existing.manifest, ...fresh, description: existing.manifest.description ?? fresh.description } : fresh;
200
+ delete manifest.frozen;
201
+ const dir = join(cr.abs, name);
202
+ const p = ecoManifestPath(dir);
203
+ const text = toYaml(manifest);
204
+ mkdirSync(dir, { recursive: true });
205
+ if (!existsSync(p) || readFileSync(p, "utf8") !== text) writeFileSync(p, text);
206
+
207
+ for (const g of d.grants) {
208
+ grant(evda, { circle: name, to: { id: g.principalId, kind: g.holder.kind, name: g.holder.name }, role: g.role, by: g.grantedBy, at: g.grantedAt }, { overwrite: true });
209
+ }
210
+ const declared = new Set(d.grants.map((g) => g.principalId));
211
+ for (const g of readGrants(evda) ?? []) {
212
+ if (g.circle === name && g.principalId !== null && !declared.has(g.principalId)) unlinkSync(g.file);
213
+ }
214
+ return { ok: true, name, mount, created: !existing };
215
+ }
216
+
217
+ // A grant on a held circle was removed by its owner: mark the copy frozen since `at`. The files
218
+ // under the mount and the grant files stay; nothing is deleted. Idempotent.
219
+ // Returns { ok: true, name, already } or { ok: false, why }.
220
+ export function freezeCircle(evda, from, circleId, at) {
221
+ if (!nonEmpty(from) || !nonEmpty(circleId)) return { ok: false, why: "a revocation names a record id and a circle id" };
222
+ const since = typeof at === "string" && /^\d{4}-\d{2}-\d{2}/.test(at) ? at.slice(0, 10) : null;
223
+ if (!since) return { ok: false, why: `a revocation carries the date it took effect, got ${JSON.stringify(at)}` };
224
+ const circles = readCircles(evda) ?? [];
225
+ const c = circles.find((x) => x.id === circleId);
226
+ if (!c) return { ok: false, why: `no circle ${circleId} is held here` };
227
+ if (!c.mount) return { ok: false, why: `circle ${circleId} is homed in this record as "${c.name}"; nobody else revokes it` };
228
+ if (c.mount.holder !== from) return { ok: false, why: `circle ${circleId} is held here from ${c.mount.holder}, not from ${from}` };
229
+ const p = ecoManifestPath(c.abs);
230
+ const m = parseYaml(readFileSync(p, "utf8"));
231
+ if (isObject(m.frozen)) return { ok: true, name: c.name, already: true };
232
+ m.frozen = { since, by: FROZEN_BY_REVOCATION };
233
+ writeFileSync(p, toYaml(m));
234
+ return { ok: true, name: c.name, already: false };
235
+ }
@@ -0,0 +1,202 @@
1
+ // Devices: the devices a holder's record id resolves to (HQ-835).
2
+ //
3
+ // A grant names a HOLDER: another record, by its record id (Spec 00 #65). A key is sealed to a
4
+ // DEVICE: one atlas, with its own identity and sealing keys, pinned as a peer. Until this module, a
5
+ // holder was one device and the two ids were the same string. Now a record id resolves to the
6
+ // devices listed for it, one file per holder under the grants role:
7
+ //
8
+ // grants/devices/<record id>.yaml
9
+ //
10
+ // kind: holder-devices
11
+ // record: ben.atlas
12
+ // devices:
13
+ // - atlasId: ben.atlas
14
+ // identityPublicKey: <raw 32 bytes, base64: the key this record pinned for the device>
15
+ // addedAt: 2026-09-22T12:00:00.000Z
16
+ // addedBy: ben.atlas
17
+ // - atlasId: ben-phone.atlas
18
+ // identityPublicKey: ...
19
+ // addedAt: ...
20
+ // addedBy: ben.atlas
21
+ // retiredAt: ... set when the holder retired the device; kept as history
22
+ // retiredBy: ben.atlas
23
+ //
24
+ // A record id with no file resolves to itself: one device whose atlas id is the record id, which is
25
+ // every holder the first two slices built and every per-device grant HQ-1219's plan writes. So
26
+ // nothing written before this module changes meaning.
27
+ //
28
+ // Who writes the file. On the holder's own record, the holder. On a record that grants the holder a
29
+ // circle, the receiving device writes it when the holder's existing device introduces a new one
30
+ // over the relay (`atlas-local/src/rendezvous.ts`, `holder-device`), signed by that device's pinned
31
+ // identity key: a holder decides its own devices, and a grant to the holder reaches every device it
32
+ // vouches for. A retired device resolves to no holder: it may no longer author into any circle, it
33
+ // is routed nothing, and the circles it could read are re-keyed without it.
34
+ //
35
+ // On the holder's own devices the list is kept the same way (`addOwnDevice`, `retireHolderDevice`
36
+ // in rendezvous.ts): the device that adds or retires writes its own copy, then tells each sibling it
37
+ // is paired with, signed, and tells a new device the whole list, itself included. The first device
38
+ // of a record carries the record id as its atlas id, which is what a new device trusts before any
39
+ // list reaches it.
40
+ //
41
+ // Grants live outside the circles they govern; so does this, beside them.
42
+
43
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
44
+ import { join, relative } from "node:path";
45
+ import { grantsDir, checkPrincipalId } from "./grants.mjs";
46
+ import { parseYaml, toYaml } from "./yaml.mjs";
47
+ import { EvdaError } from "./errors.mjs";
48
+
49
+ export const DEVICES_SUBDIR = "devices";
50
+ export const DEVICES_KIND = "holder-devices";
51
+ // atlas-arch:yaml-encoding: a device list is a config file this layer names by its suffix, like a grant
52
+ export const DEVICES_SUFFIX = ".yaml";
53
+
54
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
55
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
56
+ const active = (d) => !nonEmpty(d.retiredAt);
57
+
58
+ export const devicesFile = (record) => `${record}${DEVICES_SUFFIX}`;
59
+
60
+ // Every device list under the grants role. Returns { lists: Map<record, { file, rel, devices }>,
61
+ // problems } or null when the record has no grants role. Never throws for a problem in a file.
62
+ export function readDevices(evda) {
63
+ const role = grantsDir(evda);
64
+ if (!role.mapped) return null;
65
+ const lists = new Map();
66
+ const problems = [];
67
+ const dir = join(role.abs, DEVICES_SUBDIR);
68
+ if (!existsSync(dir)) return { lists, problems };
69
+ for (const name of readdirSync(dir).sort()) {
70
+ const abs = join(dir, name);
71
+ if (name.startsWith(".") || !statSync(abs).isFile() || !name.endsWith(DEVICES_SUFFIX)) continue;
72
+ const rel = relative(evda.dir, abs);
73
+ const fail = (m) => problems.push({ level: "fail", message: `${rel}: ${m}` });
74
+ let m;
75
+ try {
76
+ m = parseYaml(readFileSync(abs, "utf8"));
77
+ } catch (e) {
78
+ fail(`is not readable YAML (${e.message}).`);
79
+ continue;
80
+ }
81
+ const record = name.slice(0, -DEVICES_SUFFIX.length);
82
+ if (!isObject(m)) { fail("must hold a mapping."); continue; }
83
+ if (m.kind !== DEVICES_KIND) { fail(`"kind" must be "${DEVICES_KIND}", got ${JSON.stringify(m.kind)}.`); continue; }
84
+ if (m.record !== record) { fail(`names record ${JSON.stringify(m.record)} but the file says ${JSON.stringify(record)}. The filename is the address.`); continue; }
85
+ if (!Array.isArray(m.devices)) { fail(`"devices" must be a list.`); continue; }
86
+ const devices = [];
87
+ const seen = new Set();
88
+ for (const [i, d] of m.devices.entries()) {
89
+ if (!isObject(d) || !nonEmpty(d.atlasId)) { fail(`devices[${i}] names no atlasId.`); continue; }
90
+ if (seen.has(d.atlasId)) { fail(`lists ${d.atlasId} twice.`); continue; }
91
+ seen.add(d.atlasId);
92
+ if (!nonEmpty(d.addedAt) || !nonEmpty(d.addedBy)) fail(`devices[${i}] (${d.atlasId}) says not when or by whom it was added.`);
93
+ if (nonEmpty(d.retiredAt) && !nonEmpty(d.retiredBy)) fail(`devices[${i}] (${d.atlasId}) is retired and says not by whom.`);
94
+ devices.push({ ...d });
95
+ }
96
+ lists.set(record, { file: abs, rel, devices });
97
+ }
98
+ // One device, one holder: an atlas active in two lists would carry two holders' authority.
99
+ const owner = new Map();
100
+ for (const [record, list] of lists) {
101
+ for (const d of list.devices.filter(active)) {
102
+ if (owner.has(d.atlasId)) problems.push({ level: "fail", message: `${d.atlasId} is an active device of both ${owner.get(d.atlasId)} and ${record}. One device, one holder.` });
103
+ else owner.set(d.atlasId, record);
104
+ }
105
+ }
106
+ return { lists, problems };
107
+ }
108
+
109
+ // The active devices a record id resolves to. No list: the record id itself.
110
+ export function devicesOf(read, record) {
111
+ const list = read?.lists.get(record);
112
+ if (!list) return [record];
113
+ return list.devices.filter(active).map((d) => d.atlasId);
114
+ }
115
+
116
+ // The record a device speaks for, or null when it speaks for none (retired, or listed under a record
117
+ // that no longer counts it). A device no list names, whose id no list is keyed by, is its own record.
118
+ export function recordOf(read, atlasId) {
119
+ if (!nonEmpty(atlasId)) return null;
120
+ for (const [record, list] of read?.lists ?? []) {
121
+ if (list.devices.some((d) => d.atlasId === atlasId && active(d))) return record;
122
+ }
123
+ for (const [, list] of read?.lists ?? []) {
124
+ if (list.devices.some((d) => d.atlasId === atlasId)) return null;
125
+ }
126
+ if (read?.lists.has(atlasId)) return null;
127
+ return atlasId;
128
+ }
129
+
130
+ function writeList(evda, record, devices) {
131
+ const gr = grantsDir(evda);
132
+ if (!gr.mapped) throw new EvdaError('no "grants" role in the mapping, so there is nowhere to write a device list.', { exit: 2 });
133
+ const dir = join(gr.abs, DEVICES_SUBDIR);
134
+ mkdirSync(dir, { recursive: true });
135
+ const p = join(dir, devicesFile(record));
136
+ const text = toYaml({ kind: DEVICES_KIND, record, devices });
137
+ if (!existsSync(p) || readFileSync(p, "utf8") !== text) writeFileSync(p, text);
138
+ return p;
139
+ }
140
+
141
+ const entry = (device, by, at) => {
142
+ const e = { atlasId: device.atlasId };
143
+ if (nonEmpty(device.identityPublicKey)) e.identityPublicKey = device.identityPublicKey;
144
+ e.addedAt = at;
145
+ e.addedBy = by;
146
+ return e;
147
+ };
148
+
149
+ // Add a device to a holder's list, as introduced by `introducer`, a device that already speaks for
150
+ // the holder. When no list exists yet the introducer is written first, so the list says every
151
+ // device the record id now resolves to. Idempotent for a device already active with the same key.
152
+ // Returns { ok: true, already, file } or { ok: false, why }.
153
+ export function admitDevice(evda, { record, device, introducer, at }) {
154
+ try {
155
+ checkPrincipalId(record);
156
+ checkPrincipalId(device?.atlasId);
157
+ checkPrincipalId(introducer?.atlasId);
158
+ } catch (e) {
159
+ return { ok: false, why: e.message };
160
+ }
161
+ if (!nonEmpty(at)) return { ok: false, why: "a device is added at a stated time" };
162
+ const read = readDevices(evda);
163
+ if (read === null) return { ok: false, why: 'no "grants" role in the mapping, so there is nowhere to write a device list' };
164
+ if (recordOf(read, introducer.atlasId) !== record) return { ok: false, why: `${introducer.atlasId} is not a device of ${record} here, so it cannot add one` };
165
+ const holder = recordOf(read, device.atlasId);
166
+ if (read.lists.has(device.atlasId) && device.atlasId !== record) return { ok: false, why: `${device.atlasId} is a record here, not a device` };
167
+ if (holder !== null && holder !== device.atlasId && holder !== record) return { ok: false, why: `${device.atlasId} is already a device of ${holder}` };
168
+ const list = read.lists.get(record);
169
+ const devices = list ? list.devices.map((d) => ({ ...d })) : [entry(introducer, introducer.atlasId, at)];
170
+ const i = devices.findIndex((d) => d.atlasId === device.atlasId);
171
+ if (i >= 0 && active(devices[i])) {
172
+ if (nonEmpty(devices[i].identityPublicKey) && nonEmpty(device.identityPublicKey) && devices[i].identityPublicKey !== device.identityPublicKey) {
173
+ return { ok: false, why: `${device.atlasId} is already a device of ${record} under a different identity key` };
174
+ }
175
+ return { ok: true, already: true, file: list ? list.file : writeList(evda, record, devices) };
176
+ }
177
+ if (i >= 0) devices[i] = entry(device, introducer.atlasId, at);
178
+ else devices.push(entry(device, introducer.atlasId, at));
179
+ return { ok: true, already: false, file: writeList(evda, record, devices) };
180
+ }
181
+
182
+ // Retire a device of a holder: it stays in the list as history, resolves to no holder from now on,
183
+ // and is routed nothing. `by` must be a device of the holder, or this record itself (an owner may
184
+ // stop trusting a device on its own records). Idempotent. Returns { ok: true, already, file } or
185
+ // { ok: false, why }.
186
+ export function retireDevice(evda, { record, atlasId, by, at }) {
187
+ if (!nonEmpty(record) || !nonEmpty(atlasId) || !nonEmpty(by)) return { ok: false, why: "a retirement names the record, the device and who retired it" };
188
+ if (!nonEmpty(at)) return { ok: false, why: "a device is retired at a stated time" };
189
+ const read = readDevices(evda);
190
+ if (read === null) return { ok: false, why: 'no "grants" role in the mapping, so there is nowhere to write a device list' };
191
+ if (by !== evda.manifest?.id && recordOf(read, by) !== record) return { ok: false, why: `${by} is not a device of ${record} here, so it cannot retire one` };
192
+ const list = read.lists.get(record);
193
+ const devices = list ? list.devices.map((d) => ({ ...d })) : devicesOf(read, record).map((id) => entry({ atlasId: id }, record, at));
194
+ const i = devices.findIndex((d) => d.atlasId === atlasId);
195
+ // A device this record never listed for the holder resolves to no holder here already: the notice
196
+ // changes nothing, and is not a refusal (a holder tells every device it is paired with, and not
197
+ // every one of them was told of every device).
198
+ if (i < 0) return { ok: true, already: true, file: list?.file ?? null };
199
+ if (!active(devices[i])) return { ok: true, already: true, file: list.file };
200
+ devices[i] = { ...devices[i], retiredAt: at, retiredBy: by };
201
+ return { ok: true, already: false, file: writeList(evda, record, devices) };
202
+ }