create-evda 0.1.5 → 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 +74 -7
  3. package/bin/evda.mjs +391 -6
  4. package/create-evda.mjs +54 -10
  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/gather-apply.mjs +635 -0
  17. package/lib/gather.mjs +808 -0
  18. package/lib/grants.mjs +7 -1
  19. package/lib/guard.mjs +132 -0
  20. package/lib/hooks.mjs +124 -0
  21. package/lib/keep-hook.mjs +185 -0
  22. package/lib/kinds.mjs +23 -6
  23. package/lib/kinds.schema.json +2 -1
  24. package/lib/manifest.mjs +14 -1
  25. package/lib/mcp-auth.mjs +370 -0
  26. package/lib/mcp-http.mjs +336 -0
  27. package/lib/mcp.mjs +425 -36
  28. package/lib/migrate-people.mjs +231 -0
  29. package/lib/organize.mjs +52 -12
  30. package/lib/parity.mjs +111 -0
  31. package/lib/presets.mjs +58 -7
  32. package/lib/prompt-hook.mjs +88 -0
  33. package/lib/publish.mjs +264 -0
  34. package/lib/readers.mjs +319 -0
  35. package/lib/records.mjs +52 -0
  36. package/lib/references.mjs +5 -51
  37. package/lib/render.mjs +158 -0
  38. package/lib/roles.mjs +102 -1
  39. package/lib/secret-shape.mjs +58 -0
  40. package/lib/setup.mjs +118 -0
  41. package/lib/skills.mjs +266 -0
  42. package/lib/subrecord.mjs +121 -0
  43. package/lib/sync-rules.mjs +87 -0
  44. package/lib/tools.mjs +118 -27
  45. package/lib/update-check.mjs +146 -0
  46. package/lib/work.mjs +180 -0
  47. package/lib/yaml.mjs +117 -42
  48. package/package.json +24 -15
  49. package/presets/journal/preset.json +9 -1
  50. package/presets/projects-and-areas/preset.json +16 -9
  51. package/skills/evda/SKILL.md +58 -0
  52. package/templates/AGENTS.md +118 -14
  53. package/templates/README.md +15 -1
  54. package/templates/circles-README.md +3 -3
  55. package/templates/evda-digest.md +30 -6
  56. package/LICENSE +0 -21
  57. package/presets/student/preset.json +0 -13
package/lib/doctor.mjs CHANGED
@@ -39,27 +39,64 @@
39
39
  // `evda access workshop --as iris.atlas` on the same record: ALLOWED, exit 0, naming the grant
40
40
  // file; `evda access core --as iris.atlas`: REFUSED, exit 2, "no circle named "core"".
41
41
 
42
+ // FALSIFIED 2026-09-22, section 10a and the mount-root line of section 11 (HQ-835), by
43
+ // test/mounts.test.mjs: mountProblems made to return no problems and the stray-file line disabled ->
44
+ // doctor exited 0 on a record holding mounts/ava.atlas/stray.md in no held circle, and on a home
45
+ // circle claiming mounts/ava.atlas/shared-with-ben/01_Projects; three of four tests failed. Restored,
46
+ // the same records print "FAIL mounts 1 file(s) under the mount root mounts that no held circle
47
+ // covers: mounts/ava.atlas/stray.md" and exit 1.
48
+
42
49
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
43
50
  import { join, relative, resolve, sep } from "node:path";
44
51
  import { readManifest, checkVersion, unknownKeys, unknownLayers, holderOf, holderProblems, KNOWN_LAYERS, SCHEMA_VERSION } from "./manifest.mjs";
45
52
  import { readGrants } from "./grants.mjs";
46
- import { resolveAll, resolveRole, rolesForLayer, ROLES } from "./roles.mjs";
53
+ import { readDevices } from "./devices.mjs";
54
+ import { resolveAll, resolveRole, rolesForLayer, ROLES, FOUR_ROLES, fourRoles } from "./roles.mjs";
55
+ import { subRecords, boundaries } from "./records.mjs";
56
+ import { folderEfforts } from "./work.mjs";
57
+ import { isBounded, SUBRECORD_MODES } from "./manifest.mjs";
47
58
  import { parseLedger, countHeads } from "./ledger.mjs";
48
59
  import { validateEntries } from "./validate.mjs";
49
60
  import { deriveDue, currentIndex, currentDigest } from "./index.mjs";
50
61
  import { mdFiles, fileDate } from "./record.mjs";
51
62
  import { splitFence, digestFrom, digestBytes, DIGEST_BUDGET } from "./agents.mjs";
52
- import { readCircles, coverage, LEGACY_MANIFEST } from "./circles.mjs";
63
+ import { readCircles, coverage, released, mountProblems, markdownFiles, LEGACY_MANIFEST, SEALED, MARK, sealedSections } from "./circles.mjs";
64
+ import { parseReaders, describeReaders, frontOf, NOTED, READ_BY } from "./readers.mjs";
65
+ import { renderViews } from "./render.mjs";
66
+ import { planBeside } from "./circle-plan.mjs";
67
+ import { excluder, walk } from "./sync-rules.mjs";
53
68
  import { folderManifests, folderFor, viewNamedIn, FOLDER_MANIFEST } from "./folders.mjs";
54
69
  import { referencesIn } from "./references.mjs";
55
70
  import { KIND_FOR_TYPE, PERSON_KINDS, entryFromFile, readEntry } from "./kinds.mjs";
71
+ import { datedHeadings } from "./migrate-people.mjs";
56
72
  import { EvdaError } from "./errors.mjs";
73
+ import { hookStatus, promptHookStatus, HOOK_REL, PROMPT_HOOK_REL, SETTINGS_REL } from "./hooks.mjs";
74
+ import { FILING_MODES, filingOf } from "./day-file.mjs";
75
+ import { listSkills, SKILLS_REL } from "./skills.mjs";
76
+ import { guardStatus } from "./connect.mjs";
77
+
78
+ // The dated sections of a person-profile that no observation covers (HQ-1397). An observation
79
+ // covers a section when its text is the heading's exactly, or else when it carries the heading's
80
+ // date as its `since`: a reworded observation ("2026-09-22 evening, ..." for "2026-09-22, evening:
81
+ // ...") still counts. Each observation covers one section, so two sections on one day need two.
82
+ export function uncoveredSections(headings, observations) {
83
+ const free = [...observations];
84
+ const take = (i) => free.splice(i, 1);
85
+ const rest = headings.filter((h) => {
86
+ const i = free.findIndex((o) => o.text === h.text);
87
+ return i === -1 ? true : (take(i), false);
88
+ });
89
+ return rest.filter((h) => {
90
+ const i = free.findIndex((o) => String(o.since) === h.since);
91
+ return i === -1 ? true : (take(i), false);
92
+ });
93
+ }
57
94
 
58
95
  // Same rule as record.mjs fileDate: a daily file is dated by a leading YYYY-MM-DD in its name.
59
96
  // A suffix after the date is allowed (`2026-01-19 - The Scale Question.md`); no date is not.
60
97
  const DAILY_NAME = /^\d{4}-\d{2}-\d{2}(?:[ .-]|$)/;
61
98
 
62
- export function doctor(dir, { out = console.log, via = "argument" } = {}) {
99
+ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } = {}) {
63
100
  const failures = [];
64
101
  const warnings = [];
65
102
  const checked = [];
@@ -127,7 +164,10 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
127
164
  const mappedKnown = roles.filter((r) => r.mapped && r.known);
128
165
  for (const r of roles) {
129
166
  if (!r.known) continue;
167
+ // The four roles are listed by 4b; an absent one is said there, once.
168
+ if (!r.mapped && ROLES[r.role]?.four) continue;
130
169
  if (!r.mapped) { out(` ${r.role.padEnd(16)} absent (not in mapping)`); continue; }
170
+ if (r.kind === "source") { out(` ${r.role.padEnd(16)} -> ${r.source.source}${r.source.project ? ` project ${r.source.project}` : " (projects by vault_path)"} a live source`); continue; }
131
171
  if (r.kind === "file") {
132
172
  if (!r.exists) { fail(`role ${r.role}`, `is mapped to ${r.rel} but that file does not exist.`); continue; }
133
173
  if (r.isDir) { fail(`role ${r.role}`, `is mapped to ${r.rel} which is a directory, not a file.`); continue; }
@@ -142,7 +182,40 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
142
182
  out(` ${r.role.padEnd(16)} -> ${r.rel} ${detail}`);
143
183
  if (r.file && !r.fileExists) fail(`role ${r.role}`, `${r.fileRel} does not exist. The ledger role is mapped, so its file must be there.`);
144
184
  }
145
- ok(mappedKnown.length, "roles", `${mappedKnown.length} mapped, ${roles.filter((r) => r.known && !r.mapped).length} absent`);
185
+ ok(mappedKnown.length, "roles", `${mappedKnown.length} mapped, ${roles.filter((r) => r.known && !r.mapped && !ROLES[r.role]?.four).length} absent`);
186
+
187
+ // 4a. sub-records (Spec 07 section 2, HQ-1371): every evda.json below the root starts a record of
188
+ // its own. Found here so the four roles and the circles below know where this record stops; each
189
+ // is walked as its own record at the end.
190
+ if (manifest.subrecords !== undefined && !SUBRECORD_MODES.includes(manifest.subrecords)) fail("evda.json", `"subrecords" is ${JSON.stringify(manifest.subrecords)}; the one value is "bounded", and absent means this record's circles still cover its sub-records' files.`);
191
+ const subs = subRecords(dir);
192
+ const bounded = isBounded(manifest);
193
+
194
+ // 4b. the four roles (Spec 07 section 2, "Four roles, at every scale", HQ-1371): journal, work,
195
+ // collections, knowledge, each filled by one or more paths, or for work a live source. A missing
196
+ // path, one path in two roles, a path inside a sub-record and a source this tool cannot read fail.
197
+ const four = fourRoles(evda, { subRecords: subs });
198
+ for (const p of four.problems) fail("evda.json four roles", p);
199
+ if (four.declared.length === 0) {
200
+ skip("four roles", `none of ${FOUR_ROLES.join(", ")} is in the mapping; record/daily, record/captures, record/people and ledger count toward them when present`);
201
+ } else {
202
+ for (const role of FOUR_ROLES) {
203
+ const es = four.roles[role];
204
+ if (!es.length) { out(` ${role.padEnd(12)} absent`); continue; }
205
+ out(` ${role.padEnd(12)} ${es.map((e) => (e.source ? `${e.source.source}:${e.source.project ?? ""} (live)` : `${e.rel}${e.key === role ? "" : ` [${e.key}]`}`)).join(", ")}`);
206
+ }
207
+ ok(four.declared.length, "four roles", `${four.declared.join(", ")}${four.declared.length < 4 ? `; absent: ${FOUR_ROLES.filter((r) => !four.declared.includes(r)).join(", ")}` : ""}`);
208
+ // Work read from a folder is checked here, offline. A live source is read by `evda work`, which
209
+ // doctor does not call: doctor runs where there is no network and no board key.
210
+ for (const e of four.roles.work) {
211
+ if (e.source) { skip(`efforts from ${e.source.source}`, "a live source; `evda work` reads it and prints CHECKED <n> efforts"); continue; }
212
+ const r = folderEfforts(evda, e);
213
+ for (const p of r.problems) fail("work", p);
214
+ if (r.status === "failed") fail("work", r.why);
215
+ else if (r.efforts.length === 0 && r.problems.length === 0) skip(`efforts in ${e.rel}`, "the work folder holds no effort file yet");
216
+ else if (r.efforts.length) ok(r.efforts.length, "efforts", `in ${e.rel}`);
217
+ }
218
+ }
146
219
 
147
220
  // 5. operating layer, a mapped role like the others. Only a record with none is a failure.
148
221
  const agentsRole = resolveRole(evda, "agents");
@@ -175,6 +248,45 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
175
248
  ok(1, "operating layer", rel);
176
249
  }
177
250
 
251
+ // 5b. the keep-record Stop hook (HQ-1376): whether a Claude Code session opened here is asked to
252
+ // file before it stops. Reported, never required; only a registered hook with a missing or stale
253
+ // file is a warning, because that is a hook that does not do what the settings say.
254
+ const hook = hookStatus(dir);
255
+ if (hook.problem) warn(SETTINGS_REL, hook.problem);
256
+ else if (hook.registered && !hook.file) warn(SETTINGS_REL, `registers the keep-record Stop hook, but ${HOOK_REL} is missing, so every stop runs a file that is not there. \`evda hook install\` restores it.`);
257
+ else if (hook.registered && !hook.current) warn(HOOK_REL, "differs from the hook this tooling ships. `evda hook install` updates it.");
258
+ else if (hook.registered) ok(1, "keep-record Stop hook", `${SETTINGS_REL} runs ${HOOK_REL}, current`);
259
+ else skip("keep-record Stop hook", `not installed. A Claude Code session here is not asked to file before it stops; \`evda hook install\` adds it to ${SETTINGS_REL}.`);
260
+
261
+ // 5b2. filing (HQ-1385): the owner's one answer on whether their words are filed without being
262
+ // asked, read by every agent and hook. A value none of them knows fails; no answer yet is a skip,
263
+ // because the agent asks at first connect.
264
+ if (manifest.filing !== undefined && !FILING_MODES.includes(manifest.filing)) fail("evda.json", `"filing" must be ${FILING_MODES.map((m) => JSON.stringify(m)).join(" or ")}, got ${JSON.stringify(manifest.filing)}. Every agent and hook reads it, and one none of them knows is read as no answer.`);
265
+ else if (manifest.filing) ok(1, "filing", `evda.json says "${manifest.filing}": ${manifest.filing === "everything" ? "every message the owner sends is filed without being asked" : "their words are filed only when they ask"}`);
266
+ else skip("filing", "evda.json has no answer yet. The agent asks the owner once, at first connect, and records it with evda_filing.");
267
+
268
+ // 5b3. the prompt hook (HQ-1385): a UserPromptSubmit hook that files each message, for an owner
269
+ // who chose everything. Installed and current is a check; installed while filing says otherwise
270
+ // warns, because it then files nothing and the settings say it does.
271
+ const prompt = promptHookStatus(dir);
272
+ const everything = filingOf(manifest) === "everything";
273
+ if (prompt.problem) { /* the settings file's problem is reported once, above */ }
274
+ else if (prompt.registered && !prompt.file) warn(SETTINGS_REL, `registers the prompt hook, but ${PROMPT_HOOK_REL} is missing, so every message runs a file that is not there. \`evda hook install\` restores it.`);
275
+ else if (prompt.registered && !prompt.current) warn(PROMPT_HOOK_REL, "or a file copied beside it differs from what this tooling ships. `evda hook install` updates them.");
276
+ else if (prompt.registered && !everything) warn(SETTINGS_REL, `registers the prompt hook, but evda.json does not say "filing": "everything", so it files nothing.`);
277
+ else if (prompt.registered) ok(1, "prompt hook", `${SETTINGS_REL} runs ${PROMPT_HOOK_REL}, current: each message the owner sends is filed verbatim`);
278
+ else skip("prompt hook", everything ? `not installed. \`evda hook install\` adds it, and each message the owner sends in Claude Code is filed before the model sees it.` : `installed only for an owner whose evda.json says "filing": "everything".`);
279
+
280
+ // 5c. the read guard (HQ-1374), the other hook create-evda installs for a Claude owner (HQ-1377):
281
+ // a PreToolUse hook in the record's own settings that stops a Claude Code session reading a noted
282
+ // entry. Reported present or missing, like the Stop hook above; a registered guard whose file is
283
+ // gone warns, because every read then runs a command that is not there.
284
+ const guard = guardStatus(dir);
285
+ if (guard.problem) warn(SETTINGS_REL, guard.problem);
286
+ else if (guard.registered && !guard.file) warn(SETTINGS_REL, `registers the read guard, but ${guard.target} is missing, so Claude Code cannot run it and a noted entry is not held back there. \`evda hook install\` restores it.`);
287
+ else if (guard.registered) ok(1, "read guard", `${SETTINGS_REL} runs ${guard.target ?? "the evda guard through npx"} before Read, Grep and shell reads`);
288
+ else skip("read guard", `not installed. A Claude Code session here is not stopped before reading a just-noted entry (the MCP server still holds it back); \`evda hook install\` adds it to ${SETTINGS_REL}.`);
289
+
178
290
  // 6. record files
179
291
  const recordRoles = mappedKnown.filter((r) => r.layer === "record" && r.exists && r.isDir);
180
292
  if (recordRoles.length === 0) {
@@ -265,22 +377,60 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
265
377
  fail(circlesRole.rel, e.code);
266
378
  return finish(e.exit ?? 1);
267
379
  }
380
+ const circleNames = new Set(circles.map((c) => c.name));
268
381
  for (const c of circles) {
269
382
  for (const p of c.problems) (p.level === "fail" ? fail : warn)(`circle ${c.name}`, p.message);
270
- const members = Object.entries(c.members).map(([n, m]) => `${n} -> ${m.kind === "path" ? m.rel : `table ${m.table}`}`).join(", ");
383
+ // A release list names circles; one the record does not have is a boundary that points at nothing.
384
+ for (const n of Array.isArray(c.release) ? c.release : []) if (!circleNames.has(n)) fail(`circle ${c.name}`, `release names "${n}", and this record has no such circle.`);
385
+ const members = Object.entries(c.members).map(([n, m]) => `${n} -> ${m.kind === "path" ? m.rel : m.kind === "table" ? `table ${m.table}` : m.kind === "render" ? `render ${m.view} at ${m.rel}` : `where ${JSON.stringify(m.where)}`}`).join(", ");
271
386
  const policy = c.policy ? `, policy: ${c.policy.rules.length} rule(s)` : "";
272
- out(` ${c.name.padEnd(16)} ${c.manifest?.public === true ? "public" : "private"} ${c.readable ? "readable" : "not readable"}${c.source === LEGACY_MANIFEST ? " (circle.json)" : ""}${members ? ` [${members}]` : ""}${policy}`);
387
+ const release = c.manifest?.public === true ? "" : ` release: ${Array.isArray(c.release) ? c.release.join(",") : c.release}`;
388
+ out(` ${c.name.padEnd(16)} ${c.manifest?.public === true ? "public" : "private"} ${c.readable ? "readable" : "not readable"}${release}${c.source === LEGACY_MANIFEST ? " (circle.json)" : ""}${members ? ` [${members}]` : ""}${policy}`);
273
389
  }
390
+ // 10a. mounts (HQ-835): circles held from other holders, each in its own subfolder under the
391
+ // mounts role, none nested, none claimed by a home circle.
392
+ const mounts = mountProblems(evda, circles);
393
+ for (const p of mounts.problems) fail("mounts", p);
394
+ if (mounts.held === 0) skip("mounts", `no circle held from another holder${mounts.root ? ` (mount root ${mounts.root})` : ""}`);
395
+ else ok(mounts.held, "mounts", `${mounts.held} circle(s) held from ${mounts.owners} other holder(s), ${mounts.root ? `under ${mounts.root}, one subfolder each` : "no mounts role, one per owner"}`);
396
+
274
397
  if (circles.length === 0) skip("circles", `${circlesRole.rel} holds no circle directories`);
275
398
  else ok(circles.length, "circles", `${circles.filter((c) => c.readable).length} public and readable, ${circles.filter((c) => c.manifest?.public === false).length} private, ${circles.filter((c) => c.policy).length} with a policy${circles.some((c) => c.source === LEGACY_MANIFEST) ? `, ${circles.filter((c) => c.source === LEGACY_MANIFEST).length} still on circle.json` : ""}`);
276
399
 
277
400
  // 11. every entry resolves to exactly one circle (Spec 07, section 3). Zero is an entry nobody
278
401
  // can say who may read; two is an entry with two access answers, which an encryption boundary
279
- // cannot have.
402
+ // cannot have. The entries are EVERY file under the record, by the same walk `evda circles check`
403
+ // and the sync scan use (sync-rules.mjs, minus the plan's excludes when a plan sits beside the
404
+ // circles), not only the record roles and the ledger (HQ-1364): a file written at the root that
405
+ // no circle covered passed this section for a day while the sync scan skipped it.
280
406
  if (circles.length > 0) {
407
+ let beside = null;
408
+ try {
409
+ beside = planBeside(evda);
410
+ } catch (e) {
411
+ fail("circles", e.message);
412
+ }
413
+ // A bounded record's circles stop at its sub-records, so their files are not this record's
414
+ // entries; each sub-record's doctor covers its own. Unbounded, they are covered here as before.
415
+ const exclude = excluder([...(beside?.plan?.exclude ?? []), ...boundaries(evda)]);
416
+ if (subs.length && !bounded) skip("sub-record boundary", `${subs.length} sub-record(s) (${subs.slice(0, 3).join(", ")}${subs.length > 3 ? ", ..." : ""}), and this record does not declare "subrecords": "bounded", so its circles still cover their files as its own and sync sends them so`);
417
+ else if (subs.length) {
418
+ for (const c of circles) for (const [name, m] of Object.entries(c.members)) {
419
+ if (m.kind !== "path" || m.rel === "") continue;
420
+ const rel = m.rel.replace(/\/+$/, "");
421
+ const inside = subs.find((s) => rel === s || rel.startsWith(`${s}/`));
422
+ if (inside !== undefined) fail(`circle ${c.name}`, `member "${name}" (${rel}) is inside the sub-record at ${inside}. A bounded record's circles stop there; hold the sub-record's circle through a mount instead.`);
423
+ }
424
+ ok(subs.length, "sub-record boundaries", `this record's circles stop at ${subs.join(", ")}`);
425
+ }
281
426
  const entries = [];
282
- for (const r of mappedKnown.filter((x) => x.layer === "record" && x.exists && x.isDir)) entries.push(...mdFiles(r.abs));
283
- if (ledgerRole.mapped && ledgerRole.fileExists) entries.push(ledgerRole.file);
427
+ const unsyncable = [];
428
+ let excluded = 0;
429
+ for (const e of walk(dir, { exclude })) {
430
+ if (e.kind === "excluded") excluded += 1;
431
+ else if (e.kind === "file") entries.push(join(dir, e.rel));
432
+ else unsyncable.push(e);
433
+ }
284
434
  if (entries.length === 0) {
285
435
  skip("entries across circles", "the record has no entries to cover");
286
436
  } else {
@@ -288,11 +438,131 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
288
438
  const none = cov.filter((c) => c.circles.length === 0);
289
439
  const many = cov.filter((c) => c.circles.length > 1);
290
440
  const show = (list) => list.slice(0, 5).map((c) => relative(dir, c.abs)).join(", ") + (list.length > 5 ? ", ..." : "");
291
- if (none.length) fail("circles", `${none.length} entr${none.length === 1 ? "y" : "ies"} no circle covers: ${show(none)}. Every entry resolves to exactly one circle; a private circle whose members cover the rest of the record is the usual answer.`);
292
- if (many.length) fail("circles", `${many.length} entr${many.length === 1 ? "y" : "ies"} covered by more than one circle: ${many.slice(0, 5).map((c) => `${relative(dir, c.abs)} (${c.circles.join(", ")})`).join(", ")}${many.length > 5 ? ", ..." : ""}. One access answer per entry.`);
293
- ok(entries.length, "entries", `across ${circles.length} circles, ${none.length} uncovered, ${many.length} covered twice`);
441
+ if (none.length) fail("circles", `${none.length} entr${none.length === 1 ? "y" : "ies"} no circle covers: ${show(none)}. Every entry resolves to exactly one circle; a private circle whose member is the record root ("") is the usual answer.`);
442
+ // A file under the mount root belongs to a circle held from another holder, or it is a file
443
+ // of this record placed where only held circles go, which the root circle would otherwise
444
+ // sync as its own.
445
+ if (mounts.root !== null) {
446
+ const heldNames = new Set(circles.filter((c) => c.mount).map((c) => c.name));
447
+ const stray = cov.filter((c) => { const rel = relative(dir, c.abs); return rel.startsWith(`${mounts.root}/`) && !c.circles.some((n) => heldNames.has(n)); });
448
+ if (stray.length) fail("mounts", `${stray.length} file(s) under the mount root ${mounts.root} that no held circle covers: ${show(stray)}. Only circles held from other holders live there.`);
449
+ }
450
+ if (many.length) fail("circles", `${many.length} entr${many.length === 1 ? "y" : "ies"} covered by more than one circle at the same depth: ${many.slice(0, 5).map((c) => `${relative(dir, c.abs)} (${c.circles.join(", ")})`).join(", ")}${many.length > 5 ? ", ..." : ""}. One access answer per entry.`);
451
+ for (const u of unsyncable) warn("circles", `${u.rel} is a ${u.kind}, which sync never carries; \`evda circles check\` fails on it until the plan excludes it or it is replaced.`);
452
+ ok(entries.length, "entries", `across ${circles.length} circles, ${none.length} uncovered, ${many.length} covered twice; the whole record, ${excluded} excluded${beside ? ` (plan: ${beside.path})` : " (no plan beside the circles)"}`);
294
453
  }
454
+
455
+ // 11a. the released set (Spec 07 section 3, HQ-1351): for every circle with a rule member, each
456
+ // file released into it from its home circle, one line per file, every run; for the public
457
+ // circle its whole set, paths and tables too, so it cannot grow quietly. A file a rule matches
458
+ // that its home does not release to this circle is a failure here and refuses a publish.
459
+ const gathered = released(evda, circles);
460
+ if (gathered.length === 0) {
461
+ skip("released", "no public circle and no circle with a rule member in this record");
462
+ } else {
463
+ for (const r of gathered) {
464
+ // Views (HQ-1351): a public circle's render members draw from the released entries that
465
+ // name them. One line per rendered view saying how many it drew from, then those entries
466
+ // under it; the entries that name no view are listed as themselves. Every problem the
467
+ // renderer finds is a FAIL here, and the same problem refuses a publish.
468
+ const circle = circles.find((c) => c.name === r.circle);
469
+ const rendered = r.public && circle ? renderViews(evda, circle, r) : { views: [], loose: [...r.byPath, ...r.byRule], problems: [] };
470
+ const consumed = new Set(rendered.views.flatMap((v) => v.entries.map((e) => e.path)));
471
+ for (const f of r.byPath) if (!consumed.has(f.path)) out(` path ${f.path} (${r.circle}.${f.member})`);
472
+ for (const f of r.byRule) if (!consumed.has(f.path)) out(` rule ${f.path} (released from ${f.circle} into ${r.circle}.${f.member})`);
473
+ for (const v of rendered.views) {
474
+ out(` view ${v.path} rendered from ${v.entries.length} entries (${r.circle}.${v.member}, ${v.shape} "${v.view}")`);
475
+ for (const e of v.entries) out(` rule ${e.path} (released from ${e.home} into ${r.circle}, ${v.view} #${e.order})`);
476
+ }
477
+ for (const t of r.tables) out(` table ${t.table} where ${Object.entries(t.where).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ")} (${r.circle}.${t.member})`);
478
+ for (const f of r.refused) fail("released", `${f.path} matches a rule of "${r.circle}" and is refused: ${f.reason}.`);
479
+ for (const p of rendered.problems) fail("released", `${p.path} ${p.message}`);
480
+ const drawn = rendered.views.reduce((k, v) => k + v.entries.length, 0);
481
+ const n = r.byPath.length + r.byRule.length + r.tables.length;
482
+ const views = rendered.views.length ? `, ${rendered.views.length} rendered from ${drawn} entries` : "";
483
+ if (r.public && circle) {
484
+ // Provenance (HQ-1367): every derived entry names its sources and date, checked by the
485
+ // renderer above (a malformed pair is a FAIL there). Here, one line per derived entry,
486
+ // and which of its sources changed since, which is what the publish pass re-derives.
487
+ const derived = rendered.views.flatMap((v) => v.entries.filter((e) => e.provenance));
488
+ for (const e of derived) out(` derived ${e.path} from ${e.provenance.from.length} source(s) on ${e.provenance.at}${e.provenance.changed.length ? `; changed since: ${e.provenance.changed.join(", ")}` : ""}`);
489
+ const entries = rendered.views.reduce((k, v) => k + v.entries.length, 0);
490
+ if (derived.length === 0) skip(`derived entries in ${r.circle}`, `none of its ${entries} view entries carries ${"derived_from"} yet`);
491
+ else ok(derived.length, "derived entries", `${r.circle}: ${derived.filter((e) => e.provenance.changed.length).length} with a source changed since, ${entries - derived.length} not derived`);
492
+ }
493
+ if (!r.public && n === 0) skip(`released into ${r.circle}`, `its rule(s) match nothing yet${r.refused.length ? `, ${r.refused.length} refused` : ""}`);
494
+ else ok(n, "released", `${r.circle}: ${r.byPath.length} by path, ${r.byRule.length} by rule, ${r.tables.length} table, ${r.refused.length} refused${views}`);
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ // 10b. readers (HQ-1374): which AI reads each part, from read_by on each circle and on evda.json,
501
+ // printed as a fact per part ("Read by: ..."), never as a promise (Spec 06, "The call on
502
+ // phrasing"). A read_by that does not parse fails, because a reader nobody can read is refused by
503
+ // every agent that checks it. A read_by on a public circle is a warning: everyone reads it.
504
+ {
505
+ const knownCircles = circlesRole.mapped && circlesRole.exists && circlesRole.isDir ? (() => { try { return readCircles(evda) ?? []; } catch { return []; } })() : [];
506
+ const record = parseReaders(manifest[READ_BY], "read_by");
507
+ for (const p of record.problems) fail("evda.json", p);
508
+ let stated = record.readers !== null ? 1 : 0;
509
+ let inherit = 0;
510
+ let unstated = 0;
511
+ if (record.readers !== null || knownCircles.length === 0) out(` ${"the record".padEnd(16)} Read by: ${describeReaders(record.problems.length ? null : record.readers)}${knownCircles.length ? " (every part without its own)" : ""}`);
512
+ for (const c of knownCircles) {
513
+ const isPublic = c.manifest?.public === true;
514
+ const own = parseReaders(c.manifest?.[READ_BY], "read_by");
515
+ if (isPublic && own.readers !== null) warn(`circle ${c.name}`, "read_by is set on a public circle. Everyone may read it, so the reader says nothing; ignored.");
516
+ for (const p of own.problems) fail(`circle ${c.name}`, p);
517
+ if (isPublic) { out(` ${c.name.padEnd(16)} Read by: ${describeReaders(null, { isPublic: true })}`); stated += 1; continue; }
518
+ if (own.readers !== null) { stated += 1; out(` ${c.name.padEnd(16)} Read by: ${describeReaders(own.problems.length ? null : own.readers)}`); continue; }
519
+ if (record.readers !== null) { inherit += 1; out(` ${c.name.padEnd(16)} Read by: ${describeReaders(record.readers)} (from evda.json)`); continue; }
520
+ unstated += 1;
521
+ out(` ${c.name.padEnd(16)} Read by: ${describeReaders(null)}`);
522
+ }
523
+ if (stated + inherit === 0) skip("readers", "no circle and no evda.json says who reads it; whatever AI opens the record reads what it opens");
524
+ else ok(stated + inherit, "readers", `${stated} stated, ${inherit} from evda.json, ${unstated} not stated`);
525
+
526
+ // 10c. just-noted entries: read by no AI until their owner releases them. `noted` is true or
527
+ // absent; any other value is a flag no agent can be sure of, so it fails.
528
+ const md = markdownFiles(dir);
529
+ let noted = 0;
530
+ for (const abs of md) {
531
+ const front = frontOf(abs);
532
+ if (!front || front[NOTED] === undefined) continue;
533
+ if (front[NOTED] === true) { noted += 1; continue; }
534
+ if (front[NOTED] !== false) fail(relative(dir, abs), `"${NOTED}" is ${JSON.stringify(front[NOTED])}. It is true (just noted, read by no AI) or absent.`);
535
+ }
536
+ if (noted === 0) skip("noted entries", `none of ${md.length} markdown file(s) is just noted`);
537
+ else ok(noted, "noted entries", `read by no AI until their owner releases them; searchable on this device with \`evda search\``);
538
+
539
+ // 10d. sealed (HQ-1220): nothing but the owner's own devices opens it. `sealed` is true or
540
+ // absent, as noted is. A sealed file never carries a share mark, whether or not a rule matches
541
+ // it, since no mark releases it and a mark that says otherwise misleads its owner. A sealed
542
+ // fence that never closes seals to the end of its file, and fails so the owner sees it.
543
+ let sealedFiles = 0;
544
+ let sections = 0;
545
+ let sectioned = 0;
546
+ for (const abs of md) {
547
+ const rel = relative(dir, abs);
548
+ const front = frontOf(abs);
549
+ if (front && front[SEALED] !== undefined) {
550
+ if (front[SEALED] === true) {
551
+ sealedFiles += 1;
552
+ if (front[MARK] !== undefined) fail(rel, `is sealed and carries ${MARK}: ${Array.isArray(front[MARK]) ? front[MARK].join(", ") : String(front[MARK])}. No mark releases a sealed entry; take off the mark, or the seal.`);
553
+ } else if (front[SEALED] !== false) fail(rel, `"${SEALED}" is ${JSON.stringify(front[SEALED])}. It is true (sealed: only you open it, on your own devices) or absent.`);
554
+ }
555
+ let text;
556
+ try { text = readFileSync(abs, "utf8"); } catch { continue; }
557
+ const found = sealedSections(text);
558
+ if (!found.length) continue;
559
+ sections += found.length;
560
+ sectioned += 1;
561
+ const open = found.filter((x) => !x.closed);
562
+ if (open.length) fail(rel, `has a sealed section that is never closed (line ${open[0].start + 1}), so everything after it is sealed. Close it with a fence like the one that opens it.`);
295
563
  }
564
+ if (sealedFiles + sections === 0) skip("sealed entries", `none of ${md.length} markdown file(s) is sealed or holds a sealed section`);
565
+ else ok(sealedFiles + sections, "sealed entries", `${sealedFiles} sealed file(s), ${sections} sealed section(s) in ${sectioned} file(s); opened only on your own devices, read by no AI, released by no tool; searchable on this device with \`evda search\``);
296
566
  }
297
567
 
298
568
  // 11b. grants (Spec 00 #65): who may read each circle, one substrate member tuple per file under
@@ -316,6 +586,16 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
316
586
  }
317
587
  if (grants.length === 0) skip("grants", `${grantsRole.rel} holds no grant files`);
318
588
  else ok(grants.length, "grants", `across ${new Set(grants.map((g) => g.circle).filter(Boolean)).size} circle(s), ${grants.filter((g) => g.holder?.kind === "agent").length} to agents`);
589
+
590
+ // 11c. device lists (HQ-835): the devices each granted holder's record id resolves to. A holder
591
+ // with no list is one device of the same id.
592
+ const devices = readDevices(evda);
593
+ for (const p of devices.problems) fail("devices", p.message);
594
+ if (devices.lists.size === 0) skip("devices", "no device lists; every holder is one device of its record id");
595
+ else {
596
+ for (const [record, list] of devices.lists) out(` ${record.padEnd(24)} ${list.devices.filter((d) => !d.retiredAt).map((d) => d.atlasId).join(", ") || "(no active device)"}${list.devices.some((d) => d.retiredAt) ? ` retired: ${list.devices.filter((d) => d.retiredAt).map((d) => d.atlasId).join(", ")}` : ""}`);
597
+ ok(devices.lists.size, "device lists", `${[...devices.lists.values()].reduce((n, l) => n + l.devices.filter((d) => !d.retiredAt).length, 0)} active device(s), ${[...devices.lists.values()].reduce((n, l) => n + l.devices.filter((d) => d.retiredAt).length, 0)} retired`);
598
+ }
319
599
  }
320
600
 
321
601
  // 12. folder manifests (Spec 07, section 2): each folder says what its entries are and which
@@ -363,6 +643,7 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
363
643
  // kind's rules. A file of another kind, or one that does not read, fails with what is wrong.
364
644
  // Markdown with no `kind:` is left alone; it is not claiming to be an entry.
365
645
  const typed = folders.filter((f) => f.type && KIND_FOR_TYPE[f.type]);
646
+ let profilesChecked = 0;
366
647
  if (typed.length === 0) {
367
648
  skip("typed entries", `no folder manifest declares a person-layer type (${Object.keys(KIND_FOR_TYPE).join(", ")})`);
368
649
  } else {
@@ -386,12 +667,29 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
386
667
  entries += 1;
387
668
  if (kind !== want) { fail(rel, `is a ${kind}, and ${where} declares type ${f.type}, whose entries are ${want}. Move it, or say what the folder holds.`); continue; }
388
669
  counts[want] = (counts[want] ?? 0) + 1;
389
- for (const problem of readEntry(want, read.raw, { owner }).problems) fail(rel, `${want} ${problem}`);
670
+ const problems = readEntry(want, read.raw, { owner }).problems;
671
+ for (const problem of problems) fail(rel, `${want} ${problem}`);
672
+ // A People doc gets an observation for each dated section (CLAUDE.md's rule, HQ-1189):
673
+ // check the body's dated H2/H3 headings against the frontmatter's observations, so a new
674
+ // section added without one does not pass silently. Only once the shape itself is sound.
675
+ if (want === "person-profile") {
676
+ profilesChecked += 1;
677
+ if (problems.length === 0 && read.body !== undefined) {
678
+ for (const h of uncoveredSections(datedHeadings(read.body), read.raw.observations ?? [])) {
679
+ fail(rel, `has a dated section "${h.text}" with no observation in its person-profile frontmatter (since: ${h.since}). A dated heading gets an observation for it.`);
680
+ }
681
+ }
682
+ }
390
683
  }
391
684
  }
392
685
  if (entries === 0) skip("typed entries", `${typed.length} typed folder(s) hold no entry files yet`);
393
686
  else ok(entries, "typed entries", `across ${typed.length} folder(s): ${PERSON_KINDS.filter((k) => counts[k]).map((k) => `${counts[k]} ${k}`).join(", ") || "none of the declared kind"}`);
394
687
  }
688
+
689
+ // 12d. person-profile dated sections (CLAUDE.md's People rule): reports what the check above
690
+ // found, across every person-profile record read, so a run that examined none says so plainly.
691
+ if (profilesChecked === 0) skip("person-profile observations", "no person-profile record was read above");
692
+ else ok(profilesChecked, "person-profile record(s)", "checked for a dated section with no observation");
395
693
  }
396
694
 
397
695
  // 13. references (Spec 07, section 4): a pointer, a label and a date, never the payload, never
@@ -421,6 +719,41 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
421
719
  else ok(refs, "references", `across ${files.size} file(s)${refused ? `, ${refused} refused` : ""}`);
422
720
  }
423
721
 
722
+ // 13b. skills the agent wrote for itself (HQ-1377): every .claude/skills/<name>/SKILL.md carries a
723
+ // kebab-case name matching its folder and a description. A broken one fails, because Claude Code
724
+ // skips a skill it cannot parse without saying so. One with no "when" or no steps, or that reads as
725
+ // a rule about the owner's life, warns: the tool refuses to write one, and a hand-written one is the
726
+ // owner's to keep or cut. FALSIFIED 2026-09-22 by Argus on Mike's vault: with "no when" and "no
727
+ // steps" as failures, doctor exited 1 on four of his seventeen hand-written skills, which load fine.
728
+ {
729
+ const sk = listSkills(evda);
730
+ if (sk.outside) fail(SKILLS_REL, "resolves outside the record (a symlink). A skill is part of the record and travels with it.");
731
+ else if (sk.skills.length === 0) skip("skills", `none written yet in ${SKILLS_REL}`);
732
+ else {
733
+ for (const s of sk.skills) {
734
+ for (const p of s.problems) fail(s.path, p);
735
+ for (const w of s.warnings) warn(s.path, w);
736
+ }
737
+ ok(sk.skills.length, "skills", `${SKILLS_REL}: ${sk.skills.map((s) => s.folder).join(", ")}`);
738
+ }
739
+ }
740
+
741
+ // 14. every sub-record, as a record of its own, to any depth. Its failures are this run's.
742
+ if (subs.length) {
743
+ let clean = 0;
744
+ const failing = [];
745
+ for (const rel of subs) {
746
+ out(` sub-record ${rel}`);
747
+ const r = doctor(join(dir, rel), { out: (line) => out(` ${line}`), via: "parent", depth: depth + 1 });
748
+ if (r.exit === 0) clean += 1;
749
+ else failing.push(`${rel} (exit ${r.exit})`);
750
+ }
751
+ ok(subs.length, "sub-records", `${clean} clean, ${failing.length} failing, each walked as its own record`);
752
+ if (failing.length) fail("sub-records", `${failing.join(", ")} failed above. A sub-record's failure is its parent's.`);
753
+ } else if (depth === 0) {
754
+ skip("sub-records", "no evda.json below the root");
755
+ }
756
+
424
757
  return finish(0);
425
758
 
426
759
  function finish(refusedExit) {