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,264 @@
1
+ // `evda publish public`: the public circle as a signed, plaintext export (Spec 08, section 4).
2
+ //
3
+ // Runs on the owner's device, reads only the public circle's declared members, and writes
4
+ //
5
+ // <out>/manifest.json format, circle id, record id, publishedAt, files [{ path, sha256, bytes }],
6
+ // tables [{ table, where, rows }], publicKey, signature (Ed25519 over the
7
+ // canonical manifest, which is the manifest without its signature, keys sorted)
8
+ // <out>/files/<path> the member files, byte for byte; and each render member's file, rendered
9
+ // from the released entries that name its view (lib/render.mjs, HQ-1351),
10
+ // listed in the manifest with `by: "view"`, the view's name and the source
11
+ // entries' record paths and sha256s. The entries a view consumes are not
12
+ // exported as themselves: the rendering is their public form, and the
13
+ // public set stays exactly what the site reads.
14
+ //
15
+ // Never through the relay: a public circle needs no key, a durable URL and a cache, and the relay
16
+ // is delete-on-ack with sealed envelopes a browser cannot open.
17
+ //
18
+ // Three checks run on the device before anything is written, and each can refuse (exit 2):
19
+ //
20
+ // 1. the circle is opened through `publicCircle`, which throws unless exactly one public circle
21
+ // is named, and only its declared members are read;
22
+ // 2. one keyed circle per path, and that circle allows release: a file under a public path member
23
+ // that any other circle also covers is refused, so a private path cannot also be a public
24
+ // member; a file gathered by rule (Spec 07 section 3, HQ-1351) is exported at its record path
25
+ // only when its one home circle's release names the public circle, and a match the home
26
+ // refuses refuses the whole publish rather than being dropped; a view with any problem (an
27
+ // entry naming a view nothing renders, a repeated order, a body with no JSON) refuses too;
28
+ // 3. render as a stranger: every private circle's label is searched for in the bytes about to be
29
+ // written, and a hit refuses. A label is its opaque id, its namespace, its own directory, a
30
+ // member path, or its name between slashes. The exported files' own paths are the released
31
+ // set (doctor prints them every run) and are left out of that search, since a marked file
32
+ // necessarily lives under a keyed circle's member path.
33
+ //
34
+ // The fourth check in the spec (the site holds no credential to anything else) is a property of
35
+ // where the export is served, not of this code.
36
+ //
37
+ // The signing key is the record's identity key. The Node host keeps it in its own database until
38
+ // the keychain adapter (slice 2), so this reads the 32-byte Ed25519 seed from a file the device
39
+ // exports, hex encoded, named by --key.
40
+
41
+ import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
42
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
43
+ import { dirname, join, relative, resolve, sep } from "node:path";
44
+ import { openEvda } from "./manifest.mjs";
45
+ import { publicCircle, readCircles, released, homesOf, withoutSealed, carries } from "./circles.mjs";
46
+ import { renderViews } from "./render.mjs";
47
+ import { EvdaError } from "./errors.mjs";
48
+
49
+ export const EXPORT_FORMAT = "evda-public-export/1";
50
+
51
+ // The boundary checks, as a switch a test can turn off to show that the acceptance test fails
52
+ // without them (FALSIFIED lines in atlas-local/src/slice.e2e.test.ts). Nothing in this package
53
+ // turns them off.
54
+ export const PUBLISH_CHECKS = { onePerPath: true, strangerRender: true };
55
+
56
+ const ED25519_PKCS8 = "302e020100300506032b657004220420";
57
+ const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
58
+
59
+ function filesUnder(abs) {
60
+ const st = statSync(abs);
61
+ if (st.isFile()) return [abs];
62
+ const out = [];
63
+ for (const name of readdirSync(abs).sort()) {
64
+ if (name.startsWith(".")) continue;
65
+ out.push(...filesUnder(join(abs, name)));
66
+ }
67
+ return out;
68
+ }
69
+
70
+ const posix = (rel) => rel.split(sep).join("/");
71
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
+
73
+ // Build the export in memory and run the checks. Writes nothing.
74
+ export function buildPublicExport(evda, { circleName = null, publishedAt = new Date() } = {}) {
75
+ const circle = publicCircle(evda, circleName);
76
+ const all = readCircles(evda) ?? [];
77
+
78
+ const files = [];
79
+ for (const m of Object.values(circle.members)) {
80
+ if (m.kind !== "path") continue;
81
+ for (const abs of filesUnder(m.abs)) files.push({ abs, path: posix(relative(evda.dir, abs)), by: "path" });
82
+ }
83
+
84
+ if (PUBLISH_CHECKS.onePerPath) {
85
+ // A file under a public member is exported only when the public circle is its HOME, by longest
86
+ // member path (HQ-1364): a private circle at the record root does not make it private, and a
87
+ // private circle with a longer member under the public one does.
88
+ for (const f of files) {
89
+ const others = homesOf(evda, all, f.abs).filter((n) => n !== circle.name);
90
+ if (others.length) {
91
+ throw new EvdaError(`REFUSED: ${f.path} is a public member and is also covered by circle "${others[0]}". One access answer per entry; nothing was written.`, { code: "TWO_CIRCLES", exit: 2 });
92
+ }
93
+ }
94
+ }
95
+
96
+ // Files gathered by rule: from wherever they live, only out of a home whose release names this
97
+ // circle. With the check off (a test proving it matters), a refused match is exported like any other.
98
+ const gathered = released(evda, all).find((r) => r.circle === circle.name) ?? { byRule: [], refused: [] };
99
+ if (PUBLISH_CHECKS.onePerPath && gathered.refused.length) {
100
+ const f = gathered.refused[0];
101
+ throw new EvdaError(`REFUSED: ${f.path} matches a rule of the public circle and ${f.reason}. Nothing was written.`, { code: "REFUSED_MARK", exit: 2 });
102
+ }
103
+ for (const f of gathered.byRule) files.push({ abs: f.abs, path: f.path, by: "mark", circle: f.circle });
104
+ if (!PUBLISH_CHECKS.onePerPath) for (const f of gathered.refused.filter((f) => f.abs !== undefined && f.member !== "release")) files.push({ abs: f.abs, path: f.path, by: "mark", circle: f.circle });
105
+
106
+ // Views (HQ-1351): every released entry that names a view is consumed by the render member that
107
+ // declares it, and the rendered file is exported in its place. Any problem refuses the publish.
108
+ const rendered = renderViews(evda, circle, { byPath: files.filter((f) => f.by === "path"), byRule: files.filter((f) => f.by === "mark") });
109
+ if (rendered.problems.length) {
110
+ const p = rendered.problems[0];
111
+ throw new EvdaError(`REFUSED: ${p.path} ${p.message}${rendered.problems.length > 1 ? ` (${rendered.problems.length - 1} more problem(s); evda doctor names them all)` : ""} Nothing was written.`, { code: "REFUSED_VIEW", exit: 2 });
112
+ }
113
+ const consumed = new Set(rendered.views.flatMap((v) => v.entries.map((e) => e.path)));
114
+ // A sealed section (HQ-1220) is cut out of every markdown file on the way out; the rest of the
115
+ // file is exported, and nothing marks where the section was. A sealed FILE never gets here:
116
+ // released() refuses it, and a refusal refuses the publish above.
117
+ let sealedCut = 0;
118
+ const exportBytes = (abs) => {
119
+ const bytes = readFileSync(abs);
120
+ if (!/\.md$/i.test(abs) || !carries("publish", "sealed")) return bytes;
121
+ const cut = withoutSealed(bytes.toString("utf8"));
122
+ if (!cut.sections) return bytes;
123
+ sealedCut += cut.sections;
124
+ return Buffer.from(cut.text, "utf8");
125
+ };
126
+ const loaded = files.filter((f) => !consumed.has(f.path)).map((f) => ({ path: f.path, bytes: exportBytes(f.abs), by: f.by }));
127
+ for (const v of rendered.views) loaded.push({ path: v.path, bytes: v.bytes, by: "view", view: v.view, sources: v.entries.map((e) => ({ path: e.path, sha256: e.sha256 })) });
128
+ loaded.sort((a, b) => a.path.localeCompare(b.path));
129
+
130
+ const tables = Object.values(circle.members)
131
+ .filter((m) => m.kind === "table")
132
+ // A table member is a live read on the host that serves it. The snapshot for a reader that has
133
+ // no such host is not built in the first slice, so the rows are recorded as not included.
134
+ .map((m) => ({ table: m.table, where: m.where, rows: null }));
135
+ const manifest = {
136
+ format: EXPORT_FORMAT,
137
+ circle: circle.id,
138
+ record: evda.manifest.id ?? null,
139
+ publishedAt: publishedAt instanceof Date ? publishedAt.toISOString() : String(publishedAt),
140
+ files: loaded.map((f) => (f.by === "view"
141
+ ? { path: f.path, sha256: sha256Hex(f.bytes), bytes: f.bytes.length, by: "view", view: f.view, sources: f.sources }
142
+ : { path: f.path, sha256: sha256Hex(f.bytes), bytes: f.bytes.length })),
143
+ tables,
144
+ };
145
+
146
+ if (PUBLISH_CHECKS.strangerRender) {
147
+ // The manifest minus the exported paths themselves and a rendered file's source paths: they
148
+ // are the released set by definition, and a marked file's path is always under some keyed
149
+ // circle's member path. A rendered file's own path is chosen, not inherited, so it is searched.
150
+ const manifestSansPaths = { ...manifest, files: manifest.files.map(({ path: _p, sources: _s, ...rest }) => rest) };
151
+ const haystacks = [
152
+ { where: "manifest.json", text: JSON.stringify(manifestSansPaths) },
153
+ { where: "manifest.json (rendered paths)", text: manifest.files.filter((f) => f.by === "view").map((f) => f.path).join("\n") },
154
+ ...loaded.map((f) => ({ where: f.path, text: f.bytes.toString("utf8") })),
155
+ ];
156
+ for (const c of all.filter((x) => x.manifest?.public !== true)) {
157
+ // What identifies a private circle to a stranger is a LABEL: a path segment, the circle's own
158
+ // directory, or the opaque id that travels on a Change and in an export manifest. A name
159
+ // matched as a word anywhere in the bytes is not that test, and on a real record it refuses
160
+ // every publish there is: his private circles are core, health, money, people, school,
161
+ // therapy, wellness and workshop, and his public fact sheet says "10 core mechanics" and
162
+ // "help other people understand me" (2026-09-21, HQ-1199). So the name is matched only
163
+ // between slashes, and the id and namespace, which the word match never looked at, are
164
+ // matched literally. The gap this leaves, stated so the next reader does not rediscover it:
165
+ // a distinctive circle name written as bare prose passes.
166
+ const needles = [];
167
+ // A one-segment path is a bare name, so it is matched the way a name is: between slashes.
168
+ // "CLAUDE.md" is a member of his core circle AND a filename his public projects page names;
169
+ // as a raw substring it refuses every publish while disclosing nothing.
170
+ const label = (s) => (s.includes("/") ? new RegExp(escapeRe(s)) : new RegExp(`(^|[\\\\/])${escapeRe(s)}($|[\\\\/])`, "iu"));
171
+ for (const s of new Set([c.name, c.manifest?.title].filter((v) => typeof v === "string" && v.trim() !== ""))) {
172
+ needles.push({ what: `the name of private circle "${c.name}" as a path segment`, re: label(s) });
173
+ }
174
+ for (const s of new Set([c.id, c.manifest?.id, c.manifest?.namespace].filter((v) => typeof v === "string" && v.trim() !== ""))) {
175
+ needles.push({ what: `the id of private circle "${c.name}" (${s})`, re: new RegExp(escapeRe(s)) });
176
+ }
177
+ needles.push({ what: `the directory of private circle "${c.name}" (${c.rel})`, re: label(c.rel.replace(/\/+$/, "")) });
178
+ for (const m of Object.values(c.members)) {
179
+ // The record root ("") is a member with no name: it identifies nothing to a stranger, and as
180
+ // a bare label it matched every "//" in a URL and refused every publish (found 2026-09-22,
181
+ // the day after HQ-1364 made core a root member).
182
+ if (m.kind === "path" && m.rel !== "") needles.push({ what: `a member path of private circle "${c.name}" (${m.rel})`, re: label(m.rel.replace(/\/+$/, "")) });
183
+ }
184
+ for (const n of needles) {
185
+ const hit = haystacks.find((h) => n.re.test(h.text));
186
+ if (hit) throw new EvdaError(`REFUSED: rendered as a stranger, the export shows ${n.what} in ${hit.where}. Nothing was written.`, { code: "STRANGER_RENDER", exit: 2 });
187
+ }
188
+ }
189
+ }
190
+
191
+ return { manifest, files: loaded, sealedCut };
192
+ }
193
+
194
+ export function canonicalManifest(manifest) {
195
+ const sortKeys = (v) => (Array.isArray(v) ? v.map(sortKeys) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v);
196
+ const { signature: _omit, ...rest } = manifest;
197
+ return JSON.stringify(sortKeys(rest));
198
+ }
199
+
200
+ export function signManifest(manifest, seedHex) {
201
+ if (!/^[0-9a-f]{64}$/i.test(seedHex.trim())) throw new EvdaError("REFUSED: the signing key must be a 32-byte Ed25519 seed, hex encoded.", { code: "BAD_KEY", exit: 2 });
202
+ const privateKey = createPrivateKey({ key: Buffer.concat([Buffer.from(ED25519_PKCS8, "hex"), Buffer.from(seedHex.trim(), "hex")]), format: "der", type: "pkcs8" });
203
+ const { x } = createPublicKey(privateKey).export({ format: "jwk" });
204
+ const withKey = { ...manifest, publicKey: Buffer.from(x, "base64url").toString("base64") };
205
+ return { ...withKey, signature: sign(null, Buffer.from(canonicalManifest(withKey)), privateKey).toString("base64") };
206
+ }
207
+
208
+ // Verify an export against a key the reader already trusts (raw 32-byte Ed25519 public key), never
209
+ // against the key the manifest carries. Returns { ok, why }.
210
+ export function verifyExport(out, publicKeyRaw) {
211
+ let manifest;
212
+ try {
213
+ manifest = JSON.parse(readFileSync(join(out, "manifest.json"), "utf8"));
214
+ } catch (e) {
215
+ return { ok: false, why: `manifest.json is unreadable (${e.message})` };
216
+ }
217
+ const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(publicKeyRaw).toString("base64url") }, format: "jwk" });
218
+ let good = false;
219
+ try {
220
+ good = verify(null, Buffer.from(canonicalManifest(manifest)), key, Buffer.from(String(manifest.signature ?? ""), "base64"));
221
+ } catch {
222
+ good = false;
223
+ }
224
+ if (!good) return { ok: false, why: "the signature does not verify against the key given" };
225
+ for (const f of manifest.files ?? []) {
226
+ const p = join(out, "files", f.path);
227
+ if (!existsSync(p)) return { ok: false, why: `${f.path} is listed and missing` };
228
+ const bytes = readFileSync(p);
229
+ if (bytes.length !== f.bytes || sha256Hex(bytes) !== f.sha256) return { ok: false, why: `${f.path} does not match its manifest entry` };
230
+ }
231
+ return { ok: true, why: `signature verifies; ${manifest.files.length} file(s) match` };
232
+ }
233
+
234
+ // Write the export. The record is never the destination.
235
+ export function writeExport(evda, out, { manifest, files }) {
236
+ const abs = resolve(out);
237
+ if (abs === evda.dir || abs.startsWith(evda.dir + sep)) throw new EvdaError(`REFUSED: ${out} is inside the record; an export is written outside it.`, { code: "EXPORT_INSIDE", exit: 2 });
238
+ rmSync(join(abs, "files"), { recursive: true, force: true });
239
+ for (const f of files) {
240
+ const p = join(abs, "files", f.path);
241
+ mkdirSync(dirname(p), { recursive: true });
242
+ writeFileSync(p, f.bytes);
243
+ }
244
+ writeFileSync(join(abs, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
245
+ return abs;
246
+ }
247
+
248
+ // The CLI half, called from bin/evda.mjs so that file only dispatches.
249
+ export async function publishCommand(dir, circleName, { out, key }) {
250
+ if (circleName !== "public") throw new EvdaError(`publish takes the public circle: evda publish public --out <dir> --key <seed file>`, { exit: 2 });
251
+ if (!out || !key) throw new EvdaError("publish needs --out <dir> and --key <file holding the identity seed, hex>", { exit: 2 });
252
+ const evda = openEvda(dir);
253
+ const built = buildPublicExport(evda);
254
+ const signed = signManifest(built.manifest, readFileSync(key, "utf8"));
255
+ const at = writeExport(evda, out, { manifest: signed, files: built.files });
256
+ const marked = built.files.filter((f) => f.by === "mark").length;
257
+ const views = built.files.filter((f) => f.by === "view");
258
+ const drawn = views.reduce((n, f) => n + f.sources.length, 0);
259
+ console.log(`published ${built.files.length} file(s) from the public circle to ${at} (${built.files.length - marked - views.length} by path, ${marked} by mark${views.length ? `, ${views.length} rendered from ${drawn} entries` : ""})`);
260
+ for (const f of views) console.log(` view ${f.path} rendered from ${f.sources.length} entries (${f.view})`);
261
+ if (built.sealedCut) console.log(` ${built.sealedCut} sealed section(s) left out of the files they sit in`);
262
+ console.log(`CHECKED ${built.files.length} public members, signed by ${signed.publicKey}`);
263
+ return 0;
264
+ }
@@ -0,0 +1,319 @@
1
+ // Who may read each part of a record, and the entries no AI reads (HQ-1374).
2
+ //
3
+ // Two facts, both written in the record where the owner can read them:
4
+ //
5
+ // read_by on a circle's eco.yaml, or on evda.json for the whole record: which AI reads that part.
6
+ // The default for a new record is the AI the owner chose at setup (create-evda writes it
7
+ // into evda.json), because most people have no local model and the barrier to using this
8
+ // has to be as low as it can be. Restricting it is opt-in. A circle without its own
9
+ // read_by takes evda.json's; neither saying anything means the record states no reader,
10
+ // and whatever AI opens it reads what it opens.
11
+ //
12
+ // read_by: claude one AI, by its setup key
13
+ // read_by: local only a model on the owner's own devices
14
+ // read_by: none no AI; the owner reads it on their devices
15
+ // read_by: a list, each a key or one of these mappings:
16
+ // - ai: claude an AI by its setup key (lib/setup.mjs PLATFORMS)
17
+ // model: <name> optional, the model when it matters
18
+ // terms: consumer | api | business optional, which of the company's terms apply
19
+ // training: off | on | unknown what the owner confirmed
20
+ // confirmed: YYYY-MM-DD when they confirmed it
21
+ // - local: <model name, or any> a model running on the owner's own hardware
22
+ // on: <which devices> optional, in the owner's words
23
+ //
24
+ // Printed the way Spec 06 "The call on phrasing" rules: a statement of who reads it,
25
+ // never a promise that nobody can. "Read by: Anthropic through Claude, training off".
26
+ //
27
+ // noted `noted: true` in one entry's frontmatter: the owner said "just note this". It stays on
28
+ // their devices and in their own search, and no AI reads it until they say it can, which
29
+ // clears the flag (releaseNote below). It is not a circle and not a share mark, and a mark
30
+ // never releases it (lib/circles.mjs released()).
31
+ //
32
+ // sealed `sealed: true` in one entry's frontmatter, or a fenced ```sealed block inside one
33
+ // (HQ-1220): nothing but the owner's own devices opens it, ever. Stronger than noted: it
34
+ // is refused to every caller, a local model through the MCP included, no tool releases
35
+ // it, and an AI is never told where it is or whether a search touched it. A sealed
36
+ // section is taken out of the file the AI is shown and replaced by one line saying
37
+ // something sealed is there. The helpers live in lib/circles.mjs beside NOTED.
38
+ //
39
+ // Nothing here is a lock. The words are plain files on the owner's disk, and an agent that ignores
40
+ // its instructions can open them. What this module gives is the answer every well-behaved reader
41
+ // asks before it reads: the MCP server refuses in code, the Claude Code hook (hooks/) refuses the
42
+ // ordinary file tools, and AGENTS.md tells any other agent to check and decline out loud.
43
+
44
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
45
+ import { join, relative, resolve, sep } from "node:path";
46
+ import { readCircles, homesOf, frontmatterOf, NOTED, READ_BY, SEALED, sealedFront, sealedSections, withoutSealed } from "./circles.mjs";
47
+ import { resolveRole } from "./roles.mjs";
48
+ import { mdFiles } from "./record.mjs";
49
+ import { parseYaml } from "./yaml.mjs";
50
+ import { EvdaError } from "./errors.mjs";
51
+ import { PLATFORMS, PLATFORM_KEYS } from "./setup.mjs";
52
+
53
+ export { NOTED, READ_BY, SEALED, withoutSealed };
54
+
55
+ // What an AI is shown where a sealed section was, and what it is told about sealed entries it was
56
+ // not shown. Neither names a path, a count of matches, or anything that depends on the words.
57
+ export const SEALED_PLACEHOLDER = "[sealed: only the owner opens this part, on their own devices]";
58
+ export const SEALED_WHY = "It is sealed: only its owner opens it, on their own devices. No AI reads it, and no tool releases it.";
59
+ export const SEALED_HELD = "Some of this record is sealed: only its owner opens it, on their own devices. No AI is shown it or told where it is; the owner's own search on their device (evda search) covers it.";
60
+
61
+ export const TERMS = ["consumer", "api", "business"];
62
+ export const TRAINING = ["off", "on", "unknown"];
63
+ const COMPANY = { claude: "Anthropic", chatgpt: "OpenAI", gemini: "Google", cursor: "Cursor", other: null };
64
+ const APP = { claude: "Claude", chatgpt: "ChatGPT or Codex", gemini: "Gemini", cursor: "Cursor", other: null };
65
+ const TERMS_WORDS = { consumer: "personal-plan terms", api: "API terms", business: "business terms" };
66
+ const READER_KEYS = { ai: ["ai", "name", "model", "terms", "training", "confirmed"], local: ["local", "on"] };
67
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
68
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
69
+
70
+ // The test hooks. Each one breaks exactly one refusal so the tests can be watched failing.
71
+ // EVDA_READERS_FALSIFY=noted-readable a noted entry is treated as readable
72
+ // EVDA_READERS_FALSIFY=ignore-reader a circle's read_by is ignored
73
+ // EVDA_READERS_FALSIFY=accept-any a read_by that does not parse is reported as fine
74
+ const FALSIFY = () => process.env.EVDA_READERS_FALSIFY ?? "";
75
+
76
+ // Parse one read_by value. Returns { readers, problems }: readers is null when the value is absent,
77
+ // otherwise a list of { kind: "ai" | "local" | "none", ... }. Never throws; every problem is a
78
+ // sentence for doctor.
79
+ export function parseReaders(value, where = "read_by") {
80
+ const problems = [];
81
+ if (value === undefined || value === null) return { readers: null, problems };
82
+ const items = Array.isArray(value) ? value : [value];
83
+ if (items.length === 0) { problems.push(`${where} is an empty list. Say who reads it: an AI (claude, chatgpt, gemini, cursor, other), local, or none.`); return { readers: null, problems }; }
84
+ const readers = [];
85
+ items.forEach((item, i) => {
86
+ const at = Array.isArray(value) ? `${where}[${i}]` : where;
87
+ if (typeof item === "string") {
88
+ const s = item.trim().toLowerCase();
89
+ if (s === "none") readers.push({ kind: "none" });
90
+ else if (s === "local") readers.push({ kind: "local", model: null, on: null });
91
+ else if (PLATFORM_KEYS.includes(s)) readers.push({ kind: "ai", ai: s });
92
+ else problems.push(`${at} is ${JSON.stringify(item)}. A reader is one of ${[...PLATFORM_KEYS, "local", "none"].join(", ")}, or a mapping with "ai" or "local".`);
93
+ return;
94
+ }
95
+ if (!isObject(item)) { problems.push(`${at} must be a word or a mapping, got ${JSON.stringify(item)}.`); return; }
96
+ if (item.ai !== undefined && item.local !== undefined) { problems.push(`${at} has both "ai" and "local". One reader per item.`); return; }
97
+ if (item.ai !== undefined) {
98
+ const ai = String(item.ai).trim().toLowerCase();
99
+ if (!PLATFORM_KEYS.includes(ai)) { problems.push(`${at} ai is ${JSON.stringify(item.ai)}; it is one of ${PLATFORM_KEYS.join(", ")}.`); return; }
100
+ if (item.terms !== undefined && !TERMS.includes(item.terms)) problems.push(`${at} terms is ${JSON.stringify(item.terms)}; it is one of ${TERMS.join(", ")}.`);
101
+ if (item.training !== undefined && !TRAINING.includes(item.training)) problems.push(`${at} training is ${JSON.stringify(item.training)}; it is one of ${TRAINING.join(", ")}.`);
102
+ if (item.confirmed !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(item.confirmed))) problems.push(`${at} confirmed is ${JSON.stringify(item.confirmed)}; it is a date, YYYY-MM-DD.`);
103
+ const extra = Object.keys(item).filter((k) => !READER_KEYS.ai.includes(k));
104
+ if (extra.length) problems.push(`${at} has key(s) a reader does not take: ${extra.join(", ")}.`);
105
+ readers.push({ kind: "ai", ai, name: nonEmpty(item.name) ? item.name.trim() : null, model: nonEmpty(item.model) ? item.model.trim() : null, terms: TERMS.includes(item.terms) ? item.terms : null, training: TRAINING.includes(item.training) ? item.training : null, confirmed: item.confirmed ? String(item.confirmed) : null });
106
+ return;
107
+ }
108
+ if (item.local !== undefined) {
109
+ if (!nonEmpty(String(item.local ?? ""))) { problems.push(`${at} local is empty; name the model, or write any.`); return; }
110
+ const extra = Object.keys(item).filter((k) => !READER_KEYS.local.includes(k));
111
+ if (extra.length) problems.push(`${at} has key(s) a local reader does not take: ${extra.join(", ")}.`);
112
+ const model = String(item.local).trim();
113
+ readers.push({ kind: "local", model: model.toLowerCase() === "any" ? null : model, on: nonEmpty(item.on) ? item.on.trim() : null });
114
+ return;
115
+ }
116
+ problems.push(`${at} names no reader. Write ai: <${PLATFORM_KEYS.join(" | ")}> or local: <model or any>.`);
117
+ });
118
+ if (readers.some((r) => r.kind === "none") && readers.length > 1) problems.push(`${where} says none and also names a reader. None means no AI reads it; it stands alone.`);
119
+ if (FALSIFY() === "accept-any") return { readers, problems: [] };
120
+ return { readers, problems };
121
+ }
122
+
123
+ // One reader as the sentence the owner reads. States the fact, never a promise.
124
+ export function describeReader(r) {
125
+ if (r.kind === "none") return "no AI; only you, on your own devices";
126
+ if (r.kind === "local") return `a model on your own devices${r.model ? ` (${r.model})` : ""}${r.on ? `: ${r.on}` : ""}`;
127
+ const company = COMPANY[r.ai];
128
+ const app = r.ai === "other" ? r.name ?? "an AI you named at setup" : APP[r.ai];
129
+ const who = company ? `${company} through ${app}${r.model ? ` (${r.model})` : ""}` : `${app}${r.model ? ` (${r.model})` : ""}`;
130
+ const parts = [who];
131
+ if (r.terms) parts.push(TERMS_WORDS[r.terms]);
132
+ if (r.training === "off") parts.push(`training off${r.confirmed ? `, confirmed ${r.confirmed}` : ""}`);
133
+ else if (r.training === "on") parts.push("training on");
134
+ else parts.push("training not confirmed off");
135
+ return parts.join(", ");
136
+ }
137
+
138
+ export function describeReaders(readers, { isPublic = false } = {}) {
139
+ if (isPublic) return "anyone; it is public";
140
+ if (readers === null) return "not stated; whatever AI opens this record reads what it opens";
141
+ return readers.map(describeReader).join("; ");
142
+ }
143
+
144
+ // Which AI is calling. An MCP client names itself on initialize (clientInfo.name); $EVDA_READER
145
+ // overrides it, and is how a local model's client says it is local. Unknown is { ai: null }.
146
+ export function callerFrom({ clientName = null, env = process.env } = {}) {
147
+ const stated = String(env.EVDA_READER ?? "").trim().toLowerCase();
148
+ if (stated === "local") return { ai: null, local: true, label: "a model on this device" };
149
+ if (stated && PLATFORM_KEYS.includes(stated)) return { ai: stated, local: false, label: APP[stated] ?? stated };
150
+ const n = String(clientName ?? "").toLowerCase();
151
+ const ai = /claude|anthropic/.test(n) ? "claude" : /codex|openai|chatgpt/.test(n) ? "chatgpt" : /gemini|google/.test(n) ? "gemini" : /cursor/.test(n) ? "cursor" : null;
152
+ return { ai, local: false, label: ai ? APP[ai] : clientName ? `a client that calls itself ${clientName}` : "an AI that did not say which it is" };
153
+ }
154
+
155
+ // Everything a run of access checks needs, read once: the circles and evda.json's reader.
156
+ export function readContext(evda) {
157
+ let circles = [];
158
+ try { circles = readCircles(evda) ?? []; } catch { circles = []; }
159
+ const record = parseReaders(evda.manifest?.[READ_BY], "evda.json read_by");
160
+ return { evda, circles, record };
161
+ }
162
+
163
+ // The reader answer for one file: whose read_by applies, and what it says.
164
+ export function readersFor(ctx, abs) {
165
+ const homes = homesOf(ctx.evda, ctx.circles, abs);
166
+ const home = homes.length === 1 ? ctx.circles.find((c) => c.name === homes[0]) : null;
167
+ if (home?.manifest?.public === true) return { readers: null, source: `circle ${home.name}`, circle: home.name, isPublic: true };
168
+ if (home && home.manifest?.[READ_BY] !== undefined) {
169
+ const own = parseReaders(home.manifest[READ_BY], `circle ${home.name} read_by`);
170
+ return { readers: own.problems.length ? [{ kind: "none" }] : own.readers, source: `circle ${home.name}`, circle: home.name, isPublic: false, broken: own.problems.length > 0 };
171
+ }
172
+ if (ctx.record.readers !== null) return { readers: ctx.record.problems.length ? [{ kind: "none" }] : ctx.record.readers, source: "evda.json", circle: home?.name ?? null, isPublic: false, broken: ctx.record.problems.length > 0 };
173
+ return { readers: null, source: null, circle: home?.name ?? null, isPublic: false };
174
+ }
175
+
176
+ export function frontOf(abs) {
177
+ if (!/\.md$/i.test(abs)) return null;
178
+ try { return frontmatterOf(readFileSync(abs, "utf8")); } catch { return null; }
179
+ }
180
+
181
+ export const isSealedFile = (abs) => sealedFront(frontOf(abs));
182
+
183
+ // The text an AI may be shown of a file it may read: every sealed section replaced by one line.
184
+ export function aiView(text) {
185
+ return withoutSealed(text, { placeholder: SEALED_PLACEHOLDER });
186
+ }
187
+
188
+ export const isNotedFile = (abs) => FALSIFY() !== "noted-readable" && frontOf(abs)?.[NOTED] === true;
189
+
190
+ // May this caller read this file? { ok, why, kind } where kind is "noted" or "reader" on a refusal.
191
+ // A reader that is broken (a read_by doctor fails) refuses, because a boundary nobody can read is
192
+ // not one to guess at.
193
+ export function mayRead(ctx, abs, caller) {
194
+ const rel = relative(ctx.evda.dir, abs).split(sep).join("/");
195
+ // Sealed before noted: an entry that is both is refused as the stronger, and no caller is exempt.
196
+ if (isSealedFile(abs)) return { ok: false, kind: "sealed", path: rel, why: `${rel} is sealed: only its owner opens it, on their own devices. No AI reads it, and no tool releases it.` };
197
+ if (isNotedFile(abs)) return { ok: false, kind: "noted", path: rel, why: `${rel} is just noted. No AI reads it until its owner says it can; they can open it themselves, or say you can read it now.` };
198
+ if (FALSIFY() === "ignore-reader") return { ok: true };
199
+ const r = readersFor(ctx, abs);
200
+ if (r.isPublic || r.readers === null) return { ok: true };
201
+ const where = r.source === "evda.json" ? "this record" : `the ${r.circle} circle`;
202
+ const said = describeReaders(r.readers);
203
+ if (r.broken) return { ok: false, kind: "reader", path: rel, why: `${rel} is in ${where}, whose read_by does not parse, so no AI reads it until doctor passes on it.` };
204
+ const allowed = r.readers.some((x) => (x.kind === "local" && caller.local) || (x.kind === "ai" && (x.ai === caller.ai || (x.ai === "other" && caller.ai === null && !caller.local))));
205
+ if (allowed) return { ok: true };
206
+ return { ok: false, kind: "reader", path: rel, why: `${rel} is in ${where}, which its owner set to be read by ${said}. You are ${caller.label}, so it is theirs to open, or to change who reads it.` };
207
+ }
208
+
209
+ // A plain sentence for everything that was held back from one tool answer.
210
+ // A sealed refusal is never named; `sealed` true (or any sealed refusal) adds SEALED_HELD, which is the
211
+ // same sentence whatever was asked.
212
+ export function heldBack(refusals, { sealed = false } = {}) {
213
+ const anySealed = sealed || refusals.some((r) => r.kind === "sealed");
214
+ refusals = refusals.filter((r) => r.kind !== "sealed");
215
+ if (!refusals.length) return anySealed ? SEALED_HELD : "";
216
+ const noted = refusals.filter((r) => r.kind === "noted");
217
+ const reader = refusals.filter((r) => r.kind === "reader");
218
+ const show = (xs) => xs.slice(0, 10).map((r) => r.path).join(", ") + (xs.length > 10 ? `, and ${xs.length - 10} more` : "");
219
+ const lines = [];
220
+ if (noted.length) lines.push(`Not shown: ${noted.length} just-noted entr${noted.length === 1 ? "y" : "ies"} (${show(noted)}). No AI reads these until the owner says it can; they can open them, or tell you that you can read one now.`);
221
+ if (reader.length) lines.push(`Not shown: ${reader.length} file(s) in parts of the record set to be read by a different AI (${show(reader)}). ${reader[0].why}`);
222
+ if (anySealed) lines.push(SEALED_HELD);
223
+ return lines.join("\n");
224
+ }
225
+
226
+ // The captures folder, where a noted entry lands.
227
+ function capturesDir(evda) {
228
+ const r = resolveRole(evda, "record/captures");
229
+ if (r.mapped) return r;
230
+ const d = resolveRole(evda, "record/daily");
231
+ if (!d.mapped) throw new EvdaError('REFUSED: this record maps neither "record/captures" nor "record/daily", so there is nowhere to note it. Nothing was written.', { code: "NO_ROLE", exit: 2 });
232
+ return d;
233
+ }
234
+
235
+ const pad = (n) => String(n).padStart(2, "0");
236
+ const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
237
+ const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
238
+
239
+ // Write the owner's words as a just-noted capture. Its filename carries a time and nothing of the
240
+ // words, so a listing discloses nothing. Returns { path } relative to the record.
241
+ export function note(evda, words, { now = new Date() } = {}) {
242
+ const dir = capturesDir(evda);
243
+ mkdirSync(dir.abs, { recursive: true });
244
+ const stamp = `${localDate(now)}-${pad(now.getHours())}${pad(now.getMinutes())}`;
245
+ let abs = join(dir.abs, `${stamp}-noted.md`);
246
+ for (let n = 2; existsSync(abs); n++) abs = join(dir.abs, `${stamp}-noted-${n}.md`);
247
+ writeFileSync(abs, ["---", `${NOTED}: true`, `date: ${localDate(now)}`, `time: "${localTime(now)}"`, "---", "", words.replace(/\n+$/, ""), ""].join("\n"));
248
+ return { path: relative(evda.dir, abs).split(sep).join("/") };
249
+ }
250
+
251
+ // Clear the flag on one noted entry, on the owner's yes. Changes one frontmatter line and adds the
252
+ // date it was released; the words are never touched.
253
+ export function releaseNote(evda, path, { now = new Date() } = {}) {
254
+ if (typeof path !== "string" || path.trim() === "") throw new EvdaError('REFUSED: name the noted entry to release, as a path in the record. Nothing was changed.', { code: "BAD_ARGUMENT", exit: 2 });
255
+ const outside = () => new EvdaError(`REFUSED: ${path} is outside the record. Nothing was changed.`, { code: "OUTSIDE", exit: 2 });
256
+ const named = resolve(evda.dir, path.trim());
257
+ if (!existsSync(named)) {
258
+ if (!named.startsWith(evda.dir + sep)) throw outside();
259
+ throw new EvdaError(`REFUSED: nothing is at ${path}. Nothing was changed.`, { code: "MISSING", exit: 2 });
260
+ }
261
+ // Inside is decided on real paths: a record reached through a symlink (the owner's shell path to
262
+ // it) is the same record, and a symlink inside the record that leads out of it is outside
263
+ // (HQ-1406: an absolute path through a link to the record was refused as outside).
264
+ const root = realpathSync(evda.dir);
265
+ const abs = realpathSync(named);
266
+ if (!abs.startsWith(root + sep)) throw outside();
267
+ if (!statSync(abs).isFile()) throw new EvdaError(`REFUSED: nothing is at ${path}. Nothing was changed.`, { code: "MISSING", exit: 2 });
268
+ const text = readFileSync(abs, "utf8");
269
+ const lines = text.split("\n");
270
+ const end = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
271
+ const at = end === -1 ? -1 : lines.slice(1, end).findIndex(notedLine) + 1;
272
+ if (at <= 0) throw new EvdaError(`REFUSED: ${path} is not just noted (its frontmatter has no "noted: true"). Nothing was changed.`, { code: "NOT_NOTED", exit: 2 });
273
+ lines.splice(at, 1, `released: ${localDate(now)}`);
274
+ writeFileSync(abs, lines.join("\n"));
275
+ return { path: relative(root, abs).split(sep).join("/") };
276
+ }
277
+
278
+ // The top-level frontmatter line that makes an entry noted, spelled any way the frontmatter reader
279
+ // reads as `noted: true` (True, a trailing comment, a quoted key), so release clears every entry
280
+ // the rest of the package holds back (HQ-1406: `noted: True` was held back and could not be released).
281
+ function notedLine(l) {
282
+ if (/^\s/.test(l)) return false;
283
+ try {
284
+ const one = parseYaml(l);
285
+ return one !== null && typeof one === "object" && Object.keys(one).length === 1 && one[NOTED] === true;
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ // Every noted entry in the record's markdown, for doctor and for the owner's own search.
292
+ export function notedFiles(evda, files) {
293
+ return files.filter((abs) => frontOf(abs)?.[NOTED] === true);
294
+ }
295
+
296
+ // The owner's own search, on this device, with no model: every markdown file under the record,
297
+ // noted and sealed entries included and marked (a line in a sealed section is marked sealed too).
298
+ // The MCP's evda_search is the AI's and leaves them out.
299
+ export function ownerSearch(evda, query, { files }) {
300
+ const needle = query.toLowerCase();
301
+ const out = [];
302
+ for (const abs of files) {
303
+ let text;
304
+ try { text = readFileSync(abs, "utf8"); } catch { continue; }
305
+ const front = frontmatterOf(text);
306
+ const noted = front?.[NOTED] === true;
307
+ const sealedFile = sealedFront(front);
308
+ const sections = sealedSections(text);
309
+ text.split("\n").forEach((line, i) => {
310
+ if (!line.toLowerCase().includes(needle)) return;
311
+ const hit = { path: relative(evda.dir, abs).split(sep).join("/"), line: i + 1, text: line, noted };
312
+ if (sealedFile || sections.some((s) => i >= s.start && i <= s.end)) hit.sealed = true;
313
+ out.push(hit);
314
+ });
315
+ }
316
+ return out;
317
+ }
318
+
319
+ export { mdFiles, PLATFORMS };
@@ -0,0 +1,52 @@
1
+ // Records inside records (Spec 07 section 2, "A record inside a record", HQ-1371).
2
+ //
3
+ // An evda.json anywhere below a record's root starts a sub-record: its own four roles resolved from
4
+ // its own root, its own circles, its own holders, with no depth limit. This module finds them. Only
5
+ // the OUTERMOST sub-records of a record are its own; a sub-record's sub-records are found when the
6
+ // sub-record itself is read, which is how doctor walks every level.
7
+ //
8
+ // The boundary is opt-in for circles (lib/manifest.mjs `isBounded`): a record that declares
9
+ // "subrecords": "bounded" stops its circles, its coverage, its scan prefixes and its released set
10
+ // at every sub-record, and holds a sub-record's files only through a circle it mounts. A record that
11
+ // does not declare it keeps covering them as its own, which is what it synced before HQ-1371.
12
+ //
13
+ // The walk is the sync walk (lib/sync-rules.mjs), so a sub-record under an excluded path is not a
14
+ // sub-record of this record: nothing of it syncs from here either way.
15
+
16
+ import { dirname } from "node:path";
17
+ import { excluder, walk } from "./sync-rules.mjs";
18
+ import { isBounded } from "./manifest.mjs";
19
+
20
+ const MANIFEST = "evda.json";
21
+
22
+ // EVDA_SUBRECORDS_FALSIFY=<mode,...>, for the tests: no-boundary makes a bounded record's circles
23
+ // cover its sub-records anyway; blind finds no sub-record at all, so doctor walks no level below.
24
+ export const FALSIFY_MODES = ["no-boundary", "blind"];
25
+ const falsified = (mode) => (process.env.EVDA_SUBRECORDS_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
26
+
27
+ // The outermost sub-record roots under `dir`, relative, sorted. `exclude` is the plan's excluder.
28
+ export function subRecords(dir, { exclude = excluder() } = {}) {
29
+ const found = [];
30
+ if (falsified("blind")) return found;
31
+ for (const e of walk(dir, { exclude })) {
32
+ if (e.kind !== "file") continue;
33
+ if (!(e.rel === MANIFEST || e.rel.endsWith(`/${MANIFEST}`))) continue;
34
+ if (e.rel === MANIFEST) continue;
35
+ found.push(dirname(e.rel));
36
+ }
37
+ found.sort();
38
+ return found.filter((r) => !found.some((o) => o !== r && r.startsWith(`${o}/`)));
39
+ }
40
+
41
+ const cache = new WeakMap();
42
+
43
+ // The sub-record roots the record's circles stop at: every outermost sub-record when the record is
44
+ // bounded, none when it is not. Cached per open record, since coverage asks once per file.
45
+ export function boundaries(evda) {
46
+ if (!isBounded(evda.manifest) || falsified("no-boundary")) return [];
47
+ if (!cache.has(evda)) cache.set(evda, subRecords(evda.dir));
48
+ return cache.get(evda);
49
+ }
50
+
51
+ // Whether a relative path is inside one of `roots`.
52
+ export const insideAny = (roots, rel) => roots.some((r) => rel === r || rel.startsWith(`${r}/`));