create-evda 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +74 -7
- package/bin/evda.mjs +427 -9
- package/create-evda.mjs +64 -16
- package/docs/local-model.md +34 -0
- package/hooks/evda-read-guard.mjs +7 -0
- package/lib/chat-export.mjs +198 -0
- package/lib/circle-plan.mjs +277 -0
- package/lib/circles.mjs +479 -28
- package/lib/connect.mjs +46 -0
- package/lib/day-file.mjs +93 -0
- package/lib/declarations.mjs +235 -0
- package/lib/devices.mjs +202 -0
- package/lib/doctor.mjs +346 -13
- package/lib/filechanges.mjs +622 -0
- package/lib/folders.mjs +1 -1
- package/lib/gather-apply.mjs +640 -0
- package/lib/gather.mjs +858 -0
- package/lib/grants.mjs +7 -1
- package/lib/guard.mjs +132 -0
- package/lib/hooks.mjs +124 -0
- package/lib/keep-hook.mjs +185 -0
- package/lib/kinds.mjs +23 -6
- package/lib/kinds.schema.json +2 -1
- package/lib/manifest.mjs +14 -1
- package/lib/mcp-auth.mjs +370 -0
- package/lib/mcp-http.mjs +336 -0
- package/lib/mcp.mjs +456 -36
- package/lib/messages.mjs +205 -0
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +176 -32
- package/lib/parity.mjs +184 -0
- package/lib/presets.mjs +58 -7
- package/lib/prompt-hook.mjs +88 -0
- package/lib/publish.mjs +264 -0
- package/lib/readers.mjs +319 -0
- package/lib/records.mjs +52 -0
- package/lib/references.mjs +5 -51
- package/lib/render.mjs +158 -0
- package/lib/roles.mjs +106 -1
- package/lib/routines.mjs +142 -18
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +124 -4
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +93 -0
- package/lib/tools.mjs +133 -27
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +25 -15
- package/presets/journal/preset.json +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +67 -8
- package/templates/AGENTS.md +122 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +31 -6
- package/LICENSE +0 -21
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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) {
|