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
package/lib/circles.mjs CHANGED
@@ -9,26 +9,69 @@
9
9
  //
10
10
  // members where the circle's bytes live, relative to the record root. The mapping principle
11
11
  // one level down: a record adopts a circle by declaring where its members already are,
12
- // and nothing moves. A member is { path } (bytes in this record) or { table, where }
12
+ // and nothing moves. A path member covers the file it names or everything under the
13
+ // directory it names, and a member at the record root ("" or ".") covers everything.
14
+ // PRECEDENCE (HQ-1364): a file's home is the circle whose covering member path is
15
+ // LONGEST, so a circle declared at the root is the private default and every other
16
+ // circle carves its subtree out of it; `Health & Energy/Therapy` is therapy, not health,
17
+ // not core, whatever order the manifests are read in. Two circles whose members cover
18
+ // one file at the same length is a file with two access answers, and doctor fails it.
19
+ // `coverage()` here, `circleForPath` in filechanges.mjs and the scan's `scanPrefixes`
20
+ // all resolve this one way. A member is { path } (bytes in this record), { table, where }
13
21
  // (rows on a host, selected by equality; allowed only in a public circle, because a
14
- // private circle's host may not read it).
22
+ // private circle's host may not read it), or { where } alone (files in this record
23
+ // whose frontmatter matches, wherever they live: a standing release rule, on any
24
+ // circle). The mark an owner writes on one entry is `share: <circle>` or
25
+ // `share: [<circle>, ...]` in its frontmatter, and the circle's rule for it is
26
+ // `where: { share: <its name> }`; a where value matches a scalar or a list holding it.
27
+ // The fourth member is { render: { view, path, shape? } }, on a public circle only
28
+ // (HQ-1351, Spec 07 section 5): a file that does not exist in the record and is
29
+ // RENDERED at publish from every released entry whose frontmatter says `view: <view>`,
30
+ // sorted by `order`, written into the export at `path`. `shape` is "list" (the default,
31
+ // one JSON list of the entries' items) or "object" (exactly one entry, its item alone).
32
+ // The rendering is lib/render.mjs. A rendered path is never also a path member, and a
33
+ // released entry that names a view no render member declares refuses the publish.
34
+ // mount on a circle held FROM ANOTHER HOLDER (HQ-835): `{ holder: <their record id>, path:
35
+ // <where their files land here> }`. The wire carries each file at its address in the
36
+ // home holder's tree; on this device it lands under `path` (Spec 07 section 3, "at its
37
+ // address under a mount, never at a path in their own tree"). `path` must be a path
38
+ // member of the circle, so the scan and the fold agree on where the mount is.
39
+ // frozen on a circle held from another holder whose grant was revoked (HQ-835): `{ since:
40
+ // <date>, by: revocation }`. The copy stays readable and stops updating: it is neither
41
+ // scanned for edits nor minted into, and nothing under its mount is deleted (Spec 00
42
+ // #37). Written by `lib/declarations.mjs` when the owner's notice arrives; lifted by a
43
+ // re-declaration.
44
+ // release on the circle a file lives in: "never", or a list of circle names it may be released
45
+ // into. Default never, so a new circle cannot leak by a stray mark. Spec 07 section 3,
46
+ // ruled 2026-09-22 (HQ-1351): every entry has exactly one HOME circle by path; it may be
47
+ // released into any number of other circles by mark or by rule, and each release is
48
+ // the same file sealed once more at sync, derived from the one copy. Public is the
49
+ // release with no key. Routing by home circle is unchanged; released circles are added.
15
50
  // policy a list of rules (Spec 07, section 6), each one a test: what the rule is in the owner's
16
51
  // words, the correction that made it (verbatim), an example that passes, one that fails,
17
52
  // a check written so it could come back false, and a status. A rule is never edited in
18
53
  // place; a revision is a new rule with `revisedBy` written on the old one. The plain
19
54
  // sentence saying who may read the circle and why goes in the substrate's `description`.
20
55
  //
56
+ // A circle's id is opaque and random (Spec 08, ruled 2026-09-17): `id: ecos/<opaque>` and
57
+ // `namespace: <opaque>.eco`, minted once when the circle is created and never derived from its
58
+ // name. The name is the directory under the circles role and stays on the owner's devices; the id
59
+ // is what travels (a Change's `eco`, an export's manifest), so a name there would be the
60
+ // disclosure. An id still derived from the name is read, with a warning to re-mint it.
61
+ //
21
62
  // `public: true` is the whole of what makes a circle public: the reader takes the same path it
22
- // would for any circle and needs no key. Reading an ENCRYPTED circle is not built; a private circle
23
- // is declared, validated for shape, and never read.
63
+ // would for any circle and needs no key. This module declares and validates circles; it reads no
64
+ // private circle's bytes. Sealing a private circle for sync is `lib/filechanges.mjs`.
24
65
  //
25
66
  // `circle.json` is the manifest this package wrote before circles converged on ecos (2026-09-16).
26
67
  // It is still read, with a warning, so a record written against it keeps working until it is
27
68
  // rewritten as `eco.yaml`. Nothing writes it any more.
28
69
 
29
70
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
30
- import { join, resolve, sep, isAbsolute } from "node:path";
71
+ import { randomBytes } from "node:crypto";
72
+ import { join, resolve, relative, sep, isAbsolute } from "node:path";
31
73
  import { resolveRole } from "./roles.mjs";
74
+ import { boundaries, insideAny } from "./records.mjs";
32
75
  import { parseYaml, toYaml } from "./yaml.mjs";
33
76
  import { EvdaError } from "./errors.mjs";
34
77
 
@@ -38,14 +81,59 @@ export const ECO_MANIFEST = "eco.yaml";
38
81
  export const LEGACY_MANIFEST = "circle.json";
39
82
  export const KNOWN_ECO_KEYS = [
40
83
  "id", "kind", "schemaVersion", "sourceLayer", "namespace", "title", "type", "description",
41
- "ecoVersion", "layer", "public", "maintainers", "metadata", "members", "policy",
84
+ "ecoVersion", "layer", "public", "maintainers", "metadata", "members", "policy", "release", "mount",
85
+ "frozen", "read_by",
42
86
  ];
87
+ // The frontmatter key an owner writes to release one entry: a circle name, or a list of them.
88
+ export const MARK = "share";
89
+ // The frontmatter flag on an entry that is just noted (HQ-1374): kept on the owner's devices,
90
+ // searchable by them, read by no AI until they release it. Not a circle and not a mark, and a mark
91
+ // never releases a noted entry anywhere. lib/readers.mjs holds the rest of what it means.
92
+ export const NOTED = "noted";
93
+ // The frontmatter flag on an entry that is sealed (HQ-1220): nothing but the owner's own devices
94
+ // opens it, ever. Stronger than noted: no AI reads it whoever it says it is, no tool releases it,
95
+ // no mark and no public path publishes it. A section is sealed by a fenced block whose info string
96
+ // is `sealed` (withoutSealed below). lib/readers.mjs holds what each reader does with it.
97
+ export const SEALED = "sealed";
98
+ // Carrying the two flags (HQ-1414). Every command that copies or moves record content somewhere
99
+ // else carries `noted` and `sealed` with it, so a copy is never more readable than what it copied.
100
+ // The paths are COPY_PATHS; test/carry.test.mjs holds one canary test per path and fails when a new
101
+ // file-writing module appears that it has not classified. EVDA_CARRY_FALSIFY=<path>:<flag>,... makes
102
+ // that one path drop that one flag and nothing else, so each test shows the path is what holds.
103
+ export const COPY_PATHS = ["organize", "gather", "gather-apply", "migrate-people", "publish", "sync"];
104
+ export const carries = (path, flag) => !String(process.env.EVDA_CARRY_FALSIFY ?? "").split(",").map((m) => m.trim()).includes(`${path}:${flag}`);
105
+ // Which flag, if any, holds an entry back when `path` copies it: "sealed" first (the stronger),
106
+ // then "noted", else null.
107
+ export function heldBy(front, path) {
108
+ if (carries(path, SEALED) && sealedFront(front)) return SEALED;
109
+ if (carries(path, NOTED) && front?.[NOTED] === true) return NOTED;
110
+ return null;
111
+ }
112
+ // The key on a circle (or on evda.json, for the whole record) that says which AI reads it. Validated
113
+ // and printed by lib/readers.mjs; here it is only a key the manifest may carry.
114
+ export const READ_BY = "read_by";
115
+ // The shapes a render member may take: a list of every entry's item, or one entry's item alone.
116
+ export const RENDER_SHAPES = ["list", "object"];
117
+ // A circle's release: "never", or the names of the circles its files may be released into.
118
+ export const isRelease = (v) => v === "never" || (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === "string" && n.trim() !== ""));
43
119
 
44
120
  export const circlesDir = (evda) => resolveRole(evda, "circles");
45
121
  export const ecoManifestPath = (dir) => join(dir, ECO_MANIFEST);
46
122
  export const ecoId = (name) => `ecos/${name}`;
47
123
  export const ecoNamespace = (name) => `${name}.eco`;
48
124
 
125
+ // A fresh opaque circle id: `c` and 32 hex characters (128 random bits). Never a function of the
126
+ // name, so two circles called "therapy" in two records share nothing a reader of the id could use.
127
+ export const OPAQUE_ID = /^c[0-9a-f]{32}$/;
128
+ export const mintCircleId = () => `c${randomBytes(16).toString("hex")}`;
129
+
130
+ // The opaque part of an eco manifest's id, when its id and namespace agree on one. null otherwise.
131
+ export function opaqueIdOf(manifest) {
132
+ const id = typeof manifest?.id === "string" && manifest.id.startsWith("ecos/") ? manifest.id.slice("ecos/".length) : null;
133
+ if (id === null || id === "" || id.includes("/")) return null;
134
+ return manifest.namespace === ecoNamespace(id) ? id : null;
135
+ }
136
+
49
137
  // Every subdirectory of the circles role, whether or not it carries a manifest. Dotfiles are not
50
138
  // circles. A file at the top level of the role is ignored (the scaffold's README lives there).
51
139
  export function circleDirs(evda) {
@@ -151,7 +239,7 @@ export function validateCircle(evda, dir) {
151
239
  const problems = [];
152
240
  const fail = (m) => problems.push({ level: "fail", message: m });
153
241
  const warn = (m) => problems.push({ level: "warn", message: m });
154
- const out = { name: dir.name, rel: dir.rel, abs: dir.abs, manifest: null, source: null, problems, readable: false, members: {}, policy: null };
242
+ const out = { name: dir.name, rel: dir.rel, abs: dir.abs, id: null, manifest: null, source: null, problems, readable: false, members: {}, policy: null, release: "never", mount: null, frozen: null };
155
243
 
156
244
  const { manifest: m, source } = readCircleManifest(dir);
157
245
  out.source = source;
@@ -169,12 +257,23 @@ export function validateCircle(evda, dir) {
169
257
 
170
258
  // The substrate's required fields, so the substrate's own reader can open this manifest.
171
259
  if (m.kind !== "eco") fail(`${rel}: "kind" must be "eco", got ${JSON.stringify(m.kind)}. A circle is an eco.`);
172
- if (m.id !== ecoId(dir.name)) fail(`${rel}: "id" must be "${ecoId(dir.name)}" (the directory names the eco), got ${JSON.stringify(m.id)}.`);
173
- if (m.namespace !== ecoNamespace(dir.name)) fail(`${rel}: "namespace" must be "${ecoNamespace(dir.name)}", got ${JSON.stringify(m.namespace)}.`);
260
+ const opaque = opaqueIdOf(m);
261
+ if (typeof m.id !== "string" || !m.id.startsWith("ecos/")) fail(`${rel}: "id" must be "ecos/<id>", got ${JSON.stringify(m.id)}.`);
262
+ else if (opaque === null) fail(`${rel}: "id" and "namespace" must name the same eco ("${m.id}" wants namespace "${ecoNamespace(m.id.slice("ecos/".length))}"), got ${JSON.stringify(m.namespace)}.`);
263
+ else if (opaque === dir.name) warn(`${rel}: "id" is derived from the circle's name, and the id is what leaves the device. Re-mint it as a random id (Spec 08, ruled 2026-09-17).`);
264
+ else if (!OPAQUE_ID.test(opaque)) warn(`${rel}: "id" ${JSON.stringify(m.id)} is not a minted opaque id (c and 32 hex). If it says anything about the circle, it says it wherever the id travels.`);
265
+ out.id = opaque;
174
266
  if (!nonEmpty(m.schemaVersion) && typeof m.schemaVersion !== "number") fail(`${rel}: "schemaVersion" is missing.`);
175
267
  if (!nonEmpty(m.title)) fail(`${rel}: "title" is missing.`);
176
268
 
177
269
  if (typeof m.public !== "boolean") fail(`${rel}: "public" must be true or false. A circle that does not say who may read it cannot be read.`);
270
+ // release: whether a file here may be released into the public circle by the mark. Read only on
271
+ // a keyed circle; the public circle releases nothing, it gathers.
272
+ if (m.release !== undefined) {
273
+ if (!isRelease(m.release)) fail(`${rel}: "release" must be "never" or a non-empty list of circle names, got ${JSON.stringify(m.release)}. It says where a file living here may be released to.`);
274
+ else if (m.public === true) warn(`${rel}: "release" is set on a public circle. Everyone already reads it, so there is nowhere further to release to. Ignored.`);
275
+ else out.release = m.release;
276
+ }
178
277
  if (!Array.isArray(m.maintainers) || m.maintainers.length === 0) {
179
278
  fail(`${rel}: "maintainers" must be a non-empty list. A circle nobody holds has no one who may grant it.`);
180
279
  } else {
@@ -192,13 +291,64 @@ export function validateCircle(evda, dir) {
192
291
  if (!isObject(member)) { fail(`${rel}: member "${name}" is not a mapping.`); continue; }
193
292
  const hasPath = typeof member.path === "string";
194
293
  const hasTable = typeof member.table === "string";
195
- if (hasPath === hasTable) { fail(`${rel}: member "${name}" must have exactly one of "path" or "table".`); continue; }
294
+ const hasWhere = member.where !== undefined;
295
+ const hasRender = member.render !== undefined;
296
+ if (hasPath && hasTable) { fail(`${rel}: member "${name}" must have exactly one of "path" or "table".`); continue; }
297
+ if (hasRender) {
298
+ // A render member: a file the export carries that the record does not hold, rendered at
299
+ // publish from the released entries that name its view (HQ-1351). Public only, because a
300
+ // view is rendered on the way out and only the public circle is published.
301
+ if (hasPath || hasTable || hasWhere) { fail(`${rel}: member "${name}" has "render" and also path, table or where. A render member stands alone.`); continue; }
302
+ if (m.public !== true) { fail(`${rel}: member "${name}" is a render, and this circle is not public. A view is rendered at publish, and only the public circle is published.`); continue; }
303
+ const r = member.render;
304
+ if (!isObject(r) || !nonEmpty(r.view) || !nonEmpty(r.path)) { fail(`${rel}: member "${name}" render must be { view: <name>, path: <record path>, shape?: list | object }, got ${JSON.stringify(r)}.`); continue; }
305
+ const shape = r.shape ?? "list";
306
+ if (!RENDER_SHAPES.includes(shape)) { fail(`${rel}: member "${name}" render shape must be one of ${RENDER_SHAPES.join(" | ")}, got ${JSON.stringify(r.shape)}.`); continue; }
307
+ if (isAbsolute(r.path)) { fail(`${rel}: member "${name}" render path must be relative to the record root, got ${JSON.stringify(r.path)}.`); continue; }
308
+ const path = r.path.replace(/^\.?\/+/, "").replace(/\/+$/, "");
309
+ const abs = resolve(evda.dir, path);
310
+ if (path === "" || abs === evda.dir || !abs.startsWith(evda.dir + sep)) { fail(`${rel}: member "${name}" render path (${r.path}) is the record root or resolves outside the record.`); continue; }
311
+ out.members[name] = { kind: "render", view: r.view.trim(), rel: path, abs, shape };
312
+ continue;
313
+ }
314
+ if (!hasPath && !hasTable && !hasWhere) { fail(`${rel}: member "${name}" must have "path", "table", "where" (files selected by their frontmatter), or "render" (a view rendered at publish).`); continue; }
315
+ if (!hasPath && !hasTable) {
316
+ // A rule member: files in this record whose frontmatter matches, wherever they live. On any
317
+ // circle. `where: { share: <this circle> }` is the owner's per-entry mark; anything else is
318
+ // a standing rule by property, agreed once.
319
+ if (!isObject(member.where) || Object.keys(member.where).length === 0) { fail(`${rel}: member "${name}" where must be a non-empty mapping of frontmatter key -> scalar.`); continue; }
320
+ const bad = Object.entries(member.where).filter(([, v]) => !isScalar(v));
321
+ if (bad.length) { fail(`${rel}: member "${name}" where has non-scalar value(s): ${bad.map(([k]) => k).join(", ")}.`); continue; }
322
+ if (member.where[MARK] !== undefined && member.where[MARK] !== dir.name) warn(`${rel}: member "${name}" selects ${MARK}: ${JSON.stringify(member.where[MARK])}, and this circle is "${dir.name}". A mark names the circle it releases into; this rule would gather files marked for another one.`);
323
+ out.members[name] = { kind: "rule", where: member.where };
324
+ continue;
325
+ }
196
326
  if (hasPath) {
197
- if (member.path === "" || isAbsolute(member.path)) { fail(`${rel}: member "${name}" path must be relative to the record root, got ${JSON.stringify(member.path)}.`); continue; }
198
- const abs = resolve(evda.dir, member.path);
327
+ if (isAbsolute(member.path)) { fail(`${rel}: member "${name}" path must be relative to the record root, got ${JSON.stringify(member.path)}.`); continue; }
328
+ // "" or "." is the record root itself: the member claims everything a longer member of any
329
+ // circle does not (HQ-1364). It is kept as "" so every reader compares one spelling.
330
+ // Every member path is kept in one spelling, the resolved path relative to the root: the
331
+ // home of a file is picked by comparing member path lengths, so `./x` or `x/` read one
332
+ // level deeper than `x` and could take a file from the circle that really homes it, and
333
+ // `x/..` was the root under another name (HQ-1406).
334
+ const resolved = resolve(evda.dir, member.path);
335
+ const path = resolved === evda.dir ? "" : relative(evda.dir, resolved).split(sep).join("/");
336
+ if (path === "") {
337
+ if (m.public === true) { fail(`${rel}: member "${name}" is the record root, and this circle is public. The root is the private default; a public circle names what it publishes.`); continue; }
338
+ out.members[name] = { kind: "path", rel: "", abs: evda.dir, isDir: true };
339
+ continue;
340
+ }
341
+ const abs = resolve(evda.dir, path);
199
342
  if (abs !== evda.dir && !abs.startsWith(evda.dir + sep)) { fail(`${rel}: member "${name}" path (${member.path}) resolves outside the record.`); continue; }
200
- if (!existsSync(abs)) { fail(`${rel}: member "${name}" is declared at ${member.path} but nothing is there. A missing member and an empty one are different facts.`); continue; }
201
- out.members[name] = { kind: "path", rel: member.path, abs, isDir: statSync(abs).isDirectory() };
343
+ if (!existsSync(abs)) {
344
+ // Coded, so the sync layer can tell "this device was never sent the circle" from a broken
345
+ // declaration: a device without a grant on a circle has none of its members, by design.
346
+ fail(`${rel}: member "${name}" is declared at ${member.path} but nothing is there. A missing member and an empty one are different facts.`);
347
+ problems.at(-1).code = "MISSING_MEMBER";
348
+ problems.at(-1).member = { name, rel: path };
349
+ continue;
350
+ }
351
+ out.members[name] = { kind: "path", rel: path, abs, isDir: statSync(abs).isDirectory() };
202
352
  } else {
203
353
  if (member.table.trim() === "") { fail(`${rel}: member "${name}" has an empty table name.`); continue; }
204
354
  if (m.public !== true) fail(`${rel}: member "${name}" is a table, and this circle is not public. A table is readable by its host; a private circle's host may not read it (the person's threat model).`);
@@ -209,6 +359,43 @@ export function validateCircle(evda, dir) {
209
359
  out.members[name] = { kind: "table", table: member.table, where };
210
360
  }
211
361
  }
362
+ // A rendered file is written into the export at its path; two members rendering to one path,
363
+ // or two rendering one view, is one file with two answers. And a rendered path is never also
364
+ // a path member: the export cannot carry both the record's bytes and a rendering there.
365
+ const renders = Object.entries(out.members).filter(([, mm]) => mm.kind === "render");
366
+ const paths = Object.values(out.members).filter((mm) => mm.kind === "path");
367
+ const seenPath = new Map();
368
+ const seenView = new Map();
369
+ for (const [name, mm] of renders) {
370
+ if (seenPath.has(mm.rel)) fail(`${rel}: members "${seenPath.get(mm.rel)}" and "${name}" both render to ${mm.rel}. One file per path.`);
371
+ seenPath.set(mm.rel, name);
372
+ if (seenView.has(mm.view)) fail(`${rel}: members "${seenView.get(mm.view)}" and "${name}" both render view "${mm.view}". One member per view.`);
373
+ seenView.set(mm.view, name);
374
+ const under = paths.find((p) => p.rel === "" || (p.isDir ? mm.rel === p.rel || mm.rel.startsWith(`${p.rel}/`) : mm.rel === p.rel));
375
+ if (under) fail(`${rel}: member "${name}" renders to ${mm.rel}, which is also under a path member of this circle (${under.rel || "the record root"}). A rendered path is never also a path member.`);
376
+ }
377
+ }
378
+
379
+ // A circle mounted from another holder: its files land under `mount.path`, which must be one of
380
+ // this circle's path members, so what the scan walks and what the fold writes are one place.
381
+ if (m.mount !== undefined) {
382
+ if (!isObject(m.mount) || !nonEmpty(m.mount.holder) || !nonEmpty(m.mount.path)) fail(`${rel}: "mount" must be { holder: <record id>, path: <relative path> }, got ${JSON.stringify(m.mount)}.`);
383
+ else if (m.public === true) fail(`${rel}: "mount" is set on a public circle. A mount is another holder's private circle held here; a public circle is read from its export.`);
384
+ else {
385
+ const at = m.mount.path.replace(/\/+$/, "");
386
+ const member = Object.values(out.members).find((mm) => mm.kind === "path" && mm.rel.replace(/\/+$/, "") === at);
387
+ const declared = isObject(m.members) && Object.values(m.members).some((mm) => isObject(mm) && typeof mm.path === "string" && mm.path.replace(/\/+$/, "") === at);
388
+ if (!declared) fail(`${rel}: "mount.path" (${m.mount.path}) is not a path member of this circle. Declare it as one, so the scan and the fold agree on where the mount is.`);
389
+ else if (member && !member.isDir) fail(`${rel}: "mount.path" (${m.mount.path}) is a file. A mount is a directory the other holder's files land under.`);
390
+ else out.mount = { holder: m.mount.holder.trim(), rel: at };
391
+ }
392
+ }
393
+
394
+ // Frozen: a held copy whose grant was revoked. Says since when and by what; nothing else changes.
395
+ if (m.frozen !== undefined) {
396
+ if (!isObject(m.frozen) || !/^\d{4}-\d{2}-\d{2}$/.test(String(m.frozen.since ?? "")) || !nonEmpty(m.frozen.by)) fail(`${rel}: "frozen" must be { since: YYYY-MM-DD, by: <what froze it> }, got ${JSON.stringify(m.frozen)}.`);
397
+ else if (m.public === true) fail(`${rel}: "frozen" is set on a public circle. A public circle has no grant to revoke.`);
398
+ else out.frozen = { since: String(m.frozen.since), by: m.frozen.by.trim() };
212
399
  }
213
400
 
214
401
  if (!nonEmpty(m.description)) warn(`${rel}: has no "description". Say in plain language who may read this circle and why.`);
@@ -230,6 +417,54 @@ export function readCircles(evda) {
230
417
  return dirs.map((d) => validateCircle(evda, d));
231
418
  }
232
419
 
420
+ // The mounts a record holds, checked against its `mounts` role (HQ-835). A circle held from another
421
+ // holder lands at `<mounts>/<owner record id>/<one subfolder>`; without the role, anywhere its
422
+ // manifest says, one per owner. Returns { root, held, owners, problems }, where each problem is a
423
+ // sentence for doctor. Never throws for a problem; a mount root at the record root is one.
424
+ //
425
+ // - a circle mounted from this record's own id (a holder does not mount its own circles);
426
+ // - under a mapped root, a mount that is not exactly one subfolder under its owner's directory;
427
+ // - two mounts where one is the other or lies inside it (one subfolder per circle);
428
+ // - a home circle with a path member under the mount root (what lands there is another holder's).
429
+ export function mountProblems(evda, circles) {
430
+ const problems = [];
431
+ const r = resolveRole(evda, "mounts");
432
+ let root = null;
433
+ if (r.mapped) {
434
+ root = String(r.rel).replace(/\/+$/, "").replace(/^\.\/+/, "");
435
+ if (root === "" || root === ".") {
436
+ problems.push('the "mounts" role is the record root; another holder\'s files would sit beside this record\'s own. Map it to a directory such as mounts.');
437
+ root = null;
438
+ }
439
+ }
440
+ const me = evda.manifest?.id ?? null;
441
+ const held = circles.filter((c) => c.mount);
442
+ for (const c of held) {
443
+ if (me && c.mount.holder === me) problems.push(`circle "${c.name}" is mounted from ${me}, which is this record. A holder does not mount its own circles.`);
444
+ if (root !== null) {
445
+ const owner = `${root}/${c.mount.holder}/`;
446
+ const rest = c.mount.rel.startsWith(owner) ? c.mount.rel.slice(owner.length) : null;
447
+ if (rest === null || rest === "" || rest.includes("/")) problems.push(`circle "${c.name}" is mounted at ${c.mount.rel}; under the mounts role a circle from ${c.mount.holder} is held at ${owner}<one subfolder>.`);
448
+ }
449
+ }
450
+ for (let i = 0; i < held.length; i++) {
451
+ for (let j = i + 1; j < held.length; j++) {
452
+ const [a, b] = [held[i].mount.rel, held[j].mount.rel];
453
+ if (a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)) problems.push(`circles "${held[i].name}" (${a}) and "${held[j].name}" (${b}) are mounted one inside the other. One subfolder per circle, never nested.`);
454
+ }
455
+ }
456
+ if (root !== null) {
457
+ for (const c of circles.filter((x) => !x.mount)) {
458
+ for (const [name, m] of Object.entries(c.members)) {
459
+ if (m.kind !== "path" || m.rel === "") continue;
460
+ const rel = m.rel.replace(/\/+$/, "");
461
+ if (rel === root || rel.startsWith(`${root}/`)) problems.push(`circle "${c.name}" member "${name}" (${rel}) is under the mount root ${root}; what lands there is another holder's, held by the circle mounted at it.`);
462
+ }
463
+ }
464
+ }
465
+ return { root, held: held.length, owners: new Set(held.map((c) => c.mount.holder)).size, problems };
466
+ }
467
+
233
468
  // The circles this tool may read: public, and with no failing problem. Throws when the record
234
469
  // has a circles role and a circle in it is malformed, because serving a record with a broken
235
470
  // boundary declaration is worse than serving nothing.
@@ -264,30 +499,246 @@ export function holders(circle) {
264
499
  return Array.isArray(circle.manifest?.maintainers) ? circle.manifest.maintainers : [];
265
500
  }
266
501
 
267
- // Every entry resolves to exactly one circle (Spec 07, section 3). An entry is a file in a record
268
- // role, or the ledger file. A path member covers the file it names, or every file under the
269
- // directory it names; a table member covers no file. Returns each entry with the circles that
270
- // cover it, so doctor can fail on zero and on two.
502
+ // A path member covers the file it names, or every file under the directory it names; the record
503
+ // root ("") covers every file; a table, rule or render member covers no file.
504
+ const underPath = (m, abs, root) => m.kind === "path" && (m.rel === "" ? abs.startsWith(root + sep) : m.isDir ? abs.startsWith(m.abs + sep) : abs === m.abs);
505
+
506
+ // The circles that HOME an entry (Spec 07 section 3, HQ-1364): of every path member covering it,
507
+ // across every circle, the ones whose member path is longest. One name is the answer; two names is
508
+ // two circles claiming the same file at the same depth, which doctor fails; none is uncovered.
509
+ export function homesOf(evda, circles, abs) {
510
+ // A file inside a sub-record of a bounded record is the sub-record's, homed in none of these.
511
+ const stops = boundaries(evda);
512
+ if (stops.length && insideAny(stops, abs.slice(evda.dir.length + 1).split(sep).join("/"))) return [];
513
+ let best = -1;
514
+ let names = [];
515
+ for (const c of circles) {
516
+ for (const m of Object.values(c.members)) {
517
+ if (!underPath(m, abs, evda.dir)) continue;
518
+ if (m.rel.length > best) { best = m.rel.length; names = [c.name]; }
519
+ else if (m.rel.length === best && !names.includes(c.name)) names.push(c.name);
520
+ }
521
+ }
522
+ return names;
523
+ }
524
+
525
+ // Every entry resolves to exactly one circle (Spec 07, section 3). Returns each entry with the
526
+ // circles that home it, so doctor can fail on zero and on two.
271
527
  export function coverage(evda, circles, entries) {
272
- const covers = (m, abs) => m.kind === "path" && (m.isDir ? abs.startsWith(m.abs + sep) : abs === m.abs);
273
- return entries.map((abs) => ({
274
- abs,
275
- circles: circles.filter((c) => Object.values(c.members).some((m) => covers(m, abs))).map((c) => c.name),
276
- }));
528
+ return entries.map((abs) => ({ abs, circles: homesOf(evda, circles, abs) }));
529
+ }
530
+
531
+ // The frontmatter of a markdown file as an object, or null when it has none or it does not parse.
532
+ // A file with no frontmatter is not marked; a file whose frontmatter is broken is not marked either,
533
+ // and doctor's typed-entry pass names it if it claims a kind.
534
+ export function frontmatterOf(text) {
535
+ const lines = text.split("\n");
536
+ if (lines[0] !== "---") return null;
537
+ const end = lines.indexOf("---", 1);
538
+ if (end === -1) return null;
539
+ try {
540
+ const front = parseYaml(lines.slice(1, end).join("\n"));
541
+ return isObject(front) ? front : null;
542
+ } catch {
543
+ return null;
544
+ }
545
+ }
546
+
547
+ // Every markdown file under the record root, skipping dot-directories and node_modules. The mark
548
+ // walk reads the whole record, not only the record roles: a released fact may live anywhere.
549
+ export function markdownFiles(root) {
550
+ const out = [];
551
+ const walk = (abs) => {
552
+ for (const name of readdirSync(abs).sort()) {
553
+ if (name.startsWith(".") || name === "node_modules") continue;
554
+ const p = join(abs, name);
555
+ const st = statSync(p);
556
+ if (st.isDirectory()) walk(p);
557
+ else if (st.isFile() && /\.md$/i.test(name)) out.push(p);
558
+ }
559
+ };
560
+ walk(root);
561
+ return out;
562
+ }
563
+
564
+ // A where value matches the frontmatter value, or one item of it when the frontmatter holds a
565
+ // list: `share: [doctor, public]` matches `where: { share: doctor }`.
566
+ // Whether a file is just noted: markdown whose frontmatter says `noted: true`. Anything unreadable
567
+ // or without frontmatter is not.
568
+ export function isNoted(abs) {
569
+ if (!/\.md$/i.test(abs)) return false;
570
+ let text;
571
+ try { text = readFileSync(abs, "utf8"); } catch { return false; }
572
+ return frontmatterOf(text)?.[NOTED] === true;
573
+ }
574
+
575
+ // A markdown file's parsed frontmatter, or null when it is unreadable or has none.
576
+ function frontOfFile(abs) {
577
+ if (!/\.md$/i.test(abs)) return null;
578
+ try { return frontmatterOf(readFileSync(abs, "utf8")); } catch { return null; }
579
+ }
580
+
581
+ // Sealed (HQ-1220). EVDA_SEALED_FALSIFY=file ignores the file flag and =sections ignores sealed
582
+ // blocks, everywhere at once, so a test can show each reader is held by this filter and no other.
583
+ const sealedFalsified = (mode) => String(process.env.EVDA_SEALED_FALSIFY ?? "").split(",").map((m) => m.trim()).includes(mode);
584
+
585
+ // Whether parsed frontmatter seals its whole entry: `sealed: true`, nothing else.
586
+ export const sealedFront = (front) => !sealedFalsified("file") && front?.[SEALED] === true;
587
+
588
+ // Whether a file is sealed: markdown whose frontmatter says `sealed: true`. A symlink is read through,
589
+ // so a link to a sealed file is sealed.
590
+ export function isSealed(abs) {
591
+ if (!/\.md$/i.test(abs)) return false;
592
+ let text;
593
+ try { text = readFileSync(abs, "utf8"); } catch { return false; }
594
+ return sealedFront(frontmatterOf(text));
595
+ }
596
+
597
+ // The sealed sections of a text: a fence of three or more backticks or tildes whose info string
598
+ // starts with the word `sealed`, to the next fence of the same character at least as long. Fenced
599
+ // code around it does not unseal it; an unclosed one seals to the end of the text. Returns
600
+ // [{ start, end, closed }] as 0-based line indexes, the fences included.
601
+ const SEAL_OPEN = /^ {0,3}(`{3,}|~{3,})[ \t]*sealed(?:[ \t].*)?$/i;
602
+ export function sealedSections(text) {
603
+ if (sealedFalsified("sections")) return [];
604
+ const lines = String(text).split("\n");
605
+ const out = [];
606
+ for (let i = 0; i < lines.length; i++) {
607
+ const open = SEAL_OPEN.exec(lines[i].replace(/\r$/, ""));
608
+ if (!open) continue;
609
+ const fence = open[1];
610
+ const close = new RegExp(`^ {0,3}${fence[0] === "`" ? "`" : "~"}{${fence.length},}[ \\t]*$`);
611
+ let j = i + 1;
612
+ while (j < lines.length && !close.test(lines[j].replace(/\r$/, ""))) j++;
613
+ const closed = j < lines.length;
614
+ out.push({ start: i, end: closed ? j : lines.length - 1, closed });
615
+ i = j;
616
+ }
617
+ return out;
618
+ }
619
+
620
+ // The text with every sealed section taken out. `placeholder` null removes the lines (an export);
621
+ // a string replaces each section with that one line (an AI's view, which says something is there).
622
+ export function withoutSealed(text, { placeholder = null } = {}) {
623
+ const sections = sealedSections(text);
624
+ if (!sections.length) return { text, sections: 0, unclosed: 0 };
625
+ const lines = String(text).split("\n");
626
+ const kept = [];
627
+ let at = 0;
628
+ for (const s of sections) {
629
+ kept.push(...lines.slice(at, s.start));
630
+ if (placeholder !== null) kept.push(placeholder);
631
+ at = s.end + 1;
632
+ }
633
+ kept.push(...lines.slice(at));
634
+ let out = kept.join("\n");
635
+ if (at >= lines.length && placeholder === null && !out.endsWith("\n") && out !== "") out += "\n";
636
+ return { text: out, sections: sections.length, unclosed: sections.filter((s) => !s.closed).length };
277
637
  }
278
638
 
279
- // Build an eco manifest for a circle. `name` is the directory name; the id and namespace follow
280
- // from it, so the three cannot disagree.
281
- export function circleManifest({ name, title = name, isPublic = false, holders: hs = [], members = {}, policy = null, description = null }) {
639
+ export const matchesWhere =(front, where) => Object.entries(where).every(([k, v]) => front[k] === v || (Array.isArray(front[k]) && front[k].includes(v)));
640
+ const filesUnderPath = (abs) => {
641
+ const st = statSync(abs);
642
+ if (st.isFile()) return [abs];
643
+ const out = [];
644
+ for (const name of readdirSync(abs).sort()) {
645
+ if (name.startsWith(".")) continue;
646
+ out.push(...filesUnderPath(join(abs, name)));
647
+ }
648
+ return out;
649
+ };
650
+
651
+ // What each circle gathers beyond its own paths (Spec 07 section 3, HQ-1351): for every circle that
652
+ // has a rule member, each file whose frontmatter matches, released out of its one home circle when
653
+ // that home's `release` names this circle; plus every match it refused and why. For a public circle
654
+ // the files under its path members and its table members are listed too, so doctor can print the
655
+ // whole public set every run and it cannot grow quietly. `files` is the markdown to scan; the
656
+ // default is the whole record. Only circles with a rule member or public: true are returned.
657
+ export function released(evda, circles, { files = null } = {}) {
658
+ const rel = (abs) => abs.slice(evda.dir.length + 1).split(sep).join("/");
659
+ const stops = boundaries(evda);
660
+ const scan = (files ?? markdownFiles(evda.dir)).filter((abs) => !stops.length || !insideAny(stops, rel(abs)));
661
+ // The home circle(s) of a file, by longest member path: the same answer coverage() gives doctor.
662
+ const homeNames = (abs) => homesOf(evda, circles, abs);
663
+ const out = [];
664
+ for (const c of circles) {
665
+ const isPublic = c.manifest?.public === true;
666
+ const rules = Object.entries(c.members).filter(([, m]) => m.kind === "rule");
667
+ if (!isPublic && rules.length === 0) continue;
668
+ const byPath = [];
669
+ const byRule = [];
670
+ const refused = [];
671
+ const tables = [];
672
+ const renders = [];
673
+ if (isPublic) {
674
+ for (const [name, m] of Object.entries(c.members)) {
675
+ // Only the files this member HOMES: a file under it that a longer member of another circle
676
+ // claims is that circle's, not public.
677
+ if (m.kind === "path") for (const abs of filesUnderPath(m.abs)) {
678
+ if (homeNames(abs).join() !== c.name) continue;
679
+ const held = heldBy(frontOfFile(abs), "publish");
680
+ if (held === SEALED) { refused.push({ path: rel(abs), abs, member: name, reason: "it is sealed: only its owner opens it, on their own devices, and a sealed entry is never published, even from a public folder" }); continue; }
681
+ if (held === NOTED) { refused.push({ path: rel(abs), abs, member: name, reason: "it is just noted, and a noted entry is never published, even from a public folder" }); continue; }
682
+ byPath.push({ path: rel(abs), abs, member: name });
683
+ }
684
+ if (m.kind === "table") tables.push({ member: name, table: m.table, where: m.where });
685
+ // A render member is listed so the whole public set is on the screen; what it draws from
686
+ // is lib/render.mjs's answer, computed from byPath and byRule.
687
+ if (m.kind === "render") renders.push({ member: name, view: m.view, path: m.rel, shape: m.shape });
688
+ }
689
+ }
690
+ for (const [name, m] of rules) {
691
+ for (const abs of scan) {
692
+ let text;
693
+ try { text = readFileSync(abs, "utf8"); } catch { continue; }
694
+ const front = frontmatterOf(text);
695
+ if (!front || !matchesWhere(front, m.where)) continue;
696
+ // Sealed: no rule and no mark releases it, ever (HQ-1220). Checked before noted, so an entry
697
+ // that is both is refused as the stronger of the two.
698
+ const held = heldBy(front, "publish");
699
+ if (held === SEALED) { refused.push({ path: rel(abs), abs, member: name, reason: "it is sealed: only its owner opens it, on their own devices, and no mark releases a sealed entry" }); continue; }
700
+ // Just noted: no rule and no mark releases it until the owner says it can be read.
701
+ if (held === NOTED) { refused.push({ path: rel(abs), abs, member: name, reason: "it is just noted, and a noted entry goes nowhere until its owner releases it" }); continue; }
702
+ const homes = circles.filter((k) => homeNames(abs).includes(k.name));
703
+ if (homes.length === 1 && homes[0] === c) continue; // already here by path
704
+ if (homes.length === 0) { refused.push({ path: rel(abs), abs, member: name, reason: "no circle covers it by path, so nothing says it may be released" }); continue; }
705
+ if (homes.length > 1) { refused.push({ path: rel(abs), abs, member: name, reason: `covered by ${homes.length} circles (${homes.map((o) => o.name).join(", ")}); one home per entry` }); continue; }
706
+ const [home] = homes;
707
+ if (home.manifest?.public === true) { refused.push({ path: rel(abs), abs, member: name, circle: home.name, reason: `already public (home "${home.name}"), so there is nowhere further to release to` }); continue; }
708
+ if (home.release === "never") { refused.push({ path: rel(abs), abs, member: name, circle: home.name, reason: `its home circle "${home.name}" releases nothing (release: never)` }); continue; }
709
+ if (!home.release.includes(c.name)) { refused.push({ path: rel(abs), abs, member: name, circle: home.name, reason: `its home circle "${home.name}" may release to ${home.release.map((n) => `"${n}"`).join(", ")}, not to "${c.name}"` }); continue; }
710
+ byRule.push({ path: rel(abs), abs, member: name, circle: home.name });
711
+ }
712
+ }
713
+ out.push({ circle: c.name, public: isPublic, byPath, byRule, tables, renders, refused });
714
+ }
715
+ return out;
716
+ }
717
+
718
+ // Build an eco manifest for a circle. `name` is the directory name and never reaches the id: the
719
+ // id is minted at random here, once, and the namespace follows from it, so the two cannot disagree.
720
+ // Pass `id` only to rewrite a circle that already has one.
721
+ export function circleManifest({ name, id = mintCircleId(), title = name, isPublic = false, holders: hs = [], members = {}, policy = null, description = null, release = null, mount = null }) {
282
722
  const m = {
283
- id: ecoId(name),
723
+ id: ecoId(id),
284
724
  kind: "eco",
285
725
  schemaVersion: ECO_SCHEMA_VERSION,
286
- namespace: ecoNamespace(name),
726
+ namespace: ecoNamespace(id),
287
727
  title,
288
728
  };
289
729
  if (description) m.description = description;
290
730
  m.public = isPublic;
731
+ // A circle says where a file living in it may be released to. Written explicitly, so the default
732
+ // is visible in the file rather than remembered.
733
+ if (!isPublic) {
734
+ if (release !== null && !isRelease(release)) throw new EvdaError(`"release" must be "never" or a non-empty list of circle names.`, { exit: 2 });
735
+ m.release = release ?? "never";
736
+ } else if (release !== null) throw new EvdaError(`a public circle has no "release"; everyone already reads it.`, { exit: 2 });
737
+ if (mount !== null) {
738
+ if (isPublic) throw new EvdaError(`a public circle has no "mount"; it is read from its export.`, { exit: 2 });
739
+ if (!isObject(mount) || !nonEmpty(mount.holder) || !nonEmpty(mount.path)) throw new EvdaError(`"mount" must be { holder: <record id>, path: <relative path> }.`, { exit: 2 });
740
+ m.mount = { holder: mount.holder, path: mount.path };
741
+ }
291
742
  m.maintainers = hs.map((h) => (typeof h === "string" ? { name: h, role: "holder" } : { name: h.name, role: h.role ?? "holder" }));
292
743
  m.members = members;
293
744
  if (Array.isArray(policy)) m.policy = policy;